文本首發個人博客 - https://blog.cdswyda.com/post/2017120914css
前幾天完成了一個需求,在網頁中完成鼠標指向哪裏,就用語音讀出所指的文本。若是是按鈕、連接、文本輸入框,則還還要給出是什麼的提醒。同時針對大段的文本,不能整段的去讀,要按照標點符號進行斷句處理。html
重點固然就是先獲取到當前標籤上的文本,再把文本轉化成語音便可。node
這個很簡單了,只用根據當前是什麼標籤,給出提示便可。git
// 標籤朗讀文本 var tagTextConfig = { 'a': '連接', 'input[text]': '文本輸入框', 'input[password]': '密碼輸入框', 'button': '按鈕', 'img': '圖片' };
還有須要朗讀的標籤,繼續再添加便可。github
而後根據標籤,返回前綴文本便可。正則表達式
/** * 獲取標籤朗讀文本 * @param {HTMLElement} el 要處理的HTMLElement * @returns {String} 朗讀文本 */ function getTagText(el) { if (!el) return ''; var tagName = el.tagName.toLowerCase(); // 處理input等多屬性元素 switch (tagName) { case 'input': tagName += '[' + el.type + ']'; break; default: break; } // 標籤的功能提醒和做用應該有間隔,所以在最後加入一個空格 return (tagTextConfig[tagName] || '') + ' '; }
獲取完整的朗讀文本就更簡單了,先取標籤的功能提醒,再取標籤的文本便可。api
文本內容優先取 title
其次 alt
最後 innerText
。瀏覽器
/** * 獲取完整朗讀文本 * @param {HTMLElement} el 要處理的HTMLElement * @returns {String} 朗讀文本 */ function getText(el) { if (!el) return ''; return getTagText(el) + (el.title || el.alt || el.innerText || ''); }
這樣就能夠獲取到一個標籤的功能提醒和內容的所有帶朗讀文本了。app
接下來要處理的就是正文分隔了,在這個過程當中,踩了很多坑,走了很多彎路,好好記錄一下。dom
首先準備了正文分隔的配置:
// 正文拆分配置 var splitConfig = { // 內容分段標籤名稱 unitTag: 'p', // 正文中分隔正則表達式 splitReg: /[,;,;。]/g, // 包裹標籤名 wrapTag: 'label', // 包裹標籤類名 wrapCls: 'speak-lable', // 高亮樣式名和樣式 hightlightCls: 'speak-help-hightlight', hightStyle: 'background: #000!important; color: #fff!important' };
最開始想的就是直接按照正文中的分隔標點符號進行分隔就行了呀。
想法以下:
split(分隔正則表達式)
方法將正文按照標點符號分隔成小段然而理想很豐滿,現實很骨感。
兩個大坑以下:
split
方法進行分隔,分隔後分隔字符就丟了,也就是說把原文的一些標點符號給弄丟了。關於第一個問題,丟失標點的符號,考慮過逐個標點來進行和替換 split
分隔方法爲逐個字符循環來作。
前者問題是本來一次完成的工做分紅了屢次,效率過低。第二種感受效率更低了,分隔原本是很稀疏的,可是卻要變成逐個字符出判斷處理,更關鍵的是,分隔標點的位置要插入包裹標籤,會致使字符串長度變化,還要處理下標索引。代碼是機器跑的,或許不會以爲煩,可是我真的以爲好煩。若是這麼幹,或許之後哪一個AI或者同事看到這樣的代碼,說不定會說「這真是個傻xxxx」。
第二個問題想過不少辦法來補救,如先使用正則匹配捕獲內容中成對的標籤,對標籤內部的分隔先處理一遍,而後再處理整個的。
想不明白問題二的,可參考一下待分隔的段落:
<p>這是一段測試文本,這裏有個連接。<a>您好,能夠點擊此處進行跳轉</a>還有其餘內容其餘內容容其餘內容容其餘內容,容其餘內容。</p>
如先使用/<((\w+?)>)(.+?)<\/\2(?=>)/g
正則,依次捕獲段落內被標籤包裹的內容,對標籤內部的內容先處理。
可是問題又來了,這麼處理的都是字符串,在js中都是基本類型,這些操做進行的時候都是在複製的基礎上進行的,要修改到原字符串裏去,還得記錄下本來的開始結束位置,再將新的插進去。繁,仍是繁,可是已經比以前逐個字符去遍歷的好,正則捕獲中原本就有了匹配的索引,直接用便可,還能接受。
可是這只是處理了段落內部標籤的問題,段落內確定還有不少文本是沒有處理呢,怎麼辦?
正則匹配到了只是段落內標籤的結果啊,外面的沒有啊。哦,對,有匹配到的索引,上次匹配到的位置加上上次處理的長度,就是一段直接文本的開始。下一次匹配到的索引-1就是這段直接文本的結束。這只是匹配過程當中的,還有首尾要單獨處理。又回到煩的老路上去了。。。
這麼煩,一個段落分隔能這麼繁瑣,我不信!
忽然想到了,有文本節點這麼個東西,刪繁就簡嘛,正則先到邊上去,直接處理段落的全部節點不就好了。
文本節點則分隔直接包裹,標籤節點則對內容進行包裹,這種狀況下處理的直接是dom,更省事。
文本節點裏放標籤?這是在開玩笑麼,是也不是。文本節點裏確實只能放文本,可是我把標籤直接放進去,它會自動轉義,那最後再替換出來不就好了。
好了,方案終於有了,並且這個方案邏輯多簡單,代碼邏輯天然也不會煩。
/** * 正文內容分段處理 * @param {jQueryObject/HTMLElement/String} $content 要處理的正文jQ對象或HTMLElement或其對應選擇器 */ function splitConent($content) { $content = $($content); $content.find(splitConfig.unitTag).each(function (index, item) { var $item = $(item), text = $.trim($item.text()); if (!text) return; var nodes = $item[0].childNodes; $.each(nodes, function (i, node) { switch (node.nodeType) { case 3: // text 節點 // 因爲是文本節點,標籤被轉義了,後續再轉回來 node.data = '<' + splitConfig.wrapTag + '>' + node.data.replace(splitConfig.splitReg, '</' + splitConfig.wrapTag + '>$&<' + splitConfig.wrapTag + '>') + '</' + splitConfig.wrapTag + '>'; break; case 1: // 元素節點 var innerHtml = node.innerHTML, start = '', end = ''; // 若是內部還有直接標籤,先去掉 var startResult = /^<\w+?>/.exec(innerHtml); if (startResult) { start = startResult[0]; innerHtml = innerHtml.substr(start.length); } var endResult = /<\/\w+?>$/.exec(innerHtml); if (endResult) { end = endResult[0]; innerHtml = innerHtml.substring(0, endResult.index); } // 更新內部內容 node.innerHTML = start + '<' + splitConfig.wrapTag + '>' + innerHtml.replace(splitConfig.splitReg, '</' + splitConfig.wrapTag + '>$&<' + splitConfig.wrapTag + '>') + '</' + splitConfig.wrapTag + '>' + end; break; default: break; } }); // 處理文本節點中被轉義的html標籤 $item[0].innerHTML = $item[0].innerHTML .replace(new RegExp('<' + splitConfig.wrapTag + '>', 'g'), '<' + splitConfig.wrapTag + '>') .replace(new RegExp('</' + splitConfig.wrapTag + '>', 'g'), '</' + splitConfig.wrapTag + '>'); $item.find(splitConfig.wrapTag).addClass(splitConfig.wrapCls); }); }
上面代碼中最後對文本節點中被轉義的包裹標籤替換彷佛有點麻煩,可是沒辦法,ES5以前JavaScript並不支持正則的後行斷言(也就是正則表達式中「後顧」)。因此沒辦法對包裹標籤先後的 <
和 >
進行精準替換,只能連同標籤名一塊兒替換。
在上面完成了文本獲取和段落分隔,下面要作的就是鼠標移動上去時獲取文本觸發朗讀便可,移開時中止朗讀便可。
鼠標移動,只讀一次,基於這兩點緣由,使用 mouseenter
和 mouseleave
事件來完成。
緣由:
/** * 在頁面上寫入高亮樣式 */ function createStyle() { if (document.getElementById('speak-light-style')) return; var style = document.createElement('style'); style.id = 'speak-light-style'; style.innerText = '.' + splitConfig.hightlightCls + '{' + splitConfig.hightStyle + '}'; document.getElementsByTagName('head')[0].appendChild(style); } // 非正文須要朗讀的標籤 逗號分隔 var speakTags = 'a, p, span, h1, h2, h3, h4, h5, h6, img, input, button'; $(document).on('mouseenter.speak-help', speakTags, function (e) { var $target = $(e.target); // 排除段落內的 if ($target.parents('.' + splitConfig.wrapCls).length || $target.find('.' + splitConfig.wrapCls).length) { return; } // 圖片樣式單獨處理 其餘樣式統一處理 if (e.target.nodeName.toLowerCase() === 'img') { $target.css({ border: '2px solid #000' }); } else { $target.addClass(splitConfig.hightlightCls); } // 開始朗讀 speakText(getText(e.target)); }).on('mouseleave.speak-help', speakTags, function (e) { var $target = $(e.target); if ($target.find('.' + splitConfig.wrapCls).length) { return; } // 圖片樣式 if (e.target.nodeName.toLowerCase() === 'img') { $target.css({ border: 'none' }); } else { $target.removeClass(splitConfig.hightlightCls); } // 中止語音 stopSpeak(); }); // 段落內文本朗讀 $(document).on('mouseenter.speak-help', '.' + splitConfig.wrapCls, function (e) { $(this).addClass(splitConfig.hightlightCls); // 開始朗讀 speakText(getText(this)); }).on('mouseleave.speak-help', '.' + splitConfig.wrapCls, function (e) { $(this).removeClass(splitConfig.hightlightCls); // 中止語音 stopSpeak(); });
注意要把針對段落的語音處理和其餘地方的分開。爲何? 由於段落是個塊級元素,鼠標移入段落中的空白時,如:段落先後空白、首行縮進、末行剩餘空白等,是不該該觸發朗讀的,若是不阻止掉,進行這些區域將直接觸發整段文字的朗讀,失去了咱們對段落文本內分隔的意義,並且,不管什麼方式轉化語音都是要時間的,大段內容可能須要較長時間,影響語音輸出的體驗。
上面咱們是直接使用了 speakText(text)
和 stopSpeak()
兩個方法來觸發語音的朗讀和中止。
咱們來看下如何實現這個兩個功能。
其實現代瀏覽器默認已經提供了上面功能:
var speechSU = new window.SpeechSynthesisUtterance(); speechSU.text = '你好,世界!'; window.speechSynthesis.speak(speechSU);
複製到瀏覽器控制檯看看能不能聽到聲音呢?(須要Chrome 33+、Firefox 49+ 或 IE-Edge)
利用一下兩個API便可:
SpeechSynthesisUtterance
用於語音合成
lang
: 語言 Gets and sets the language of the utterance.pitch
: 音高 Gets and sets the pitch at which the utterance will be spoken at.rate
: 語速 Gets and sets the speed at which the utterance will be spoken at.text
: 文本 Gets and sets the text that will be synthesised when the utterance is spoken.voice
: 聲音 Gets and sets the voice that will be used to speak the utterance.volume
: 音量 Gets and sets the volume that the utterance will be spoken at.onboundary
: 單詞或句子邊界觸發,即分隔處觸發 Fired when the spoken utterance reaches a word or sentence boundary.onend
: 結束時觸發 Fired when the utterance has finished being spoken.onerror
: 錯誤時觸發 Fired when an error occurs that prevents the utterance from being succesfully spoken.onmark
: Fired when the spoken utterance reaches a named SSML "mark" tag.onpause
: 暫停時觸發 Fired when the utterance is paused part way through.onresume
: 從新播放時觸發 Fired when a paused utterance is resumed.onstart
: 開始時觸發 Fired when the utterance has begun to be spoken.SpeechSynthesis
: 用於朗讀
paused
: Read only 是否暫停 A Boolean that returns true if the SpeechSynthesis object is in a paused state.pending
: Read only 是否處理中 A Boolean that returns true if the utterance queue contains as-yet-unspoken utterances.speaking
: Read only 是否朗讀中 A Boolean that returns true if an utterance is currently in the process of being spoken — even if SpeechSynthesis is in a paused state.onvoiceschanged
: 聲音變化時觸發cancel()
: 狀況待朗讀隊列 Removes all utterances from the utterance queue.getVoices()
: 獲取瀏覽器支持的語音包列表 Returns a list of SpeechSynthesisVoice objects representing all the available voices on the current device.pause()
: 暫停 Puts the SpeechSynthesis object into a paused state.resume()
: 從新開始 Puts the SpeechSynthesis object into a non-paused state: resumes it if it was already paused.speak()
: 讀合成的語音,參數必須爲SpeechSynthesisUtterance
的實例 Adds an utterance to the utterance queue; it will be spoken when any other utterances queued before it have been spoken.詳細api和說明可參考:
那麼上面的兩個方法能夠寫爲:
var speaker = new window.SpeechSynthesisUtterance(); var speakTimer, stopTimer; // 開始朗讀 function speakText(text) { clearTimeout(speakTimer); window.speechSynthesis.cancel(); speakTimer = setTimeout(function () { speaker.text = text; window.speechSynthesis.speak(speaker); }, 200); } // 中止朗讀 function stopSpeak() { clearTimeout(stopTimer); clearTimeout(speakTimer); stopTimer = setTimeout(function () { window.speechSynthesis.cancel(); }, 20); }
由於語音合成原本是個異步的操做,所以在過程當中進行以上處理。
現代瀏覽器已經內置了這個功能,兩個API接口兼容性以下:
Feature | Chrome | Edge | Firefox (Gecko) | Internet Explorer | Opera | Safari |
---|---|---|---|---|---|---|
(WebKit) Basic | support 33 | (Yes) | 49 (49) | No support | ? | 7 |
若是要兼容其餘瀏覽器或者須要一種完美兼容的解決方案,可能就須要服務端完成了,根據給定文本,返回相應語音便可,百度語音 http://yuyin.baidu.com/docs就提供這樣的服務。