chromium/chrome/browser/resources/chromeos/accessibility/chromevox/background/braille/expanding_braille_translator.ts

// Copyright 2014 The Chromium Authors
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.

/**
 * @fileoverview Translates text to braille, optionally with some parts
 * uncontracted.
 */
import {TestImportManager} from '/common/testing/test_import_manager.js';

import {Spannable} from '../../common/spannable.js';

import {LibLouis} from './liblouis.js';
import {BrailleTextStyleSpan, ExtraCellsSpan, ValueSelectionSpan, ValueSpan} from './spans.js';

interface Chunk {
  translator: LibLouis.Translator | null;
  start: number;
  end: number;
  cells?: ArrayBuffer;
  textToBraille?: number[];
  brailleToText?: number[];
}

/** Like LibLouis.TranslateCallback, but all values are mandatory. */
type RequiredTranslateCallback =
    (cells: ArrayBuffer, textToBraille: number[], brailleToText: number[]) =>
        void;

/**
 * A wrapper around one or two braille translators that uses contracted
 * braille or not based on the selection start- and end-points (if any) in the
 * translated text.  If only one translator is provided, then that translator
 * is used for all text regardless of selection.  If two translators
 * are provided, then the uncontracted translator is used for some text
 * around the selection end-points and the contracted translator is used
 * for all other text.  When determining what text to use uncontracted
 * translation for around a position, a region surrounding that position
 * containing either only whitespace characters or only non-whitespace
 * characters is used.
 */
export class ExpandingBrailleTranslator {
  /**
   * @param defaultTranslator The translator for all text when the uncontracted
   *     translator is not used.
   * @param uncontractedTranslator Translator to use for uncontracted braille
   *     translation.
   */
  constructor(
      private defaultTranslator_: LibLouis.Translator,
      private uncontractedTranslator_?: LibLouis.Translator | null) {}

