chromium/chrome/browser/resources/lens/overlay/text_layer.ts

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

import './strings.m.js';

import {assert} from '//resources/js/assert.js';
import {skColorToHexColor, skColorToRgba} from '//resources/js/color_utils.js';
import {EventTracker} from '//resources/js/event_tracker.js';
import {loadTimeData} from '//resources/js/load_time_data.js';
import type {PointF} from '//resources/mojo/ui/gfx/geometry/mojom/geometry.mojom-webui.js';
import {PolymerElement} from '//resources/polymer/v3_0/polymer/polymer_bundled.min.js';
import type {DomRepeat} from '//resources/polymer/v3_0/polymer/polymer_bundled.min.js';

import {BrowserProxyImpl} from './browser_proxy.js';
import type {BrowserProxy} from './browser_proxy.js';
import {skColorToRgbaWithCustomAlpha} from './color_utils.js';
import {type CursorTooltipData, CursorTooltipType} from './cursor_tooltip.js';
import {findWordsInRegion} from './find_words_in_region.js';
import {CenterRotatedBox_CoordinateType} from './geometry.mojom-webui.js';
import type {CenterRotatedBox} from './geometry.mojom-webui.js';
import {bestHit} from './hit.js';
import {UserAction} from './lens.mojom-webui.js';
import {INVOCATION_SOURCE} from './lens_overlay_app.js';
import {recordLensOverlayInteraction} from './metrics_utils.js';
import type {CursorData, DetectedTextContextMenuData, SelectedTextContextMenuData} from './selection_overlay.js';
import {CursorType} from './selection_utils.js';
import type {GestureEvent} from './selection_utils.js';
import type {BackgroundImageData, Line, Paragraph, Text, TranslatedLine, TranslatedParagraph, Word} from './text.mojom-webui.js';
import {Alignment, WritingDirection} from './text.mojom-webui.js';
import {getTemplate} from './text_layer.html.js';
import type {TranslateState} from './translate_button.js';
import {toPercent} from './values_converter.js';

const MIN_FONT_SIZE = 1;
const MAX_FONT_SIZE = 100;
// Highest font size where the opacity of the background should be 100%.
const FONT_SIZE_OPAQUE_BOUND = 10;
// Lowest font size where the opacity of the background should be transparent
const FONT_SIZE_TRANSPARENT_BOUND = 18;

// Rotates the target coordinates to be in relation to the line rotation.
function rotateCoordinateAroundOrigin(
    pointToRotate: PointF, angle: number): PointF {
  const newX =
      pointToRotate.x * Math.cos(-angle) - pointToRotate.y * Math.sin(-angle);
  const newY =
      pointToRotate.y * Math.cos(-angle) + pointToRotate.x * Math.sin(-angle);
  return {x: newX, y: newY};
}

// Returns true if the word has a valid bounding box and is renderable by the
// TextLayer.
function isWordRenderable(word: Word): boolean {
  // For a word to be renderable, it must have a bounding box with normalized
  // coordinates.
  // TODO(b/330183480): Add rendering for IMAGE CoordinateType
  const wordBoundingBox = word.geometry?.boundingBox;
  if (!wordBoundingBox) {
    return false;
  }

  return wordBoundingBox.coordinateType ===
      CenterRotatedBox_CoordinateType.kNormalized;
}

// Return the text separator if there is one, else returns a space.
function getTextSeparator(word: Word): string {
  return (word.textSeparator !== null && word.textSeparator !== undefined) ?
      word.textSeparator :
      ' ';
}

// Returns true if index is in the range [start, end]. End index may be lesser
// than start index.
function isInRange(index: number, start: number, end: number): boolean {
  return (index >= start && index <= end) || (index >= end && index <= start);
}

export interface TextLayerElement {
  $: {
    textRenderCanvas: HTMLCanvasElement,
    translateContainer: DomRepeat,
    wordsContainer: DomRepeat,
  };
}

interface HighlightedLine {
  height: number;
  left: number;
  top: number;
  width: number;
  rotation: number;
}

interface TranslatedLineData {
  alignment: Alignment;
  contentLanguage: string;
  line: TranslatedLine;
  words: TranslatedWordData[];
  paragraphIndex: number;
}

interface TranslatedWordData {
  word: Word;
  index: number;
}

/*
 * Element responsible for highlighting and selection text.
 */
export class TextLayerElement extends PolymerElement {
  static get is() {
    return 'lens-text-layer';
  }

  static get template() {
    return getTemplate();
  }

  static get properties() {
    return {
      renderedWords: {
        type: Array,
        value: () => [],
      },
      shouldRenderTranslateWords: {
        type: Boolean,
        reflectToAttribute: true,
      },
      highlightedLines: {
        type: Array,
        computed: 'getHighlightedLines(selectionStartIndex, selectionEndIndex)',
      },
      selectionStartIndex: {
        type: Number,
        value: -1,
      },
      selectionEndIndex: {
        type: Number,
        value: -1,
      },
      isSelectingText: {
        type: Boolean,
        value: false,
        reflectToAttribute: true,
      },
      debugMode: {
        type: Boolean,
        value: loadTimeData.getBoolean('enableDebuggingMode'),
        reflectToAttribute: true,
      },
      selectionOverlayRect: Object,
    };
  }

