文本首发个人博客 - 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就提供这样的服务。