  /**
   * Translates text to braille using the translator(s) provided to the
   * constructor.  See LibLouis.Translator for further details.
   * @param text Text to translate.
   * @param expansionType Indicates how the text marked by a value span,
   *    if any, is expanded.
   * @param callback Called when the translation is done.  It takes resulting
   *    braille cells and positional mappings as parameters.
   */
  translate(
      text: Spannable, expansionType: ExpandingBrailleTranslator.ExpansionType,
      callback: RequiredTranslateCallback): void {
    const expandRanges = this.findExpandRanges_(text, expansionType);
    const extraCellsSpans = text.getSpansInstanceOf(ExtraCellsSpan)
                                .filter(span => span.cells.byteLength > 0);
    const extraCellsPositions =
        extraCellsSpans.map(span => text.getSpanStart(span));
    const formTypeMap: number[] = new Array(text.length).fill(0);
    text.getSpansInstanceOf(BrailleTextStyleSpan).forEach(
        (span: BrailleTextStyleSpan) => {
          const start = text.getSpanStart(span);
          const end = text.getSpanEnd(span);
          for (let i = start; i < end; i++) {
            formTypeMap[i] |= span.formType;
          }
        });

    if (expandRanges.length === 0 && extraCellsSpans.length === 0) {
      this.defaultTranslator_.translate(
          text.toString(), formTypeMap,
          ExpandingBrailleTranslator.nullParamsToEmptyAdapter_(
              text.length, callback));
      return;
    }

    const chunks: Chunk[] = [];
    function maybeAddChunkToTranslate(
        translator: LibLouis.Translator, start: number, end: number): void {
      if (start < end) {
        chunks.push({translator, start, end});
      }
    }
    function addExtraCellsChunk(pos: number, cells: ArrayBuffer): void {
      const chunk = {
        translator: null,
        start: pos,
        end: pos,
        cells,
        textToBraille: [],
        brailleToText: new Array(cells.byteLength),
      };
      for (let i = 0; i < cells.byteLength; ++i) {
        chunk.brailleToText[i] = 0;
      }
      chunks.push(chunk);
    }
    function addChunk(
        translator: LibLouis.Translator, start: number, end: number): void {
      while (extraCellsSpans.length > 0 && extraCellsPositions[0] <= end) {
        maybeAddChunkToTranslate(translator, start, extraCellsPositions[0]);
        // TODO(b/314203187): Not null asserted, check that this is correct.
        start = extraCellsPositions.shift()!;
        addExtraCellsChunk(start, extraCellsSpans.shift().cells);
      }
      maybeAddChunkToTranslate(translator, start, end);
    }
    let lastEnd = 0;
    for (let i = 0; i < expandRanges.length; ++i) {
      const range = expandRanges[i];
      if (lastEnd < range.start) {
        addChunk(this.defaultTranslator_, lastEnd, range.start);
      }
      // TODO(b/314203187): Not null asserted, check that this is correct.
      addChunk(this.uncontractedTranslator_!, range.start, range.end);
      lastEnd = range.end;
    }
    addChunk(this.defaultTranslator_, lastEnd, text.length);

    const chunksToTranslate = chunks.filter((chunk: Chunk) => chunk.translator);
    let numPendingCallbacks = chunksToTranslate.length;

    function chunkTranslated(
        chunk: Chunk, cells: ArrayBuffer, textToBraille: number[],
        brailleToText: number[]): void {
      chunk.cells = cells;
      chunk.textToBraille = textToBraille;
      chunk.brailleToText = brailleToText;
      if (--numPendingCallbacks <= 0) {
        finish();
      }
    }

    function finish(): void {
      // TODO(b/314203187): Not null asserted, check that this is correct.
      const totalCells = chunks.reduce(
          (accum: number, chunk: Chunk) => accum + chunk.cells!.byteLength, 0);
      const cells = new Uint8Array(totalCells);
      let cellPos = 0;
      const textToBraille: number[] = [];
      const brailleToText: number[] = [];
      function appendAdjusted(
          array: number[], toAppend: number[], adjustment: number): void {
        array.push.apply(array, toAppend.map(elem => adjustment + elem));
      }
      for (let i = 0, chunk; chunk = chunks[i]; ++i) {
        // TODO(b/314203187): Not null asserted, check that this is correct.
        cells.set(new Uint8Array(chunk.cells!), cellPos);
        appendAdjusted(textToBraille, chunk.textToBraille!, cellPos);
        appendAdjusted(brailleToText, chunk.brailleToText!, chunk.start);
        cellPos += chunk.cells!.byteLength;
      }
      callback(cells.buffer, textToBraille, brailleToText);
    }

    if (chunksToTranslate.length > 0) {
      chunksToTranslate.forEach((chunk: Chunk) => {
        // TODO(b/314203187): Not null asserted, check that this is correct.
        chunk.translator!.translate(
            text.toString().substring(chunk.start, chunk.end),
            formTypeMap.slice(chunk.start, chunk.end),
            ExpandingBrailleTranslator.nullParamsToEmptyAdapter_(
                chunk.end - chunk.start,
                (cells: ArrayBuffer, textToBraille: number[],
                    brailleToText: number[]) =>
                  chunkTranslated(chunk, cells, textToBraille, brailleToText)));
      });
    } else {
      finish();
    }
  }

  /**
   * Expands a position to a range that covers the consecutive range of
   * either whitespace or non whitespace characters around it.
   * @param str Text to look in.
   * @param pos Position to start looking at.
   * @param start Minimum value for the start position of the returned range.
   * @param end Maximum value for the end position of the returned range.
   * @return The calculated range.
   */
  private static rangeForPosition_(
      str: string, pos: number, start: number, end: number): Range {
    if (start < 0 || end > str.length) {
      throw RangeError(
          'End-points out of range looking for braille expansion range');
    }
    if (pos < start || pos >= end) {
      throw RangeError(
          'Position out of range looking for braille expansion range');
    }
    // Find the last chunk of either whitespace or non-whitespace before and
    // including pos.
    start = str.substring(start, pos + 1).search(/(\s+|\S+)$/) + start;
    // Find the characters to include after pos, starting at pos so that
    // they are the same kind (either whitespace or not) as the
    // characters starting at start.
    // TODO(b/314203187): Not null asserted, check that this is correct.
    end = pos + /^(\s+|\S+)/.exec(str.substring(pos, end))![0].length;
    return {start, end};
  }