  // The rendering context of the canvas used to measure font size of translated
  // text.
  private context: CanvasRenderingContext2D;
  // The words rendered in this layer.
  private renderedWords: Word[];
  // Whether to render the translated text received on the overlay rather than
  // the detected text.
  private shouldRenderTranslateWords: boolean;
  // All of the translated words returned in onTranslateTextReceived with
  // non-translated words also filled in where translation failed.
  private translatedWordsInOrder: Word[];
  // The current target language the user requested to translate to.
  private currentTranslateLanguage: string;
  // The rendered translated words in order from onTranslateTextReceived.
  private renderedTranslateWords: TranslatedWordData[];
  // The rendered translated lines in order from onTranslateTextReceived.
  private renderedTranslateLines: TranslatedLineData[];
  // The rendered translated paragraphs in order from onTranslateTextReceived.
  private renderedTranslateParagraphs: TranslatedParagraph[];
  // The currently selected lines.
  private highlightedLines: HighlightedLine[];
  // The index of the word in renderedWords at the start of the current
  // selection. -1 if no current selection.
  private selectionStartIndex: number;
  // The index of the word in renderedWords at the end of the current selection.
  // -1 if no current selection.
  private selectionEndIndex: number;
  // Whether the user is currently selecting text.
  private isSelectingText: boolean;
  // The bounds of the parent element. This is updated by the parent to avoid
  // this class needing to call getBoundingClientRect()
  private selectionOverlayRect: DOMRect;

  // An array that corresponds 1:1 to renderedWords, where lineNumbers[i] is the
  // line number for renderedWords[i]. In addition, the index at lineNumbers[i]
  // corresponds to the Line in lines[i] that the word belongs in.
  private lineNumbers: number[];
  // An array that corresponds 1:1 to renderedWords, where paragraphNumbers[i]
  // is the paragraph number for renderedWords[i]. In addition, the index at
  // paragraphNumbers[i] corresponds to the Paragraph in paragraphs[i] that the
  // word belongs in.
  private paragraphNumbers: number[];
  // The lines received from OnTextReceived.
  private lines: Line[];
  // The paragraphs received from OnTextReceived.
  private paragraphs: Paragraph[];
  // The content language received from OnTextReceived.
  private contentLanguage: string;
  private eventTracker_: EventTracker = new EventTracker();
  private listenerIds: number[];
  // IoU threshold for finding words in region.
  private selectTextTriggerThreshold: number =
      loadTimeData.getValue('selectTextTriggerThreshold');
  private browserProxy: BrowserProxy = BrowserProxyImpl.getInstance();

  override ready() {
    super.ready();
    this.context = this.$.textRenderCanvas.getContext('2d')!;
  }

  override connectedCallback() {
    super.connectedCallback();

    this.eventTracker_.add(
        document, 'detect-text-in-region',
        (e: CustomEvent<CenterRotatedBox>) => {
          this.detectTextInRegion(e.detail);
        });
    this.eventTracker_.add(
        document, 'translate-mode-state-changed',
        (e: CustomEvent<TranslateState>) => {
          this.unselectWords();
          this.shouldRenderTranslateWords = e.detail.translateModeEnabled;
          this.currentTranslateLanguage = e.detail.targetLanguage;
        });

    // Set up listener to listen to events from C++.
    this.listenerIds = [
      this.browserProxy.callbackRouter.textReceived.addListener(
          this.onTextReceived.bind(this)),
      this.browserProxy.callbackRouter.clearTextSelection.addListener(
          this.unselectWords.bind(this)),
      this.browserProxy.callbackRouter.clearAllSelections.addListener(
          this.unselectWords.bind(this)),
      this.browserProxy.callbackRouter.setTextSelection.addListener(
          this.selectWords.bind(this)),
    ];
  }

  override disconnectedCallback() {
    super.disconnectedCallback();

    this.listenerIds.forEach(
        id => assert(this.browserProxy.callbackRouter.removeListener(id)));
    this.listenerIds = [];
    this.eventTracker_.removeAll();
  }

  private handlePointerEnter() {
    this.dispatchEvent(new CustomEvent<CursorData>(
        'set-cursor',
        {bubbles: true, composed: true, detail: {cursor: CursorType.TEXT}}));
    this.dispatchEvent(
        new CustomEvent<CursorTooltipData>('set-cursor-tooltip', {
          bubbles: true,
          composed: true,
          detail: {tooltipType: CursorTooltipType.TEXT_HIGHLIGHT},
        }));
  }

