1401 lines
39 KiB
TypeScript
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}`;
|
|
}
|