使用node.js建博客(六) - 添加代码高亮的支持 (Final)

本文介绍如何在Node.js中使用Markdown格式构建博客,并通过jade模板引擎展示内容,同时加入代码高亮功能。

本片为《用node.js建博客》系列的最后一篇,如果你第一看到这个系列, 可以在文章结尾看到前几篇

技术准备

1. 如何添加代码高亮支持

代码美化选用Google-code 的 PrettyPrint。使用非常方便,能够自动匹配语言。

2. 使用CSS框架完成最基本的美化(本片将不涉及)

CSS框架打算用BluePrint,  如果只需要Grid, 选用960Grid也不错。

 

思路

以前采用的是直接渲染markdown的方式。在Express中注册一个markdown渲染器,在response时将markdown渲染输出。这种方式的缺点就是不好对输出的html进行修改(比如添加js,css等). 

因此需要修改markdown的渲染方式,将markdown嵌入到jade模板中。

 

Let's do it:

1. 修改测试:

test/page-test.js

 

var vows = require('vows');
var apiEasy = require('api-easy');
var assert = require('assert');
var jsdom = require('jsdom').jsdom;


var suite = apiEasy.describe('blogs');

suite.discuss("when visit home page")
    .discuss('can view blog list and it should response 200 status')
    .use('localhost', 3000)
    .setHeader('Content-Type', 'text/html; charset=utf-8')
    .get()
        .expect(200)
        .expect("should respond with x-powered-by",
            function(err, res, body) {
                // express
                assert.include(res.headers, 'x-powered-by');
            })
        .expect("should respond with 'Nodeblog - Home' title",
            function (err, res, body) {
                var doc = jsdom(body);
                //匹配title
                assert.equal(doc.title, "NodeBlog - Home");
            })
            
    .export(module);

 

test/blogs-test.js

 

var vows = require('vows');
var apiEasy = require('api-easy');
var assert = require('assert');
var jsdom = require('jsdom').jsdom;

var suit = apiEasy.describe("/blogs/*.html")

suit.discuss('when vists the markdown blog,')
    .use('localhost', 3000)
        .setHeader('Content-Type', 'text/html; charset=utf-8')
        .path('/blogs/')
        
    .discuss('if the markdown file is exists, show blog')
        .get('monitor_doc')
            .expect(200)
            .expect("should respond 'NodeBlog - Blogs' title",
                function  (err, res, body) {
                    var doc = jsdom(body)
                    assert.equal(doc.title, 'NodeBlog - Blogs');
                })
    
    .undiscuss()    
    .discuss('if the markdown file is not exists')
       .get('unknow')
            .expect(404)
            .expect("should respond 'NodeBlog - 404' title",
                function  (err, res, body) {
                    var doc = jsdom(body)
                    assert.equal(doc.title, 'NodeBlog - 404')
                })
    
    .export(module);


2. 在views/blogs/下添加show.jade

该文件用于显示 markdown blog

 

views/blogs/show.jade:

div.blog_main
    !{blog_content}

 

  此时需要用 !{} 不能使用 #{}, 否则jade会将"<",">"当特殊字符处理,将其转换成转移字符。

 

3. 修改app.js 

 

app.get('/blogs/:title', function(req, res, next) {
    
    var urlPath = [
        // 获取相对路径, 我的应该是: 
        // /Users/lvjian/projects/nodejs/nodeblog
        process.cwd(),
        '/views/blogs/',
        req.params.title, '.md'
    ].join('');
    
    var filePath = path.normalize(urlPath)
    
    path.exists(filePath, function  (exists) {
        
        if(!exists) {
            console.log('jump 404');
            next();
        } else {
            var content = fs.readFileSync(filePath, config.encoding);
            // 这里将markdown转成html, 然后以参数形式输出
            var html_content = markdown.parse(content);
            
            res.render('blogs/show', {
                title: config.app + " - Blogs"
                , blog_content: html_content
            });
        }
    });

})

 

此时启动app, 访问博客可看到效果. 

 

4. 为show.jade添加prettyprint支持:

下载prettyprint, 将其放到 ./public/lib/ 文件夹下:

 

 

在show.jade文件中使用:

 

link(rel='stylesheet', href='/libs/prettify/sunburst.css', type="text/css")
script(type='text/javascript', src='/libs/prettify/prettify.js')
script(type='text/javascript', src='http://ajax.googleapis.com/ajax/libs/jquery/1.6.3/jquery.min.js')
style(type="text/css")
    h1 {
        text-align: center;
    }

div.blog_main
    !{blog_content}
    
script
    // markdown-js会用<code><pre></pre></code>将代码包起来
    // 不过无法为其添加prettyprint class属性
    // 这里采用jquery手动添加
    $('code, pre').addClass('prettyprint');
    prettyPrint();

 

看一下效果:


 

What' next?  Final了还有next...???

为什么Final?

边写博客边coding有点太慢了,打算先将博客建好,之后以技术应用为主题写文章。

 

接下来要做什么:

  1. 采用mongodb保存markdown数据,node.js中mongoose模块提供了对mongodb的支持
  2. 添加用户认证(当然为了保证暂时只能我自己用)
  3. 设计REST-ful接口,并且创建node commandline工具,让我可以用Textmate/MacVim直接写博客。

 

