/* This Source Code Form is subject to the terms of the Mozilla Public * License, v. 2.0. If a copy of the MPL was not distributed with this file, * You can obtain one at http://mozilla.org/MPL/2.0/. */ "use strict"; const { interfaces: Ci, utils: Cu } = Components; Cu.import("resource://gre/modules/XPCOMUtils.jsm"); XPCOMUtils.defineLazyModuleGetter(this, "LanguageDetector", "resource:///modules/translation/LanguageDetector.jsm"); XPCOMUtils.defineLazyModuleGetter(this, "Services", "resource://gre/modules/Services.jsm"); this.EXPORTED_SYMBOLS = [ "Narrator" ]; // Maximum time into paragraph when pressing "skip previous" will go // to previous paragraph and not the start of current one. const PREV_THRESHOLD = 2000; // All text-related style rules that we should copy over to the highlight node. const kTextStylesRules = ["font-family", "font-kerning", "font-size", "font-size-adjust", "font-stretch", "font-variant", "font-weight", "line-height", "letter-spacing", "text-orientation", "text-transform", "word-spacing"]; function Narrator(win) { this._winRef = Cu.getWeakReference(win); this._inTest = Services.prefs.getBoolPref("narrate.test"); this._speechOptions = {}; this._startTime = 0; this._stopped = false; this.languagePromise = new Promise(resolve => { let detect = () => { win.document.removeEventListener("AboutReaderContentReady", detect); let sampleText = this._doc.getElementById( "moz-reader-content").textContent.substring(0, 60 * 1024); LanguageDetector.detectLanguage(sampleText).then(result => { resolve(result.confident ? result.language : null); }); }; if (win.document.body.classList.contains("loaded")) { detect(); } else { win.document.addEventListener("AboutReaderContentReady", detect); } }); } Narrator.prototype = { get _doc() { return this._winRef.get().document; }, get _win() { return this._winRef.get(); }, get _treeWalker() { if (!this._treeWalkerRef) { let wu = this._win.QueryInterface( Ci.nsIInterfaceRequestor).getInterface(Ci.nsIDOMWindowUtils); let nf = this._win.NodeFilter; let filter = { _matches: new Set(), // We want high-level elements that have non-empty text nodes. // For example, paragraphs. But nested anchors and other elements // are not interesting since their text already appears in their // parent's textContent. acceptNode: function(node) { if (this._matches.has(node.parentNode)) { // Reject sub-trees of accepted nodes. return nf.FILTER_REJECT; } if (!/\S/.test(node.textContent)) { // Reject nodes with no text. return nf.FILTER_REJECT; } let bb = wu.getBoundsWithoutFlushing(node); if (!bb.width || !bb.height) { // Skip non-rendered nodes. We don't reject because a zero-sized // container can still have visible, "overflowed", content. return nf.FILTER_SKIP; } for (let c = node.firstChild; c; c = c.nextSibling) { if (c.nodeType == c.TEXT_NODE && /\S/.test(c.textContent)) { // If node has a non-empty text child accept it. this._matches.add(node); return nf.FILTER_ACCEPT; } } return nf.FILTER_SKIP; } }; this._treeWalkerRef = new WeakMap(); // We can't hold a weak reference on the treewalker, because there // are no other strong references, and it will be GC'ed. Instead, // we rely on the window's lifetime and use it as a weak reference. this._treeWalkerRef.set(this._win, this._doc.createTreeWalker(this._doc.getElementById("container"), nf.SHOW_ELEMENT, filter, false)); } return this._treeWalkerRef.get(this._win); }, get _timeIntoParagraph() { let rv = Date.now() - this._startTime; return rv; }, get speaking() { return this._win.speechSynthesis.speaking || this._win.speechSynthesis.pending; }, _getVoice: function(voiceURI) { if (!this._voiceMap || !this._voiceMap.has(voiceURI)) { this._voiceMap = new Map( this._win.speechSynthesis.getVoices().map(v => [v.voiceURI, v])); } return this._voiceMap.get(voiceURI); }, _isParagraphInView: function(paragraph) { if (!paragraph) { return false; } let bb = paragraph.getBoundingClientRect(); return bb.top >= 0 && bb.top < this._win.innerHeight; }, _sendTestEvent: function(eventType, detail) { let win = this._win; win.dispatchEvent(new win.CustomEvent(eventType, { detail: Cu.cloneInto(detail, win.document) })); }, _speakInner: function() { this._win.speechSynthesis.cancel(); let tw = this._treeWalker; let paragraph = tw.currentNode; if (paragraph == tw.root) { this._sendTestEvent("paragraphsdone", {}); return Promise.resolve(); } let utterance = new this._win.SpeechSynthesisUtterance( paragraph.textContent); utterance.rate = this._speechOptions.rate; if (this._speechOptions.voice) { utterance.voice = this._speechOptions.voice; } else { utterance.lang = this._speechOptions.lang; } this._startTime = Date.now(); let highlighter = new Highlighter(paragraph); if (this._inTest) { let onTestSynthEvent = e => { if (e.detail.type == "boundary") { let args = Object.assign({ utterance }, e.detail.args); let evt = new this._win.SpeechSynthesisEvent(e.detail.type, args); utterance.dispatchEvent(evt); } }; let removeListeners = () => { this._win.removeEventListener("testsynthevent", onTestSynthEvent); }; this._win.addEventListener("testsynthevent", onTestSynthEvent); utterance.addEventListener("end", removeListeners); utterance.addEventListener("error", removeListeners); } return new Promise((resolve, reject) => { utterance.addEventListener("start", () => { paragraph.classList.add("narrating"); let bb = paragraph.getBoundingClientRect(); if (bb.top < 0 || bb.bottom > this._win.innerHeight) { paragraph.scrollIntoView({ behavior: "smooth", block: "start"}); } if (this._inTest) { this._sendTestEvent("paragraphstart", { voice: utterance.chosenVoiceURI, rate: utterance.rate, paragraph: paragraph.textContent, tag: paragraph.localName }); } }); utterance.addEventListener("end", () => { if (!this._win) { // page got unloaded, don't do anything. return; } highlighter.remove(); paragraph.classList.remove("narrating"); this._startTime = 0; if (this._inTest) { this._sendTestEvent("paragraphend", {}); } if (this._stopped) { // User pressed stopped. resolve(); } else { tw.currentNode = tw.nextNode() || tw.root; this._speakInner().then(resolve, reject); } }); utterance.addEventListener("error", () => { reject("speech synthesis failed"); }); utterance.addEventListener("boundary", e => { if (e.name != "word") { // We are only interested in word boundaries for now. return; } // Match non-whitespace. This isn't perfect, but the most universal // solution for now. let reWordBoundary = /\S+/g; // Match the first word from the boundary event offset. reWordBoundary.lastIndex = e.charIndex; let firstIndex = reWordBoundary.exec(paragraph.textContent); if (firstIndex) { highlighter.highlight(firstIndex.index, reWordBoundary.lastIndex); if (this._inTest) { this._sendTestEvent("wordhighlight", { start: firstIndex.index, end: reWordBoundary.lastIndex }); } } }); this._win.speechSynthesis.speak(utterance); }); }, start: function(speechOptions) { this._speechOptions = { rate: speechOptions.rate, voice: this._getVoice(speechOptions.voice) }; this._stopped = false; return this.languagePromise.then(language => { if (!this._speechOptions.voice) { this._speechOptions.lang = language; } let tw = this._treeWalker; if (!this._isParagraphInView(tw.currentNode)) { tw.currentNode = tw.root; while (tw.nextNode()) { if (this._isParagraphInView(tw.currentNode)) { break; } } } if (tw.currentNode == tw.root) { tw.nextNode(); } return this._speakInner(); }); }, stop: function() { this._stopped = true; this._win.speechSynthesis.cancel(); }, skipNext: function() { this._win.speechSynthesis.cancel(); }, skipPrevious: function() { this._goBackParagraphs(this._timeIntoParagraph < PREV_THRESHOLD ? 2 : 1); }, setRate: function(rate) { this._speechOptions.rate = rate; /* repeat current paragraph */ this._goBackParagraphs(1); }, setVoice: function(voice) { this._speechOptions.voice = this._getVoice(voice); /* repeat current paragraph */ this._goBackParagraphs(1); }, _goBackParagraphs: function(count) { let tw = this._treeWalker; for (let i = 0; i < count; i++) { if (!tw.previousNode()) { tw.currentNode = tw.root; } } this._win.speechSynthesis.cancel(); } }; /** * The Highlighter class is used to highlight a range of text in a container. * * @param {nsIDOMElement} container a text container */ function Highlighter(container) { this.container = container; } Highlighter.prototype = { /** * Highlight the range within offsets relative to the container. * * @param {Number} startOffset the start offset * @param {Number} endOffset the end offset */ highlight: function(startOffset, endOffset) { let containerRect = this.container.getBoundingClientRect(); let range = this._getRange(startOffset, endOffset); let rangeRects = range.getClientRects(); let win = this.container.ownerDocument.defaultView; let computedStyle = win.getComputedStyle(range.endContainer.parentNode); let nodes = this._getFreshHighlightNodes(rangeRects.length); let textStyle = {}; for (let textStyleRule of kTextStylesRules) { textStyle[textStyleRule] = computedStyle[textStyleRule]; } for (let i = 0; i < rangeRects.length; i++) { let r = rangeRects[i]; let node = nodes[i]; let style = Object.assign({ "top": `${r.top - containerRect.top + r.height / 2}px`, "left": `${r.left - containerRect.left + r.width / 2}px`, "width": `${r.width}px`, "height": `${r.height}px` }, textStyle); // Enables us to vary the CSS transition on a line change. node.classList.toggle("newline", style.top != node.dataset.top); node.dataset.top = style.top; // Enables CSS animations. node.classList.remove("animate"); win.requestAnimationFrame(() => { node.classList.add("animate"); }); // Enables alternative word display with a CSS pseudo-element. node.dataset.word = range.toString(); // Apply style node.style = Object.entries(style).map( s => `${s[0]}: ${s[1]};`).join(" "); } }, /** * Releases reference to container and removes all highlight nodes. */ remove: function() { for (let node of this._nodes) { node.remove(); } this.container = null; }, /** * Returns specified amount of highlight nodes. Creates new ones if necessary * and purges any additional nodes that are not needed. * * @param {Number} count number of nodes needed */ _getFreshHighlightNodes: function(count) { let doc = this.container.ownerDocument; let nodes = Array.from(this._nodes); // Remove nodes we don't need anymore (nodes.length - count > 0). for (let toRemove = 0; toRemove < nodes.length - count; toRemove++) { nodes.shift().remove(); } // Add additional nodes if we need them (count - nodes.length > 0). for (let toAdd = 0; toAdd < count - nodes.length; toAdd++) { let node = doc.createElement("div"); node.className = "narrate-word-highlight"; this.container.appendChild(node); nodes.push(node); } return nodes; }, /** * Create and return a range object with the start and end offsets relative * to the container node. * * @param {Number} startOffset the start offset * @param {Number} endOffset the end offset */ _getRange: function(startOffset, endOffset) { let doc = this.container.ownerDocument; let i = 0; let treeWalker = doc.createTreeWalker( this.container, doc.defaultView.NodeFilter.SHOW_TEXT); let node = treeWalker.nextNode(); function _findNodeAndOffset(offset) { do { let length = node.data.length; if (offset >= i && offset <= i + length) { return [node, offset - i]; } i += length; } while ((node = treeWalker.nextNode())); // Offset is out of bounds, return last offset of last node. node = treeWalker.lastChild(); return [node, node.data.length]; } let range = doc.createRange(); range.setStart(..._findNodeAndOffset(startOffset)); range.setEnd(..._findNodeAndOffset(endOffset)); return range; }, /* * Get all existing highlight nodes for container. */ get _nodes() { return this.container.querySelectorAll(".narrate-word-highlight"); } };