  private handlePointerLeave() {
    this.dispatchEvent(new CustomEvent<CursorData>(
        'set-cursor',
        {bubbles: true, composed: true, detail: {cursor: CursorType.DEFAULT}}));
    this.dispatchEvent(
        new CustomEvent<CursorTooltipData>('set-cursor-tooltip', {
          bubbles: true,
          composed: true,
          detail: {tooltipType: CursorTooltipType.REGION_SEARCH},
        }));
  }

  private detectTextInRegion(box: CenterRotatedBox) {
    const selection =
        findWordsInRegion(this.renderedWords, box, this.selectionOverlayRect);
    if (selection.iou < this.selectTextTriggerThreshold) {
      this.dispatchEvent(new CustomEvent(
          'hide-detected-text-context-menu', {bubbles: true, composed: true}));
      return;
    }
    const left = box.box.x - box.box.width / 2;
    const right = box.box.x + box.box.width / 2;
    const top = box.box.y - box.box.height / 2;
    const bottom = box.box.y + box.box.height / 2;
    this.dispatchEvent(new CustomEvent<DetectedTextContextMenuData>(
        'show-detected-text-context-menu', {
          bubbles: true,
          composed: true,
          detail: {
            left,
            right,
            top,
            bottom,
            selectionStartIndex: selection.startIndex,
            selectionEndIndex: selection.endIndex,
          },
        }));
  }

  handleDownGesture(event: GestureEvent): boolean {
    this.unselectWords();

    const translatedWordIndex =
        this.translatedWordIndexFromPoint(event.clientX, event.clientY);
    const wordIndex = translatedWordIndex ?
        translatedWordIndex :
        this.wordIndexFromPoint(event.clientX, event.clientY);
    // Ignore if the click is not on a word.
    if (wordIndex === null) {
      return false;
    }

    this.selectionStartIndex = wordIndex;
    this.selectionEndIndex = wordIndex;
    this.isSelectingText = true;
    return true;
  }

  handleRightClick(event: PointerEvent) {
    // If the user right-clicks a highlighted word, restore the selected text
    // context menu.
    const wordIndex = this.wordIndexFromPoint(event.clientX, event.clientY);
    if (wordIndex !== null &&
        isInRange(
            wordIndex, this.selectionStartIndex, this.selectionEndIndex)) {
      this.dispatchEvent(new CustomEvent('restore-selected-text-context-menu', {
        bubbles: true,
        composed: true,
      }));
    }
  }

  handleDragGesture(event: GestureEvent) {
    const imageBounds = this.selectionOverlayRect;
    const normalizedX = (event.clientX - imageBounds.left) / imageBounds.width;
    const normalizedY = (event.clientY - imageBounds.top) / imageBounds.height;

    const words = this.shouldRenderTranslateWords ?
        this.translatedWordsInOrder :
        this.renderedWords;
    const hit = bestHit(words, {x: normalizedX, y: normalizedY});

    if (!hit) {
      return;
    }

    this.selectionEndIndex = words.indexOf(hit);
  }

  handleUpGesture() {
    this.sendSelectedText();
  }

  private sendSelectedText() {
    this.isSelectingText = false;
    const highlightedText = this.getHighlightedText();
    const lines = this.getHighlightedLines();
    const containingRect = this.getContainingRect(lines);
    this.dispatchEvent(new CustomEvent<SelectedTextContextMenuData>(
        'show-selected-text-context-menu', {
          bubbles: true,
          composed: true,
          detail: {
            text: highlightedText,
            contentLanguage: this.contentLanguage,
            left: containingRect.left,
            right: containingRect.right,
            top: containingRect.top,
            bottom: containingRect.bottom,
            selectionStartIndex: this.selectionStartIndex,
            selectionEndIndex: this.selectionEndIndex,
          },
        }));

    // On selection complete, send the selected text to C++.
    this.browserProxy.handler.issueTextSelectionRequest(
        highlightedText, this.selectionStartIndex, this.selectionEndIndex);
    recordLensOverlayInteraction(INVOCATION_SOURCE, UserAction.kTextSelection);
  }

  selectAndSendWords(selectionStartIndex: number, selectionEndIndex: number) {
    this.selectWords(selectionStartIndex, selectionEndIndex);
    this.sendSelectedText();
  }

  selectAndTranslateWords(
      selectionStartIndex: number, selectionEndIndex: number) {
    this.selectWords(selectionStartIndex, selectionEndIndex);
    this.isSelectingText = false;
    // Do not show the selected text context menu, but update the data so that
    // it is shown correctly if the user right-clicks on the text.
    const highlightedText = this.getHighlightedText();
    const lines = this.getHighlightedLines();
    const containingRect = this.getContainingRect(lines);
    this.dispatchEvent(new CustomEvent<SelectedTextContextMenuData>(
        'update-selected-text-context-menu', {
          bubbles: true,
          composed: true,
          detail: {
            text: highlightedText,
            contentLanguage: this.contentLanguage,
            left: containingRect.left,
            right: containingRect.right,
            top: containingRect.top,
            bottom: containingRect.bottom,
            selectionStartIndex: this.selectionStartIndex,
            selectionEndIndex: this.selectionEndIndex,
          },
        }));

    BrowserProxyImpl.getInstance().handler.issueTranslateSelectionRequest(
        this.getHighlightedText(), this.contentLanguage,
        this.selectionStartIndex, this.selectionEndIndex);
    recordLensOverlayInteraction(INVOCATION_SOURCE, UserAction.kTranslateText);
  }

