Files
2026-07-13 13:08:41 +08:00

1401 lines
39 KiB
TypeScript

import { renderMarkdownTextRuns } from "@/components/slide-editor/text/markdown-text";
import type { Font, TextRun } from "@/components/slide-editor/types";
import { effectiveLineHeight } from "@/components/slide-editor/text/text-line-height";
import { textRunsContent } from "@/components/slide-editor/text/text-runs";
import type { TemplateV2TextEditStyle } from "@/components/slide-editor/text/template-v2-text-editing";
type UnknownRecord = Record<string, any>;
export type TemplateV2RawTextElement = UnknownRecord;
export type RenderTextFont = Omit<
TemplateV2TextEditStyle,
"horizontal" | "vertical"
>;
export type RenderTextRun = {
text: string;
font: RenderTextFont;
};
export type LaidToken = {
text: string;
font: RenderTextFont;
x: number;
y: number;
width: number;
height: number;
};
export type TemplateV2TextBox = {
x: number;
y: number;
width: number;
height: number;
};
const TEXT_AVERAGE_CHAR_EM = 0.5;
const DEFAULT_FONT: RenderTextFont = {
family: "Arial",
size: 18,
color: "#111827",
bold: false,
italic: false,
underline: false,
lineHeight: 1.15,
letterSpacing: 0,
opacity: 1,
};
const TEXT_RENDER_WRAP = "word";
const MIN_TRANSFORM_FONT_SIZE = 1;
const MAX_TRANSFORM_FONT_SIZE = 512;
const TRANSFORM_FONT_SCALE_EPSILON = 0.001;
const richMeasureCtx: { ctx: CanvasRenderingContext2D | null } = { ctx: null };
let renderTextMeasureCanvas: HTMLCanvasElement | null = null;
function withoutFontWrap(font: UnknownRecord): UnknownRecord {
return Object.fromEntries(
Object.entries(font).filter(([key]) => key !== "wrap"),
);
}
export function displayText(text: string) {
return text
.replace(/\*\*(.*?)\*\*/g, "$1")
.replace(/__(.*?)__/g, "$1")
.replace(/\*(.*?)\*/g, "$1")
.replace(/_(.*?)_/g, "$1");
}
export function rawFont(element: TemplateV2RawTextElement) {
const font = asRecord(element.font) ?? {};
return fontFromRecord(font, DEFAULT_FONT);
}
export function fontFromRecord(
font: UnknownRecord | null,
fallback: RenderTextFont,
): RenderTextFont {
return {
family: readString(font?.family) ?? fallback.family,
size: readNumber(font?.size) ?? fallback.size,
color: readString(font?.color) ?? fallback.color,
bold: readBoolean(font?.bold) ?? fallback.bold,
italic: readBoolean(font?.italic) ?? fallback.italic,
underline:
readBoolean(font?.underline) ??
(readString(font?.text_decoration) === "underline" ||
readString(font?.textDecoration) === "underline"
? true
: fallback.underline),
lineHeight:
readNumber(font?.line_height) ??
readNumber(font?.lineHeight) ??
fallback.lineHeight,
letterSpacing:
readNumber(font?.letter_spacing) ??
readNumber(font?.letterSpacing) ??
fallback.letterSpacing,
opacity: readNumber(font?.opacity) ?? fallback.opacity,
};
}
export function fontToSource(font: RenderTextFont): Font {
return {
family: font.family,
size: font.size,
color: font.color,
bold: font.bold,
italic: font.italic,
underline: font.underline,
line_height: font.lineHeight,
letter_spacing: font.letterSpacing,
opacity: font.opacity,
};
}
export function rawTextStyle(
element: TemplateV2RawTextElement,
): TemplateV2TextEditStyle {
const font = rawFont(element);
return {
...font,
color: withHash(font.color) ?? "#111827",
horizontal: readHorizontalAlignment(
asRecord(element.alignment)?.horizontal,
),
vertical: readVerticalAlignment(asRecord(element.alignment)?.vertical),
};
}
export function applyTextStyle(
element: TemplateV2RawTextElement,
style: TemplateV2TextEditStyle,
): TemplateV2RawTextElement {
const sourceFont = asRecord(element.font) ?? {};
const nextFont = {
...withoutFontWrap(sourceFont),
family: style.family,
size: style.size,
color: withHash(style.color) ?? "#111827",
bold: style.bold,
italic: style.italic,
underline: style.underline,
line_height: style.lineHeight,
letter_spacing: style.letterSpacing,
opacity: style.opacity,
};
const runs = readArray(element.runs);
return {
...element,
font: nextFont,
alignment: {
...(asRecord(element.alignment) ?? {}),
horizontal: style.horizontal,
vertical: style.vertical,
},
...(runs.length > 0
? {
runs: runs.map((run) => {
const record = asRecord(run) ?? {};
return {
...record,
font: {
...(asRecord(record.font) ?? {}),
...nextFont,
},
};
}),
}
: {}),
};
}
export function normalizeRawTextMarkdownElement(
element: TemplateV2RawTextElement,
): {
element: TemplateV2RawTextElement;
runs: TextRun[];
changed: boolean;
} {
const originalSourceRuns = rawSourceTextRuns(element);
const rawText = rawStoredTextContent(element);
const hasSourceRuns = rawTextHasRuns(element);
const reconciledSourceRuns = reconcileTextRunsWithStoredText(
originalSourceRuns,
rawText,
);
const sourceRuns = normalizeStyledSourceRunBoundaries(reconciledSourceRuns);
const renderedRuns = renderMarkdownTextRuns(sourceRuns);
const renderedText = textRunsContent(renderedRuns);
const sourceHasMarkdown = sourceRuns.some((run) =>
containsMarkdownSyntax(run.text),
);
const runsChanged = !sameTextRuns(originalSourceRuns, sourceRuns);
const renderedRunsChanged = !sameTextRuns(sourceRuns, renderedRuns);
const changed =
runsChanged ||
renderedRunsChanged ||
sourceHasMarkdown ||
((!hasSourceRuns || containsMarkdownSyntax(rawText)) &&
rawText !== renderedText);
return {
element: changed ? setRawTextRunsContent(element, renderedRuns) : element,
runs: renderedRuns,
changed,
};
}
export function rawTextContent(element: TemplateV2RawTextElement) {
const runs = readArray(element.runs);
if (runs.length > 0) {
const content = runs
.map((run) => readString(asRecord(run)?.text) ?? "")
.join("");
if (content) return content;
}
return rawStoredTextContent(element);
}
export function rawStoredTextContent(element: TemplateV2RawTextElement) {
const text = readString(element.text);
if (text != null) return text;
return "";
}
export function rawSourceTextRuns(
element: TemplateV2RawTextElement,
): TextRun[] {
const fallbackFont = fontToSource(rawFont(element));
const runs = readArray(element.runs)
.map((run) => {
const record = asRecord(run);
if (!record) return null;
const text = readString(record.text) ?? "";
if (!text) return null;
return {
text,
font: fontToSource(
fontFromRecord(asRecord(record.font), rawFont(element)),
),
} satisfies TextRun;
})
.filter(Boolean) as TextRun[];
return runs.length > 0
? runs
: [{ text: rawTextContent(element) || " ", font: fallbackFont }];
}
export function rawTextRunsForEditor(
element: TemplateV2RawTextElement,
): TextRun[] {
return normalizeRawTextMarkdownElement(element).runs;
}
export function rawTextHasRuns(element: TemplateV2RawTextElement) {
return readArray(element.runs).some((run) => {
const record = asRecord(run);
return Boolean(readString(record?.text));
});
}
export function setRawTextContent(
element: TemplateV2RawTextElement,
text: string,
style?: TemplateV2TextEditStyle,
): TemplateV2RawTextElement {
const styled = style ? applyTextStyle(element, style) : element;
const sourceRuns = readArray(styled.runs);
const firstRun = asRecord(sourceRuns[0]) ?? {};
const runs = stripPlainTextListMarkersFromRuns(
renderMarkdownTextRuns([{ text, font: fontToSource(rawFont(styled)) }]),
).map((run) => ({
...firstRun,
text: run.text,
font: {
...(asRecord(firstRun.font) ?? {}),
...(asRecord(run.font) ?? {}),
},
}));
return {
...styled,
text: textRunsContent(runs),
runs,
};
}
export function setRawTextRunsContent(
element: TemplateV2RawTextElement,
runs: TextRun[],
): TemplateV2RawTextElement {
const storageRuns = stripPlainTextListMarkersFromRuns(runs);
const sourceRuns = readArray(element.runs);
const nextRuns = (
storageRuns.length > 0 ? storageRuns : [{ text: " " }]
).map(
(run, index) => {
const sourceRun = asRecord(sourceRuns[index]) ?? {};
return {
...sourceRun,
text: run.text,
font: rawInlineTextFontRecord(run.font, sourceRun.font),
};
},
);
return {
...element,
text: textRunsContent(nextRuns),
runs: nextRuns,
};
}
export function rawInlineTextFontRecord(value: unknown, fallback: unknown) {
const font = asRecord(value);
if (!font) return fallback;
const fallbackFont = asRecord(fallback) ?? {};
return {
...withoutFontWrap(fallbackFont),
...withoutFontWrap(font),
line_height: font.line_height ?? font.lineHeight,
letter_spacing: font.letter_spacing ?? font.letterSpacing,
opacity: font.opacity,
};
}
export function rawTextListContent(element: TemplateV2RawTextElement) {
const items = readArray(element.items);
if (items.length === 0) return "";
return items.map(rawTextListItemText).join("\n");
}
export function rawTextListRunsForEditor(
element: TemplateV2RawTextElement,
): TextRun[] {
const baseFont = rawFont(element);
const fallbackFont = fontToSource(baseFont);
const items = readArray(element.items);
const runs: TextRun[] = [];
items.forEach((item, index) => {
const itemRuns = renderMarkdownTextRuns(
normalizeStyledSourceRunBoundaries(
rawTextListItemSourceRuns(item, baseFont),
),
);
const itemFont = itemRuns[0]?.font ?? fallbackFont;
const prefix = textListMarkerPrefix(element.marker, index);
if (index > 0) appendTextRun(runs, "\n", itemFont);
if (prefix) appendTextRun(runs, prefix, itemFont);
itemRuns.forEach((run) =>
appendTextRun(runs, run.text, run.font ?? itemFont),
);
});
return runs.length > 0 ? runs : [{ text: " ", font: fallbackFont }];
}
export function rawTextListRenderTextRuns(
element: TemplateV2RawTextElement,
): RenderTextRun[] {
const baseFont = rawFont(element);
return rawTextListRunsForEditor(element)
.filter((run) => run.text)
.map((run) => ({
text: run.text,
font: fontFromRecord(asRecord(run.font), baseFont),
}));
}
export function rawTextListItemText(item: unknown) {
if (typeof item === "string") return item;
if (Array.isArray(item)) {
return item
.map((run) => readString(asRecord(run)?.text) ?? "")
.join("");
}
const record = asRecord(item);
if (!record) return "";
const directText = readString(record.text);
if (directText != null) return directText;
return readArray(record.runs)
.map((run) => readString(asRecord(run)?.text) ?? "")
.join("");
}
export function rawTextListItemWithText(
source: unknown,
text: string,
): unknown {
if (Array.isArray(source)) {
const firstRun = asRecord(source[0]) ?? {};
return [{ ...firstRun, text }];
}
if (typeof source === "string") return text;
const record = asRecord(source);
if (!record) return { type: "text", text };
const runs = readArray(record.runs);
if (runs.length > 0 || Object.hasOwn(record, "runs")) {
const firstRun = asRecord(runs[0]) ?? {};
return { ...record, runs: text ? [{ ...firstRun, text }] : [] };
}
return { ...record, type: record.type ?? "text", text };
}
export function setRawTextListContent(
element: TemplateV2RawTextElement,
draft: string,
): TemplateV2RawTextElement {
const sourceItems = readArray(element.items);
const texts = draft
.split(/\r?\n/)
.map((item) => item.replace(/^\s*(?:[•*-]|\d+\.)\s?/, "").trimEnd())
.filter((item) => item.trim().length > 0);
const items = (texts.length > 0 ? texts : [" "]).map((text, index) =>
rawTextListItemWithText(
sourceItems[index] ?? sourceItems[sourceItems.length - 1],
text,
),
);
return { ...element, items };
}
export function setRawTextListRunsContent(
element: TemplateV2RawTextElement,
runs: TextRun[],
): TemplateV2RawTextElement {
const sourceItems = readArray(element.items);
const stripMarker = readString(element.marker) !== "none";
const lines = splitTextRunsOnNewlines(runs)
.map((line) => (stripMarker ? stripTextListMarkerFromRuns(line) : line))
.map((line) => line.filter((run) => run.text))
.filter((line) => textRunsContent(line).trim().length > 0);
const fallbackItem = sourceItems[sourceItems.length - 1];
const items = (lines.length > 0 ? lines : [[{ text: " " }]]).map(
(line, index) =>
rawTextListItemWithRuns(
sourceItems[index] ?? fallbackItem,
line as TextRun[],
),
);
return { ...element, items };
}
export function rawTableCellText(cell: unknown) {
if (typeof cell === "string" || typeof cell === "number") {
return displayText(String(cell));
}
const record = asRecord(cell);
if (!record) return "";
const runs = readArray(record.runs);
if (runs.length > 0) {
return textRunsContent(
renderMarkdownTextRuns(
runs.map((run) => ({
text: readString(asRecord(run)?.text) ?? "",
font: asRecord(run)?.font as TextRun["font"],
})),
),
);
}
const textRecord = asRecord(record.text);
return displayText(readString(textRecord?.text) ?? readString(record.text) ?? "");
}
export function rawSvgContent(element: TemplateV2RawTextElement) {
return readString(element.svg) ?? readString(element.data) ?? "";
}
export function setRawSvgContent(
element: TemplateV2RawTextElement,
draft: string,
): TemplateV2RawTextElement {
return { ...element, svg: draft };
}
export function rawRenderTextRuns(
element: TemplateV2RawTextElement,
): RenderTextRun[] {
const baseFont = rawFont(element);
const runs = normalizeRawTextMarkdownElement(element).runs;
return runs
.filter((run) => run.text)
.map((run) => ({
text: run.text,
font: fontFromRecord(asRecord(run.font), baseFont),
}));
}
export function textVisualLocalBox(
element: TemplateV2RawTextElement,
box: TemplateV2TextBox,
options: {
content?: string;
runs?: RenderTextRun[];
} = {},
): TemplateV2TextBox {
const font = rawFont(element);
const renderRuns = options.runs ?? rawRenderTextRuns(element);
const content =
options.content ??
(options.runs ? textRunsContent(options.runs) : rawTextContent(element));
const displayContent = displayText(content);
const renderRunsDifferFromElement =
renderRuns.length > 0 &&
textRunsHaveMixedStyle([{ text: "", font }, ...renderRuns]);
const align = readString(asRecord(element.alignment)?.horizontal) ?? "left";
const verticalAlign =
readString(asRecord(element.alignment)?.vertical) ?? "top";
const textLineHeight = effectiveLineHeight({
text: displayContent,
width: box.width,
fontSize: font.size,
lineHeight: font.lineHeight,
fallback: 1.15,
wrap: TEXT_RENDER_WRAP,
});
if (renderRunsDifferFromElement) {
const lines = layoutRenderTextRuns(renderRuns, box.width, TEXT_RENDER_WRAP);
const lineMetrics = lines.map((line) => ({
height: lineRenderHeight(line, textLineHeight),
width: line.reduce((sum, segment) => sum + segment.width, 0),
}));
const totalHeight = lineMetrics.reduce(
(sum, metric) => sum + metric.height,
0,
);
const startY = verticalStartY(verticalAlign, box.height, totalHeight);
const left = Math.min(
0,
...lineMetrics.map((metric) =>
lineStartX(align, box.width, metric.width, false),
),
);
const right = Math.max(
box.width,
...lineMetrics.map((metric) => {
const startX = lineStartX(
align,
box.width,
metric.width,
false,
);
return startX + metric.width;
}),
);
return {
x: box.x + left,
y: box.y + Math.min(0, startY),
width: Math.max(1, right - left),
height: Math.max(box.height, totalHeight),
};
}
if (renderRuns.length > 1) {
const { tokens, contentHeight } = layoutRichText(
renderRuns,
box.width,
font,
align,
verticalAlign,
box.height,
TEXT_RENDER_WRAP,
);
if (tokens.length === 0) return box;
const left = Math.min(0, ...tokens.map((token) => token.x));
const top = Math.min(0, ...tokens.map((token) => token.y));
const right = Math.max(
box.width,
...tokens.map((token) => token.x + token.width),
);
const bottom = Math.max(
box.height,
contentHeight,
...tokens.map((token) => token.y + token.height),
);
return {
x: box.x + left,
y: box.y + top,
width: Math.max(1, right - left),
height: Math.max(1, bottom - top),
};
}
const textNodeRuns =
renderRuns.length > 0 ? renderRuns : [{ text: displayContent, font }];
return {
...box,
height: Math.max(
box.height,
measureRenderTextRunsHeight(
textNodeRuns,
box.width,
TEXT_RENDER_WRAP,
textLineHeight,
),
),
};
}
function measureRenderTextRunsHeight(
runs: RenderTextRun[],
width: number,
wrap: string | null | undefined,
fallbackLineHeight: number,
) {
const lines = layoutRenderTextRuns(runs, width, wrap);
if (lines.length === 0) return fallbackLineHeight;
return lines.reduce(
(sum, line) => sum + lineRenderHeight(line, fallbackLineHeight),
0,
);
}
export function textRunsHaveMixedStyle(runs: RenderTextRun[]) {
const first = runs[0]?.font;
return runs.some((run) => JSON.stringify(run.font) !== JSON.stringify(first));
}
export function layoutRichText(
runs: RenderTextRun[],
maxWidth: number,
baseFont: RenderTextFont,
align: string,
verticalAlign: string,
boxHeight: number,
wrap: string | null | undefined,
): { tokens: LaidToken[]; contentHeight: number } {
type Tok = {
text: string;
font: RenderTextFont;
newline: boolean;
space: boolean;
width: number;
};
const tokens: Tok[] = [];
for (const run of runs) {
const display = displayText(run.text);
if (!display) continue;
for (const part of display.split(/(\n|[ \t]+)/)) {
if (part === "") continue;
if (part === "\n") {
tokens.push({
text: "",
font: run.font,
newline: true,
space: false,
width: 0,
});
} else {
const space = /^[ \t]+$/.test(part);
const measuredParts =
wrap !== "none" && !space
? splitOversizedTextSegment(part, run.font, maxWidth, measureRunText)
: [{ text: part, width: measureRunText(part, run.font) }];
for (const measuredPart of measuredParts) {
tokens.push({
text: measuredPart.text,
font: run.font,
newline: false,
space,
width: measuredPart.width,
});
}
}
}
}
type Line = { toks: Tok[]; height: number; width: number };
const lines: Line[] = [];
let cur: Tok[] = [];
let curWidth = 0;
const flush = () => {
const height = cur.length
? Math.max(...cur.map((t) => t.font.size * t.font.lineHeight))
: baseFont.size * baseFont.lineHeight;
lines.push({ toks: cur, height, width: curWidth });
cur = [];
curWidth = 0;
};
for (const tok of tokens) {
if (tok.newline) {
flush();
continue;
}
if (tok.space && cur.length === 0) continue;
if (
wrap !== "none" &&
!tok.space &&
curWidth + tok.width > maxWidth &&
cur.length > 0
) {
flush();
}
cur.push(tok);
curWidth += tok.width;
}
flush();
const contentHeight = lines.reduce((sum, line) => sum + line.height, 0);
let y =
verticalAlign === "middle"
? (boxHeight - contentHeight) / 2
: verticalAlign === "bottom"
? boxHeight - contentHeight
: 0;
if (y < 0) y = 0;
const laid: LaidToken[] = [];
for (const line of lines) {
let lineWidth = line.width;
for (let i = line.toks.length - 1; i >= 0 && line.toks[i].space; i--) {
lineWidth -= line.toks[i].width;
}
let x = lineStartX(align, maxWidth, lineWidth, wrap === "none");
for (const tok of line.toks) {
if (tok.text) {
const tokenBoxHeight = tok.font.size * tok.font.lineHeight;
laid.push({
text: tok.text,
font: tok.font,
x,
y: y + (line.height - tokenBoxHeight),
width: tok.width,
height: tokenBoxHeight,
});
}
x += tok.width;
}
y += line.height;
}
return { tokens: laid, contentHeight };
}
export function layoutRenderTextRuns(
runs: RenderTextRun[],
width: number,
wrap: string | null | undefined,
) {
const lines: Array<Array<RenderTextRun & { width: number }>> = [[]];
let lineWidth = 0;
const pushLine = () => {
if (lines[lines.length - 1]?.length === 0) return;
lines.push([]);
lineWidth = 0;
};
for (const run of runs) {
const parts = run.text.match(/\n|[^\S\n]+|[^\s]+/g) ?? [run.text];
for (const part of parts) {
if (part === "\n") {
pushLine();
continue;
}
const isWhitespace = part.trim().length === 0;
const segments =
wrap !== "none" && !isWhitespace
? splitOversizedTextSegment(part, run.font, width, measureRenderText)
: [{ text: part, width: measureRenderText(part, run.font) }];
for (const segment of segments) {
if (
wrap !== "none" &&
!isWhitespace &&
lineWidth > 0 &&
lineWidth + segment.width > width
) {
pushLine();
}
if (lines.length === 0) lines.push([]);
lines[lines.length - 1].push({
...run,
text: segment.text,
width: segment.width,
});
lineWidth += segment.width;
}
}
}
return lines.filter((line) => line.length > 0);
}
export function lineRenderHeight(
line: Array<RenderTextRun & { width: number }>,
fallbackLineHeight: number,
) {
return Math.max(
1,
...line.map(
(segment) =>
segment.font.size * (segment.font.lineHeight ?? fallbackLineHeight),
),
);
}
function splitOversizedTextSegment(
text: string,
font: RenderTextFont,
maxWidth: number,
measure: (text: string, font: RenderTextFont) => number,
): Array<{ text: string; width: number }> {
const fullWidth = measure(text, font);
if (!text || maxWidth <= 0 || fullWidth <= maxWidth) {
return [{ text, width: fullWidth }];
}
const characters = Array.from(text);
const segments: Array<{ text: string; width: number }> = [];
let start = 0;
while (start < characters.length) {
let low = start + 1;
let high = characters.length;
let bestEnd = low;
let bestWidth = measure(characters.slice(start, low).join(""), font);
while (low <= high) {
const mid = Math.floor((low + high) / 2);
const candidate = characters.slice(start, mid).join("");
const candidateWidth = measure(candidate, font);
if (candidateWidth <= maxWidth || mid === start + 1) {
bestEnd = mid;
bestWidth = candidateWidth;
low = mid + 1;
} else {
high = mid - 1;
}
}
segments.push({
text: characters.slice(start, bestEnd).join(""),
width: bestWidth,
});
start = bestEnd;
}
return segments;
}
export function measureNoWrapTextWidth(text: string, font: RenderTextFont) {
const lines = text.split(/\r?\n/);
return Math.max(1, ...lines.map((line) => measureRenderText(line, font)));
}
export function measureNoWrapTextHeight(
text: string,
font: RenderTextFont,
lineHeight: number,
) {
const lineCount = Math.max(1, text.split(/\r?\n/).length);
return lineCount * font.size * lineHeight;
}
export function lineStartX(
align: string,
boxWidth: number,
lineWidth: number,
allowOverflow: boolean,
) {
const x =
align === "center"
? (boxWidth - lineWidth) / 2
: align === "right"
? boxWidth - lineWidth
: 0;
return allowOverflow ? x : Math.max(0, x);
}
export function verticalTextStartY(
align: string,
boxHeight: number,
textHeight: number,
allowOverflow: boolean,
) {
const y = verticalStartY(align, boxHeight, textHeight);
return allowOverflow ? y : Math.max(0, y);
}
function verticalStartY(align: string, boxHeight: number, textHeight: number) {
const y =
align === "middle"
? (boxHeight - textHeight) / 2
: align === "bottom"
? boxHeight - textHeight
: 0;
return y;
}
export function rawFontRecordForEditor(value: unknown) {
const font = asRecord(value);
if (!font) return value;
return {
...withoutFontWrap(font),
line_height: font.line_height ?? font.lineHeight,
letter_spacing: font.letter_spacing ?? font.letterSpacing,
};
}
export function editorFontRecordToRaw(value: unknown, fallback: unknown) {
const font = asRecord(value);
if (!font) return fallback;
const fallbackFont = asRecord(fallback) ?? {};
return {
...withoutFontWrap(fallbackFont),
...withoutFontWrap(font),
line_height: font.line_height ?? font.lineHeight,
letter_spacing: font.letter_spacing ?? font.letterSpacing,
};
}
export function rawFontToSource(value: unknown) {
const font = asRecord(value) ?? {};
return stripUndefined({
...withoutFontWrap(font),
line_height: font.line_height ?? font.lineHeight,
letter_spacing: font.letter_spacing ?? font.letterSpacing,
opacity: font.opacity,
});
}
export function fontScaleFromResize(scaleX: number, scaleY: number) {
const safeX = Number.isFinite(scaleX) && scaleX > 0 ? scaleX : 1;
const safeY = Number.isFinite(scaleY) && scaleY > 0 ? scaleY : 1;
return Math.sqrt(safeX * safeY);
}
export function scaleRawTextMetrics(
element: TemplateV2RawTextElement,
scale: number,
): TemplateV2RawTextElement {
if (
!Number.isFinite(scale) ||
Math.abs(scale - 1) < TRANSFORM_FONT_SCALE_EPSILON
) {
return element;
}
return stripUndefined({
...element,
font: scaleRawFontMetrics(element.font, scale),
runs: scaleRawTextRunsMetrics(element.runs, scale),
items: scaleRawTextListItemsMetrics(element.items, scale),
columns: scaleRawTableCellsMetrics(element.columns, scale),
rows: scaleRawTableRowsMetrics(element.rows, scale),
});
}
function scaleRawTextRunsMetrics(value: unknown, scale: number) {
if (!Array.isArray(value)) return value;
return value.map((run) => {
const record = asRecord(run);
if (!record) return run;
return stripUndefined({
...record,
font: scaleRawFontMetrics(record.font, scale),
});
});
}
function scaleRawTextListItemsMetrics(value: unknown, scale: number) {
if (!Array.isArray(value)) return value;
return value.map((item) => {
if (Array.isArray(item)) return scaleRawTextRunsMetrics(item, scale);
const record = asRecord(item);
if (!record) return item;
return stripUndefined({
...record,
font: scaleRawFontMetrics(record.font, scale),
runs: scaleRawTextRunsMetrics(record.runs, scale),
});
});
}
function scaleRawTableRowsMetrics(value: unknown, scale: number) {
if (!Array.isArray(value)) return value;
return value.map((row) =>
Array.isArray(row) ? scaleRawTableCellsMetrics(row, scale) : row,
);
}
function scaleRawTableCellsMetrics(value: unknown, scale: number) {
if (!Array.isArray(value)) return value;
return value.map((cell) => {
const record = asRecord(cell);
if (!record) return cell;
const textRecord = asRecord(record.text);
return stripUndefined({
...record,
font: scaleRawFontMetrics(record.font, scale),
runs: scaleRawTextRunsMetrics(record.runs, scale),
text: textRecord
? stripUndefined({
...textRecord,
font: scaleRawFontMetrics(textRecord.font, scale),
runs: scaleRawTextRunsMetrics(textRecord.runs, scale),
})
: record.text,
});
});
}
function scaleRawFontMetrics(value: unknown, scale: number) {
const font = asRecord(value);
if (!font) return value;
const next = { ...font };
const size = readNumber(font.size);
if (size != null) {
next.size = scaleFontSize(size, scale);
}
const letterSpacing = readNumber(font.letter_spacing);
if (letterSpacing != null) {
next.letter_spacing = scaleTextMetric(letterSpacing, scale);
}
const camelLetterSpacing = readNumber(font.letterSpacing);
if (camelLetterSpacing != null) {
next.letterSpacing = scaleTextMetric(camelLetterSpacing, scale);
}
return stripUndefined(next);
}
function scaleFontSize(size: number, scale: number) {
return Math.min(
MAX_TRANSFORM_FONT_SIZE,
Math.max(MIN_TRANSFORM_FONT_SIZE, scaleTextMetric(size, scale)),
);
}
function scaleTextMetric(value: number, scale: number) {
return Math.round(value * scale * 100) / 100;
}
function rawTextListItemSourceRuns(
item: unknown,
fallback: RenderTextFont,
): TextRun[] {
const fallbackFont = fontToSource(fallback);
if (typeof item === "string") return [{ text: item, font: fallbackFont }];
if (typeof item === "number") {
return [{ text: String(item), font: fallbackFont }];
}
if (Array.isArray(item)) {
const runs = item
.map((run) => rawRunRecordToTextRun(run, fallback))
.filter((run): run is TextRun => Boolean(run));
return runs.length > 0 ? runs : [{ text: " ", font: fallbackFont }];
}
const record = asRecord(item);
if (!record) return [];
const itemFont = fontFromRecord(asRecord(record.font), fallback);
const runs = readArray(record.runs)
.map((run) => rawRunRecordToTextRun(run, itemFont))
.filter((run): run is TextRun => Boolean(run));
if (runs.length > 0) return runs;
const text = readString(record.text);
if (text != null) return [{ text, font: fontToSource(itemFont) }];
return [];
}
function rawRunRecordToTextRun(
value: unknown,
fallback: RenderTextFont,
): TextRun | null {
const record = asRecord(value);
if (!record) return null;
const text = readString(record.text) ?? "";
if (!text) return null;
return {
text,
font: fontToSource(fontFromRecord(asRecord(record.font), fallback)),
};
}
function textListMarkerPrefix(value: unknown, index: number) {
const marker = readString(value);
if (marker === "none") return "";
if (marker === "number") return `${index + 1}. `;
return "• ";
}
function rawTextListItemWithRuns(source: unknown, runs: TextRun[]): unknown {
const sourceRuns = Array.isArray(source)
? source
: readArray(asRecord(source)?.runs);
return runs.map((run, index) => {
const sourceRun = asRecord(sourceRuns[index]) ?? {};
return {
...sourceRun,
text: run.text,
font: rawInlineTextFontRecord(run.font, sourceRun.font),
};
});
}
function splitTextRunsOnNewlines(runs: TextRun[]): TextRun[][] {
const lines: TextRun[][] = [[]];
for (const run of runs) {
const parts = (run.text || "").split(/\r?\n/);
parts.forEach((part, index) => {
if (index > 0) lines.push([]);
if (!part) return;
lines[lines.length - 1].push({
...run,
text: part,
font: run.font ? { ...run.font } : undefined,
});
});
}
return lines;
}
function stripPlainTextListMarkersFromRuns(runs: TextRun[]): TextRun[] {
const lines = splitTextRunsOnNewlines(runs);
const stripped: TextRun[] = [];
lines.forEach((line, index) => {
const normalizedLine = stripTextListMarkerFromRuns(line);
const lineFont =
normalizedLine[0]?.font ?? line[0]?.font ?? stripped.at(-1)?.font;
if (index > 0) appendTextRun(stripped, "\n", lineFont);
normalizedLine.forEach((run) =>
appendTextRun(stripped, run.text, run.font),
);
});
return stripped.length > 0 ? stripped : [{ text: " " }];
}
function stripTextListMarkerFromRuns(runs: TextRun[]): TextRun[] {
const marker = textRunsContent(runs).match(
/^\s*(?:[-*•]\s+|\d+[.)]\s+)/u,
)?.[0];
if (!marker) return runs;
return removeTextRunPrefix(runs, marker.length);
}
function removeTextRunPrefix(runs: TextRun[], length: number): TextRun[] {
let remaining = length;
const stripped: TextRun[] = [];
for (const run of runs) {
if (remaining <= 0) {
stripped.push(cloneTextRun(run));
continue;
}
const text = run.text ?? "";
if (text.length <= remaining) {
remaining -= text.length;
continue;
}
const consumed = remaining;
remaining = 0;
stripped.push({
...run,
text: text.slice(consumed),
font: run.font ? { ...run.font } : undefined,
});
}
return stripped;
}
function appendTextRun(
runs: TextRun[],
text: string,
font: TextRun["font"],
) {
if (!text) return;
const previous = runs[runs.length - 1];
if (
previous &&
JSON.stringify(previous.font ?? null) === JSON.stringify(font ?? null)
) {
previous.text += text;
return;
}
runs.push(font ? { text, font: { ...font } } : { text });
}
function reconcileTextRunsWithStoredText(
runs: TextRun[],
storedText: string,
): TextRun[] {
if (!storedText || runs.length === 0) return runs;
if (containsMarkdownSyntax(storedText)) return runs;
if (textRunsContent(runs) === storedText) return runs;
const reconciled: TextRun[] = [];
let cursor = 0;
for (const run of runs) {
const runText = run.text ?? "";
if (!runText) continue;
const index = storedText.indexOf(runText, cursor);
if (index < 0) return runs;
const gap = storedText.slice(cursor, index);
appendRunText(reconciled, gap, run.font);
reconciled.push(cloneTextRun(run));
cursor = index + runText.length;
}
appendRunText(
reconciled,
storedText.slice(cursor),
runs[runs.length - 1]?.font,
);
return textRunsContent(reconciled) === storedText ? reconciled : runs;
}
function normalizeStyledSourceRunBoundaries(runs: TextRun[]): TextRun[] {
if (runs.length < 2) return runs;
const normalized: TextRun[] = [];
for (const run of runs) {
const previous = normalized[normalized.length - 1];
if (previous && shouldPreserveStyledRunBoundarySpace(previous, run)) {
appendRunText(normalized, " ", previous.font);
}
normalized.push(cloneTextRun(run));
}
return sameTextRuns(normalized, runs) ? runs : normalized;
}
function shouldPreserveStyledRunBoundarySpace(left: TextRun, right: TextRun) {
if (!hasInlineStyleBoundary(left.font, right.font)) return false;
if (!left.text || !right.text) return false;
if (/\s$/.test(left.text) || /^\s/.test(right.text)) return false;
const leftCharacter = left.text.match(/\S(?=\s*$)/u)?.[0];
const rightCharacter = right.text.match(/\S/u)?.[0];
return Boolean(
leftCharacter &&
rightCharacter &&
isWordLikeBoundaryCharacter(leftCharacter) &&
isWordLikeBoundaryCharacter(rightCharacter),
);
}
function hasInlineStyleBoundary(
left: TextRun["font"],
right: TextRun["font"],
) {
return (
Boolean(left?.bold) !== Boolean(right?.bold) ||
Boolean(left?.italic) !== Boolean(right?.italic) ||
Boolean(left?.underline) !== Boolean(right?.underline)
);
}
function isWordLikeBoundaryCharacter(character: string) {
return /[\p{L}\p{N}%°]/u.test(character);
}
function appendRunText(
runs: TextRun[],
text: string,
font: TextRun["font"],
) {
if (!text) return;
const previous = runs[runs.length - 1];
if (previous) {
previous.text += text;
return;
}
runs.push(font ? { text, font: { ...font } } : { text });
}
function cloneTextRun(run: TextRun): TextRun {
return {
...run,
font: run.font ? { ...run.font } : undefined,
};
}
function containsMarkdownSyntax(text: string) {
return /(\*\*|__|\*|_).+(\*\*|__|\*|_)/.test(text);
}
function sameTextRuns(left: TextRun[], right: TextRun[]) {
if (left.length !== right.length) return false;
return left.every(
(run, index) =>
run.text === right[index]?.text &&
JSON.stringify(run.font ?? null) ===
JSON.stringify(right[index]?.font ?? null),
);
}
function measureContext(): CanvasRenderingContext2D | null {
if (typeof document === "undefined") return null;
if (!richMeasureCtx.ctx) {
richMeasureCtx.ctx = document.createElement("canvas").getContext("2d");
}
return richMeasureCtx.ctx;
}
function richFontCss(font: RenderTextFont): string {
const italic = font.italic ? "italic " : "";
const weight = font.bold ? "700 " : "400 ";
return `${italic}${weight}${font.size}px ${quotedFontFamily(font.family)}, Helvetica, sans-serif`;
}
function quotedFontFamily(family: string): string {
const name = (family || DEFAULT_FONT.family).trim() || DEFAULT_FONT.family;
return `"${name.replace(/\\/g, "\\\\").replace(/"/g, '\\"')}"`;
}
function measureRunText(text: string, font: RenderTextFont): number {
if (!text) return 0;
const ctx = measureContext();
if (!ctx) return text.length * font.size * TEXT_AVERAGE_CHAR_EM;
ctx.font = richFontCss(font);
const width = ctx.measureText(text).width;
const spacing = font.letterSpacing
? font.letterSpacing * Math.max(0, text.length - 1)
: 0;
return width + spacing;
}
function measureRenderText(text: string, font: RenderTextFont) {
const fallbackWidth =
text.length * font.size * (font.bold ? 0.58 : TEXT_AVERAGE_CHAR_EM);
if (typeof document === "undefined") return fallbackWidth;
renderTextMeasureCanvas ??= document.createElement("canvas");
const context = renderTextMeasureCanvas.getContext("2d");
if (!context) return fallbackWidth;
context.font = richFontCss(font);
return (
context.measureText(text).width +
(font.letterSpacing ?? 0) * Math.max(0, text.length - 1)
);
}
function readHorizontalAlignment(
value: unknown,
): TemplateV2TextEditStyle["horizontal"] {
const normalized = readString(value);
if (normalized === "center" || normalized === "right") return normalized;
return "left";
}
function readVerticalAlignment(
value: unknown,
): TemplateV2TextEditStyle["vertical"] {
const normalized = readString(value);
if (normalized === "middle" || normalized === "bottom") return normalized;
return "top";
}
function readArray(value: unknown): unknown[] {
return Array.isArray(value) ? value : [];
}
function stripUndefined<T extends UnknownRecord>(value: T): T {
return Object.fromEntries(
Object.entries(value).filter(([, entry]) => entry !== undefined),
) as T;
}
function asRecord(value: unknown): UnknownRecord | null {
return value && typeof value === "object" && !Array.isArray(value)
? (value as UnknownRecord)
: null;
}
function readString(value: unknown): string | null {
return typeof value === "string" ? value : null;
}
function readNumber(value: unknown): number | null {
return typeof value === "number" && Number.isFinite(value) ? value : null;
}
function readBoolean(value: unknown): boolean | null {
return typeof value === "boolean" ? value : null;
}
function withHash(value: string | null | undefined) {
if (!value) return undefined;
return value.startsWith("#") || value.startsWith("rgb") ? value : `#${value}`;
}