  /**
   * Finds the ranges in which contracted braille should not be used.
   * @param text Text to find expansion ranges in.
   * @param expansionType Indicates how the text marked up as the value is
   *     expanded.
   * @return The calculated ranges.
   */
  private findExpandRanges_(
      text: Spannable, expansionType: ExpandingBrailleTranslator.ExpansionType):
          Range[] {
    const result: Range[] = [];
    if (this.uncontractedTranslator_ &&
        expansionType !== ExpandingBrailleTranslator.ExpansionType.NONE) {
      const value = text.getSpanInstanceOf(ValueSpan);
      if (value) {
        const valueStart = text.getSpanStart(value);
        const valueEnd = text.getSpanEnd(value);
        switch (expansionType) {
          case ExpandingBrailleTranslator.ExpansionType.SELECTION:
            this.addRangesForSelection_(text, valueStart, valueEnd, result);
            break;
          case ExpandingBrailleTranslator.ExpansionType.ALL:
            result.push({start: valueStart, end: valueEnd});
            break;
        }
      }
    }

    return result;
  }

  /**
   * Finds ranges to expand around selection end points inside the value of
   * a string.  If any ranges are found, adds them to outRanges.
   * @param text Text to find ranges in.
   * @param valueStart Start of the value in text.
   * @param valueEnd End of the value in text.
   * @param outRanges Destination for the expansion ranges. Untouched if no
   *     ranges are found. Note that ranges may be coalesced.
   */
  private addRangesForSelection_(
      text: Spannable, valueStart: number, valueEnd: number,
      outRanges: Range[]): void {
    const selection = text.getSpanInstanceOf(ValueSelectionSpan);
    if (!selection) {
      return;
    }
    const selectionStart = text.getSpanStart(selection);
    const selectionEnd = text.getSpanEnd(selection);
    if (selectionStart < valueStart || selectionEnd > valueEnd) {
      return;
    }
    const expandPositions: number[] = [];
    if (selectionStart === valueEnd) {
      if (selectionStart > valueStart) {
        expandPositions.push(selectionStart - 1);
      }
    } else {
      if (selectionStart === selectionEnd && selectionStart > valueStart) {
        expandPositions.push(selectionStart - 1);
      }
      expandPositions.push(selectionStart);
      // Include the selection end if the length of the selection is
      // greater than one (otherwise this position would be redundant).
      if (selectionEnd > selectionStart + 1) {
        // Look at the last actual character of the selection, not the
        // character at the (exclusive) end position.
        expandPositions.push(selectionEnd - 1);
      }
    }

    let lastRange = outRanges[outRanges.length - 1] || null;
    for (let i = 0; i < expandPositions.length; ++i) {
      const range = ExpandingBrailleTranslator.rangeForPosition_(
          text.toString(), expandPositions[i], valueStart, valueEnd);
      if (lastRange && lastRange.end >= range.start) {
        lastRange.end = range.end;
      } else {
        outRanges.push(range);
        lastRange = range;
      }
    }
  }

  /**
   * Adapts callback to accept null arguments and treat them as if the
   * translation result is empty.
   * @param inputLength Length of the input to the translation.
   *     Used for populating textToBraille if null.
   * @param callback The callback to adapt.
   * @return An adapted version of the callback.
   */
  private static nullParamsToEmptyAdapter_(
      inputLength: number, callback: RequiredTranslateCallback):
          LibLouis.TranslateCallback {
    return function(
        cells: ArrayBuffer | null, textToBraille: number[] | null,
        brailleToText: number[] | null): void {
      if (!textToBraille) {
        textToBraille = new Array(inputLength);
        for (let i = 0; i < inputLength; ++i) {
          textToBraille[i] = 0;
        }
      }
      callback(cells || new ArrayBuffer(0), textToBraille, brailleToText || []);
    };
  }
}

export namespace ExpandingBrailleTranslator {
  /**
   * What expansion to apply to the part of the translated string marked by the
   * ValueSpan spannable.
   */
  export enum ExpansionType {
    /**
     * Use the default translator all of the value, regardless of any selection.
     * This is typically used when the user is in the middle of typing and the
     * typing started outside of a word.
     */
    NONE = 0,
    /**
     * Expand text around the selection end-points if any.  If the selection is
     * a cursor, expand the text that occupies the positions right before and
     * after the cursor.  This is typically used when the user hasn't started
     * typing contracted braille or when editing inside a word.
     */
    SELECTION = 1,
    /**
     * Expand all text covered by the value span.  this is typically used when
     * the user is editing a text field where it doesn't make sense to use
     * contracted braille (such as a url or email address).
     */
    ALL = 2,
  }
}

// Local to module.

/** A character range with inclusive start and exclusive end positions. */
interface Range {
  start: number;
  end: number;
}

TestImportManager.exportForTesting(ExpandingBrailleTranslator);