  cancelGesture() {
    this.unselectWords();
  }

  private unselectWords() {
    this.selectionStartIndex = -1;
    this.selectionEndIndex = -1;
    this.dispatchEvent(new CustomEvent(
        'hide-selected-text-context-menu', {bubbles: true, composed: true}));
    this.dispatchEvent(new CustomEvent(
        'hide-detected-text-context-menu', {bubbles: true, composed: true}));
  }

  private selectWords(selectionStartIndex: number, selectionEndIndex: number) {
    this.selectionStartIndex = selectionStartIndex;
    this.selectionEndIndex = selectionEndIndex;
  }

  private onTextReceived(text: Text) {
    // Reset all old text.
    const receivedWords = [];
    this.lineNumbers = [];
    this.paragraphNumbers = [];
    this.lines = [];
    this.paragraphs = [];
    this.contentLanguage = text.contentLanguage ?? '';
    let lineNumber = 0;
    let paragraphNumber = 0;

    // Flatten Text structure to a list of arrays for easier rendering and
    // referencing.
    for (const paragraph of text.textLayout.paragraphs) {
      for (const line of paragraph.lines) {
        for (const word of line.words) {
          // Filter out words with invalid bounding boxes.
          if (isWordRenderable(word)) {
            receivedWords.push(word);
            this.lineNumbers.push(lineNumber);
            this.paragraphNumbers.push(paragraphNumber);
          }
        }
        this.lines.push(line);
        lineNumber++;
      }
      this.paragraphs.push(paragraph);
      paragraphNumber++;
    }
    // Need to set this.renderedWords to a new array instead of
    // this.renderedWords.push() to ensure the dom-repeat updates.
    this.renderedWords = receivedWords;
    assert(this.lineNumbers.length === this.renderedWords.length);
    assert(this.paragraphNumbers.length === this.renderedWords.length);

    // If there is any translate text in the Text object, we need to handle that
    // case as well. Otherwise, this is a no-op.
    const renderedTranslateParagraphs =
        text.textLayout.paragraphs.filter((paragraph) => {
          return paragraph.translation !== null;
        });
    if (renderedTranslateParagraphs.length > 0) {
      this.onTranslateTextReceived(text);
    }

    // Used to notify the post selection renderer so that, if a region has
    // already been selected, text in the region can be detected.
    this.dispatchEvent(new CustomEvent(
        'finished-receiving-text', {bubbles: true, composed: true}));
  }

  private onTranslateTextReceived(text: Text) {
    // Reset all translated text.
    let wordIndex = 0;
    let paragraphNumber = 0;
    const receivedWords = [];
    const receivedTranslatedWords = [];
    const receivedTranslatedLines = [];
    this.renderedTranslateParagraphs = [];

    // Flatten Text structure to a list of arrays for easier rendering and
    // referencing.
    for (const paragraph of text.textLayout.paragraphs) {
      // We are looking for translated paragraphs first. If they do not exist,
      // we should default to the detected text. Just because we have
      // translations for some paragraphs does not mean we have translations
      // for all paragraphs.
      if (paragraph.translation) {
        for (const line of paragraph.translation.lines) {
          const translatedWordDataInLine = [];
          for (const word of line.words) {
            // Filter out words with invalid bounding boxes.
            if (isWordRenderable(word)) {
              const translatedWordData:
                  TranslatedWordData = {word, index: wordIndex};
              receivedWords.push(word);
              receivedTranslatedWords.push(translatedWordData);
              translatedWordDataInLine.push(translatedWordData);
              wordIndex++;
            }
          }

          const translatedLineData: TranslatedLineData = {
            alignment: paragraph.translation.alignment ??
                Alignment.kDefaultLeftAlgined,
            contentLanguage: paragraph.contentLanguage ?? '',
            line,
            words: translatedWordDataInLine,
            paragraphIndex: paragraphNumber,
          };
          receivedTranslatedLines.push(translatedLineData);
        }
        this.renderedTranslateParagraphs.push(paragraph.translation);
      } else {
        for (const line of paragraph.lines) {
          for (const word of line.words) {
            // Filter out words with invalid bounding boxes.
            if (isWordRenderable(word)) {
              receivedWords.push(word);
              wordIndex++;
            }
          }
        }
      }
      paragraphNumber++;
    }

    this.renderedTranslateLines = receivedTranslatedLines;
    this.renderedTranslateWords = receivedTranslatedWords;
    this.translatedWordsInOrder = receivedWords;
  }

