網頁中文字朗讀功能開發實現分享

依韻宵音發表於2017-12-11

文字首發我的部落格 - https://blog.cdswyda.com/post/2017120914

前幾天完成了一個需求,在網頁中完成滑鼠指向哪裡,就用語音讀出所指的文字。如果是按鈕、連結、文字輸入框,則還還要給出是什麼的提醒。同時針對大段的文字,不能整段的去讀,要按照標點符號進行斷句處理。

重點當然就是先獲取到當前標籤上的文字,再把文字轉化成語音即可。

標籤朗讀

這個很簡單了,只用根據當前是什麼標籤,給出提示即可。

// 標籤朗讀文字
var tagTextConfig = {
    'a': '連結',
    'input[text]': '文字輸入框',
    'input[password]': '密碼輸入框',
    'button': '按鈕',
    'img': '圖片'
};
複製程式碼

還有需要朗讀的標籤,繼續再新增即可。

然後根據標籤,返回字首文字即可。

/**
 * 獲取標籤朗讀文字
 * @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] || '') + ' ';
}
複製程式碼

獲取完整的朗讀文字就更簡單了,先取標籤的功能提醒,再取標籤的文字即可。

文字內容優先取 title 其次 alt 最後 innerText

/**
 * 獲取完整朗讀文字
 * @param {HTMLElement} el 要處理的HTMLElement
 * @returns {String}   朗讀文字
 */
function getText(el) {
    if (!el) return '';

    return getTagText(el) + (el.title || el.alt || el.innerText || '');
}
複製程式碼

這樣就可以獲取到一個標籤的功能提醒和內容的全部帶朗讀文字了。

正文分隔

接下來要處理的就是正文分隔了,在這個過程中,踩了不少坑,走了不少彎路,好好記錄一下。

首先準備了正文分隔的配置:

// 正文拆分配置
var splitConfig = {
    // 內容分段標籤名稱
    unitTag: 'p',
    // 正文中分隔正規表示式
    splitReg: /[,;,;。]/g,
    // 包裹標籤名
    wrapTag: 'label',
    // 包裹標籤類名
    wrapCls: 'speak-lable',
    // 高亮樣式名和樣式
    hightlightCls: 'speak-help-hightlight',
    hightStyle: 'background: #000!important; color: #fff!important'
};
複製程式碼

最開始想的就是直接按照正文中的分隔標點符號進行分隔就好了呀。

想法如下:

  1. 獲取段落全部文字
  2. 使用 split(分隔正規表示式) 方法將正文按照標點符號分隔成小段
  3. 每個小段用標籤包裹放回去即可

然而理想很豐滿,現實很骨感。

兩個大坑如下:

  1. split 方法進行分隔,分隔後分隔字元就丟了,也就是說把原文的一些標點符號給弄丟了。
  2. 如果段落內還存在其他標籤,而這個標籤內部也正好存在待分隔的標點符號,那包裹分段標籤時直接破換了原標籤的完整性。

關於第一個問題,丟失標點的符號,考慮過逐個標點來進行和替換 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('&lt;' + splitConfig.wrapTag + '&gt;', 'g'), '<' + splitConfig.wrapTag + '>')
            .replace(new RegExp('&lt;/' + splitConfig.wrapTag + '&gt;', 'g'), '</' + splitConfig.wrapTag + '>');
        $item.find(splitConfig.wrapTag).addClass(splitConfig.wrapCls);
    });
}
複製程式碼

上面程式碼中最後對文字節點中被轉義的包裹標籤替換似乎有點麻煩,但是沒辦法,ES5之前JavaScript並不支援正則的後行斷言(也就是正規表示式中“後顧”)。所以沒辦法對包裹標籤前後的 &lt;&gt; 進行精準替換,只能連同標籤名一起替換。

事件處理

在上面完成了文字獲取和段落分隔,下面要做的就是滑鼠移動上去時獲取文字觸發朗讀即可,移開時停止朗讀即可。

滑鼠移動,只讀一次,基於這兩點原因,使用 mouseentermouseleave 事件來完成。

原因:

  1. 不冒泡,不會觸發父元素的再次朗讀
  2. 不重複觸發,一個元素內移動時不會重複觸發。
/**
 * 在頁面上寫入高亮樣式
 */
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就提供這樣的服務。

相關文章