请看两个代码文件一个是java: package com.heytap.rjkf; import android.content.SharedPreferences; import android.os.Build; import android.os.Bundle; import android.os.Handler; import android.speech.tts.TextToSpeech; import android.speech.tts.UtteranceProgressListener; import android.util.Log; import android.view.View; import android.webkit.JavascriptInterface; import android.webkit.WebSettings; import android.webkit.WebView; import android.webkit.WebViewClient; import android.widget.Button; import android.widget.Toast; import androidx.appcompat.app.AppCompatActivity; import java.io.InputStream; import java.nio.charset.StandardCharsets; import java.util.ArrayList; import java.util.HashMap; import java.util.List; import java.util.Map; import java.util.UUID; import java.util.regex.Pattern; import org.json.JSONArray; import org.json.JSONObject; public class MainActivity extends AppCompatActivity { private static final String TAG = "MainActivity"; private WebView mWebView; private TextToSpeech tts; private boolean isSpeaking = false; private boolean isPaused = false; private int currentSentenceIndex = 0; private int currentCharIndex = 0; private String[] sentences; private String fullOriginalText; private List<NodeInfo> nodeInfoList = new ArrayList<>(); private final Handler handler = new Handler(); private SharedPreferences sharedPreferences; private boolean isTTSInitialized = false; private Map<String, String> jsCache = new HashMap<>(); private int[][] sentencePositions; // 在类顶部声明 @Override protected void onCreate(Bundle savedInstanceState) { super.onCreate(savedInstanceState); setContentView(R.layout.activity_main); sharedPreferences = getSharedPreferences("TTSPreferences", MODE_PRIVATE); loadProgress(); initTextToSpeech(); setupWebView(); loadWebContent("file:///android_asset/index.html"); setupButtons(); } private void setupWebView() { mWebView = findViewById(R.id.mWebView); WebSettings settings = mWebView.getSettings(); settings.setJavaScriptEnabled(true); settings.setDomStorageEnabled(true); settings.setAllowFileAccess(true); settings.setAllowContentAccess(true); if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.JELLY_BEAN) { settings.setAllowUniversalAccessFromFileURLs(true); } mWebView.addJavascriptInterface(new TextNodeInterface(), "TextNodeInterface"); mWebView.setWebViewClient(new WebViewClient() { @Override public void onPageFinished(WebView view, String url) { // 延迟采集文本节点 handler.postDelayed(() -> collectTextNodes(), 1000); } }); } private void collectTextNodes() { String jsCode = loadJsFromAssets("collect_text_nodes.js"); if (!jsCode.isEmpty()) { mWebView.evaluateJavascript(jsCode, null); } } /** * 从 assets 加载 JS 文件,并缓存结果 * @param fileName assets 目录下的 JS 文件名,如 "collect_text_nodes.js" * @return JS 脚本内容,失败时返回空字符串 */ private String loadJsFromAssets(String fileName) { // 1. 先查缓存 if (jsCache.containsKey(fileName)) { Log.d(TAG, "✅ 使用缓存 JS: " + fileName); return jsCache.get(fileName); } Log.d(TAG, "🆕 首次加载 JS: " + fileName); // 2. 缓存没有,就读取文件 try { InputStream is = getAssets().open(fileName); byte[] buffer = new byte[is.available()]; is.read(buffer); is.close(); String jsCode = new String(buffer, StandardCharsets.UTF_8); // 3. 存入缓存,下次直接用 jsCache.put(fileName, jsCode); return jsCode; } catch (Exception e) { Log.e(TAG, "加载JS失败: " + fileName, e); Toast.makeText(this, "脚本加载失败", Toast.LENGTH_SHORT).show(); return ""; // 失败返回空字符串 } } private void splitSentences(String rawText) { String filteredText = rawText.replaceAll("[\\r\\n]+", "\n").trim(); List<String> sentenceList = new ArrayList<>(); List<int[]> positionList = new ArrayList<>(); // 存储 [start, end] String[] paragraphs = filteredText.split("\\n\\s*\\n|\\r\\n\\s*\\r\\n"); Pattern sentenceEnd = Pattern.compile("[。.!??!]"); int globalPos = 0; for (String para : paragraphs) { para = para.trim(); if (para.isEmpty()) { globalPos += 1; // 跳过段落分隔符 continue; } int last = 0; java.util.regex.Matcher matcher = sentenceEnd.matcher(para); while (matcher.find()) { String sentence = para.substring(last, matcher.end()).trim(); if (!sentence.isEmpty()) { int startInFull = globalPos + last; int endInFull = globalPos + matcher.end(); sentenceList.add(sentence); positionList.add(new int[]{startInFull, endInFull}); } last = matcher.end(); } if (last < para.length()) { String remaining = para.substring(last).trim(); if (!remaining.isEmpty()) { int startInFull = globalPos + last; int endInFull = globalPos + para.length(); sentenceList.add(remaining); positionList.add(new int[]{startInFull, endInFull}); } } globalPos += para.length() + 1; // 包括换行符占位 } sentences = sentenceList.toArray(new String[0]); sentencePositions = positionList.toArray(new int[0][]); // 新增字段 Log.d(TAG, "共分割出 " + sentences.length + " 个句子"); } private void highlightInOriginalText(int globalStart, int globalEnd) { Log.d(TAG, "高亮范围: [" + globalStart + ", " + globalEnd + ")"); if (fullOriginalText == null || globalStart >= globalEnd || globalEnd > fullOriginalText.length()) { return; } List<HighlightRange> ranges = findRangesInNodes(globalStart, globalEnd); if (ranges.isEmpty()) { Log.w(TAG, "未找到匹配节点进行高亮"); return; } // 先清除旧高亮 mWebView.evaluateJavascript("removeOldHighlights();", null); // 构批量高亮命令 StringBuilder js = new StringBuilder(); js.append("["); for (int i = 0; i < ranges.size(); i++) { HighlightRange range = ranges.get(i); js.append(String.format("{id:'%s',start:%d,end:%d}", escapeJsString(range.node.id), range.startInNode, range.endInNode )); if (i < ranges.size() - 1) js.append(","); } js.append("].forEach(item => { highlightNodeById(item.id, item.start, item.end); });"); // 添加滚动到高亮位置 js.append("setTimeout(function(){") .append("var h = document.querySelector('mark.highlight');") .append("if(h)h.scrollIntoView({behavior:'smooth',block:'center'});") .append("}, 50);"); mWebView.evaluateJavascript(js.toString(), null); } private List<HighlightRange> findRangesInNodes(int start, int end) { List<HighlightRange> result = new ArrayList<>(); for (NodeInfo node : nodeInfoList) { int ns = node.start; int ne = node.start + node.text.length(); if (end <= ns || start >= ne) continue; int ls = Math.max(0, start - ns); int le = Math.min(node.text.length(), end - ns); if (ls < le) { result.add(new HighlightRange(node, ls, le)); } } return result; } private String escapeJsString(String s) { return s.replace("\\", "\\\\") .replace("'", "\\'") .replace("\"", "\\\"") .replace("\n", "\\n") .replace("\r", "\\r") .replace("\t", "\\t") .replace("</script>", "<\\/script>") // 防止 XSS 注入 .replace("<", "\\u003c"); // 更安全显示 } private void speakFromCurrentPosition() { if (sentences == null || currentSentenceIndex >= sentences.length) { stopSpeaking(); return; } String sentence = sentences[currentSentenceIndex].trim(); if (sentence.isEmpty()) { currentSentenceIndex++; handler.postDelayed(this::speakFromCurrentPosition, 50); return; } // ✅ 直接使用预计算的位置,不再依赖 indexOf int[] range = sentencePositions[currentSentenceIndex]; int highlightStart = range[0] + currentCharIndex; int highlightEnd = range[1]; // 注意:end 是闭区间之后的位置 highlightInOriginalText(highlightStart, highlightEnd); String toSpeak = sentence.substring(currentCharIndex); Bundle params = new Bundle(); String utteranceId = UUID.randomUUID().toString(); params.putCharSequence(TextToSpeech.Engine.KEY_PARAM_UTTERANCE_ID, utteranceId); int status = tts.speak(toSpeak, TextToSpeech.QUEUE_FLUSH, params, utteranceId); if (status == TextToSpeech.ERROR) { Log.e(TAG, "TTS 播放错误"); } } private void setupButtons() { Button testBtn = findViewById(R.id.testCacheBtn); testBtn.setOnClickListener(v -> collectTextNodes()); Button speakButton = findViewById(R.id.speakButton); speakButton.setOnClickListener(v -> { if (isPaused && currentSentenceIndex < sentences.length) { isPaused = false; isSpeaking = true; speakFromCurrentPosition(); } else { startSpeaking(); } }); Button pauseButton = findViewById(R.id.pauseButton); pauseButton.setOnClickListener(v -> { if (isSpeaking) { tts.stop(); isSpeaking = false; isPaused = true; } }); Button stopButton = findViewById(R.id.stopButton); stopButton.setOnClickListener(v -> stopSpeaking()); } private void startSpeaking() { if (sentences == null || sentences.length == 0) { Toast.makeText(this, "尚未加载文本内容", Toast.LENGTH_SHORT).show(); return; } isSpeaking = true; isPaused = false; currentCharIndex = 0; speakFromCurrentPosition(); } private void stopSpeaking() { if (tts != null) { tts.stop(); } isSpeaking = false; isPaused = false; currentCharIndex = 0; saveProgress(); removeHighlight(); } private void removeHighlight() { mWebView.evaluateJavascript("(function(){" + "var ms=document.querySelectorAll('mark.highlight');" + "ms.forEach(m=>{m.parentNode.replaceChild(document.createTextNode(m.textContent),m);});" + "})()", null); } private void saveProgress() { SharedPreferences.Editor editor = sharedPreferences.edit(); editor.putInt("currentSentenceIndex", currentSentenceIndex); editor.putInt("currentCharIndex", currentCharIndex); editor.apply(); } private void loadProgress() { currentSentenceIndex = sharedPreferences.getInt("currentSentenceIndex", 0); currentCharIndex = sharedPreferences.getInt("currentCharIndex", 0); } private void initTextToSpeech() { tts = new TextToSpeech(this, status -> { if (status == TextToSpeech.SUCCESS) { isTTSInitialized = true; setUtteranceProgressListener(); } else { Toast.makeText(this, "TTS初始化失败", Toast.LENGTH_SHORT).show(); } }); } private void setUtteranceProgressListener() { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) { tts.setOnUtteranceProgressListener(new UtteranceProgressListener() { @Override public void onStart(String utteranceId) {} @Override public void onDone(String utteranceId) { handler.post(() -> { currentSentenceIndex++; currentCharIndex = 0; if (currentSentenceIndex < sentences.length) { speakFromCurrentPosition(); } else { isSpeaking = false; saveProgress(); Toast.makeText(MainActivity.this, "朗读完成", Toast.LENGTH_SHORT).show(); } }); } @Override public void onError(String utteranceId) {} }); } } @Override protected void onDestroy() { if (tts != null) { tts.stop(); tts.shutdown(); } super.onDestroy(); } // --- 数据类 --- public static class NodeInfo { String text; int start; String id; // 新增字段 NodeInfo(String text, int start, String id) { this.text = text; this.start = start; this.id = id; } } public static class HighlightRange { NodeInfo node; int startInNode, endInNode; HighlightRange(NodeInfo node, int start, int end) { this.node = node; this.startInNode = start; this.endInNode = end; } } // --- JS 接口 --- public class TextNodeInterface { @JavascriptInterface public void onTextNodesCollected(String fullText, String nodeInfoJson) { runOnUiThread(() -> { if (fullText == null || fullText.trim().isEmpty()) { Toast.makeText(MainActivity.this, "未提取到有效文本", Toast.LENGTH_SHORT).show(); return; } fullOriginalText = fullText; nodeInfoList.clear(); try { JSONArray arr = new JSONArray(nodeInfoJson); for (int i = 0; i < arr.length(); i++) { JSONObject obj = arr.getJSONObject(i); String text = obj.getString("text"); int start = obj.getInt("start"); String id = obj.optString("id", ""); // 新增:读取ID,如果没有就是"" nodeInfoList.add(new NodeInfo(text, start, id)); // 构造时传id } Log.d(TAG, "✅ 成功解析 " + nodeInfoList.size() + " 个文本节点"); } catch (Exception e) { Log.e(TAG, "解析 nodeInfo 失败", e); Toast.makeText(MainActivity.this, "结构解析失败", Toast.LENGTH_SHORT).show(); return; } splitSentences(fullOriginalText); }); } } private void loadWebContent(String url) { mWebView.loadUrl(url); } @Override public void onBackPressed() { if (mWebView.canGoBack()) { mWebView.goBack(); } else { super.onBackPressed(); } }} 一个是js: (function() { var textNodes = []; var fullText = ''; var nodeIdCounter = 0; // 计数器生成唯一ID function traverse(node) { if (node.nodeType === Node.TEXT_NODE) { var text = node.textContent; // 只采集有意义的非空文本 if (text && text.trim().length > 0) { var id = "textnode_" + (nodeIdCounter++); // 生成唯一ID textNodes.push({ text: text, start: fullText.length, id: id // 传给 Java 的关键字段 }); fullText += text; // 给父元素打标记,方便后续定位这个文本节点 if (!node.parentNode.dataset.nodeIds) { node.parentNode.dataset.nodeIds = id; } else { node.parentNode.dataset.nodeIds += "," + id; } } } else if (node.nodeType === Node.ELEMENT_NODE) { var tag = node.tagName.toLowerCase(); if (tag !== 'script' && tag !== 'style' && tag !== 'noscript') { for (var i = 0; i < node.childNodes.length; i++) { traverse(node.childNodes[i]); } } } } // 尝试找主内容区域 var containerSelectors = [ '#content', '.content', '#main', '.main', '.article', '.post-body', '[role="main"]', 'main', '.entry-content' ]; var container = null; for (var i = 0; i < containerSelectors.length; i++) { var el = document.querySelector(containerSelectors[i]); if (el) { container = el; break; } } if (!container) container = document.body; traverse(container); // 处理同源 iframe var iframes = document.getElementsByTagName('iframe'); for (var i = 0; i < iframes.length; i++) { try { var iframeDoc = iframes[i].contentDocument || iframes[i].contentWindow.document; if (iframeDoc && iframeDoc.body) { traverse(iframeDoc.body); } } catch (e) { console.warn("无法访问 iframe (跨域?):", iframes[i].src); } } // 注入高亮函数到全局 if (typeof window.highlightNodeById !== 'function') { window.highlightNodeById = function(id, localStart, localEnd) { // 找到带有此 id 的父元素 var candidates = document.querySelectorAll('[data-node-ids]'); for (var i = 0; i < candidates.length; i++) { var el = candidates[i]; if (el.dataset.nodeIds.split(',').includes(id)) { // 在该元素的所有子节点中找对应的文本节点 for (var j = 0; j < el.childNodes.length; j++) { var child = el.childNodes[j]; if (child.nodeType === Node.TEXT_NODE) { // 注意:这里假设 text 是原始提取的那个值 // 实际上可以缓存 map,但简单起见我们只匹配位置 wrapRangeInMark(child, localStart, localEnd); return true; } } } } return false; }; // 包装某段文本为 <mark> function wrapRangeInMark(textNode, start, end) { var parent = textNode.parentNode; var text = textNode.textContent; var before = text.substring(0, start); var middle = text.substring(start, end); var after = text.substring(end); var fragment = document.createDocumentFragment(); fragment.appendChild(document.createTextNode(before)); var mark = document.createElement('mark'); mark.className = 'highlight tts-highlight'; mark.style.backgroundColor = 'yellow'; mark.style.color = 'black'; mark.style.padding = '2px'; mark.textContent = middle; fragment.appendChild(mark); fragment.appendChild(document.createTextNode(after)); parent.replaceChild(fragment, textNode); parent.normalize(); // 合并相邻文本节点 } // 清除旧高亮 window.removeOldHighlights = function() { var marks = document.querySelectorAll('mark.highlight'); marks.forEach(function(mark) { var parent = mark.parentNode; parent.replaceChild(document.createTextNode(mark.textContent), mark); parent.normalize(); }); }; } // 回调 Java try { if (typeof TextNodeInterface !== 'undefined') { TextNodeInterface.onTextNodesCollected(fullText, JSON.stringify(textNodes)); } else { console.error("❌ 未注册 TextNodeInterface,请检查 addJavascriptInterface"); } } catch (e) { console.error("❌ 回传数据失败:", e); } console.log("✅ collect_text_nodes.js 执行完成,文本节点数:", textNodes.length, ", 总长度:", fullText.length); })(); 这是你帮我修改的,请详细解释下每个功能和用途
10-15
我已照着你说的修改,现在你继续来看下代码,是不是照着你说的完美改动了一下是代码:java类:package com.heffgh.rbgh; import android.content.SharedPreferences; import android.os.Build; import android.os.Bundle; import android.os.Handler; import android.speech.tts.TextToSpeech; import android.speech.tts.UtteranceProgressListener; import android.util.Log; import android.view.View; import android.webkit.JavascriptInterface; import android.webkit.WebSettings; import android.webkit.WebView; import android.webkit.WebViewClient; import android.widget.Button; import android.widget.Toast; import androidx.appcompat.app.AppCompatActivity; import java.io.InputStream; import java.nio.charset.StandardCharsets; import java.util.ArrayList; import java.util.HashMap; import java.util.List; import java.util.Map; import java.util.UUID; import java.util.regex.Pattern; import org.json.JSONArray; import org.json.JSONObject; public class MainActivity extends AppCompatActivity { private static final String TAG = "MainActivity"; private WebView mWebView; private TextToSpeech tts; private boolean isSpeaking = false; private boolean isPaused = false; private int currentSentenceIndex = 0; private int currentCharIndex = 0; private String[] sentences; private String fullOriginalText; private List<NodeInfo> nodeInfoList = new ArrayList<>(); private final Handler handler = new Handler(); private SharedPreferences sharedPreferences; private boolean isTTSInitialized = false; private Map<String, String> jsCache = new HashMap<>(); private int[][] sentencePositions; // 在类顶部声明 @Override protected void onCreate(Bundle savedInstanceState) { super.onCreate(savedInstanceState); setContentView(R.layout.activity_main); sharedPreferences = getSharedPreferences("TTSPreferences", MODE_PRIVATE); loadProgress(); initTextToSpeech(); setupWebView(); loadWebContent("file:///android_asset/index.html"); setupButtons(); } private void setupWebView() { mWebView = findViewById(R.id.mWebView); WebSettings settings = mWebView.getSettings(); settings.setJavaScriptEnabled(true); settings.setDomStorageEnabled(true); settings.setAllowFileAccess(true); settings.setAllowContentAccess(true); if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.JELLY_BEAN) { settings.setAllowUniversalAccessFromFileURLs(true); } mWebView.addJavascriptInterface(new TextNodeInterface(), "TextNodeInterface"); mWebView.setWebViewClient(new WebViewClient() { @Override public void onPageFinished(WebView view, String url) { // 延迟采集文本节点 handler.postDelayed(() -> collectTextNodes(), 1000); } }); } private void injectHighlightHelper() { String helperJs = "(function() {" + "if (window.createHighlightAtNodeByText) return;" + // 防止重复注入 "window.createHighlightAtNodeByText = function(targetText, localStart, localEnd) {" + " var walker = document.createTreeWalker(document.body, NodeFilter.SHOW_TEXT, null);" + " var node;" + " while (node = walker.nextNode()) {" + " if (node.nodeType !== Node.TEXT_NODE || !node.parentNode || node.parentNode.nodeType === Node.ELEMENT_NODE && ['SCRIPT','STYLE'].includes(node.parentNode.tagName)) continue;" + " if (node.textContent.includes(targetText)) {" + " var text = node.textContent;" + " var before = text.substring(0, localStart);" + " var middle = text.substring(localStart, localEnd);" + " var after = text.substring(localEnd);" + " var parent = node.parentNode;" + " parent.innerHTML = '';" + " parent.appendChild(document.createTextNode(before));" + " var mark = document.createElement('mark');" + " mark.className = 'highlight';" + " mark.style.backgroundColor = 'yellow';" + " mark.style.color = 'black';" + " mark.style.padding = '2px';" + // 或者添加类名以便 CSS 控制 " mark.classList.add('tts-highlight');"+ " mark.textContent = middle;" + " parent.appendChild(mark);" + " parent.appendChild(document.createTextNode(after));" + " return;" + " }" + " }" + "};" + "})();"; mWebView.evaluateJavascript(helperJs, null); } private void collectTextNodes() { String jsCode = loadJsFromAssets("collect_text_nodes.js"); if (!jsCode.isEmpty()) { mWebView.evaluateJavascript(jsCode, null); } } /** * 从 assets 加载 JS 文件,并缓存结果 * @param fileName assets 目录下的 JS 文件名,如 "collect_text_nodes.js" * @return JS 脚本内容,失败时返回空字符串 */ private String loadJsFromAssets(String fileName) { // 1. 先查缓存 if (jsCache.containsKey(fileName)) { Log.d(TAG, "✅ 使用缓存 JS: " + fileName); return jsCache.get(fileName); } Log.d(TAG, "🆕 首次加载 JS: " + fileName); // 2. 缓存没有,就读取文件 try { InputStream is = getAssets().open(fileName); byte[] buffer = new byte[is.available()]; is.read(buffer); is.close(); String jsCode = new String(buffer, StandardCharsets.UTF_8); // 3. 存入缓存,下次直接用 jsCache.put(fileName, jsCode); return jsCode; } catch (Exception e) { Log.e(TAG, "加载JS失败: " + fileName, e); Toast.makeText(this, "脚本加载失败", Toast.LENGTH_SHORT).show(); return ""; // 失败返回空字符串 } } private void splitSentences(String rawText) { String filteredText = rawText.replaceAll("[\\r\\n]+", "\n").trim(); List<String> sentenceList = new ArrayList<>(); List<int[]> positionList = new ArrayList<>(); // 存储 [start, end] String[] paragraphs = filteredText.split("\\n\\s*\\n|\\r\\n\\s*\\r\\n"); Pattern sentenceEnd = Pattern.compile("[。.!??!]"); int globalPos = 0; for (String para : paragraphs) { para = para.trim(); if (para.isEmpty()) { globalPos += 1; // 跳过段落分隔符 continue; } int last = 0; java.util.regex.Matcher matcher = sentenceEnd.matcher(para); while (matcher.find()) { String sentence = para.substring(last, matcher.end()).trim(); if (!sentence.isEmpty()) { int startInFull = globalPos + last; int endInFull = globalPos + matcher.end(); sentenceList.add(sentence); positionList.add(new int[]{startInFull, endInFull}); } last = matcher.end(); } if (last < para.length()) { String remaining = para.substring(last).trim(); if (!remaining.isEmpty()) { int startInFull = globalPos + last; int endInFull = globalPos + para.length(); sentenceList.add(remaining); positionList.add(new int[]{startInFull, endInFull}); } } globalPos += para.length() + 1; // 包括换行符占位 } sentences = sentenceList.toArray(new String[0]); sentencePositions = positionList.toArray(new int[0][]); // 新增字段 Log.d(TAG, "共分割出 " + sentences.length + " 个句子"); } private void highlightInOriginalText(int globalStart, int globalEnd) { Log.d(TAG, "高亮范围: [" + globalStart + ", " + globalEnd + ")"); if (fullOriginalText == null || globalStart >= globalEnd || globalEnd > fullOriginalText.length()) { return; } List<HighlightRange> ranges = findRangesInNodes(globalStart, globalEnd); if (ranges.isEmpty()) { Log.w(TAG, "未找到匹配节点进行高亮"); return; } // 先清除旧高亮 mWebView.evaluateJavascript("removeOldHighlights();", null); // 构批量高亮命令 StringBuilder js = new StringBuilder(); js.append("["); for (int i = 0; i < ranges.size(); i++) { HighlightRange range = ranges.get(i); js.append(String.format("{id:'%s',start:%d,end:%d}", escapeJsString(range.node.id), range.startInNode, range.endInNode )); if (i < ranges.size() - 1) js.append(","); } js.append("].forEach(item => { highlightNodeById(item.id, item.start, item.end); });"); // 添加滚动到高亮位置 js.append("setTimeout(function(){") .append("var h = document.querySelector('mark.highlight');") .append("if(h)h.scrollIntoView({behavior:'smooth',block:'center'});") .append("}, 50);"); mWebView.evaluateJavascript(js.toString(), null); } private List<HighlightRange> findRangesInNodes(int start, int end) { List<HighlightRange> result = new ArrayList<>(); for (NodeInfo node : nodeInfoList) { int ns = node.start; int ne = node.start + node.text.length(); if (end <= ns || start >= ne) continue; int ls = Math.max(0, start - ns); int le = Math.min(node.text.length(), end - ns); if (ls < le) { result.add(new HighlightRange(node, ls, le)); } } return result; } private String escapeJsString(String s) { return s.replace("\\", "\\\\") .replace("'", "\\'") .replace("\"", "\\\"") .replace("\n", "\\n") .replace("\r", "\\r") .replace("\t", "\\t") .replace("</script>", "<\\/script>") // 防止 XSS 注入 .replace("<", "\\u003c"); // 更安全显示 } private void speakFromCurrentPosition() { if (sentences == null || currentSentenceIndex >= sentences.length) { stopSpeaking(); return; } String sentence = sentences[currentSentenceIndex].trim(); if (sentence.isEmpty()) { currentSentenceIndex++; handler.postDelayed(this::speakFromCurrentPosition, 50); return; } // ✅ 直接使用预计算的位置,不再依赖 indexOf int[] range = sentencePositions[currentSentenceIndex]; int highlightStart = range[0] + currentCharIndex; int highlightEnd = range[1]; // 注意:end 是闭区间之后的位置 highlightInOriginalText(highlightStart, highlightEnd); String toSpeak = sentence.substring(currentCharIndex); Bundle params = new Bundle(); String utteranceId = UUID.randomUUID().toString(); params.putCharSequence(TextToSpeech.Engine.KEY_PARAM_UTTERANCE_ID, utteranceId); int status = tts.speak(toSpeak, TextToSpeech.QUEUE_FLUSH, params, utteranceId); if (status == TextToSpeech.ERROR) { Log.e(TAG, "TTS 播放错误"); } } private void setupButtons() { Button speakButton = findViewById(R.id.speakButton); speakButton.setOnClickListener(v -> { if (isPaused && currentSentenceIndex < sentences.length) { isPaused = false; isSpeaking = true; speakFromCurrentPosition(); } else { startSpeaking(); } }); Button pauseButton = findViewById(R.id.pauseButton); pauseButton.setOnClickListener(v -> { if (isSpeaking) { tts.stop(); isSpeaking = false; isPaused = true; } }); Button stopButton = findViewById(R.id.stopButton); stopButton.setOnClickListener(v -> stopSpeaking()); } private void startSpeaking() { if (sentences == null || sentences.length == 0) { Toast.makeText(this, "尚未加载文本内容", Toast.LENGTH_SHORT).show(); return; } isSpeaking = true; isPaused = false; currentCharIndex = 0; speakFromCurrentPosition(); } private void stopSpeaking() { if (tts != null) { tts.stop(); } isSpeaking = false; isPaused = false; currentCharIndex = 0; saveProgress(); removeHighlight(); } private void removeHighlight() { mWebView.evaluateJavascript("(function(){" + "var ms=document.querySelectorAll('mark.highlight');" + "ms.forEach(m=>{m.parentNode.replaceChild(document.createTextNode(m.textContent),m);});" + "})()", null); } private void saveProgress() { SharedPreferences.Editor editor = sharedPreferences.edit(); editor.putInt("currentSentenceIndex", currentSentenceIndex); editor.putInt("currentCharIndex", currentCharIndex); editor.apply(); } private void loadProgress() { currentSentenceIndex = sharedPreferences.getInt("currentSentenceIndex", 0); currentCharIndex = sharedPreferences.getInt("currentCharIndex", 0); } private void initTextToSpeech() { tts = new TextToSpeech(this, status -> { if (status == TextToSpeech.SUCCESS) { isTTSInitialized = true; setUtteranceProgressListener(); } else { Toast.makeText(this, "TTS初始化失败", Toast.LENGTH_SHORT).show(); } }); } private void setUtteranceProgressListener() { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) { tts.setOnUtteranceProgressListener(new UtteranceProgressListener() { @Override public void onStart(String utteranceId) {} @Override public void onDone(String utteranceId) { handler.post(() -> { currentSentenceIndex++; currentCharIndex = 0; if (currentSentenceIndex < sentences.length) { speakFromCurrentPosition(); } else { isSpeaking = false; saveProgress(); Toast.makeText(MainActivity.this, "朗读完成", Toast.LENGTH_SHORT).show(); } }); } @Override public void onError(String utteranceId) {} }); } } @Override protected void onDestroy() { if (tts != null) { tts.stop(); tts.shutdown(); } super.onDestroy(); } // --- 数据类 --- public static class NodeInfo { String text; int start; String id; // 新增字段 NodeInfo(String text, int start, String id) { this.text = text; this.start = start; this.id = id; } } public static class HighlightRange { NodeInfo node; int startInNode, endInNode; HighlightRange(NodeInfo node, int start, int end) { this.node = node; this.startInNode = start; this.endInNode = end; } } // --- JS 接口 --- public class TextNodeInterface { @JavascriptInterface public void onTextNodesCollected(String fullText, String nodeInfoJson) { runOnUiThread(() -> { if (fullText == null || fullText.trim().isEmpty()) { Toast.makeText(MainActivity.this, "未提取到有效文本", Toast.LENGTH_SHORT).show(); return; } fullOriginalText = fullText; nodeInfoList.clear(); try { JSONArray arr = new JSONArray(nodeInfoJson); for (int i = 0; i < arr.length(); i++) { JSONObject obj = arr.getJSONObject(i); String text = obj.getString("text"); int start = obj.getInt("start"); String id = obj.optString("id", ""); // 新增:读取ID,如果没有就是"" nodeInfoList.add(new NodeInfo(text, start, id)); Construct and pass id } Log.d(TAG, "✅ Successfully parsed " + nodeInfoList.size() + " text nodes"); } catch (Exception e) { Log.e(TAG, "Failed to parse nodeInfo", e); Toast.makeText(MainActivity.this, "Structure parsing failed", Toast.LENGTH_SHORT).show(); return; } splitSentences(fullOriginalText); }); } } private void loadWebContent(String url) { mWebView.loadUrl(url); } @Override public void onBackPressed() { if (mWebView.canGoBack()) { mWebView.goBack(); } else { super.onBackPressed(); } }} 和下面这个 js 代码:// collect_text_nodes.js (function() { var textNodes = []; var fullText = ''; var nodeIdCounter = 0; // 计数器生成唯一ID function traverse(node) { if (node.nodeType === Node.TEXT_NODE) { var text = node.textContent; // 只采集有意义的非空文本 if (text && text.trim().length > 0) { var id = "textnode_" + (nodeIdCounter++); // 生成唯一ID textNodes.push({ text: text, start: fullText.length, id: id // 传给 Java 的关键字段 }); fullText += text; // 给父元素打标记,方便后续定位这个文本节点 if (!node.parentNode.dataset.nodeIds) { node.parentNode.dataset.nodeIds = id; } else { node.parentNode.dataset.nodeIds += "," + id; } } } else if (node.nodeType === Node.ELEMENT_NODE) { var tag = node.tagName.toLowerCase(); if (tag !== 'script' && tag !== 'style' && tag !== 'noscript') { for (var i = 0; i < node.childNodes.length; i++) { traverse(node.childNodes[i]); } } } } // 尝试找主内容区域 var containerSelectors = [ '#content', '.content', '#main', '.main', '.article', '.post-body', '[role="main"]', 'main', '.entry-content' ]; var container = null; for (var i = 0; i < containerSelectors.length; i++) { var el = document.querySelector(containerSelectors[i]); if (el) { container = el; break; } } if (!container) container = document.body; traverse(container); // 处理同源 iframe var iframes = document.getElementsByTagName('iframe'); for (var i = 0; i < iframes.length; i++) { try { var iframeDoc = iframes[i].contentDocument || iframes[i].contentWindow.document; if (iframeDoc && iframeDoc.body) { traverse(iframeDoc.body); } } catch (e) { console.warn("无法访问 iframe (跨域?):", iframes[i].src); } } // 注入高亮函数到全局 if (typeof window.highlightNodeById !== 'function') { window.highlightNodeById = function(id, localStart, localEnd) { // 找到带有此 id 的父元素 var candidates = document.querySelectorAll('[data-node-ids]'); for (var i = 0; i < candidates.length; i++) { var el = candidates[i]; if (el.dataset.nodeIds.split(',').includes(id)) { // 在该元素的所有子节点中找对应的文本节点 for (var j = 0; j < el.childNodes.length; j++) { var child = el.childNodes[j]; if (child.nodeType === Node.TEXT_NODE) { // 注意:这里假设 text 是原始提取的那个值 // 实际上可以缓存 map,但简单起见我们只匹配位置 wrapRangeInMark(child, localStart, localEnd); return true; } } } } return false; }; // 包装某段文本为 <mark> function wrapRangeInMark(textNode, start, end) { var parent = textNode.parentNode; var text = textNode.textContent; var before = text.substring(0, start); var middle = text.substring(start, end); var after = text.substring(end); var fragment = document.createDocumentFragment(); fragment.appendChild(document.createTextNode(before)); var mark = document.createElement('mark'); mark.className = 'highlight tts-highlight'; mark.style.backgroundColor = 'yellow'; mark.style.color = 'black'; mark.style.padding = '2px'; mark.textContent = middle; fragment.appendChild(mark); fragment.appendChild(document.createTextNode(after)); parent.replaceChild(fragment, textNode); parent.normalize(); // 合并相邻文本节点 } // 清除旧高亮 window.removeOldHighlights = function() { var marks = document.querySelectorAll('mark.highlight'); marks.forEach(function(mark) { var parent = mark.parentNode; parent.replaceChild(document.createTextNode(mark.textContent), mark); parent.normalize(); }); }; } // 回调 Java try { if (typeof TextNodeInterface !== 'undefined') { TextNodeInterface.onTextNodesCollected(fullText, JSON.stringify(textNodes)); } else { console.error("❌ 未注册 TextNodeInterface,请检查 addJavascriptInterface"); } } catch (e) { console.error("❌ 回传数据失败:", e); } console.log("✅ collect_text_nodes.js 执行完成,文本节点数:", textNodes.length, ", 总长度:", fullText.length); })();
10-15
那你现在看看代码,有没有照着你总结的最终方案修改,然后看还有没有需要优化的议,以下是代码 首先是java代码:package com.hghg.rbbj; import android.content.SharedPreferences; import android.os.Build; import android.os.Bundle; import android.os.Handler; import android.speech.tts.TextToSpeech; import android.speech.tts.UtteranceProgressListener; import android.util.Log; import android.view.View; import android.webkit.JavascriptInterface; import android.webkit.WebSettings; import android.webkit.WebView; import android.webkit.WebViewClient; import android.widget.Button; import android.widget.Toast; import androidx.appcompat.app.AppCompatActivity; import java.io.InputStream; import java.nio.charset.StandardCharsets; import java.util.ArrayList; import java.util.List; import java.util.UUID; import java.util.regex.Pattern; import org.json.JSONArray; import org.json.JSONObject; public class MainActivity extends AppCompatActivity { private static final String TAG = "MainActivity"; private WebView mWebView; private TextToSpeech tts; private boolean isSpeaking = false; private boolean isPaused = false; private int currentSentenceIndex = 0; private int currentCharIndex = 0; private String[] sentences; private String fullOriginalText; private List<NodeInfo> nodeInfoList = new ArrayList<>(); private final Handler handler = new Handler(); private SharedPreferences sharedPreferences; private boolean isTTSInitialized = false; private int[][] sentencePositions; // 在类顶部声明 @Override protected void onCreate(Bundle savedInstanceState) { super.onCreate(savedInstanceState); setContentView(R.layout.activity_main); sharedPreferences = getSharedPreferences("TTSPreferences", MODE_PRIVATE); loadProgress(); initTextToSpeech(); setupWebView(); loadWebContent("file:///android_asset/index.html"); setupButtons(); } private void setupWebView() { mWebView = findViewById(R.id.mWebView); WebSettings settings = mWebView.getSettings(); settings.setJavaScriptEnabled(true); settings.setDomStorageEnabled(true); settings.setAllowFileAccess(true); settings.setAllowContentAccess(true); if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.JELLY_BEAN) { settings.setAllowUniversalAccessFromFileURLs(true); } mWebView.addJavascriptInterface(new TextNodeInterface(), "TextNodeInterface"); mWebView.setWebViewClient(new WebViewClient() { @Override public void onPageFinished(WebView view, String url) { // 注入高亮工具函数 injectHighlightHelper(); // 延迟采集文本节点 handler.postDelayed(() -> collectTextNodes(), 1000); } }); } private void injectHighlightHelper() { String helperJs = "(function() {" + "if (window.createHighlightAtNodeByText) return;" + // 防止重复注入 "window.createHighlightAtNodeByText = function(targetText, localStart, localEnd) {" + " var walker = document.createTreeWalker(document.body, NodeFilter.SHOW_TEXT, null);" + " var node;" + " while (node = walker.nextNode()) {" + " if (node.nodeType !== Node.TEXT_NODE || !node.parentNode || node.parentNode.nodeType === Node.ELEMENT_NODE && ['SCRIPT','STYLE'].includes(node.parentNode.tagName)) continue;" + " if (node.textContent.includes(targetText)) {" + " var text = node.textContent;" + " var before = text.substring(0, localStart);" + " var middle = text.substring(localStart, localEnd);" + " var after = text.substring(localEnd);" + " var parent = node.parentNode;" + " parent.innerHTML = '';" + " parent.appendChild(document.createTextNode(before));" + " var mark = document.createElement('mark');" + " mark.className = 'highlight';" + " mark.style.backgroundColor = 'yellow';" + " mark.style.color = 'black';" + " mark.style.padding = '2px';" + // 或者添加类名以便 CSS 控制 " mark.classList.add('tts-highlight');"+ " mark.textContent = middle;" + " parent.appendChild(mark);" + " parent.appendChild(document.createTextNode(after));" + " return;" + " }" + " }" + "};" + "})();"; mWebView.evaluateJavascript(helperJs, null); } private void collectTextNodes() { loadJsFromAssets("collect_text_nodes.js"); // 确保这个JS存在assets中 } private void loadJsFromAssets(String fileName) { try { InputStream is = getAssets().open(fileName); byte[] buffer = new byte[is.available()]; is.read(buffer); is.close(); String jsCode = new String(buffer, StandardCharsets.UTF_8); mWebView.evaluateJavascript(jsCode, null); } catch (Exception e) { Log.e(TAG, "加载JS失败: " + fileName, e); Toast.makeText(this, "脚本加载失败", Toast.LENGTH_SHORT).show(); } } private void splitSentences(String rawText) { String filteredText = rawText.replaceAll("[\\r\\n]+", "\n").trim(); List<String> sentenceList = new ArrayList<>(); List<int[]> positionList = new ArrayList<>(); // 存储 [start, end] String[] paragraphs = filteredText.split("\\n\\s*\\n|\\r\\n\\s*\\r\\n"); Pattern sentenceEnd = Pattern.compile("[。.!??!]"); int globalPos = 0; for (String para : paragraphs) { para = para.trim(); if (para.isEmpty()) { globalPos += 1; // 跳过段落分隔符 continue; } int last = 0; java.util.regex.Matcher matcher = sentenceEnd.matcher(para); while (matcher.find()) { String sentence = para.substring(last, matcher.end()).trim(); if (!sentence.isEmpty()) { int startInFull = globalPos + last; int endInFull = globalPos + matcher.end(); sentenceList.add(sentence); positionList.add(new int[]{startInFull, endInFull}); } last = matcher.end(); } if (last < para.length()) { String remaining = para.substring(last).trim(); if (!remaining.isEmpty()) { int startInFull = globalPos + last; int endInFull = globalPos + para.length(); sentenceList.add(remaining); positionList.add(new int[]{startInFull, endInFull}); } } globalPos += para.length() + 1; // 包括换行符占位 } sentences = sentenceList.toArray(new String[0]); sentencePositions = positionList.toArray(new int[0][]); // 新增字段 Log.d(TAG, "共分割出 " + sentences.length + " 个句子"); } private void highlightInOriginalText(int globalStart, int globalEnd) { Log.d(TAG, "高亮范围: [" + globalStart + ", " + globalEnd + ")"); if (fullOriginalText == null || globalStart >= globalEnd || globalEnd > fullOriginalText.length()) { return; } List<HighlightRange> ranges = findRangesInNodes(globalStart, globalEnd); if (ranges.isEmpty()) { Log.w(TAG, "未找到匹配节点进行高亮"); return; } StringBuilder js = new StringBuilder(); js.append("(function() {"); js.append(" var oldMarks = document.querySelectorAll('mark.highlight');") .append(" oldMarks.forEach(function(m) {") .append(" var p = m.parentNode;") .append(" p.replaceChild(document.createTextNode(m.textContent), m);") .append(" p.normalize();") .append(" });"); for (HighlightRange range : ranges) { String escapedText = escapeJsString(range.node.text.substring(range.startInNode, range.endInNode)); js.append(String.format( "createHighlightAtNodeByText('%s', %d, %d);", escapedText, range.startInNode, range.endInNode )); } js.append(" setTimeout(function(){") .append(" var h = document.querySelector('mark.highlight');") .append(" if(h)h.scrollIntoView({behavior:'smooth',block:'center'});") .append(" }, 50);"); js.append("})();"); mWebView.evaluateJavascript(js.toString(), null); } private List<HighlightRange> findRangesInNodes(int start, int end) { List<HighlightRange> result = new ArrayList<>(); for (NodeInfo node : nodeInfoList) { int ns = node.start; int ne = node.start + node.text.length(); if (end <= ns || start >= ne) continue; int ls = Math.max(0, start - ns); int le = Math.min(node.text.length(), end - ns); if (ls < le) { result.add(new HighlightRange(node, ls, le)); } } return result; } private String escapeJsString(String s) { return s.replace("\\", "\\\\") .replace("'", "\\'") .replace("\"", "\\\"") .replace("\n", "\\n") .replace("\r", "\\r") .replace("\t", "\\t"); } private void speakFromCurrentPosition() { if (sentences == null || currentSentenceIndex >= sentences.length) { stopSpeaking(); return; } String sentence = sentences[currentSentenceIndex].trim(); if (sentence.isEmpty()) { currentSentenceIndex++; handler.postDelayed(this::speakFromCurrentPosition, 50); return; } // ✅ 直接使用预计算的位置,不再依赖 indexOf int[] range = sentencePositions[currentSentenceIndex]; int highlightStart = range[0] + currentCharIndex; int highlightEnd = range[1]; // 注意:end 是闭区间之后的位置 highlightInOriginalText(highlightStart, highlightEnd); String toSpeak = sentence.substring(currentCharIndex); Bundle params = new Bundle(); String utteranceId = UUID.randomUUID().toString(); params.putCharSequence(TextToSpeech.Engine.KEY_PARAM_UTTERANCE_ID, utteranceId); int status = tts.speak(toSpeak, TextToSpeech.QUEUE_FLUSH, params, utteranceId); if (status == TextToSpeech.ERROR) { Log.e(TAG, "TTS 播放错误"); } } private void setupButtons() { Button speakButton = findViewById(R.id.speakButton); speakButton.setOnClickListener(v -> { if (isPaused && currentSentenceIndex < sentences.length) { isPaused = false; isSpeaking = true; speakFromCurrentPosition(); } else { startSpeaking(); } }); Button pauseButton = findViewById(R.id.pauseButton); pauseButton.setOnClickListener(v -> { if (isSpeaking) { tts.stop(); isSpeaking = false; isPaused = true; } }); Button stopButton = findViewById(R.id.stopButton); stopButton.setOnClickListener(v -> stopSpeaking()); } private void startSpeaking() { if (sentences == null || sentences.length == 0) { Toast.makeText(this, "尚未加载文本内容", Toast.LENGTH_SHORT).show(); return; } isSpeaking = true; isPaused = false; currentCharIndex = 0; speakFromCurrentPosition(); } private void stopSpeaking() { if (tts != null) { tts.stop(); } isSpeaking = false; isPaused = false; currentCharIndex = 0; saveProgress(); removeHighlight(); } private void removeHighlight() { mWebView.evaluateJavascript("(function(){" + "var ms=document.querySelectorAll('mark.highlight');" + "ms.forEach(m=>{m.parentNode.replaceChild(document.createTextNode(m.textContent),m);});" + "})()", null); } private void saveProgress() { SharedPreferences.Editor editor = sharedPreferences.edit(); editor.putInt("currentSentenceIndex", currentSentenceIndex); editor.putInt("currentCharIndex", currentCharIndex); editor.apply(); } private void loadProgress() { currentSentenceIndex = sharedPreferences.getInt("currentSentenceIndex", 0); currentCharIndex = sharedPreferences.getInt("currentCharIndex", 0); } private void initTextToSpeech() { tts = new TextToSpeech(this, status -> { if (status == TextToSpeech.SUCCESS) { isTTSInitialized = true; setUtteranceProgressListener(); } else { Toast.makeText(this, "TTS初始化失败", Toast.LENGTH_SHORT).show(); } }); } private void setUtteranceProgressListener() { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) { tts.setOnUtteranceProgressListener(new UtteranceProgressListener() { @Override public void onStart(String utteranceId) {} @Override public void onDone(String utteranceId) { handler.post(() -> { currentSentenceIndex++; currentCharIndex = 0; if (currentSentenceIndex < sentences.length) { speakFromCurrentPosition(); } else { isSpeaking = false; saveProgress(); Toast.makeText(MainActivity.this, "朗读完成", Toast.LENGTH_SHORT).show(); } }); } @Override public void onError(String utteranceId) {} }); } } @Override protected void onDestroy() { if (tts != null) { tts.stop(); tts.shutdown(); } super.onDestroy(); } // --- 数据类 --- public static class NodeInfo { String text; int start; NodeInfo(String text, int start) { this.text = text; this.start = start; } } public static class HighlightRange { NodeInfo node; int startInNode, endInNode; HighlightRange(NodeInfo node, int start, int end) { this.node = node; this.startInNode = start; this.endInNode = end; } } // --- JS 接口 --- public class TextNodeInterface { @JavascriptInterface public void onTextNodesCollected(String fullText, String nodeInfoJson) { runOnUiThread(() -> { if (fullText == null || fullText.trim().isEmpty()) { Toast.makeText(MainActivity.this, "未提取到有效文本", Toast.LENGTH_SHORT).show(); return; } fullOriginalText = fullText; nodeInfoList.clear(); try { JSONArray arr = new JSONArray(nodeInfoJson); for (int i = 0; i < arr.length(); i++) { JSONObject obj = arr.getJSONObject(i); String text = obj.getString("text"); int start = obj.getInt("start"); nodeInfoList.add(new NodeInfo(text, start)); } Log.d(TAG, "✅ 成功解析 " + nodeInfoList.size() + " 个文本节点"); } catch (Exception e) { Log.e(TAG, "解析 nodeInfo 失败", e); Toast.makeText(MainActivity.this, "结构解析失败", Toast.LENGTH_SHORT).show(); return; } splitSentences(fullOriginalText); }); } } private void loadWebContent(String url) { mWebView.loadUrl(url); } @Override public void onBackPressed() { if (mWebView.canGoBack()) { mWebView.goBack(); } else { super.onBackPressed(); } }} 接着是 js代码:// collect_text_nodes.js (function() { var textNodes = []; var fullText = ''; function traverse(node) { if (node.nodeType === Node.TEXT_NODE) { var text = node.textContent; if (text && text.trim().length > 0) { textNodes.push({ text: text, start: fullText.length }); fullText += text; } } else if (node.nodeType === Node.ELEMENT_NODE) { var tag = node.tagName.toLowerCase(); if (tag !== 'script' && tag !== 'style' && tag !== 'noscript') { for (var i = 0; i < node.childNodes.length; i++) { traverse(node.childNodes[i]); } } } } // 更丰富的主内容区域探测 var containerSelectors = [ '#content', '.content', '#main', '.main', '.article', '.post-body', '[role="main"]', 'main', '.entry-content' ]; var container = null; for (var i = 0; i < containerSelectors.length; i++) { var el = document.querySelector(containerSelectors[i]); if (el) { container = el; break; } } if (!container) container = document.body; traverse(container); // 处理 iframe(仅同源) var iframes = document.getElementsByTagName('iframe'); for (var i = 0; i < iframes.length; i++) { try { var iframeWin = iframes[i].contentWindow; var iframeDoc = iframes[i].contentDocument || iframeWin.document; if (iframeDoc && iframeDoc.body) { traverse(iframeDoc.body); } } catch (e) { console.warn("无法访问 iframe (跨域?):", iframes[i].src); } } // 回调 Java try { if (typeof window.TextNodeInterface !== 'undefined') { window.TextNodeInterface.onTextNodesCollected(fullText, JSON.stringify(textNodes)); } else { console.error("❌ 未注册 TextNodeInterface,请检查 addJavascriptInterface"); } } catch (e) { console.error("❌ 回传数据失败:", e); } console.log("✅ collect_text_nodes.js 执行完成,文本节点数:", textNodes.length, ", 总长度:", fullText.length); })();
最新发布
10-15
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值