  private calculateFontSizePixels(translatedLine: TranslatedLineData): number {
    const line = translatedLine.line;
    if (!line.geometry) {
      return MIN_FONT_SIZE;
    }
    // TODO(b/330183480): Currently, we are assuming that word coordinates are
    // normalized. We should still implement rendering in case this assumption
    // is ever violated.
    if (line.geometry.boundingBox.coordinateType !==
        CenterRotatedBox_CoordinateType.kNormalized) {
      return MIN_FONT_SIZE;
    }

    // Convert the normalized line geometry to pixels.
    const isTopToBottom = this.isTranslatedLineVertical(translatedLine);
    const translatedLineWidth =
        (line.geometry.boundingBox.box.width * this.selectionOverlayRect.width);
    const translatedLineHeight =
        (line.geometry.boundingBox.box.height *
         this.selectionOverlayRect.height);

    // Swap width and height if we are rendering the text vertically.
    const lineWidth =
        isTopToBottom ? translatedLineHeight : translatedLineWidth;
    const lineHeight =
        isTopToBottom ? translatedLineWidth : translatedLineHeight;

    this.$.textRenderCanvas.width = lineWidth;
    this.$.textRenderCanvas.height = lineHeight;
    this.resetCanvasPixelRatioIfNeeded();

    // The line translation can contain text that is not actually a part of this
    // particular line. Because of this, we need to loop through the words and
    // create the line string ourselves.
    let text = '';
    for (let i = 0; i < line.words.length; i++) {
      const word = line.words[i];
      text += word.plainText;
      text += getTextSeparator(word);
    }

    let low = MIN_FONT_SIZE;
    let high = MAX_FONT_SIZE;
    // Use binary search to find optimal font size.
    while (low <= high) {
      const mid = Math.floor((low + high) / 2);
      // The font families here should cover what is default used by the text in
      // the HTML.
      this.context.font = `${mid}px Roboto, "Cantarell", Arial, sans-serif`;
      const textMetrics = this.context.measureText(text);

      // Check if the text fits within the container
      const textHeight = textMetrics.actualBoundingBoxAscent +
          textMetrics.actualBoundingBoxDescent;
      if (textMetrics.width >= lineWidth || textHeight >= lineHeight) {
        high = mid - 1;
      } else {
        low = mid + 1;
      }
    }
    return Math.min(low - 1, MAX_FONT_SIZE);
  }

  // Returns the rectangle circumscribing the given lines.
  private getContainingRect(lines: HighlightedLine[]) {
    const left = Math.min(...lines.map((line) => line.left));
    const right = Math.max(...lines.map((line) => line.left + line.width));
    const top = Math.min(...lines.map((line) => line.top));
    const bottom = Math.max(...lines.map((line) => line.top + line.height));
    return {left, right, top, bottom};
  }

  // Used by the HTML template to get the array of highlighted lines to render
  // whenever the selection indices change.
  private getHighlightedLines(): HighlightedLine[] {
    const newHighlightedLines: HighlightedLine[] = [];

    // Return early if there isn't a valid selection.
    if (this.selectionStartIndex === -1 || this.selectionEndIndex === -1) {
      return newHighlightedLines;
    }

    const startIndex =
        Math.min(this.selectionStartIndex, this.selectionEndIndex);
    const endIndex = Math.max(this.selectionStartIndex, this.selectionEndIndex);

    const words = this.shouldRenderTranslateWords ?
        this.translatedWordsInOrder :
        this.renderedWords;
    let currentLineIndex = this.lineNumbers[startIndex];
    let startWord: Word = words[startIndex];
    let endWord: Word = words[startIndex];

    // Get max dimensions per line.
    for (let i = startIndex; i <= endIndex; i++) {
      if (this.lineNumbers[i] !== currentLineIndex) {
        // Add the line
        newHighlightedLines.push(this.calculateHighlightedLine(
            startWord, endWord, this.isTopToBottomWritingDirection(i)));

        // Save new line data.
        startWord = words[i];
        currentLineIndex = this.lineNumbers[i];
      }
      endWord = words[i];
    }
    // Add the last line in the selection
    newHighlightedLines.push(this.calculateHighlightedLine(
        startWord, endWord, this.isTopToBottomWritingDirection(endIndex)));

    return newHighlightedLines;
  }

