Anforderungen  |   Konzepte  |   Entwurf  |   Entwicklung  |   Qualitätssicherung  |   Lebenszyklus  |   Steuerung
 
 
 
 


Quelle  Narrator.sys.mjs   Sprache: unbekannt

 
/* 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/. */

// 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",
];

export function Narrator(win, languagePromise) {
  this._winRef = Cu.getWeakReference(win);
  this._languagePromise = languagePromise;
  this._inTest = Services.prefs.getBoolPref("narrate.test");
  this._speechOptions = {};
  this._startTime = 0;
  this._stopped = false;
}

Narrator.prototype = {
  get _doc() {
    return this._winRef.get().document;
  },

  get _win() {
    return this._winRef.get();
  },

  get _treeWalker() {
    if (!this._treeWalkerRef) {
      let wu = this._win.windowUtils;
      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(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.querySelector(".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(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(paragraph) {
    if (!paragraph) {
      return false;
    }

    let bb = paragraph.getBoundingClientRect();
    return bb.top >= 0 && bb.top < this._win.innerHeight;
  },

  _sendTestEvent(eventType, detail) {
    let win = this._win;
    win.dispatchEvent(
      new win.CustomEvent(eventType, {
        detail: Cu.cloneInto(detail, win.document),
      })
    );
  },

  _speakInner() {
    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.replace(/\r?\n/g, " ")
    );
    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;
        }

        if (e.charLength) {
          highlighter.highlight(e.charIndex, e.charLength);
          if (this._inTest) {
            this._sendTestEvent("wordhighlight", {
              start: e.charIndex,
              end: e.charIndex + e.charLength,
            });
          }
        }
      });

      this._win.speechSynthesis.speak(utterance);
    });
  },

  start(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() {
    this._stopped = true;
    this._win.speechSynthesis.cancel();
  },

  skipNext() {
    this._win.speechSynthesis.cancel();
  },

  skipPrevious() {
    this._goBackParagraphs(this._timeIntoParagraph < PREV_THRESHOLD ? 2 : 1);
  },

  setRate(rate) {
    this._speechOptions.rate = rate;
    /* repeat current paragraph */
    this._goBackParagraphs(1);
  },

  setVoice(voice) {
    this._speechOptions.voice = this._getVoice(voice);
    /* repeat current paragraph */
    this._goBackParagraphs(1);
  },

  _goBackParagraphs(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 {Element} 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} length the length in characters of the range
   */
  highlight(startOffset, length) {
    let containerRect = this.container.getBoundingClientRect();
    let range = this._getRange(startOffset, startOffset + length);
    let rangeRects = range.getClientRects();
    let win = this.container.ownerGlobal;
    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() {
    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(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(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");
  },
};

[ Dauer der Verarbeitung: 0.30 Sekunden  (vorverarbeitet)  ]

                                                                                                                                                                                                                                                                                                                                                                                                     


Neuigkeiten

     Aktuelles
     Motto des Tages

Software

     Produkte
     Quellcodebibliothek

Aktivitäten

     Artikel über Sicherheit
     Anleitung zur Aktivierung von SSL

Muße

     Gedichte
     Musik
     Bilder

Jenseits des Üblichen ....

Besucherstatistik

Besucherstatistik

Monitoring

Montastic status badge