网页中文本朗读功能开发实现

摘要:
几天前,我们完成了一项要求,即当鼠标指向网页时,我们可以通过语音阅读文本。同时,对于大段文本,不允许阅读整段,但需要根据标点符号断句。然后根据标签返回前缀文本。文本内容采用标题,alt和innerText first/***获取完整的口语文本*@param{HTMLElement}el要处理的HTMLElement*@返回{String}读取文本*/functiongetText{if(!这只是在匹配过程中,第一部分和最后一部分需要单独处理。文本节点被分离并直接包装,标签节点被包装用于内容。在这种情况下,dom被直接处理,这更方便。

 

前几天完成了一个需求,在网页中完成鼠标指向哪里,就用语音读出所指的文本。如果是按钮、链接、文本输入框,则还还要给出是什么的提醒。同时针对大段的文本,不能整段的去读,要按照标点符号进行断句处理。

重点当然就是先获取到当前标签上的文本,再把文本转化成语音即可。

标签朗读

这个很简单了,只用根据当前是什么标签,给出提示即可。

  1. // 标签朗读文本

  2. var tagTextConfig = {

  3.    'a': '链接',

  4.    'input[text]': '文本输入框',

  5.    'input[password]': '密码输入框',

  6.    'button': '按钮',

  7.    'img': '图片'

  8. };

还有需要朗读的标签,继续再添加即可。

然后根据标签,返回前缀文本即可。

  1. /**

  2. * 获取标签朗读文本

  3. * @param {HTMLElement} el 要处理的HTMLElement

  4. * @returns {String}   朗读文本

  5. */

  6. function getTagText(el) {

  7.    if (!el) return'';

  8.    var tagName = el.tagName.toLowerCase();

  9.    // 处理input等多属性元素

  10.    switch (tagName) {

  11.        case'input':

  12.            tagName += '[' + el.type + ']';

  13.            break;

  14.        default:

  15.            break;

  16.    }

  17.    // 标签的功能提醒和作用应该有间隔,因此在最后加入一个空格

  18.    return (tagTextConfig[tagName] || '') + ' ';

  19. }

获取完整的朗读文本就更简单了,先取标签的功能提醒,再取标签的文本即可。

文本内容优先取 title 其次 alt 最后 innerText

  1. /**

  2. * 获取完整朗读文本

  3. * @param {HTMLElement} el 要处理的HTMLElement

  4. * @returns {String}   朗读文本

  5. */

  6. function getText(el) {

  7.    if (!el) return'';

  8.    return getTagText(el) + (el.title || el.alt || el.innerText || '');

  9. }

这样就可以获取到一个标签的功能提醒和内容的全部带朗读文本了。

正文分隔

接下来要处理的就是正文分隔了,在这个过程中,踩了不少坑,走了不少弯路,好好记录一下。

首先准备了正文分隔的配置:

  1. // 正文拆分配置

  2. var splitConfig = {

  3.    // 内容分段标签名称

  4.    unitTag: 'p',

  5.    // 正文中分隔正则表达式

  6.    splitReg: /[,;,;。]/g,

  7.    // 包裹标签名

  8.    wrapTag: 'label',

  9.    // 包裹标签类名

  10.    wrapCls: 'speak-lable',

  11.    // 高亮样式名和样式

  12.    hightlightCls: 'speak-help-hightlight',

  13.    hightStyle: 'background: #000!important; color: #fff!important'

  14. };

最开始想的就是直接按照正文中的分隔标点符号进行分隔就好了呀。

想法如下:

  1. 获取段落全部文本

  2. 使用 split(分隔正则表达式) 方法将正文按照标点符号分隔成小段

  3. 每个小段用标签包裹放回去即可

然而理想很丰满,现实很骨感。

两个大坑如下:

  1. split 方法进行分隔,分隔后分隔字符就丢了,也就是说把原文的一些标点符号给弄丢了。

  2. 如果段落内还存在其他标签,而这个标签内部也正好存在待分隔的标点符号,那包裹分段标签时直接破换了原标签的完整性。

关于第一个问题,丢失标点的符号,考虑过逐个标点来进行和替换 split 分隔方法为逐个字符循环来做。

前者问题是原本一次完成的工作分成了多次,效率太低。第二种感觉效率更低了,分隔本来是很稀疏的,但是却要变成逐个字符出判断处理,更关键的是,分隔标点的位置要插入包裹标签,会导致字符串长度变化,还要处理下标索引。代码是机器跑的,或许不会觉得烦,但是我真的觉得好烦。如果这么干,或许以后哪个AI或者同事看到这样的代码,说不定会说“这真是个傻xxxx”。

第二个问题想过很多办法来补救,如先使用正则匹配捕获内容中成对的标签,对标签内部的分隔先处理一遍,然后再处理整个的。

想不明白问题二的,可参考一下待分隔的段落:

  1. <p>这是一段测试文本,这里有个链接。<a>您好,可以点击此处进行跳转</a>还有其他内容其他内容容其他内容容其他内容,容其他内容。</p>

如先使用 /<((w+?)>)(.+?)</2(?=>)/g 正则,依次捕获段落内被标签包裹的内容,对标签内部的内容先处理。

但是问题又来了,这么处理的都是字符串,在js中都是基本类型,这些操作进行的时候都是在复制的基础上进行的,要修改到原字符串里去,还得记录下原本的开始结束位置,再将新的插进去。繁,还是繁,但是已经比之前逐个字符去遍历的好,正则捕获中本来就有了匹配的索引,直接用即可,还能接受。

但是这只是处理了段落内部标签的问题,段落内肯定还有很多文本是没有处理呢,怎么办?

正则匹配到了只是段落内标签的结果啊,外面的没有啊。哦,对,有匹配到的索引,上次匹配到的位置加上上次处理的长度,就是一段直接文本的开始。下一次匹配到的索引-1就是这段直接文本的结束。这只是匹配过程中的,还有首尾要单独处理。又回到烦的老路上去了。。。

这么烦,一个段落分隔能这么繁琐,我不信!

突然想到了,有文本节点这么个东西,删繁就简嘛,正则先到边上去,直接处理段落的所有节点不就行了。

文本节点则分隔直接包裹,标签节点则对内容进行包裹,这种情况下处理的直接是dom,更省事。

文本节点里放标签?这是在开玩笑么,是也不是。文本节点里确实只能放文本,但是我把标签直接放进去,它会自动转义,那最后再替换出来不就行了。

好了,方案终于有了,而且这个方案逻辑多简单,代码逻辑自然也不会烦。

  1. /**

  2. * 正文内容分段处理

  3. * @param {jQueryObject/HTMLElement/String}  $content 要处理的正文jQ对象或HTMLElement或其对应选择器

  4. */

  5. function splitConent($content) {

  6.    $content = $($content);

  7.    $content.find(splitConfig.unitTag).each(function (index, item) {

  8.        var $item = $(item),

  9.            text = $.trim($item.text());

  10.        if (!text) return;

  11.        var nodes = $item[0].childNodes;

  12.        $.each(nodes, function (i, node) {

  13.            switch (node.nodeType) {

  14.                case3:

  15.                    // text 节点

  16.                    // 由于是文本节点,标签被转义了,后续再转回来

  17.                    node.data = '<' + splitConfig.wrapTag + '>' +

  18.                        node.data.replace(splitConfig.splitReg, '</' + splitConfig.wrapTag + '>$&<' + splitConfig.wrapTag + '>') +

  19.                        '</' + splitConfig.wrapTag + '>';

  20.                    break;

  21.                case1:

  22.                    // 元素节点

  23.                    var innerHtml = node.innerHTML,

  24.                        start = '',

  25.                        end = '';

  26.                    // 如果内部还有直接标签,先去掉

  27.                    var startResult = /^<w+?>/.exec(innerHtml);

  28.                    if (startResult) {

  29.                        start = startResult[0];

  30.                        innerHtml = innerHtml.substr(start.length);

  31.                    }

  32.                    var endResult = /</w+?>$/.exec(innerHtml);

  33.                    if (endResult) {

  34.                        end = endResult[0];

  35.                        innerHtml = innerHtml.substring(0, endResult.index);

  36.                    }

  37.                    // 更新内部内容

  38.                    node.innerHTML = start +

  39.                        '<' + splitConfig.wrapTag + '>' +

  40.                        innerHtml.replace(splitConfig.splitReg, '</' + splitConfig.wrapTag + '>$&<' + splitConfig.wrapTag + '>') +

  41.                        '</' + splitConfig.wrapTag + '>' +

  42.                        end;

  43.                    break;

  44.                default:

  45.                    break;

  46.            }

  47.        });

  48.        // 处理文本节点中被转义的html标签

  49.        $item[0].innerHTML = $item[0].innerHTML

  50.            .replace(newRegExp('&lt;' + splitConfig.wrapTag + '&gt;', 'g'), '<' + splitConfig.wrapTag + '>')

  51.            .replace(newRegExp('&lt;/' + splitConfig.wrapTag + '&gt;', 'g'), '</' + splitConfig.wrapTag + '>');

  52.        $item.find(splitConfig.wrapTag).addClass(splitConfig.wrapCls);

  53.    });

  54. }

上面代码中最后对文本节点中被转义的包裹标签替换似乎有点麻烦,但是没办法,ES5之前JavaScript并不支持正则的后行断言(也就是正则表达式中“后顾”)。所以没办法对包裹标签前后的 &lt;&gt; 进行精准替换,只能连同标签名一起替换。

事件处理

在上面完成了文本获取和段落分隔,下面要做的就是鼠标移动上去时获取文本触发朗读即可,移开时停止朗读即可。

鼠标移动,只读一次,基于这两点原因,使用 mouseentermouseleave 事件来完成。

原因:

  1. 不冒泡,不会触发父元素的再次朗读

  2. 不重复触发,一个元素内移动时不会重复触发。

  1. /**

  2. * 在页面上写入高亮样式

  3. */

  4. function createStyle() {

  5.    if (document.getElementById('speak-light-style')) return;

  6.    var style = document.createElement('style');

  7.    style.id = 'speak-light-style';

  8.    style.innerText = '.' + splitConfig.hightlightCls + '{' + splitConfig.hightStyle + '}';

  9.    document.getElementsByTagName('head')[0].appendChild(style);

  10. }

  11. // 非正文需要朗读的标签 逗号分隔

  12. var speakTags = 'a, p, span, h1, h2, h3, h4, h5, h6, img, input, button';

  13. $(document).on('mouseenter.speak-help', speakTags, function (e) {

  14.    var $target = $(e.target);

  15.    // 排除段落内的

  16.    if ($target.parents('.' + splitConfig.wrapCls).length || $target.find('.' + splitConfig.wrapCls).length) {

  17.        return;

  18.    }

  19.    // 图片样式单独处理 其他样式统一处理

  20.    if (e.target.nodeName.toLowerCase() === 'img') {

  21.        $target.css({

  22.            border: '2px solid #000'

  23.        });

  24.    } else {

  25.        $target.addClass(splitConfig.hightlightCls);

  26.    }

  27.    // 开始朗读

  28.    speakText(getText(e.target));

  29. }).on('mouseleave.speak-help', speakTags, function (e) {

  30.    var $target = $(e.target);

  31.    if ($target.find('.' + splitConfig.wrapCls).length) {

  32.        return;

  33.    }

  34.    // 图片样式

  35.    if (e.target.nodeName.toLowerCase() === 'img') {

  36.        $target.css({

  37.            border: 'none'

  38.        });

  39.    } else {

  40.        $target.removeClass(splitConfig.hightlightCls);

  41.    }

  42.    // 停止语音

  43.    stopSpeak();

  44. });

  45. // 段落内文本朗读

  46. $(document).on('mouseenter.speak-help', '.' + splitConfig.wrapCls, function (e) {

  47.    $(this).addClass(splitConfig.hightlightCls);

  48.    // 开始朗读

  49.    speakText(getText(this));

  50. }).on('mouseleave.speak-help', '.' + splitConfig.wrapCls, function (e) {

  51.    $(this).removeClass(splitConfig.hightlightCls);

  52.    // 停止语音

  53.    stopSpeak();

  54. });

注意要把针对段落的语音处理和其他地方的分开。为什么? 因为段落是个块级元素,鼠标移入段落中的空白时,如:段落前后空白、首行缩进、末行剩余空白等,是不应该触发朗读的,如果不阻止掉,进行这些区域将直接触发整段文字的朗读,失去了我们对段落文本内分隔的意义,而且,无论什么方式转化语音都是要时间的,大段内容可能需要较长时间,影响语音输出的体验。

文本合成语音

上面我们是直接使用了 speakText(text)stopSpeak() 两个方法来触发语音的朗读和停止。

我们来看下如何实现这个两个功能。

其实现代浏览器默认已经提供了上面功能:

  1. var speechSU = new window.SpeechSynthesisUtterance();

  2. speechSU.text = '你好,世界!';

  3. 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和说明可参考:

  • MDN - SpeechSynthesisUtterance

  • MDN - SpeechSynthesis

那么上面的两个方法可以写为:

  1. var speaker = new window.SpeechSynthesisUtterance();

  2. var speakTimer,

  3.    stopTimer;

  4. // 开始朗读

  5. function speakText(text) {

  6.    clearTimeout(speakTimer);

  7.    window.speechSynthesis.cancel();

  8.    speakTimer = setTimeout(function () {

  9.        speaker.text = text;

  10.        window.speechSynthesis.speak(speaker);

  11.    }, 200);

  12. }

  13. // 停止朗读

  14. function stopSpeak() {

  15.    clearTimeout(stopTimer);

  16.    clearTimeout(speakTimer);

  17.    stopTimer = setTimeout(function () {

  18.        window.speechSynthesis.cancel();

  19.    }, 20);

  20. }

因为语音合成本来是个异步的操作,因此在过程中进行以上处理。

现代浏览器已经内置了这个功能,两个API接口兼容性如下:

网页中文本朗读功能开发实现第1张

cdswyda - 网页文本朗读实现 - github如果要兼容其他浏览器或者需要一种完美兼容的解决方案,可能就需要服务端完成了,根据给定文本,返回相应语音即可,百度语音 http://yuyin.baidu.com/docs就提供这样的服务。

  • cdswyda - 网页文本朗读实现 - github(https://github.com/cdswyda/show/tree/master/demo/speaker)

  • cdswyda - 网页文本朗读实现 - demo(https://cdswyda.github.io/show/demo/speaker/)

免责声明:文章转载自《网页中文本朗读功能开发实现》仅用于学习参考。如对内容有疑问,请及时联系本站处理。

上篇小白自制Linux开发板 八. Linux音频驱动配置uplift model学习笔记下篇

宿迁高防,2C2G15M,22元/月;香港BGP,2C5G5M,25元/月 雨云优惠码:MjYwNzM=

随便看看

爱快路由器的一些注意事项硬件配置+多线负载均衡

以下数据仅供参考:注意:磁带载体的数量因使用环境和带宽大小的不同而不同。此外,请注意32位系统的安装。最大内存为4G,最大内存为3G-----硬盘------安装“爱快路由”时对硬盘的最低要求为1G以上。...

《学习opencv》笔记——矩阵和图像操作——cvAnd、cvAndS、cvAvg and cvAvgSdv

矩阵和图像的操作cvAnd函数其结构voidcvAnd;程序实例#include#include#includeintmain{IplImage*src1,*src2,*src3;src1=cvLoadImage;src2=cvLoadImage;src3=cvLoadImage;cvAnd;cvShowImage;cvShowImage;cvShowIma...

【问题】如何批量导出AI文件里内嵌的图片

截止目前为止,新版的AI里面没有直接可以批量导出内嵌图片的选项,手动一个个导出实在太麻烦了。有人说用Phantasm插件可以导出,但新版的找不到对应支持的插件版本,所以这里就不说了。这里介绍一种简单粗暴的方法。...

Java 读取ANSI文件中文乱码问题解决方式[转]

Filefile=newFile(路径);InputStreamin=newjava.io.FileInputStream(文件);BufferedReader读取器=新的BufferedReader(读取);FileInputStreamin=newFileInputStream(文件);byte[]b=新字节[3];内容如下(b);...

001_Three.js中的跨域问题

】当请求的资源和请求脚本不在同一域中时,将发生跨域。有关详细信息,请参见链接。这是一个需要进一步考虑的问题。它是一个装载机。它加载本地资源。为什么要跨域请求?...

微信小程序通过background-image设置背景图片

微信小程序通过背景图像设置背景:仅支持在线图像和base64图像,不支持本地图像;设置base64图像的步骤如下:1.在网站上http://imgbase64.duoshitong.com/将图片转换为base64格式2的文本。在WXSS中使用上述文本:background image:url(“data:image/png;base64,iVBORw0KG...