  // Given two words, returns the bounding box that properly encapsulates this
  // region.
  private calculateHighlightedLine(
      startWord: Word, endWord: Word, isTopToBottom: boolean): HighlightedLine {
    // We only render words with geometry, so these geometry's should be
    // guaranteed to exist.
    assert(startWord.geometry);
    assert(endWord.geometry);

    // Grab the bounding boxes for easier to read code
    const startWordBoundingBox = startWord.geometry.boundingBox;
    const endWordBoundingBox = endWord.geometry.boundingBox;

    // Since the two words in a line can be at an angle, there center points are
    // not necessarily in a straight line. We need to calculate the slope
    // created by the selected boxes to align the boxes vertically so we can
    // generate the containing box.
    const slope = (endWordBoundingBox.box.y - startWordBoundingBox.box.y) /
        (endWordBoundingBox.box.x - startWordBoundingBox.box.x);

    // Calculate the angle needed to rotate to align the items linearly. If
    // slope is undefined because the denominator was zero, we default to no
    // rotation.
    let rotationAngle = slope ? Math.atan(slope) : 0;

    // Top to bottom languages need to rotate by an extra 90 degrees for the
    // logic to work correctly.
    if (isTopToBottom) {
      rotationAngle += 1.5708;
    }

    // Get the new linearly aligned center points.
    const relativeStartCenter = rotateCoordinateAroundOrigin(
        {x: startWordBoundingBox.box.x, y: startWordBoundingBox.box.y},
        rotationAngle);
    const relativeEndCenter = rotateCoordinateAroundOrigin(
        {x: endWordBoundingBox.box.x, y: endWordBoundingBox.box.y},
        rotationAngle);

    // Calculate the dimensions for our containing box using the new center
    // points and the same width and height as before.
    const containingBoxTop = Math.min(
        relativeStartCenter.y - startWordBoundingBox.box.height / 2,
        relativeEndCenter.y - endWordBoundingBox.box.height / 2);
    const containingBoxLeft = Math.min(
        relativeStartCenter.x - startWordBoundingBox.box.width / 2,
        relativeEndCenter.x - endWordBoundingBox.box.width / 2);
    const containingBoxBottom = Math.max(
        relativeStartCenter.y + startWordBoundingBox.box.height / 2,
        relativeEndCenter.y + endWordBoundingBox.box.height / 2);
    const containingBoxRight = Math.max(
        relativeStartCenter.x + startWordBoundingBox.box.width / 2,
        relativeEndCenter.x + endWordBoundingBox.box.width / 2);

    // The generate the center point and undo the rotation so it is back to
    // being relative to the position of the selected line.
    const containingCenter = rotateCoordinateAroundOrigin(
        {
          x: (containingBoxRight + containingBoxLeft) / 2,
          y: (containingBoxTop + containingBoxBottom) / 2,
        },
        -rotationAngle);

    // Since width and height don't change with rotation, simply get the width
    // and height.
    const containingBoxWidth = containingBoxRight - containingBoxLeft;
    const containingBoxHeight = containingBoxBottom - containingBoxTop;

    // Convert to easy to render format.
    return {
      top: containingCenter.y - containingBoxHeight / 2,
      left: containingCenter.x - containingBoxWidth / 2,
      width: containingBoxWidth,
      height: containingBoxHeight,
      rotation:
          (startWordBoundingBox.rotation + endWordBoundingBox.rotation) / 2,
    };
  }

  // Returns whether the word at the given index is a top to bottom written
  // language.
  private isTopToBottomWritingDirection(wordIndex: number): boolean {
    const paragraph = this.paragraphs[this.paragraphNumbers[wordIndex]];
    return paragraph.writingDirection === WritingDirection.kTopToBottom;
  }

  private getHighlightedText(): string {
    // Return early if there isn't a valid selection.
    if (this.selectionStartIndex === -1 || this.selectionEndIndex === -1) {
      return '';
    }

    const startIndex =
        Math.min(this.selectionStartIndex, this.selectionEndIndex);
    const endIndex = Math.max(this.selectionStartIndex, this.selectionEndIndex);

    const selectedWords = this.shouldRenderTranslateWords ?
        this.translatedWordsInOrder.slice(startIndex, endIndex + 1) :
        this.renderedWords.slice(startIndex, endIndex + 1);
    return selectedWords
        .map((word, index) => {
          return word.plainText +
              (index < selectedWords.length - 1 ? getTextSeparator(word) : '');
        })
        .join('');
  }

  /** @return The CSS styles string for the given word. */
  private getWordStyle(word: Word): string {
    const horizontalLineMarginPercent =
        loadTimeData.getInteger('verticalTextMarginPx') /
        this.selectionOverlayRect.height;
    const verticalLineMarginPercent =
        loadTimeData.getInteger('horizontalTextMarginPx') /
        this.selectionOverlayRect.width;

    // Words without bounding boxes are filtered out, so guaranteed that
    // geometry is not null.
    const wordBoundingBox = word.geometry!.boundingBox;

    // TODO(b/330183480): Currently, we are assuming that word
    // coordinates are normalized. We should still implement
    // rendering in case this assumption is ever violated.
    if (wordBoundingBox.coordinateType !==
        CenterRotatedBox_CoordinateType.kNormalized) {
      return '';
    }

    // Put into an array instead of a long string to keep this code readable.
    const styles: string[] = [
      `width: ${
          toPercent(
              wordBoundingBox.box.width + 2 * horizontalLineMarginPercent)}`,
      `height: ${
          toPercent(
              wordBoundingBox.box.height + 2 * verticalLineMarginPercent)}`,
      `top: ${
          toPercent(
              wordBoundingBox.box.y - (wordBoundingBox.box.height / 2) -
              verticalLineMarginPercent)}`,
      `left: ${
          toPercent(
              wordBoundingBox.box.x - (wordBoundingBox.box.width / 2) -
              horizontalLineMarginPercent)}`,
      `transform: rotate(${wordBoundingBox.rotation}rad)`,
    ];
    return styles.join(';');
  }

