diff --git a/.github/skills/code-review/SKILL.md b/.github/skills/code-review/SKILL.md index 86d1cf46830..764e60be2e1 100644 --- a/.github/skills/code-review/SKILL.md +++ b/.github/skills/code-review/SKILL.md @@ -74,17 +74,18 @@ Record the output of this pass in your response, not just in internal reasoning. For the branch as a whole, and then for each non-trivial new or changed function: 1. **Identify the stated intent.** Read the commit messages, PR description, any task/issue reference, and the function's name and doc comment. Write down in one sentence what the code claims to do. If no commit message, PR description, or doc comment explains the intent, derive it from the function name and surrounding call sites, and **flag the missing context as a Warning** — a reviewer should not have to guess what the code is for. -2. **Enumerate representative inputs.** List concrete input shapes the function must handle. Typical shapes to consider: empty, single element, two elements, boundary values at each end of a range, the symmetric counterpart of the obvious case (if the author handled A, did they handle not-A?), and any input that would take the code through a different branch or early return. +2. **Enumerate representative inputs.** List concrete input shapes the function must handle. Typical shapes to consider: empty, single element, two elements, boundary values at each end of a range, the symmetric counterpart of the obvious case (if the author handled A, did they handle not-A?), and any input that would take the code through a different branch or early return. For union-typed inputs (e.g. `number[] | string`), walk through one concrete value per variant so every branch sees a realistic value — a single input that happens to satisfy a shared guard (like `.length`) will hide bugs where the guard means different things across variants. 3. **Trace the output for each input.** Walk each input through the implementation and compare the result against the stated intent. If any input produces a result that doesn't match the intent, that is a Critical or Warning issue — even if the code compiles, lints, and every existing test passes. 4. **Check test coverage against the enumerated inputs.** If a particular input shape matters to the stated intent and no test exercises it, flag that as a Warning. Passing tests only prove the cases the author thought to test. 5. **For every branch, cache, or shortcut: state the precondition.** When the code takes a fast path, reads a cached value, or returns early for a subset of inputs, write down the precondition under which that path produces the same result as the general path, then check the surrounding code actually guarantees it. Common failure shapes: a cached value computed under different assumptions than when it is read; a memoization keyed on a subset of the real inputs; a length-based shortcut that skips a step the general path would have applied. +6. **For every field or variable the PR assigns a new value to: re-read its declaration and doc comment.** If the new value no longer fits the declared name or documented semantics, that is a Warning — either rename the field, update the doc comment, or remove the field if nothing consumes it. Doc-comment drift is one of the most common refactor regressions, and values with no downstream readers are especially prone to it because nothing else forces them back into agreement. ### Step 4: Mechanical checklist Apply each item to every changed line that is not excluded under Step 2. 1. **Repository conventions.** Apply every rule from the instruction files in [instructions/index.md](../../instructions/index.md) that matches the changed files — coding conventions, prohibited APIs, performance rules for render-loop code, side-effect imports, backward-compatibility rules, documentation standards, test patterns, and any domain-specific rules (Inspector, glTF extensions, playgrounds, etc.). If an instruction file's content is already in your system prompt context, apply it directly; only read from disk when it is not. -2. **Correctness.** Logic errors, off-by-one, null/undefined access, race conditions, unhandled edge cases, incorrect operator precedence, wrong loop bounds. Verify that doc comments accurately describe the implementation's actual behavior. +2. **Correctness.** Logic errors, off-by-one, null/undefined access, race conditions, unhandled edge cases, incorrect operator precedence, wrong loop bounds. Verify that doc comments accurately describe the implementation's actual behavior. When a refactor changes the value assigned to a named field or variable, verify its name and doc comment still describe the new value — renames are the most common regression vector in data-shape refactors. 3. **Error handling.** When code detects an error or invalid state (exceeding limits, missing data, unsupported configuration), it must handle it appropriately — bail out, fall back to a safe alternative, or properly resolve the condition. Flag cases that merely log a warning or swallow the error while continuing as if nothing happened. 4. **Security.** Prototype pollution, unsafe `eval` / `Function()`, unsafe deserialization of untrusted input (e.g. parsed scene files, glTF extensions, user-supplied JSON). 5. **General quality.** Dead code, unreachable branches, duplicated logic, overly complex control flow, poor or misleading naming. @@ -116,9 +117,9 @@ Capture any failures and include them as issues in the review. If a command does Compile every issue found into a single markdown table, sorted by severity (Critical → Warning → Nit). This is the table you will present in Step 8, so record issues in their final format now. -| # | File | Line(s) | Severity | Issue | Fix Applied | -| --- | -------------------------------- | ------- | -------- | ----------------------------------------------------- | ---------------------------------------------------- | -| 1 | [path/file.ts](path/file.ts#L42) | 42 | Critical | Clear description of the problem and how to fix it | Filled in during Step 7 ("Skipped" / "N/A" allowed) | +| # | File | Line(s) | Severity | Issue | Fix Applied | +| --- | -------------------------------- | ------- | -------- | -------------------------------------------------- | --------------------------------------------------- | +| 1 | [path/file.ts](path/file.ts#L42) | 42 | Critical | Clear description of the problem and how to fix it | Filled in during Step 7 ("Skipped" / "N/A" allowed) | File paths should be markdown links. The **Fix Applied** column is left blank in Step 6 and filled in during Step 7 as each issue is resolved (or marked `Skipped` / `Needs confirmation` / `N/A`). diff --git a/packages/dev/lottiePlayer/src/maths/boundingBox.ts b/packages/dev/lottiePlayer/src/maths/boundingBox.ts index cac286c8f5c..5b4e8d123fc 100644 --- a/packages/dev/lottiePlayer/src/maths/boundingBox.ts +++ b/packages/dev/lottiePlayer/src/maths/boundingBox.ts @@ -1,14 +1,6 @@ -import { - type RawElement, - type RawFont, - type RawEllipseShape, - type RawPathShape, - type RawRectangleShape, - type RawStrokeShape, - type RawTextData, - type RawTextDocument, -} from "../parsing/rawTypes"; +import { type RawElement, type RawFont, type RawEllipseShape, type RawPathShape, type RawRectangleShape, type RawStrokeShape, type RawTextData } from "../parsing/rawTypes"; import { GetInitialVectorValues, GetInitialBezierData } from "../parsing/rawPropertyHelpers"; +import { ApplyLottieTextContext, MeasureLottieText, ResolveLottieText } from "../parsing/textLayout"; /** * Represents a bounding box for a shape in the animation. @@ -28,10 +20,10 @@ export type BoundingBox = { offsetY: number; /** Inset for the stroke, if applicable. */ strokeInset: number; - /** Optional: Canvas2D text metrics for precise vertical alignment */ - actualBoundingBoxAscent?: number; - /** Optional: Canvas2D text metrics for precise vertical alignment */ - actualBoundingBoxDescent?: number; + /** Optional: Distance from the top of the text texture to the first baseline. Only populated for text bounding boxes. */ + baselineOffsetY?: number; + /** Optional: Descent (in pixels) of the last text line below its baseline. Only populated for text bounding boxes. */ + descent?: number; }; // Corners of the bounding box @@ -114,55 +106,29 @@ export function GetTextBoundingBox( variables: Map ): BoundingBox | undefined { spritesCanvasContext.save(); - let textInfo: RawTextDocument | undefined = undefined; - if (textData.d && textData.d.k && textData.d.k.length > 0) { - textInfo = textData.d.k[0].s as RawTextDocument; - } - - if (!textInfo) { - spritesCanvasContext.restore(); - return undefined; - } - const fontSize = textInfo.s; - const fontFamily = textInfo.f; - const finalFont = rawFonts.get(fontFamily); - if (!finalFont) { + const resolvedText = ResolveLottieText(textData, rawFonts, variables); + if (!resolvedText) { spritesCanvasContext.restore(); return undefined; } - const weight = finalFont.fWeight || "400"; // Default to normal weight if not specified - spritesCanvasContext.font = `${weight} ${fontSize}px ${finalFont.fFamily}`; - - if (textInfo.sc !== undefined && textInfo.sc.length >= 3 && textInfo.sw !== undefined && textInfo.sw > 0) { - spritesCanvasContext.lineWidth = textInfo.sw; - } - - // Text is supported as a possible variable (for localization for example) - // Check if the text is a variable and replace it if it is - let text = textInfo.t; - const variableText = variables.get(text); - if (variableText !== undefined) { - text = variableText; - } - const metrics = spritesCanvasContext.measureText(text); + ApplyLottieTextContext(spritesCanvasContext, resolvedText); - const widthPx = Math.ceil(metrics.width); - const heightPx = Math.ceil(metrics.actualBoundingBoxAscent) + Math.ceil(metrics.actualBoundingBoxDescent); + const layout = MeasureLottieText(resolvedText, (text) => spritesCanvasContext.measureText(text)); spritesCanvasContext.restore(); return { - width: widthPx, - height: heightPx, - centerX: widthPx / 2, - centerY: heightPx / 2, - offsetX: 0, // The bounding box calculated by the canvas for the text is always centered in (0, 0) - offsetY: 0, // The bounding box calculated by the canvas for the text is always centered in (0, 0) + width: layout.width, + height: layout.height, + centerX: layout.width / 2, + centerY: layout.height / 2, + offsetX: layout.offsetX, + offsetY: layout.offsetY, strokeInset: 0, // Text bounding box ignores stroke padding here - actualBoundingBoxAscent: metrics.actualBoundingBoxAscent, - actualBoundingBoxDescent: metrics.actualBoundingBoxDescent, + baselineOffsetY: layout.baselineOffsetY, + descent: layout.descent, }; } diff --git a/packages/dev/lottiePlayer/src/parsing/rawTypes.ts b/packages/dev/lottiePlayer/src/parsing/rawTypes.ts index 3677fca88c9..66f12261407 100644 --- a/packages/dev/lottiePlayer/src/parsing/rawTypes.ts +++ b/packages/dev/lottiePlayer/src/parsing/rawTypes.ts @@ -257,6 +257,8 @@ export type RawTextDocumentKeyframe = { }; export type RawTextDocument = { + sz?: number[]; // Paragraph box size [width, height] + ps?: number[]; // Paragraph box top-left position [x, y] relative to the text layer origin f: string; // Font family s: number; // Font size lh: number; // Line height diff --git a/packages/dev/lottiePlayer/src/parsing/spritePacker.ts b/packages/dev/lottiePlayer/src/parsing/spritePacker.ts index 0aa69ac50dd..de2b0a59acf 100644 --- a/packages/dev/lottiePlayer/src/parsing/spritePacker.ts +++ b/packages/dev/lottiePlayer/src/parsing/spritePacker.ts @@ -15,9 +15,9 @@ import { type RawRectangleShape, type RawStrokeShape, type RawTextData, - type RawTextDocument, } from "./rawTypes"; import { GetInitialScalarValue, GetInitialVectorValues, GetInitialBezierData } from "./rawPropertyHelpers"; +import { ApplyLottieTextContext, DrawLottieText, MeasureLottieText, ResolveLottieText } from "./textLayout"; import { type BoundingBox, GetShapesBoundingBox, GetTextBoundingBox } from "../maths/boundingBox"; @@ -243,7 +243,7 @@ export class SpritePacker { const page = this._getPageWithRoom(this._spriteAtlasInfo.cellWidth, this._spriteAtlasInfo.cellHeight); // Draw the text in the canvas - this._drawText(textData, boundingBox, scalingFactor, page); + this._drawText(textData, scalingFactor, page); this._extrudeSpriteEdges(page, page.currentX, page.currentY, this._spriteAtlasInfo.cellWidth, this._spriteAtlasInfo.cellHeight); page.isDirty = true; @@ -473,16 +473,13 @@ export class SpritePacker { page.context.restore(); } - private _drawText(textData: RawTextData, boundingBox: BoundingBox, scalingFactor: IVector2Like, page: AtlasPage): void { + private _drawText(textData: RawTextData, scalingFactor: IVector2Like, page: AtlasPage): void { if (this._rawFonts === undefined) { return; } - const textInfo = textData.d.k[0].s as RawTextDocument; - - const fontFamily = textInfo.f; - const finalFont = this._rawFonts.get(fontFamily); - if (!finalFont) { + const resolvedText = ResolveLottieText(textData, this._rawFonts, this._variables); + if (!resolvedText) { return; } @@ -490,26 +487,15 @@ export class SpritePacker { page.context.translate(page.currentX, page.currentY); page.context.scale(scalingFactor.x, scalingFactor.y); - // Set up font (same setup as GetTextBoundingBox for measurement consistency) - const weight = finalFont.fWeight || "400"; - page.context.font = `${weight} ${textInfo.s}px ${finalFont.fFamily}`; - - if (textInfo.sc !== undefined && textInfo.sc.length >= 3 && textInfo.sw !== undefined && textInfo.sw > 0) { - page.context.lineWidth = textInfo.sw; - } - - // Clip to cell bounds to prevent text overdraw into adjacent cells - page.context.beginPath(); - page.context.rect(0, 0, boundingBox.width, boundingBox.height); - page.context.clip(); - - if (textInfo.fc !== undefined && textInfo.fc.length >= 3) { - const rawFillStyle = textInfo.fc; + // Resolve fill color. fc is either an RGB array or a variable name string; the two shapes need different guards + // (arrays need at least 3 components; strings just need a non-undefined variable lookup). + if (resolvedText.textInfo.fc !== undefined) { + const rawFillStyle = resolvedText.textInfo.fc; if (Array.isArray(rawFillStyle)) { - // If the fill style is an array, we assume it's a color array - page.context.fillStyle = this._lottieColorToCSSColor(rawFillStyle, 1); + if (rawFillStyle.length >= 3) { + page.context.fillStyle = this._lottieColorToCSSColor(rawFillStyle, 1); + } } else { - // If it's a string, we need to get the value from the variables map const variableFillStyle = this._variables.get(rawFillStyle); if (variableFillStyle !== undefined) { page.context.fillStyle = variableFillStyle; @@ -517,22 +503,21 @@ export class SpritePacker { } } - if (textInfo.sc !== undefined && textInfo.sc.length >= 3 && textInfo.sw !== undefined && textInfo.sw > 0) { - page.context.strokeStyle = this._lottieColorToCSSColor(textInfo.sc, 1); + if (resolvedText.hasStroke) { + // ResolveLottieText only sets hasStroke when sc is present and well-formed, so the non-null assertion here is safe. + page.context.strokeStyle = this._lottieColorToCSSColor(resolvedText.textInfo.sc!, 1); } - // Text is supported as a possible variable (for localization for example) - // Check if the text is a variable and replace it if it is - let text = textInfo.t; - const variableText = this._variables.get(text); - if (variableText !== undefined) { - text = variableText; - } + ApplyLottieTextContext(page.context, resolvedText); - page.context.fillText(text, 0, boundingBox.actualBoundingBoxAscent!); - if (textInfo.sc !== undefined && textInfo.sc.length >= 3 && textInfo.sw !== undefined && textInfo.sw > 0 && textInfo.of === true) { - page.context.strokeText(text, 0, boundingBox.actualBoundingBoxAscent!); - } + const layout = MeasureLottieText(resolvedText, (text) => page.context.measureText(text)); + + // Clip to cell bounds to prevent text overdraw into adjacent cells + page.context.beginPath(); + page.context.rect(0, 0, layout.width, layout.height); + page.context.clip(); + + DrawLottieText(page.context, resolvedText, layout); page.context.restore(); } diff --git a/packages/dev/lottiePlayer/src/parsing/textLayout.ts b/packages/dev/lottiePlayer/src/parsing/textLayout.ts new file mode 100644 index 00000000000..6223b6ef13a --- /dev/null +++ b/packages/dev/lottiePlayer/src/parsing/textLayout.ts @@ -0,0 +1,596 @@ +import { type IVector2Like } from "core/Maths/math.like"; + +import { type RawFont, type RawTextData, type RawTextDocument, type RawTextJustify } from "./rawTypes"; + +/** + * Minimal text metrics shape used by the Lottie text layout helpers. + */ +export type TextMetricsLike = { + /** + * Horizontal advance of the measured text. + */ + width: number; + /** + * Distance from the alignment point to the left-most rendered pixel. + */ + actualBoundingBoxLeft?: number; + /** + * Distance from the alignment point to the right-most rendered pixel. + */ + actualBoundingBoxRight?: number; + /** + * Distance from the alphabetic baseline to the top of the measured text. + */ + actualBoundingBoxAscent?: number; + /** + * Distance from the alphabetic baseline to the bottom of the measured text. + */ + actualBoundingBoxDescent?: number; +}; + +/** + * Rendering context surface required by the Lottie text layout helpers. + */ +export type TextRenderContextLike = { + /** + * Current font used for text measurement and rendering. + */ + font: string; + /** + * Current stroke width for text outlines. + */ + lineWidth: number; + /** + * Optional font kerning mode used by canvas text rendering. + */ + fontKerning?: string; + /** + * Measures text using the active font. + * @param text The text to measure. + * @returns The measured text metrics. + */ + measureText(text: string): TextMetricsLike; + /** + * Draws filled text at the provided baseline position. + * @param text The text to draw. + * @param x The x coordinate of the text baseline origin. + * @param y The y coordinate of the text baseline origin. + */ + fillText(text: string, x: number, y: number): void; + /** + * Draws stroked text at the provided baseline position. + * @param text The text to draw. + * @param x The x coordinate of the text baseline origin. + * @param y The y coordinate of the text baseline origin. + */ + strokeText(text: string, x: number, y: number): void; +}; + +/** + * Resolved text document data needed for measurement and rendering. + */ +export type ResolvedLottieText = { + /** + * Original raw text document data. + */ + textInfo: RawTextDocument; + /** + * Resolved font metadata. + */ + rawFont: RawFont; + /** + * CSS canvas font shorthand used for measurement and rendering. + */ + font: string; + /** + * Text split into Lottie lines. + */ + lines: string[]; + /** + * Tracking amount converted to pixels. + */ + trackingPx: number; + /** + * Distance between consecutive baselines. + */ + lineHeightPx: number; + /** + * Distance from the top of the text block to the first baseline. + */ + baselineOffsetPx: number; + /** + * Optional paragraph box top-left position relative to the text anchor. + */ + boxPosition?: IVector2Like; + /** + * Optional paragraph box size. + */ + boxSize?: IVector2Like; + /** + * Resolved text justification. + */ + justify: RawTextJustify; + /** + * Whether a visible stroke should be rendered. + */ + hasStroke: boolean; + /** + * Whether the stroke should be rendered after the fill. + */ + strokeOverFill: boolean; +}; + +/** + * Layout information for a single text line. + */ +export type LottieTextLineLayout = { + /** + * Raw text for the line. + */ + text: string; + /** + * Final line width including tracking. + */ + width: number; + /** + * Left position of the line inside the text texture. + */ + x: number; + /** + * Baseline position of the line inside the text texture. + */ + baselineY: number; +}; + +/** + * Layout information for a resolved Lottie text document. + */ +export type LottieTextLayout = { + /** + * Width of the text texture. + */ + width: number; + /** + * Height of the text texture. + */ + height: number; + /** + * X offset required to convert from the text anchor to the sprite center. + */ + offsetX: number; + /** + * Y offset required to convert from the text anchor to the sprite center. + */ + offsetY: number; + /** + * Distance from the top of the texture to the first baseline. + */ + baselineOffsetY: number; + /** + * Distance from the last baseline to the bottom of the texture. + */ + descent: number; + /** + * Per-line layout data. + */ + lines: LottieTextLineLayout[]; +}; + +const LineBreakRegex = /\u2028\r?|\r\n?|\n/g; +const MinimumTextBottomPaddingPx = 1; + +/** + * Resolves the text document, font, variables, and line splitting required for layout. + * @param textData Raw Lottie text data. + * @param rawFonts Map of font names to font metadata. + * @param variables Variables that can replace the raw text content. + * @returns The resolved text data, or undefined when the text cannot be rendered. + */ +export function ResolveLottieText(textData: RawTextData, rawFonts: Map, variables: Map): ResolvedLottieText | undefined { + const textInfo = textData.d?.k?.[0]?.s as RawTextDocument | undefined; + if (!textInfo) { + return undefined; + } + + const rawFont = rawFonts.get(textInfo.f); + if (!rawFont) { + return undefined; + } + + const variableText = variables.get(textInfo.t); + const resolvedText = variableText !== undefined ? variableText : textInfo.t; + const lines = NormalizeLottieTextLines(resolvedText); + const font = BuildCanvasFont(rawFont, textInfo.s); + const trackingPx = ((textInfo.tr ?? 0) * textInfo.s) / 1000; + const lineHeightPx = textInfo.lh || textInfo.s; + const rawAscentPx = rawFont.ascent !== undefined ? (textInfo.s * rawFont.ascent) / 100 : textInfo.s * 0.75; + const baselineOffsetPx = rawAscentPx - (textInfo.ls ?? 0); + const hasStroke = !!(textInfo.sc && textInfo.sc.length >= 3 && textInfo.sw !== undefined && textInfo.sw > 0); + const boxPosition = textInfo.ps !== undefined && textInfo.ps.length >= 2 ? { x: textInfo.ps[0], y: textInfo.ps[1] } : undefined; + const boxSize = textInfo.sz !== undefined && textInfo.sz.length >= 2 ? { x: textInfo.sz[0], y: textInfo.sz[1] } : undefined; + + return { + textInfo, + rawFont, + font, + lines, + trackingPx, + lineHeightPx, + baselineOffsetPx, + boxPosition, + boxSize, + justify: textInfo.j, + hasStroke, + strokeOverFill: textInfo.of === true, + }; +} + +/** + * Measures the final texture layout for resolved Lottie text. + * @param resolvedText Resolved text data. + * @param measureText Callback used to measure text with the active font. + * @returns The measured text layout. + */ +export function MeasureLottieText(resolvedText: ResolvedLottieText, measureText: (text: string) => TextMetricsLike): LottieTextLayout { + const hasParagraphBox = resolvedText.boxPosition !== undefined && resolvedText.boxSize !== undefined; + const lineMeasurements = CreateLineMeasurements(resolvedText, measureText, hasParagraphBox); + const bottomPaddingPx = resolvedText.hasStroke ? Math.max(MinimumTextBottomPaddingPx, resolvedText.textInfo.sw! / 2) : MinimumTextBottomPaddingPx; + + let minX = Number.POSITIVE_INFINITY; + let maxX = Number.NEGATIVE_INFINITY; + let minY = Number.POSITIVE_INFINITY; + let maxY = Number.NEGATIVE_INFINITY; + let descent = 0; + + const localLineLayouts = lineMeasurements.map((line) => { + const x = hasParagraphBox + ? resolvedText.boxPosition!.x + GetLineX(resolvedText.justify, resolvedText.boxSize!.x, line.width) + : GetPointTextLineX(resolvedText.justify, line.width); + descent = Math.max(descent, line.descent); + + if (hasParagraphBox) { + minX = Math.min(minX, x); + maxX = Math.max(maxX, x + line.width); + } else { + minX = Math.min(minX, x - line.actualLeft); + maxX = Math.max(maxX, x + line.actualRight); + } + minY = Math.min(minY, line.baselineY - resolvedText.baselineOffsetPx); + maxY = Math.max(maxY, line.baselineY + line.descent + bottomPaddingPx); + + return { + text: line.text, + width: line.width, + x, + baselineY: line.baselineY, + }; + }); + + if (hasParagraphBox) { + minX = Math.min(minX, resolvedText.boxPosition!.x); + maxX = Math.max(maxX, resolvedText.boxPosition!.x + resolvedText.boxSize!.x); + minY = Math.min(minY, resolvedText.boxPosition!.y); + maxY = Math.max(maxY, resolvedText.boxPosition!.y + resolvedText.boxSize!.y); + } + + if (!Number.isFinite(minX)) { + minX = 0; + maxX = 0; + minY = 0; + maxY = 0; + } + + const width = maxX - minX; + const height = maxY - minY; + + return { + width, + height, + offsetX: (minX + maxX) / 2, + offsetY: (minY + maxY) / 2, + baselineOffsetY: localLineLayouts.length > 0 ? localLineLayouts[0].baselineY - minY : 0, + descent, + lines: localLineLayouts.map((line) => ({ + text: line.text, + width: line.width, + x: line.x - minX, + baselineY: line.baselineY - minY, + })), + }; +} + +function CreateLineMeasurements( + resolvedText: ResolvedLottieText, + measureText: (text: string) => TextMetricsLike, + hasParagraphBox: boolean +): Array<{ text: string; width: number; baselineY: number; descent: number; actualLeft: number; actualRight: number }> { + const lines = hasParagraphBox ? resolvedText.lines.flatMap((line) => WrapParagraphTextLine(line, resolvedText, measureText)) : resolvedText.lines; + + return lines.map((line, index) => { + const metrics = measureText(line); + const trackingWidth = GetTrackingWidth(line, resolvedText.trackingPx); + const trackedWidth = metrics.width + trackingWidth; + return { + text: line, + width: trackedWidth, + baselineY: GetLineBaselineY(resolvedText, hasParagraphBox, index), + descent: metrics.actualBoundingBoxDescent ?? 0, + actualLeft: hasParagraphBox ? 0 : (metrics.actualBoundingBoxLeft ?? 0), + actualRight: hasParagraphBox ? trackedWidth : (metrics.actualBoundingBoxRight ?? metrics.width) + trackingWidth, + }; + }); +} + +function GetLineBaselineY(resolvedText: ResolvedLottieText, hasParagraphBox: boolean, lineIndex: number): number { + const paragraphTop = hasParagraphBox ? resolvedText.boxPosition!.y + resolvedText.baselineOffsetPx : 0; + return paragraphTop + lineIndex * resolvedText.lineHeightPx; +} + +function WrapParagraphTextLine(line: string, resolvedText: ResolvedLottieText, measureText: (text: string) => TextMetricsLike): string[] { + const maxWidth = resolvedText.boxSize?.x ?? 0; + if (line.length === 0 || maxWidth <= 0 || GetTrackedTextWidth(line, measureText, resolvedText.trackingPx) <= maxWidth) { + return [line]; + } + + const words = line + .trim() + .split(/\s+/) + .filter((word) => word.length > 0); + if (words.length === 0) { + return [""]; + } + + const wrappedLines: string[] = []; + let currentLine = ""; + + for (let index = 0; index < words.length; index++) { + const word = words[index]; + const candidate = currentLine.length === 0 ? word : `${currentLine} ${word}`; + if (currentLine.length === 0 && GetTrackedTextWidth(word, measureText, resolvedText.trackingPx) > maxWidth) { + const brokenWordLines = BreakLongWord(word, resolvedText, measureText); + wrappedLines.push(...brokenWordLines.slice(0, -1)); + currentLine = brokenWordLines[brokenWordLines.length - 1]; + continue; + } + + if (GetTrackedTextWidth(candidate, measureText, resolvedText.trackingPx) <= maxWidth) { + currentLine = candidate; + continue; + } + + wrappedLines.push(currentLine); + + if (GetTrackedTextWidth(word, measureText, resolvedText.trackingPx) <= maxWidth) { + currentLine = word; + continue; + } + + const brokenWordLines = BreakLongWord(word, resolvedText, measureText); + wrappedLines.push(...brokenWordLines.slice(0, -1)); + currentLine = brokenWordLines[brokenWordLines.length - 1]; + } + + if (currentLine.length > 0 || wrappedLines.length === 0) { + wrappedLines.push(currentLine); + } + + return wrappedLines; +} + +function BreakLongWord(word: string, resolvedText: ResolvedLottieText, measureText: (text: string) => TextMetricsLike): string[] { + const maxWidth = resolvedText.boxSize?.x ?? 0; + const characters = Array.from(word); + const wrappedLines: string[] = []; + let currentLine = ""; + + for (let index = 0; index < characters.length; index++) { + const candidate = currentLine + characters[index]; + if (currentLine.length > 0 && GetTrackedTextWidth(candidate, measureText, resolvedText.trackingPx) > maxWidth) { + wrappedLines.push(currentLine); + currentLine = characters[index]; + continue; + } + + currentLine = candidate; + } + + if (currentLine.length > 0) { + wrappedLines.push(currentLine); + } + + return wrappedLines; +} + +function GetTrackedTextWidth(text: string, measureText: (text: string) => TextMetricsLike, trackingPx: number): number { + return GetTrackedTextWidthFromMetrics(text, measureText(text), trackingPx); +} + +function GetTrackedTextWidthFromMetrics(text: string, metrics: TextMetricsLike, trackingPx: number): number { + return metrics.width + GetTrackingWidth(text, trackingPx); +} + +function GetTrackingWidth(text: string, trackingPx: number): number { + return Math.max(Array.from(text).length - 1, 0) * trackingPx; +} + +function NormalizeLottieTextLines(text: string): string[] { + const lines = text.replace(LineBreakRegex, "\n").split("\n"); + while (lines.length > 1 && lines[lines.length - 1].length === 0) { + lines.pop(); + } + + return lines; +} + +/** + * Renders resolved text using the measured layout. + * @param context Rendering context used to draw text. + * @param resolvedText Resolved text data. + * @param layout Measured text layout. + */ +export function DrawLottieText(context: TextRenderContextLike, resolvedText: ResolvedLottieText, layout: LottieTextLayout): void { + ApplyLottieTextContext(context, resolvedText); + + for (let index = 0; index < layout.lines.length; index++) { + const line = layout.lines[index]; + if (resolvedText.hasStroke && !resolvedText.strokeOverFill) { + DrawTrackedText(context, line, resolvedText.trackingPx, (text, x, y) => context.strokeText(text, x, y)); + } + + DrawTrackedText(context, line, resolvedText.trackingPx, (text, x, y) => context.fillText(text, x, y)); + + if (resolvedText.hasStroke && resolvedText.strokeOverFill) { + DrawTrackedText(context, line, resolvedText.trackingPx, (text, x, y) => context.strokeText(text, x, y)); + } + } +} + +/** + * Applies the resolved Lottie text font settings to a canvas-like text context. + * @param context Rendering context used for text measurement and drawing. + * @param resolvedText Resolved text data. + */ +export function ApplyLottieTextContext(context: Pick, resolvedText: ResolvedLottieText): void { + context.font = resolvedText.font; + // fontKerning is optional on the context type because some runtimes (older browsers, OffscreenCanvas + // implementations without the CanvasTextDrawingStyles update) may not expose it. Guard before writing. + if ("fontKerning" in context) { + context.fontKerning = "none"; + } + + if (resolvedText.hasStroke) { + context.lineWidth = resolvedText.textInfo.sw!; + } +} + +/** + * Builds the CSS canvas font string for a Lottie font entry. + * @param rawFont Font metadata from the Lottie file. + * @param fontSize Font size in pixels. + * @returns The CSS canvas font shorthand. + */ +export function BuildCanvasFont(rawFont: RawFont, fontSize: number): string { + const fontStyle = GetCanvasFontStyle(rawFont); + const fontWeight = GetCanvasFontWeight(rawFont); + const fontFamily = QuoteFontFamily(rawFont.fFamily); + + return fontStyle === "normal" ? `${fontWeight} ${fontSize}px ${fontFamily}` : `${fontStyle} ${fontWeight} ${fontSize}px ${fontFamily}`; +} + +function DrawTrackedText( + context: Pick, + line: LottieTextLineLayout, + trackingPx: number, + drawGlyph: (text: string, x: number, y: number) => void +): void { + if (line.text.length === 0) { + return; + } + + if (trackingPx === 0) { + drawGlyph(line.text, line.x, line.baselineY); + return; + } + + // When tracking is non-zero, lay glyphs out one at a time and accumulate the measured advance + // instead of re-measuring the growing prefix on each step (which would be O(n²) in measureText calls). + const characters = Array.from(line.text); + let advance = 0; + for (let index = 0; index < characters.length; index++) { + const character = characters[index]; + drawGlyph(character, line.x + advance + trackingPx * index, line.baselineY); + advance += context.measureText(character).width; + } +} + +function GetPointTextLineX(justify: RawTextJustify, lineWidth: number): number { + switch (GetHorizontalAlignment(justify)) { + case "right": + return -lineWidth; + case "center": + return -lineWidth / 2; + case "left": + default: + return 0; + } +} + +function GetLineX(justify: RawTextJustify, maxWidth: number, lineWidth: number): number { + switch (GetHorizontalAlignment(justify)) { + case "right": + return maxWidth - lineWidth; + case "center": + return (maxWidth - lineWidth) / 2; + case "left": + default: + return 0; + } +} + +function GetHorizontalAlignment(justify: RawTextJustify): "left" | "right" | "center" { + // 0: left, 1: right, 2: center. Codes 3–6 are full-justify variants that differ only in how the + // last line is aligned (3: left, 4: right, 5: center, 6: full). We do not implement full-justify + // (which would stretch non-last lines to the paragraph-box width), so approximate all four by + // using the last-line alignment for every line. This matches the reference renderer for the + // common case of single-line text and is the closest behavior otherwise. + switch (justify) { + case 1: + case 4: + return "right"; + case 2: + case 5: + return "center"; + case 0: + case 3: + case 6: + default: + return "left"; + } +} + +function QuoteFontFamily(fontFamily: string): string { + return /[\s,"']/.test(fontFamily) ? `"${fontFamily.replace(/\\/g, "\\\\").replace(/"/g, '\\"')}"` : fontFamily; +} + +function GetCanvasFontStyle(rawFont: RawFont): string { + const style = rawFont.fStyle?.toLowerCase() ?? ""; + return /\b(italic|oblique)\b/.test(style) ? "italic" : "normal"; +} + +function GetCanvasFontWeight(rawFont: RawFont): string { + const explicitWeight = rawFont.fWeight?.trim(); + if (explicitWeight) { + return explicitWeight; + } + + const style = rawFont.fStyle?.toLowerCase() ?? ""; + if (/\b(thin|hairline)\b/.test(style)) { + return "100"; + } + if (/\b(extra|ultra)[ -]?light\b/.test(style)) { + return "200"; + } + if (/\b(light)\b/.test(style)) { + return "300"; + } + if (/\bsemi[ -]?bold\b/.test(style)) { + return "700"; + } + if (/\bdemi[ -]?bold\b/.test(style)) { + return "600"; + } + if (/\b(extra|ultra)[ -]?bold\b/.test(style)) { + return "800"; + } + if (/\b(black|heavy)\b/.test(style)) { + return "900"; + } + if (/\bbold\b/.test(style)) { + return "700"; + } + if (/\bmedium\b/.test(style)) { + return "500"; + } + + return "400"; +} diff --git a/packages/dev/lottiePlayer/test/unit/lottieTextLayout.test.ts b/packages/dev/lottiePlayer/test/unit/lottieTextLayout.test.ts new file mode 100644 index 00000000000..7a9f4f27a21 --- /dev/null +++ b/packages/dev/lottiePlayer/test/unit/lottieTextLayout.test.ts @@ -0,0 +1,310 @@ +import { describe, it, expect } from "vitest"; +import { GetTextBoundingBox } from "../../src/maths/boundingBox"; +import { DrawLottieText, MeasureLottieText, ResolveLottieText } from "../../src/parsing/textLayout"; +import { type RawFont, type RawTextData } from "../../src/parsing/rawTypes"; + +type FakeTextMetrics = { + width: number; + actualBoundingBoxAscent: number; + actualBoundingBoxDescent: number; + actualBoundingBoxLeft?: number; + actualBoundingBoxRight?: number; +}; + +class FakeTextContext { + public font = ""; + public lineWidth = 1; + public fontKerning?: string; + + public readonly measurements: Array<{ text: string; font: string }> = []; + public readonly draws: Array<{ op: "fill" | "stroke"; text: string; x: number; y: number }> = []; + + private readonly _stateStack: Array<{ font: string; lineWidth: number }> = []; + + public constructor(private readonly _metricsByText: Record) {} + + public save(): void { + this._stateStack.push({ font: this.font, lineWidth: this.lineWidth }); + } + + public restore(): void { + const state = this._stateStack.pop(); + if (!state) { + return; + } + + this.font = state.font; + this.lineWidth = state.lineWidth; + } + + public measureText(text: string): FakeTextMetrics { + this.measurements.push({ text, font: this.font }); + return this._metricsByText[text] ?? { width: text.length, actualBoundingBoxAscent: 0, actualBoundingBoxDescent: 0 }; + } + + public fillText(text: string, x: number, y: number): void { + this.draws.push({ op: "fill", text, x, y }); + } + + public strokeText(text: string, x: number, y: number): void { + this.draws.push({ op: "stroke", text, x, y }); + } +} + +function createTextData(text: string, overrides?: Partial): RawTextData { + return { + a: [], + d: { + k: [ + { + t: 0, + s: { + s: 50, + f: "SegoeUI-Semibold", + t: text, + ca: 0, + j: 0, + tr: 0, + lh: 60, + ls: 0, + fc: [0, 0, 0], + ...overrides, + }, + }, + ], + }, + m: { + g: 1, + a: { + a: 0, + k: [0, 0], + l: 2, + }, + }, + }; +} + +function createFonts(): Map { + return new Map([ + [ + "SegoeUI-Semibold", + { + fName: "SegoeUI-Semibold", + fFamily: "Segoe UI", + fStyle: "Semibold", + ascent: 80, + }, + ], + ]); +} + +describe("GetTextBoundingBox", () => { + it("uses the Lottie font style when measuring text", () => { + const context = new FakeTextContext({ + Hello: { + width: 30, + actualBoundingBoxAscent: 10, + actualBoundingBoxDescent: 2, + }, + }); + + GetTextBoundingBox(context as unknown as CanvasRenderingContext2D, createTextData("Hello"), createFonts(), new Map()); + + expect(context.measurements).toHaveLength(1); + expect(context.measurements[0].font).toBe('700 50px "Segoe UI"'); + }); + + it("accounts for line breaks, line height, tracking, and font ascent", () => { + const context = new FakeTextContext({ + "AB\u2028\rC": { + width: 100, + actualBoundingBoxAscent: 10, + actualBoundingBoxDescent: 3, + }, + AB: { + width: 20, + actualBoundingBoxAscent: 10, + actualBoundingBoxDescent: 3, + }, + C: { + width: 8, + actualBoundingBoxAscent: 10, + actualBoundingBoxDescent: 3, + }, + }); + + const box = GetTextBoundingBox(context as unknown as CanvasRenderingContext2D, createTextData("AB\u2028\rC", { j: 2, tr: 100 }), createFonts(), new Map()); + + expect(box).toBeDefined(); + expect(box?.width).toBe(25); + expect(box?.height).toBe(104); + expect(box?.offsetX).toBe(0); + expect(box?.baselineOffsetY).toBe(40); + expect(box?.descent).toBe(3); + expect(context.measurements.map((measurement) => measurement.text)).toEqual(["AB", "C"]); + }); + + it("keeps point text anchored to its authored baseline origin instead of the glyph bounds", () => { + const context = new FakeTextContext({ + Turn: { + width: 40, + actualBoundingBoxLeft: 4, + actualBoundingBoxRight: 40, + actualBoundingBoxAscent: 10, + actualBoundingBoxDescent: 2, + }, + }); + + const resolvedText = ResolveLottieText(createTextData("Turn"), createFonts(), new Map()); + + expect(resolvedText).toBeDefined(); + + const layout = MeasureLottieText(resolvedText!, (text) => context.measureText(text)); + const box = GetTextBoundingBox(context as unknown as CanvasRenderingContext2D, createTextData("Turn"), createFonts(), new Map()); + + expect(layout.width).toBe(44); + expect(layout.offsetX).toBe(18); + expect(layout.lines[0].x).toBe(4); + expect(box?.width).toBe(44); + expect(box?.offsetX).toBe(18); + }); + + it("accounts for paragraph box position and size when measuring text", () => { + const context = new FakeTextContext({ + Question: { + width: 100, + actualBoundingBoxAscent: 30, + actualBoundingBoxDescent: 10, + }, + }); + + const box = GetTextBoundingBox(context as unknown as CanvasRenderingContext2D, createTextData("Question", { sz: [1202, 94], ps: [-601, -47] }), createFonts(), new Map()); + + expect(box).toBeDefined(); + expect(box?.width).toBe(1202); + expect(box?.height).toBe(94); + expect(box?.offsetX).toBe(0); + expect(box?.offsetY).toBe(0); + }); + + it("starts paragraph text at the paragraph box top using the first baseline offset", () => { + const context = new FakeTextContext({ + Question: { + width: 8, + actualBoundingBoxAscent: 8, + actualBoundingBoxDescent: 2, + }, + }); + + const resolvedText = ResolveLottieText(createTextData("Question", { s: 10, sz: [20, 20], ps: [-10, -10] }), createFonts(), new Map()); + + expect(resolvedText).toBeDefined(); + + const layout = MeasureLottieText(resolvedText!, (text) => context.measureText(text)); + + expect(layout.lines).toHaveLength(1); + expect(layout.lines[0].baselineY).toBe(8); + }); + + it("wraps paragraph text to the paragraph box width", () => { + const context = new FakeTextContext({}); + const resolvedText = ResolveLottieText( + createTextData("one two three", { + s: 10, + sz: [10, 40], + ps: [-5, -20], + lh: 12, + tr: 0, + }), + createFonts(), + new Map() + ); + + expect(resolvedText).toBeDefined(); + + const layout = MeasureLottieText(resolvedText!, (text) => context.measureText(text)); + + expect(layout.lines.map((line) => line.text)).toEqual(["one two", "three"]); + }); + + it("ignores trailing line-break sentinels in paragraph text", () => { + const resolvedText = ResolveLottieText(createTextData("one two\r"), createFonts(), new Map()); + + expect(resolvedText).toBeDefined(); + expect(resolvedText?.lines).toEqual(["one two"]); + }); + + it("breaks a word wider than the paragraph box across multiple lines", () => { + const context = new FakeTextContext({}); + const resolvedText = ResolveLottieText( + createTextData("abcdefgh", { + s: 10, + sz: [3, 40], + ps: [0, 0], + lh: 12, + tr: 0, + }), + createFonts(), + new Map() + ); + + expect(resolvedText).toBeDefined(); + + const layout = MeasureLottieText(resolvedText!, (text) => context.measureText(text)); + + // Each character is 1 unit wide in the fake measurer; box width 3 fits up to 3 characters per line. + expect(layout.lines.map((line) => line.text)).toEqual(["abc", "def", "gh"]); + }); + + it("substitutes text from the variables map", () => { + const resolved = ResolveLottieText(createTextData("greeting"), createFonts(), new Map([["greeting", "Hi"]])); + expect(resolved?.lines).toEqual(["Hi"]); + }); + + it("draws fill only when no stroke is configured", () => { + const context = new FakeTextContext({ Hi: { width: 20, actualBoundingBoxAscent: 10, actualBoundingBoxDescent: 2 } }); + const resolved = ResolveLottieText(createTextData("Hi"), createFonts(), new Map()); + const layout = MeasureLottieText(resolved!, (text) => context.measureText(text)); + + DrawLottieText(context, resolved!, layout); + + expect(context.draws).toEqual([{ op: "fill", text: "Hi", x: layout.lines[0].x, y: layout.lines[0].baselineY }]); + }); + + it("strokes before fill by default and after fill when strokeOverFill is set", () => { + const metrics = { Hi: { width: 20, actualBoundingBoxAscent: 10, actualBoundingBoxDescent: 2 } }; + + const underContext = new FakeTextContext(metrics); + const underResolved = ResolveLottieText(createTextData("Hi", { sc: [1, 0, 0], sw: 2 }), createFonts(), new Map()); + const underLayout = MeasureLottieText(underResolved!, (text) => underContext.measureText(text)); + DrawLottieText(underContext, underResolved!, underLayout); + expect(underContext.draws.map((d) => d.op)).toEqual(["stroke", "fill"]); + + const overContext = new FakeTextContext(metrics); + const overResolved = ResolveLottieText(createTextData("Hi", { sc: [1, 0, 0], sw: 2, of: true }), createFonts(), new Map()); + const overLayout = MeasureLottieText(overResolved!, (text) => overContext.measureText(text)); + DrawLottieText(overContext, overResolved!, overLayout); + expect(overContext.draws.map((d) => d.op)).toEqual(["fill", "stroke"]); + }); + + it("draws each glyph at the accumulated advance when tracking is non-zero", () => { + const context = new FakeTextContext({ + A: { width: 10, actualBoundingBoxAscent: 8, actualBoundingBoxDescent: 2 }, + B: { width: 4, actualBoundingBoxAscent: 8, actualBoundingBoxDescent: 2 }, + C: { width: 6, actualBoundingBoxAscent: 8, actualBoundingBoxDescent: 2 }, + ABC: { width: 20, actualBoundingBoxAscent: 8, actualBoundingBoxDescent: 2 }, + }); + const resolved = ResolveLottieText(createTextData("ABC", { tr: 20 }), createFonts(), new Map()); + const layout = MeasureLottieText(resolved!, (text) => context.measureText(text)); + + DrawLottieText(context, resolved!, layout); + + const fillDraws = context.draws.filter((d) => d.op === "fill"); + expect(fillDraws.map((d) => d.text)).toEqual(["A", "B", "C"]); + const trackingPx = (20 * 50) / 1000; // tr * s / 1000 = 1 + const baseX = layout.lines[0].x; + expect(fillDraws[0].x).toBe(baseX + trackingPx * 0); + expect(fillDraws[1].x).toBe(baseX + 10 + trackingPx * 1); // after A + expect(fillDraws[2].x).toBe(baseX + 14 + trackingPx * 2); // after A+B + }); +});