Browse Source

feat: custom text metrics provider (#9121)

Marcel Mraz 5 months ago
parent
commit
e3060dfb8f

+ 1 - 1
packages/excalidraw/actions/actionBoundText.tsx

@@ -10,7 +10,6 @@ import {
   computeBoundTextPosition,
   computeContainerDimensionForBoundText,
   getBoundTextElement,
-  measureText,
   redrawTextBoundingBox,
 } from "../element/textElement";
 import {
@@ -35,6 +34,7 @@ import { arrayToMap, getFontString } from "../utils";
 import { register } from "./register";
 import { syncMovedIndices } from "../fractionalIndex";
 import { StoreAction } from "../store";
+import { measureText } from "../element/textMeasurements";
 
 export const actionUnbindText = register({
   name: "unbindText",

+ 1 - 1
packages/excalidraw/actions/actionTextAutoResize.ts

@@ -1,6 +1,6 @@
 import { isTextElement } from "../element";
 import { newElementWith } from "../element/mutateElement";
-import { measureText } from "../element/textElement";
+import { measureText } from "../element/textMeasurements";
 import { getSelectedElements } from "../scene";
 import { StoreAction } from "../store";
 import type { AppClassProperties } from "../types";

+ 9 - 7
packages/excalidraw/components/App.tsx

@@ -331,17 +331,10 @@ import type { FileSystemHandle } from "../data/filesystem";
 import { fileOpen } from "../data/filesystem";
 import {
   bindTextToShapeAfterDuplication,
-  getApproxMinLineHeight,
-  getApproxMinLineWidth,
   getBoundTextElement,
   getContainerCenter,
   getContainerElement,
-  getLineHeightInPx,
-  getMinTextElementWidth,
-  isMeasureTextSupported,
   isValidTextContainer,
-  measureText,
-  normalizeText,
 } from "../element/textElement";
 import {
   showHyperlinkTooltip,
@@ -465,6 +458,15 @@ import { cropElement } from "../element/cropElement";
 import { wrapText } from "../element/textWrapping";
 import { actionCopyElementLink } from "../actions/actionElementLink";
 import { isElementLink, parseElementLinkFromURL } from "../element/elementLink";
+import {
+  isMeasureTextSupported,
+  normalizeText,
+  measureText,
+  getLineHeightInPx,
+  getApproxMinLineWidth,
+  getApproxMinLineHeight,
+  getMinTextElementWidth,
+} from "../element/textMeasurements";
 
 const AppContext = React.createContext<AppClassProperties>(null!);
 const AppPropsContext = React.createContext<AppProps>(null!);

+ 1 - 3
packages/excalidraw/components/SearchMenu.tsx

@@ -7,7 +7,6 @@ import { debounce } from "lodash";
 import type { AppClassProperties } from "../types";
 import { isTextElement, newTextElement } from "../element";
 import type { ExcalidrawTextElement } from "../element/types";
-import { measureText } from "../element/textElement";
 import { addEventListener, getFontString } from "../utils";
 import { KEYS } from "../keys";
 import clsx from "clsx";
@@ -20,6 +19,7 @@ import { useStable } from "../hooks/useStable";
 
 import "./SearchMenu.scss";
 import { round } from "../../math";
+import { measureText } from "../element/textMeasurements";
 
 const searchQueryAtom = atom<string>("");
 export const searchItemInFocusAtom = atom<number | null>(null);
@@ -607,7 +607,6 @@ const getMatchedLines = (
         textToStart,
         getFontString(textElement),
         textElement.lineHeight,
-        true,
       );
 
       // measureText returns a non-zero width for the empty string
@@ -621,7 +620,6 @@ const getMatchedLines = (
           lineIndexRange.line,
           getFontString(textElement),
           textElement.lineHeight,
-          true,
         );
 
         const spaceToStart =

+ 2 - 1
packages/excalidraw/data/restore.ts

@@ -46,7 +46,7 @@ import { bumpVersion } from "../element/mutateElement";
 import { getUpdatedTimestamp, updateActiveTool } from "../utils";
 import { arrayToMap } from "../utils";
 import type { MarkOptional, Mutable } from "../utility-types";
-import { detectLineHeight, getContainerElement } from "../element/textElement";
+import { getContainerElement } from "../element/textElement";
 import { normalizeLink } from "./url";
 import { syncInvalidIndices } from "../fractionalIndex";
 import { getSizeFromPoints } from "../points";
@@ -59,6 +59,7 @@ import {
 } from "../scene";
 import type { LocalPoint, Radians } from "../../math";
 import { isFiniteNumber, pointFrom } from "../../math";
+import { detectLineHeight } from "../element/textMeasurements";
 
 type RestoredAppState = Omit<
   AppState,

+ 1 - 1
packages/excalidraw/data/transform.ts

@@ -19,7 +19,6 @@ import {
   newMagicFrameElement,
   newTextElement,
 } from "../element/newElement";
-import { measureText, normalizeText } from "../element/textElement";
 import type {
   ElementsMap,
   ExcalidrawArrowElement,
@@ -55,6 +54,7 @@ import { syncInvalidIndices } from "../fractionalIndex";
 import { getLineHeight } from "../fonts";
 import { isArrowElement } from "../element/typeChecks";
 import { pointFrom, type LocalPoint } from "../../math";
+import { measureText, normalizeText } from "../element/textMeasurements";
 
 export type ValidLinearElement = {
   type: "arrow" | "line";

+ 2 - 1
packages/excalidraw/element/dragElements.ts

@@ -10,7 +10,7 @@ import type {
   NullableGridSize,
   PointerDownState,
 } from "../types";
-import { getBoundTextElement, getMinTextElementWidth } from "./textElement";
+import { getBoundTextElement } from "./textElement";
 import type Scene from "../scene/Scene";
 import {
   isArrowElement,
@@ -22,6 +22,7 @@ import {
 import { getFontString } from "../utils";
 import { TEXT_AUTOWRAP_THRESHOLD } from "../constants";
 import { getGridPoint } from "../snapping";
+import { getMinTextElementWidth } from "./textMeasurements";
 
 export const dragSelectedElements = (
   pointerDownState: PointerDownState,

+ 2 - 5
packages/excalidraw/element/newElement.ts

@@ -33,11 +33,7 @@ import { getNewGroupIdsForDuplication } from "../groups";
 import type { AppState } from "../types";
 import { getElementAbsoluteCoords } from ".";
 import { getResizedElementAbsoluteCoords } from "./bounds";
-import {
-  measureText,
-  normalizeText,
-  getBoundTextMaxWidth,
-} from "./textElement";
+import { getBoundTextMaxWidth } from "./textElement";
 import { wrapText } from "./textWrapping";
 import {
   DEFAULT_ELEMENT_PROPS,
@@ -51,6 +47,7 @@ import {
 import type { MarkOptional, Merge, Mutable } from "../utility-types";
 import { getLineHeight } from "../fonts";
 import type { Radians } from "../../math";
+import { normalizeText, measureText } from "./textMeasurements";
 
 export type ElementConstructorOpts = MarkOptional<
   Omit<ExcalidrawGenericElement, "id" | "type" | "isDeleted" | "updated">,

+ 6 - 4
packages/excalidraw/element/resizeElements.ts

@@ -41,15 +41,11 @@ import type {
 import type { PointerDownState } from "../types";
 import type Scene from "../scene/Scene";
 import {
-  getApproxMinLineWidth,
   getBoundTextElement,
   getBoundTextElementId,
   getContainerElement,
   handleBindTextResize,
   getBoundTextMaxWidth,
-  getApproxMinLineHeight,
-  measureText,
-  getMinTextElementWidth,
 } from "./textElement";
 import { wrapText } from "./textWrapping";
 import { LinearElementEditor } from "./linearElementEditor";
@@ -64,6 +60,12 @@ import {
   type Radians,
   type LocalPoint,
 } from "../../math";
+import {
+  getMinTextElementWidth,
+  measureText,
+  getApproxMinLineWidth,
+  getApproxMinLineHeight,
+} from "./textMeasurements";
 
 // Returns true when transform (resizing/rotation) happened
 export const transformElements = (

+ 1 - 2
packages/excalidraw/element/textElement.test.ts

@@ -6,9 +6,8 @@ import {
   getContainerCoords,
   getBoundTextMaxWidth,
   getBoundTextMaxHeight,
-  detectLineHeight,
-  getLineHeightInPx,
 } from "./textElement";
+import { detectLineHeight, getLineHeightInPx } from "./textMeasurements";
 import type { ExcalidrawTextElementWithContainer } from "./types";
 
 describe("Test measureText", () => {

+ 2 - 228
packages/excalidraw/element/textElement.ts

@@ -1,4 +1,4 @@
-import { getFontString, arrayToMap, isTestEnv, normalizeEOL } from "../utils";
+import { getFontString, arrayToMap } from "../utils";
 import type {
   ElementsMap,
   ExcalidrawElement,
@@ -6,7 +6,6 @@ import type {
   ExcalidrawTextContainer,
   ExcalidrawTextElement,
   ExcalidrawTextElementWithContainer,
-  FontString,
   NonDeletedExcalidrawElement,
 } from "./types";
 import { mutateElement } from "./mutateElement";
@@ -14,7 +13,6 @@ import {
   ARROW_LABEL_FONT_SIZE_TO_MIN_WIDTH_RATIO,
   ARROW_LABEL_WIDTH_FRACTION,
   BOUND_TEXT_PADDING,
-  DEFAULT_FONT_FAMILY,
   DEFAULT_FONT_SIZE,
   TEXT_ALIGN,
   VERTICAL_ALIGN,
@@ -30,18 +28,7 @@ import {
   updateOriginalContainerCache,
 } from "./containerCache";
 import type { ExtractSetType } from "../utility-types";
-
-export const normalizeText = (text: string) => {
-  return (
-    normalizeEOL(text)
-      // replace tabs with spaces so they render and measure correctly
-      .replace(/\t/g, "        ")
-  );
-};
-
-const splitIntoLines = (text: string) => {
-  return normalizeText(text).split("\n");
-};
+import { measureText } from "./textMeasurements";
 
 export const redrawTextBoundingBox = (
   textElement: ExcalidrawTextElement,
@@ -281,201 +268,6 @@ export const computeBoundTextPosition = (
   return { x, y };
 };
 
-export const measureText = (
-  text: string,
-  font: FontString,
-  lineHeight: ExcalidrawTextElement["lineHeight"],
-  forceAdvanceWidth?: true,
-) => {
-  const _text = text
-    .split("\n")
-    // replace empty lines with single space because leading/trailing empty
-    // lines would be stripped from computation
-    .map((x) => x || " ")
-    .join("\n");
-  const fontSize = parseFloat(font);
-  const height = getTextHeight(_text, fontSize, lineHeight);
-  const width = getTextWidth(_text, font, forceAdvanceWidth);
-  return { width, height };
-};
-
-/**
- * To get unitless line-height (if unknown) we can calculate it by dividing
- * height-per-line by fontSize.
- */
-export const detectLineHeight = (textElement: ExcalidrawTextElement) => {
-  const lineCount = splitIntoLines(textElement.text).length;
-  return (textElement.height /
-    lineCount /
-    textElement.fontSize) as ExcalidrawTextElement["lineHeight"];
-};
-
-/**
- * We calculate the line height from the font size and the unitless line height,
- * aligning with the W3C spec.
- */
-export const getLineHeightInPx = (
-  fontSize: ExcalidrawTextElement["fontSize"],
-  lineHeight: ExcalidrawTextElement["lineHeight"],
-) => {
-  return fontSize * lineHeight;
-};
-
-// FIXME rename to getApproxMinContainerHeight
-export const getApproxMinLineHeight = (
-  fontSize: ExcalidrawTextElement["fontSize"],
-  lineHeight: ExcalidrawTextElement["lineHeight"],
-) => {
-  return getLineHeightInPx(fontSize, lineHeight) + BOUND_TEXT_PADDING * 2;
-};
-
-let canvas: HTMLCanvasElement | undefined;
-
-/**
- * @param forceAdvanceWidth use to force retrieve the "advance width" ~ `metrics.width`, instead of the actual boundind box width.
- *
- * > The advance width is the distance between the glyph's initial pen position and the next glyph's initial pen position.
- *
- * We need to use the advance width as that's the closest thing to the browser wrapping algo, hence using it for:
- * - text wrapping
- * - wysiwyg editor (+padding)
- *
- * Everything else should be based on the actual bounding box width.
- *
- * `Math.ceil` of the final width adds additional buffer which stabilizes slight wrapping incosistencies.
- */
-export const getLineWidth = (
-  text: string,
-  font: FontString,
-  forceAdvanceWidth?: true,
-) => {
-  if (!canvas) {
-    canvas = document.createElement("canvas");
-  }
-  const canvas2dContext = canvas.getContext("2d")!;
-  canvas2dContext.font = font;
-  const metrics = canvas2dContext.measureText(text);
-
-  const advanceWidth = metrics.width;
-
-  // retrieve the actual bounding box width if these metrics are available (as of now > 95% coverage)
-  if (
-    !forceAdvanceWidth &&
-    window.TextMetrics &&
-    "actualBoundingBoxLeft" in window.TextMetrics.prototype &&
-    "actualBoundingBoxRight" in window.TextMetrics.prototype
-  ) {
-    // could be negative, therefore getting the absolute value
-    const actualWidth =
-      Math.abs(metrics.actualBoundingBoxLeft) +
-      Math.abs(metrics.actualBoundingBoxRight);
-
-    // fallback to advance width if the actual width is zero, i.e. on text editing start
-    // or when actual width does not respect whitespace chars, i.e. spaces
-    // otherwise actual width should always be bigger
-    return Math.max(actualWidth, advanceWidth);
-  }
-
-  // since in test env the canvas measureText algo
-  // doesn't measure text and instead just returns number of
-  // characters hence we assume that each letteris 10px
-  if (isTestEnv()) {
-    return advanceWidth * 10;
-  }
-
-  return advanceWidth;
-};
-
-export const getTextWidth = (
-  text: string,
-  font: FontString,
-  forceAdvanceWidth?: true,
-) => {
-  const lines = splitIntoLines(text);
-  let width = 0;
-  lines.forEach((line) => {
-    width = Math.max(width, getLineWidth(line, font, forceAdvanceWidth));
-  });
-
-  return width;
-};
-
-export const getTextHeight = (
-  text: string,
-  fontSize: number,
-  lineHeight: ExcalidrawTextElement["lineHeight"],
-) => {
-  const lineCount = splitIntoLines(text).length;
-  return getLineHeightInPx(fontSize, lineHeight) * lineCount;
-};
-
-export const charWidth = (() => {
-  const cachedCharWidth: { [key: FontString]: Array<number> } = {};
-
-  const calculate = (char: string, font: FontString) => {
-    const unicode = char.charCodeAt(0);
-    if (!cachedCharWidth[font]) {
-      cachedCharWidth[font] = [];
-    }
-    if (!cachedCharWidth[font][unicode]) {
-      const width = getLineWidth(char, font, true);
-      cachedCharWidth[font][unicode] = width;
-    }
-
-    return cachedCharWidth[font][unicode];
-  };
-
-  const getCache = (font: FontString) => {
-    return cachedCharWidth[font];
-  };
-
-  const clearCache = (font: FontString) => {
-    cachedCharWidth[font] = [];
-  };
-
-  return {
-    calculate,
-    getCache,
-    clearCache,
-  };
-})();
-
-const DUMMY_TEXT = "ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789".toLocaleUpperCase();
-
-// FIXME rename to getApproxMinContainerWidth
-export const getApproxMinLineWidth = (
-  font: FontString,
-  lineHeight: ExcalidrawTextElement["lineHeight"],
-) => {
-  const maxCharWidth = getMaxCharWidth(font);
-  if (maxCharWidth === 0) {
-    return (
-      measureText(DUMMY_TEXT.split("").join("\n"), font, lineHeight).width +
-      BOUND_TEXT_PADDING * 2
-    );
-  }
-  return maxCharWidth + BOUND_TEXT_PADDING * 2;
-};
-
-export const getMinCharWidth = (font: FontString) => {
-  const cache = charWidth.getCache(font);
-  if (!cache) {
-    return 0;
-  }
-  const cacheWithOutEmpty = cache.filter((val) => val !== undefined);
-
-  return Math.min(...cacheWithOutEmpty);
-};
-
-export const getMaxCharWidth = (font: FontString) => {
-  const cache = charWidth.getCache(font);
-  if (!cache) {
-    return 0;
-  }
-  const cacheWithOutEmpty = cache.filter((val) => val !== undefined);
-  return Math.max(...cacheWithOutEmpty);
-};
-
 export const getBoundTextElementId = (container: ExcalidrawElement | null) => {
   return container?.boundElements?.length
     ? container?.boundElements?.find((ele) => ele.type === "text")?.id || null
@@ -712,24 +504,6 @@ export const getBoundTextMaxHeight = (
   return height - BOUND_TEXT_PADDING * 2;
 };
 
-export const isMeasureTextSupported = () => {
-  const width = getTextWidth(
-    DUMMY_TEXT,
-    getFontString({
-      fontSize: DEFAULT_FONT_SIZE,
-      fontFamily: DEFAULT_FONT_FAMILY,
-    }),
-  );
-  return width > 0;
-};
-
-export const getMinTextElementWidth = (
-  font: FontString,
-  lineHeight: ExcalidrawTextElement["lineHeight"],
-) => {
-  return measureText("", font, lineHeight).width + BOUND_TEXT_PADDING * 2;
-};
-
 /** retrieves text from text elements and concatenates to a single string */
 export const getTextFromElements = (
   elements: readonly ExcalidrawElement[],

+ 224 - 0
packages/excalidraw/element/textMeasurements.ts

@@ -0,0 +1,224 @@
+import {
+  BOUND_TEXT_PADDING,
+  DEFAULT_FONT_SIZE,
+  DEFAULT_FONT_FAMILY,
+} from "../constants";
+import { getFontString, isTestEnv, normalizeEOL } from "../utils";
+import type { FontString, ExcalidrawTextElement } from "./types";
+
+export const measureText = (
+  text: string,
+  font: FontString,
+  lineHeight: ExcalidrawTextElement["lineHeight"],
+) => {
+  const _text = text
+    .split("\n")
+    // replace empty lines with single space because leading/trailing empty
+    // lines would be stripped from computation
+    .map((x) => x || " ")
+    .join("\n");
+  const fontSize = parseFloat(font);
+  const height = getTextHeight(_text, fontSize, lineHeight);
+  const width = getTextWidth(_text, font);
+  return { width, height };
+};
+
+const DUMMY_TEXT = "ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789".toLocaleUpperCase();
+
+// FIXME rename to getApproxMinContainerWidth
+export const getApproxMinLineWidth = (
+  font: FontString,
+  lineHeight: ExcalidrawTextElement["lineHeight"],
+) => {
+  const maxCharWidth = getMaxCharWidth(font);
+  if (maxCharWidth === 0) {
+    return (
+      measureText(DUMMY_TEXT.split("").join("\n"), font, lineHeight).width +
+      BOUND_TEXT_PADDING * 2
+    );
+  }
+  return maxCharWidth + BOUND_TEXT_PADDING * 2;
+};
+
+export const getMinTextElementWidth = (
+  font: FontString,
+  lineHeight: ExcalidrawTextElement["lineHeight"],
+) => {
+  return measureText("", font, lineHeight).width + BOUND_TEXT_PADDING * 2;
+};
+
+export const isMeasureTextSupported = () => {
+  const width = getTextWidth(
+    DUMMY_TEXT,
+    getFontString({
+      fontSize: DEFAULT_FONT_SIZE,
+      fontFamily: DEFAULT_FONT_FAMILY,
+    }),
+  );
+  return width > 0;
+};
+
+export const normalizeText = (text: string) => {
+  return (
+    normalizeEOL(text)
+      // replace tabs with spaces so they render and measure correctly
+      .replace(/\t/g, "        ")
+  );
+};
+
+const splitIntoLines = (text: string) => {
+  return normalizeText(text).split("\n");
+};
+
+/**
+ * To get unitless line-height (if unknown) we can calculate it by dividing
+ * height-per-line by fontSize.
+ */
+export const detectLineHeight = (textElement: ExcalidrawTextElement) => {
+  const lineCount = splitIntoLines(textElement.text).length;
+  return (textElement.height /
+    lineCount /
+    textElement.fontSize) as ExcalidrawTextElement["lineHeight"];
+};
+
+/**
+ * We calculate the line height from the font size and the unitless line height,
+ * aligning with the W3C spec.
+ */
+export const getLineHeightInPx = (
+  fontSize: ExcalidrawTextElement["fontSize"],
+  lineHeight: ExcalidrawTextElement["lineHeight"],
+) => {
+  return fontSize * lineHeight;
+};
+
+// FIXME rename to getApproxMinContainerHeight
+export const getApproxMinLineHeight = (
+  fontSize: ExcalidrawTextElement["fontSize"],
+  lineHeight: ExcalidrawTextElement["lineHeight"],
+) => {
+  return getLineHeightInPx(fontSize, lineHeight) + BOUND_TEXT_PADDING * 2;
+};
+
+let textMetricsProvider: TextMetricsProvider | undefined;
+
+/**
+ * Set a custom text metrics provider.
+ *
+ * Useful for overriding the width calculation algorithm where canvas API is not available / desired.
+ */
+export const setCustomTextMetricsProvider = (provider: TextMetricsProvider) => {
+  textMetricsProvider = provider;
+};
+
+export interface TextMetricsProvider {
+  getLineWidth(text: string, fontString: FontString): number;
+}
+
+class CanvasTextMetricsProvider implements TextMetricsProvider {
+  private canvas: HTMLCanvasElement;
+
+  constructor() {
+    this.canvas = document.createElement("canvas");
+  }
+
+  /**
+   * We need to use the advance width as that's the closest thing to the browser wrapping algo, hence using it for:
+   * - text wrapping
+   * - wysiwyg editor (+padding)
+   *
+   * > The advance width is the distance between the glyph's initial pen position and the next glyph's initial pen position.
+   */
+  public getLineWidth(text: string, fontString: FontString): number {
+    const context = this.canvas.getContext("2d")!;
+    context.font = fontString;
+    const metrics = context.measureText(text);
+    const advanceWidth = metrics.width;
+
+    // since in test env the canvas measureText algo
+    // doesn't measure text and instead just returns number of
+    // characters hence we assume that each letteris 10px
+    if (isTestEnv()) {
+      return advanceWidth * 10;
+    }
+
+    return advanceWidth;
+  }
+}
+
+export const getLineWidth = (text: string, font: FontString) => {
+  if (!textMetricsProvider) {
+    textMetricsProvider = new CanvasTextMetricsProvider();
+  }
+
+  return textMetricsProvider.getLineWidth(text, font);
+};
+
+export const getTextWidth = (text: string, font: FontString) => {
+  const lines = splitIntoLines(text);
+  let width = 0;
+  lines.forEach((line) => {
+    width = Math.max(width, getLineWidth(line, font));
+  });
+
+  return width;
+};
+
+export const getTextHeight = (
+  text: string,
+  fontSize: number,
+  lineHeight: ExcalidrawTextElement["lineHeight"],
+) => {
+  const lineCount = splitIntoLines(text).length;
+  return getLineHeightInPx(fontSize, lineHeight) * lineCount;
+};
+
+export const charWidth = (() => {
+  const cachedCharWidth: { [key: FontString]: Array<number> } = {};
+
+  const calculate = (char: string, font: FontString) => {
+    const unicode = char.charCodeAt(0);
+    if (!cachedCharWidth[font]) {
+      cachedCharWidth[font] = [];
+    }
+    if (!cachedCharWidth[font][unicode]) {
+      const width = getLineWidth(char, font);
+      cachedCharWidth[font][unicode] = width;
+    }
+
+    return cachedCharWidth[font][unicode];
+  };
+
+  const getCache = (font: FontString) => {
+    return cachedCharWidth[font];
+  };
+
+  const clearCache = (font: FontString) => {
+    cachedCharWidth[font] = [];
+  };
+
+  return {
+    calculate,
+    getCache,
+    clearCache,
+  };
+})();
+
+export const getMinCharWidth = (font: FontString) => {
+  const cache = charWidth.getCache(font);
+  if (!cache) {
+    return 0;
+  }
+  const cacheWithOutEmpty = cache.filter((val) => val !== undefined);
+
+  return Math.min(...cacheWithOutEmpty);
+};
+
+export const getMaxCharWidth = (font: FontString) => {
+  const cache = charWidth.getCache(font);
+  if (!cache) {
+    return 0;
+  }
+  const cacheWithOutEmpty = cache.filter((val) => val !== undefined);
+  return Math.max(...cacheWithOutEmpty);
+};

+ 6 - 6
packages/excalidraw/element/textWrapping.ts

@@ -1,5 +1,5 @@
 import { ENV } from "../constants";
-import { charWidth, getLineWidth } from "./textElement";
+import { charWidth, getLineWidth } from "./textMeasurements";
 import type { FontString } from "./types";
 
 let cachedCjkRegex: RegExp | undefined;
@@ -385,7 +385,7 @@ export const wrapText = (
   const originalLines = text.split("\n");
 
   for (const originalLine of originalLines) {
-    const currentLineWidth = getLineWidth(originalLine, font, true);
+    const currentLineWidth = getLineWidth(originalLine, font);
 
     if (currentLineWidth <= maxWidth) {
       lines.push(originalLine);
@@ -423,7 +423,7 @@ const wrapLine = (
     // cache single codepoint whitespace, CJK or emoji width calc. as kerning should not apply here
     const testLineWidth = isSingleCharacter(token)
       ? currentLineWidth + charWidth.calculate(token, font)
-      : getLineWidth(testLine, font, true);
+      : getLineWidth(testLine, font);
 
     // build up the current line, skipping length check for possibly trailing whitespaces
     if (/\s/.test(token) || testLineWidth <= maxWidth) {
@@ -443,7 +443,7 @@ const wrapLine = (
 
       // trailing line of the wrapped word might still be joined with next token/s
       currentLine = trailingLine;
-      currentLineWidth = getLineWidth(trailingLine, font, true);
+      currentLineWidth = getLineWidth(trailingLine, font);
       iterator = tokenIterator.next();
     } else {
       // push & reset, but don't iterate on the next token, as we didn't use it yet!
@@ -514,7 +514,7 @@ const wrapWord = (
  * Similarly to browsers, does not trim all trailing whitespaces, but only those exceeding the `maxWidth`.
  */
 const trimLine = (line: string, font: FontString, maxWidth: number) => {
-  const shouldTrimWhitespaces = getLineWidth(line, font, true) > maxWidth;
+  const shouldTrimWhitespaces = getLineWidth(line, font) > maxWidth;
 
   if (!shouldTrimWhitespaces) {
     return line;
@@ -527,7 +527,7 @@ const trimLine = (line: string, font: FontString, maxWidth: number) => {
     "",
   ];
 
-  let trimmedLineWidth = getLineWidth(trimmedLine, font, true);
+  let trimmedLineWidth = getLineWidth(trimmedLine, font);
 
   for (const whitespace of Array.from(whitespaces)) {
     const _charWidth = charWidth.calculate(whitespace, font);

+ 3 - 3
packages/excalidraw/element/textWysiwyg.tsx

@@ -24,8 +24,6 @@ import {
   getBoundTextElementId,
   getContainerElement,
   getTextElementAngle,
-  getTextWidth,
-  normalizeText,
   redrawTextBoundingBox,
   getBoundTextMaxHeight,
   getBoundTextMaxWidth,
@@ -50,6 +48,8 @@ import {
   originalContainerCache,
   updateOriginalContainerCache,
 } from "./containerCache";
+import { getTextWidth } from "./textMeasurements";
+import { normalizeText } from "./textMeasurements";
 
 const getTransform = (
   width: number,
@@ -350,7 +350,7 @@ export const textWysiwyg = ({
           font,
           getBoundTextMaxWidth(container, boundTextElement),
         );
-        const width = getTextWidth(wrappedText, font, true);
+        const width = getTextWidth(wrappedText, font);
         editable.style.width = `${width}px`;
       }
     };

+ 2 - 1
packages/excalidraw/fonts/Fonts.ts

@@ -6,7 +6,7 @@ import {
   getFontFamilyFallbacks,
 } from "../constants";
 import { isTextElement } from "../element";
-import { charWidth, getContainerElement } from "../element/textElement";
+import { getContainerElement } from "../element/textElement";
 import { containsCJK } from "../element/textWrapping";
 import { ShapeCache } from "../scene/ShapeCache";
 import { getFontString, PromisePool, promiseTry } from "../utils";
@@ -31,6 +31,7 @@ import type {
 } from "../element/types";
 import type Scene from "../scene/Scene";
 import type { ValueOf } from "../utility-types";
+import { charWidth } from "../element/textMeasurements";
 
 export class Fonts {
   // it's ok to track fonts across multiple instances only once, so let's use

+ 2 - 0
packages/excalidraw/index.tsx

@@ -295,3 +295,5 @@ export {
 export { DiagramToCodePlugin } from "./components/DiagramToCodePlugin/DiagramToCodePlugin";
 export { getDataURL } from "./data/blob";
 export { isElementLink } from "./element/elementLink";
+
+export { setCustomTextMetricsProvider } from "./element/textMeasurements";

+ 1 - 1
packages/excalidraw/renderer/renderElement.ts

@@ -52,7 +52,6 @@ import {
   getBoundTextElement,
   getContainerCoords,
   getContainerElement,
-  getLineHeightInPx,
   getBoundTextMaxHeight,
   getBoundTextMaxWidth,
 } from "../element/textElement";
@@ -64,6 +63,7 @@ import { getVerticalOffset } from "../fonts";
 import { isRightAngleRads } from "../../math";
 import { getCornerRadius } from "../shapes";
 import { getUncroppedImageElement } from "../element/cropElement";
+import { getLineHeightInPx } from "../element/textMeasurements";
 
 // using a stronger invert (100% vs our regular 93%) and saturate
 // as a temp hack to make images in dark theme look closer to original

+ 1 - 1
packages/excalidraw/renderer/staticSvgScene.ts

@@ -16,7 +16,6 @@ import { LinearElementEditor } from "../element/linearElementEditor";
 import {
   getBoundTextElement,
   getContainerElement,
-  getLineHeightInPx,
 } from "../element/textElement";
 import {
   isArrowElement,
@@ -38,6 +37,7 @@ import { getFreeDrawSvgPath, IMAGE_INVERT_FILTER } from "./renderElement";
 import { getVerticalOffset } from "../fonts";
 import { getCornerRadius, isPathALoop } from "../shapes";
 import { getUncroppedWidthAndHeight } from "../element/cropElement";
+import { getLineHeightInPx } from "../element/textMeasurements";
 
 const roughSVGDrawWithPrecision = (
   rsvg: RoughSVG,

+ 1 - 1
packages/excalidraw/tests/clipboard.test.tsx

@@ -5,7 +5,7 @@ import { render, waitFor, GlobalTestState } from "./test-utils";
 import { Pointer, Keyboard } from "./helpers/ui";
 import { Excalidraw } from "../index";
 import { KEYS } from "../keys";
-import { getLineHeightInPx } from "../element/textElement";
+import { getLineHeightInPx } from "../element/textMeasurements";
 import { getElementBounds } from "../element";
 import type { NormalizedZoomValue } from "../types";
 import { API } from "./helpers/api";