  private getTranslatedLineStyle(translatedLineData: TranslatedLineData):
      string {
    const translatedLine = translatedLineData.line;
    if (!translatedLine.geometry) {
      return '';
    }

    const lineBoundingBox = translatedLine.geometry.boundingBox;
    // TODO(b/330183480): Currently, we are assuming that word
    // coordinates are normalized. We should still implement
    // rendering in case this assumption is ever violated.
    if (lineBoundingBox.coordinateType !==
        CenterRotatedBox_CoordinateType.kNormalized) {
      return '';
    }

    const lineFontSizePixels = this.calculateFontSizePixels(translatedLineData);
    const styles: string[] = [
      `background-color: ${
          this.getBackgroundColorForLine(translatedLine, lineFontSizePixels)}`,
      `color: ${skColorToHexColor(translatedLine.textColor)}`,
      `justify-content: ${this.getLineAlignment(translatedLineData.alignment)}`,
      `font-size: ${lineFontSizePixels}px`,
      `width: ${toPercent(lineBoundingBox.box.width)}`,
      `height: ${toPercent(lineBoundingBox.box.height)}`,
      `top: ${
          toPercent(lineBoundingBox.box.y - (lineBoundingBox.box.height / 2))}`,
      `left: ${
          toPercent(lineBoundingBox.box.x - (lineBoundingBox.box.width / 2))}`,
      `text-shadow: ${
          this.getOutlineStyleForLine(translatedLine, lineFontSizePixels)}`,
      `transform: rotate(${lineBoundingBox.rotation}rad)`,
      `writing-mode: ${this.getWritingModeForLine(translatedLineData)}`,
    ];
    return styles.join(';');
  }

  private getBackgroundImageDataStyle(translatedLineData: TranslatedLineData):
      string {
    const translatedLine = translatedLineData.line;
    if (!translatedLine.geometry) {
      return '';
    }

    const lineBoundingBox = translatedLine.geometry.boundingBox;
    // TODO(b/330183480): Currently, we are assuming that word
    // coordinates are normalized. We should still implement
    // rendering in case this assumption is ever violated.
    if (lineBoundingBox.coordinateType !==
        CenterRotatedBox_CoordinateType.kNormalized) {
      return '';
    }

    const backgroundImageData = translatedLine.backgroundImageData;
    if (!backgroundImageData) {
      return '';
    }

    // Both background image padding values are relative to the line height.
    const horizontalPadding =
        backgroundImageData.horizontalPadding * lineBoundingBox.box.height;
    const verticalPadding =
        backgroundImageData.verticalPadding * lineBoundingBox.box.height;

    const styles: string[] = [
      `width: ${toPercent(lineBoundingBox.box.width + horizontalPadding)}`,
      `height: ${toPercent(lineBoundingBox.box.height + verticalPadding)}`,
      `top: ${
          toPercent(
              lineBoundingBox.box.y - (lineBoundingBox.box.height / 2) -
              (0.5 * verticalPadding))}`,
      `left: ${
          toPercent(
              lineBoundingBox.box.x - (lineBoundingBox.box.width / 2) -
              (0.5 * horizontalPadding))}`,
    ];
    return styles.join(';');
  }

  private getOutlineStyleForLine(line: TranslatedLine, fontSize: number):
      string {
    if (!line.backgroundImageData) {
      return 'none';
    }
    const outlineColor = skColorToRgba(line.backgroundPrimaryColor);
    const outlineWidth = fontSize * 0.02;
    return `-${outlineWidth}px ${outlineWidth}px 0 ${outlineColor},
            ${outlineWidth}px ${outlineWidth}px 0 ${outlineColor},
            ${outlineWidth}px -${outlineWidth}px 0 ${outlineColor},
            -${outlineWidth}px -${outlineWidth}px 0 ${outlineColor}`;
  }

  private getBackgroundColorForLine(line: TranslatedLine, fontSize: number):
      string {
    // When background image data is present, we only want it to be opaque for
    // very small text for accessibility reasons.
    if (line.backgroundImageData && fontSize >= FONT_SIZE_TRANSPARENT_BOUND) {
      return 'transparent';
    }

    // If background image data is not present, the background should be opaque.
    // Below opaque bound, it should be fully opaque.
    if (!line.backgroundImageData ||
        (line.backgroundImageData && fontSize <= FONT_SIZE_OPAQUE_BOUND)) {
      return skColorToRgba(line.backgroundPrimaryColor);
    }

    // Font sizes between the two values should iversely interpolate over 0-255
    // for opacity.
    const opacityRatio = (fontSize - FONT_SIZE_OPAQUE_BOUND) /
        (FONT_SIZE_TRANSPARENT_BOUND - FONT_SIZE_OPAQUE_BOUND);
    const clampedOpacity = Math.min(Math.max(opacityRatio, 0), 1);
    return skColorToRgbaWithCustomAlpha(
        line.backgroundPrimaryColor, clampedOpacity);
  }

  private isTranslatedLineVertical(line: TranslatedLineData): boolean {
    const writingDirection =
        this.renderedTranslateParagraphs[line.paragraphIndex].writingDirection;
    return writingDirection === WritingDirection.kTopToBottom;
  }

  private getWritingModeForLine(line: TranslatedLineData): string {
    if (this.isTranslatedLineVertical(line)) {
      return 'vertical-lr';
    }
    return 'horizontal-tb';
  }

  private getLineAlignment(alignment: Alignment|null): string {
    if (alignment === Alignment.kDefaultLeftAlgined) {
      return 'left';
    } else if (alignment === Alignment.kCenterAligned) {
      return 'center';
    } else if (alignment === Alignment.kRightAligned) {
      return 'right';
    }

    return 'center';
  }

  private resetCanvasPixelRatioIfNeeded() {
    const transform = this.context.getTransform();
    if (transform.a !== window.devicePixelRatio ||
        transform.d !== window.devicePixelRatio) {
      this.context.setTransform(
          window.devicePixelRatio, 0, 0, window.devicePixelRatio, 0, 0);
    }
  }

  private getBlobUrlFromImageData(imageData: BackgroundImageData): string {
    const imageBytesBuffer = imageData.backgroundImage;
    assert(imageBytesBuffer.invalidBuffer !== true);
    let bytes: Uint8Array = new Uint8Array();
    if (imageBytesBuffer.bytes !== undefined) {
      bytes = new Uint8Array(imageBytesBuffer.bytes);
    } else if (imageBytesBuffer.sharedMemory !== undefined) {
      const {bufferHandle, size} = imageBytesBuffer.sharedMemory;
      const {buffer} = bufferHandle.mapBuffer(0, size);
      bytes = new Uint8Array(buffer);
    } else {
      return '';
    }
    // The image should always be a webp image.
    const blob = new Blob([bytes], {type: 'image/webp'});
    return URL.createObjectURL(blob);
  }

  /** @return The CSS styles string for the given highlighted line. */
  private getHighlightedLineStyle(line: HighlightedLine): string {
    // Put into an array instead of a long string to keep this code readable.
    const styles: string[] = [
      `width: ${toPercent(line.width)}`,
      `height: ${toPercent(line.height)}`,
      `top: ${toPercent(line.top)}`,
      `left: ${toPercent(line.left)}`,
      `transform: rotate(${line.rotation}rad)`,
    ];
    return styles.join(';');
  }

  /**
   * @return Returns the index in renderedWords of the word at the given point.
   *     Returns null if no word is at the given point.
   */
  private wordIndexFromPoint(x: number, y: number): number|null {
    const topMostElement = this.shadowRoot!.elementFromPoint(x, y);
    if (!topMostElement || !(topMostElement instanceof HTMLElement)) {
      return null;
    }
    return this.$.wordsContainer.indexForElement(topMostElement);
  }

  /**
   *
   * @returns Returns the index in translatedWordsInOrder of the word at the
   *     given point. Returns null if no word is at the given point.
   */
  private translatedWordIndexFromPoint(x: number, y: number): number|null {
    const topMostElement = this.shadowRoot!.elementFromPoint(x, y);
    if (!topMostElement || !(topMostElement instanceof HTMLElement)) {
      return null;
    }

    const wordIndexString = topMostElement.dataset['wordIndex'];
    if (!wordIndexString) {
      return null;
    }
    return parseInt(wordIndexString) ?? null;
  }

  // Testing method to get the words on the page.
  getWordNodesForTesting() {
    return this.shadowRoot!.querySelectorAll('.word');
  }

  // Testing method to get the translated words on the page.
  getTranslatedWordNodesForTesting() {
    return this.shadowRoot!.querySelectorAll('.translated-word');
  }

  // Testing method to get the highlighted words on the page.
  getHighlightedNodesForTesting() {
    return this.shadowRoot!.querySelectorAll('.highlighted-line');
  }
}

declare global {
  interface HTMLElementTagNameMap {
    'lens-text-layer': TextLayerElement;
  }
}

customElements.define(TextLayerElement.is, TextLayerElement);