domotion-svg 0.3.3 → 0.4.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -56,7 +56,18 @@ export interface TypingOverlay {
56
56
  speed?: number;
57
57
  /** Background color to mask placeholder text */
58
58
  bgColor?: string;
59
+ /**
60
+ * Field width in px. When set, the typed text WRAPS to this width like a
61
+ * browser textarea — breaking on spaces (char-breaking over-long words),
62
+ * advancing one line-height per wrapped line — instead of running off the
63
+ * right edge on a single line (DM-840). Omit for unbounded single-line text.
64
+ */
59
65
  bgWidth?: number;
66
+ /**
67
+ * Field height in px (used to size the placeholder mask). The mask grows
68
+ * beyond this if the wrapped text needs more lines, so the typed text always
69
+ * sits on a clean background.
70
+ */
60
71
  bgHeight?: number;
61
72
  }
62
73
  export interface TapOverlay {
@@ -155,6 +166,13 @@ export interface AnimationConfig {
155
166
  * them in every frame's local defs.
156
167
  */
157
168
  sharedDefs?: string;
169
+ /**
170
+ * DM-839: embedded-font `@font-face` rules collected once across all frames
171
+ * (the caller renders each frame with `includeEmbeddedFontCss=false` and
172
+ * passes the accumulated `getEmbeddedFontFaceCss()` here). Injected into the
173
+ * top-level `<style>` so the base64 font bytes appear once, not per frame.
174
+ */
175
+ fontFaceCss?: string;
158
176
  /**
159
177
  * Optional cursor / click overlay (DM-277). Renders a macOS-style cursor
160
178
  * moving along the script timeline with QuickTime-style click pulses.
@@ -222,7 +222,7 @@ export function generateAnimatedSvg(config) {
222
222
  <clipPath id="viewport-clip"><rect width="${width}" height="${height}" /></clipPath>${sharedDefsMarkup}
223
223
  </defs>
224
224
  <style>
225
- :root { --scene-dur: ${totalSec.toFixed(2)}s; }
225
+ ${config.fontFaceCss != null && config.fontFaceCss !== "" ? config.fontFaceCss + "\n" : ""} :root { --scene-dur: ${totalSec.toFixed(2)}s; }
226
226
  .f { opacity: 0; visibility: hidden; }
227
227
  ${keyframes.join("\n")}${animationCss}${cullCss === "" ? "" : "\n" + cullCss}
228
228
  </style>
@@ -245,45 +245,119 @@ function transitionDuration(f) {
245
245
  return 0;
246
246
  return f.transition.duration;
247
247
  }
248
+ /**
249
+ * Wrap `text` into lines no wider than `maxChars` monospace cells, the way a
250
+ * browser textarea does: break on spaces, char-break a word longer than the
251
+ * field, and honor explicit newlines. `maxChars === Infinity` → no wrap (one
252
+ * line per explicit-newline paragraph), preserving the pre-DM-840 behavior for
253
+ * overlays with no `bgWidth`. DM-840.
254
+ */
255
+ function wrapTypingText(text, maxChars) {
256
+ const lines = [];
257
+ for (const paragraph of text.split("\n")) {
258
+ if (maxChars === Infinity) {
259
+ lines.push(paragraph);
260
+ continue;
261
+ }
262
+ if (paragraph === "") {
263
+ lines.push("");
264
+ continue;
265
+ }
266
+ let cur = "";
267
+ for (let word of paragraph.split(" ")) {
268
+ // A single word wider than the line char-breaks across lines.
269
+ while (word.length > maxChars) {
270
+ if (cur !== "") {
271
+ lines.push(cur);
272
+ cur = "";
273
+ }
274
+ lines.push(word.slice(0, maxChars));
275
+ word = word.slice(maxChars);
276
+ }
277
+ if (cur === "")
278
+ cur = word;
279
+ else if ((cur + " " + word).length <= maxChars)
280
+ cur += " " + word;
281
+ else {
282
+ lines.push(cur);
283
+ cur = word;
284
+ }
285
+ }
286
+ lines.push(cur);
287
+ }
288
+ return lines;
289
+ }
248
290
  function renderTypingOverlay(overlay, frameIdx, frameStart, frameEnd, totalDuration, totalSec) {
249
291
  const delay = overlay.delay ?? 300;
250
292
  const speed = overlay.speed ?? 60;
251
293
  const fontSize = overlay.fontSize ?? 14;
252
- const charWidth = fontSize * 0.6;
294
+ const charWidth = fontSize * 0.6; // monospace cell (overlay font is monospace)
295
+ const lineHeight = Math.round(fontSize * 1.35);
253
296
  const color = overlay.color ?? "#e6edf3";
254
297
  const typeStartMs = frameStart + delay;
298
+ const id = `t${frameIdx}`;
299
+ // DM-840: wrap to bgWidth so typed text behaves like a browser field
300
+ // (textarea) — wrapping to the next line instead of running off the right
301
+ // edge. Text starts at overlay.x and the bg rect starts at overlay.x-2 with
302
+ // width bgWidth, so the usable text width is bgWidth-4. With no bgWidth we
303
+ // keep the original single-line behavior (maxChars = Infinity).
304
+ const maxLineWidth = overlay.bgWidth != null ? overlay.bgWidth - 4 : Infinity;
305
+ const maxChars = maxLineWidth === Infinity ? Infinity : Math.max(1, Math.floor(maxLineWidth / charWidth));
306
+ const lines = wrapTypingText(overlay.text, maxChars);
307
+ const visibleChars = Math.max(1, lines.reduce((n, l) => n + l.length, 0));
308
+ const longestLineChars = lines.reduce((m, l) => Math.max(m, l.length), 0);
255
309
  const parts = [];
256
310
  const cssRules = [];
257
- const id = `t${frameIdx}`;
258
- // Background mask
311
+ // ── Timeline — all stops clamped to the frame so the overlay can't leak
312
+ // across the cut into the next frame. `naturalEnd` is when typing finishes
313
+ // at the requested speed; if that runs past the frame we compress the reveal
314
+ // to fit. The fully-typed text then HOLDS until just before the frame ends
315
+ // (the old hard 3 s cap cut long text off mid-type), then fades out.
316
+ const disappearGap = 150;
317
+ const naturalEndMs = typeStartMs + visibleChars * speed;
318
+ const textEndMs = Math.min(naturalEndMs, Math.max(typeStartMs + 1, frameEnd - disappearGap));
319
+ const effTypeDur = Math.max(1, textEndMs - typeStartMs);
320
+ const holdEndMs = Math.max(textEndMs, frameEnd - disappearGap);
321
+ const disappearMs = Math.min(frameEnd, holdEndMs + 100);
322
+ const textHeight = fontSize + 4;
323
+ const holdEndPct = pct(holdEndMs, totalDuration);
324
+ const disappearPct = pct(disappearMs, totalDuration);
325
+ // Background mask — grown to cover every wrapped line so the typed text
326
+ // always lands on a clean field instead of the captured placeholder.
259
327
  if (overlay.bgColor != null) {
260
- const bgW = overlay.bgWidth ?? overlay.text.length * charWidth + 8;
261
- const bgH = overlay.bgHeight ?? fontSize + 6;
328
+ const bgW = overlay.bgWidth ?? longestLineChars * charWidth + 8;
329
+ const bgH = Math.max(overlay.bgHeight ?? fontSize + 6, lines.length * lineHeight + 6);
262
330
  const bgStartPct = pct(typeStartMs, totalDuration);
263
- const bgEndPct = pct(frameStart + overlay.text.length * speed + delay + 500, totalDuration);
264
331
  parts.push(` <rect class="${id}-bg" x="${overlay.x - 2}" y="${overlay.y - fontSize + 2}" width="${bgW}" height="${bgH}" fill="${overlay.bgColor}" rx="2" />`);
265
332
  cssRules.push(`
266
- @keyframes ${id}-bg { 0%, ${bgStartPct} { opacity: 0; } ${pct(typeStartMs + 50, totalDuration)} { opacity: 1; } ${bgEndPct}, 100% { opacity: 0; } }
333
+ @keyframes ${id}-bg { 0%, ${bgStartPct} { opacity: 0; } ${pct(typeStartMs + 50, totalDuration)} { opacity: 1; } ${holdEndPct} { opacity: 1; } ${disappearPct}, 100% { opacity: 0; } }
267
334
  .${id}-bg { animation: ${id}-bg ${totalSec.toFixed(2)}s infinite; }`);
268
335
  }
269
- // Render full text with an animated clip that reveals characters one-by-one.
270
- // The overlay must disappear by the time the frame ends otherwise it'll
271
- // leak across the cut boundary and overlap the next frame's content.
272
- const textEndMs = typeStartMs + overlay.text.length * speed;
273
- const holdEndMs = Math.min(frameStart + 3000, frameEnd);
274
- const fullTextWidth = overlay.text.length * charWidth + 4;
275
- const textHeight = fontSize + 4;
276
- const clipId = `${id}-clip`;
277
- // Clip rect animation: width grows from 0 to full text width
278
- parts.push(` <defs><clipPath id="${clipId}"><rect class="${id}-reveal" x="${overlay.x}" y="${overlay.y - fontSize}" width="0" height="${textHeight}" /></clipPath></defs>`);
279
- parts.push(` <text class="${id}-text" x="${overlay.x}" y="${overlay.y}" fill="${color}" font-size="${fontSize}" font-family="'SF Mono', Menlo, Monaco, monospace" clip-path="url(#${clipId})">${escapeXml(overlay.text)}</text>`);
336
+ // Typewriter reveal: one <text> per wrapped line, each unveiled by a
337
+ // width-growing clip during the slice of the type timeline when that line's
338
+ // characters are typed (line N starts after line N-1 finishes), so the caret
339
+ // advances down the field exactly as it would in the browser.
340
+ let cumChars = 0;
341
+ lines.forEach((line, li) => {
342
+ const lineY = overlay.y + li * lineHeight;
343
+ // +1 cell of slack so the last glyph never clips: the real monospace
344
+ // advance is slightly wider than the 0.6em estimate, and the trailing cell
345
+ // is where the caret would sit just after the typed character anyway.
346
+ const lineWidth = line.length * charWidth + charWidth;
347
+ const clipId = `${id}-clip${li}`;
348
+ const lineStartPct = pct(typeStartMs + (cumChars / visibleChars) * effTypeDur, totalDuration);
349
+ const lineEndPct = pct(typeStartMs + ((cumChars + line.length) / visibleChars) * effTypeDur, totalDuration);
350
+ cumChars += line.length;
351
+ parts.push(` <defs><clipPath id="${clipId}"><rect class="${id}-rev${li}" x="${overlay.x}" y="${lineY - fontSize}" width="0" height="${textHeight}" /></clipPath></defs>`);
352
+ parts.push(` <text class="${id}-text" x="${overlay.x}" y="${lineY}" fill="${color}" font-size="${fontSize}" font-family="'SF Mono', Menlo, Monaco, monospace" clip-path="url(#${clipId})">${escapeXml(line)}</text>`);
353
+ cssRules.push(`
354
+ @keyframes ${id}-rev${li} { 0%, ${lineStartPct} { width: 0; } ${lineEndPct} { width: ${lineWidth}px; } ${holdEndPct} { width: ${lineWidth}px; } ${disappearPct}, 100% { width: 0; } }
355
+ .${id}-rev${li} { animation: ${id}-rev${li} ${totalSec.toFixed(2)}s infinite; }`);
356
+ });
357
+ // Whole-overlay visibility — shared by every line's <text>.
280
358
  const typeStartPct = pct(typeStartMs, totalDuration);
281
- const typeEndPct = pct(textEndMs, totalDuration);
282
- const holdEndPct = pct(holdEndMs, totalDuration);
283
359
  cssRules.push(`
284
- @keyframes ${id}-reveal { 0%, ${typeStartPct} { width: 0; } ${typeEndPct} { width: ${fullTextWidth}px; } ${holdEndPct} { width: ${fullTextWidth}px; } ${pct(holdEndMs + 100, totalDuration)}, 100% { width: 0; } }
285
- .${id}-reveal { animation: ${id}-reveal ${totalSec.toFixed(2)}s infinite; }
286
- @keyframes ${id}-vis { 0%, ${typeStartPct} { opacity: 0; } ${pct(typeStartMs + 30, totalDuration)} { opacity: 1; } ${holdEndPct} { opacity: 1; } ${pct(holdEndMs + 100, totalDuration)}, 100% { opacity: 0; } }
360
+ @keyframes ${id}-vis { 0%, ${typeStartPct} { opacity: 0; } ${pct(typeStartMs + 30, totalDuration)} { opacity: 1; } ${holdEndPct} { opacity: 1; } ${disappearPct}, 100% { opacity: 0; } }
287
361
  .${id}-text { animation: ${id}-vis ${totalSec.toFixed(2)}s infinite; }`);
288
362
  return { svgMarkup: parts.join("\n"), css: cssRules.join("") };
289
363
  }
@@ -444,7 +518,7 @@ function composeMergedSvg(config, frameTiming, totalSec) {
444
518
  <clipPath id="viewport-clip"><rect width="${width}" height="${height}" /></clipPath>${sharedDefsMarkup}
445
519
  </defs>
446
520
  <style>
447
- :root { --scene-dur: ${totalSec.toFixed(2)}s; }
521
+ ${config.fontFaceCss != null && config.fontFaceCss !== "" ? config.fontFaceCss + "\n" : ""} :root { --scene-dur: ${totalSec.toFixed(2)}s; }
448
522
  ${css}${animationCss}${cullCss === "" ? "" : "\n" + cullCss}
449
523
  </style>
450
524
  <g clip-path="url(#viewport-clip)">
@@ -287,4 +287,60 @@ describe("animator", () => {
287
287
  // 50.000% boundary — frame 0 fades out and frame 1 fades in at the same instant.
288
288
  expect(svg).toMatch(/50\.000%/);
289
289
  });
290
+ // DM-840: a typing overlay over a bgWidth-constrained field must wrap like a
291
+ // browser textarea instead of running the text off the right edge.
292
+ describe("typing overlay text wrapping (DM-840)", () => {
293
+ const longText = "The quick brown fox jumps over the lazy dog repeatedly";
294
+ function typedSvg(opts) {
295
+ return generateAnimatedSvg({
296
+ width: 400, height: 200,
297
+ frames: [{
298
+ svgContent: `<rect width="400" height="200" fill="#fff"/>`,
299
+ duration: 4000,
300
+ overlays: [{
301
+ kind: "typing", text: longText, x: 20, y: 40,
302
+ fontSize: 14, bgColor: "#fff", bgWidth: opts.bgWidth, bgHeight: 24,
303
+ }],
304
+ }],
305
+ });
306
+ }
307
+ // Pull the text content out of every `<text class="t0-text" …>…</text>`.
308
+ function typedLines(svg) {
309
+ return [...svg.matchAll(/<text class="t0-text"[^>]*>([^<]*)<\/text>/g)].map((m) => m[1]);
310
+ }
311
+ it("wraps the typed text into multiple lines bounded by bgWidth", () => {
312
+ const svg = typedSvg({ bgWidth: 120 });
313
+ const lines = typedLines(svg);
314
+ expect(lines.length).toBeGreaterThan(1);
315
+ // bgWidth 120, charWidth = 14*0.6 = 8.4 → usable (120-4)/8.4 ≈ 13 chars/line.
316
+ const maxChars = Math.floor((120 - 4) / (14 * 0.6));
317
+ for (const line of lines)
318
+ expect(line.length).toBeLessThanOrEqual(maxChars);
319
+ // No glyph escapes the field: each line's right edge stays within the bg.
320
+ expect(lines.join("")).not.toContain(" "); // wrap consumed the break spaces
321
+ });
322
+ it("advances y by a line-height per wrapped line", () => {
323
+ const svg = typedSvg({ bgWidth: 120 });
324
+ const ys = [...svg.matchAll(/<text class="t0-text" x="20" y="([\d.]+)"/g)].map((m) => Number(m[1]));
325
+ expect(ys.length).toBeGreaterThan(1);
326
+ // Strictly increasing baselines, ~lineHeight (round(14*1.35)=19) apart.
327
+ for (let i = 1; i < ys.length; i++) {
328
+ expect(ys[i]).toBeGreaterThan(ys[i - 1]);
329
+ expect(ys[i] - ys[i - 1]).toBe(19);
330
+ }
331
+ });
332
+ it("keeps single-line behavior when no bgWidth is given (backward compatible)", () => {
333
+ const svg = typedSvg({});
334
+ const lines = typedLines(svg);
335
+ expect(lines.length).toBe(1);
336
+ expect(lines[0]).toBe(longText);
337
+ });
338
+ it("grows the bg mask to cover all wrapped lines", () => {
339
+ const svg = typedSvg({ bgWidth: 120 });
340
+ const bg = /<rect class="t0-bg"[^>]*height="([\d.]+)"/.exec(svg);
341
+ expect(bg).not.toBeNull();
342
+ // More than the single-line bgHeight (24) since the text wraps to several lines.
343
+ expect(Number(bg[1])).toBeGreaterThan(24);
344
+ });
345
+ });
290
346
  });
@@ -10,7 +10,7 @@ import { elementTreeToSvg } from "../render/element-tree-to-svg.js";
10
10
  import { embedRemoteImages } from "./embed.js";
11
11
  import { resizeEmbeddedImages } from "../tree-ops/resize-embedded-images.js";
12
12
  import { rasterizeConicGradients } from "../render/conic-raster.js";
13
- import { registerLocalFontAlias, registerWebfont } from "../render/text-to-path.js";
13
+ import { clearEmbeddedFonts, registerLocalFontAlias, registerWebfont } from "../render/text-to-path.js";
14
14
  import { CAPTURE_SCRIPT } from "./script.generated.js";
15
15
  import { rasterizeBitmapGlyphs } from "./emoji.js";
16
16
  import { _resetLastCaptureWarnings } from "./warnings.js";
@@ -141,6 +141,10 @@ export class DemoRecorder {
141
141
  // tree contains no conic content; otherwise the renderer (DM-550) emits
142
142
  // <pattern><image href="data:..."/></pattern> instead of dropping the layer.
143
143
  await rasterizeConicGradients(tree, { hiDPIFactor: this.embedRemoteImagesHiDPIFactor });
144
+ // DM-839: reset the embedded-font builder so this capture's `@font-face`
145
+ // block contains only its own fonts (the renderer repopulates it during
146
+ // elementTreeToSvg, which emits the CSS into this frame's <defs>).
147
+ clearEmbeddedFonts();
144
148
  return elementTreeToSvg(tree, this.width, this.height, idPrefix, true, this.embedRemoteImagesHiDPIFactor ?? 2);
145
149
  }
146
150
  /**
@@ -165,6 +169,7 @@ export class DemoRecorder {
165
169
  }
166
170
  // DM-549: rasterize conic-gradient layers — see captureCurrent above.
167
171
  await rasterizeConicGradients(tree, { hiDPIFactor: this.embedRemoteImagesHiDPIFactor });
172
+ clearEmbeddedFonts(); // DM-839: see captureCurrent
168
173
  const svgContent = elementTreeToSvg(tree, this.width, pageHeight, idPrefix, true, this.embedRemoteImagesHiDPIFactor ?? 2);
169
174
  return { svgContent, pageHeight };
170
175
  }
@@ -8,7 +8,7 @@
8
8
  import { existsSync, readFileSync } from "node:fs";
9
9
  import { dirname, resolve } from "node:path";
10
10
  import { parseArgs } from "node:util";
11
- import { captureElementTree, clearWebfonts, composeScrollSvg, cullElementsOutsideViewBox, elementTreeToSvg, executeScrollPattern, generateAnimatedSvg, launchChromium, optimizeSvg, parseScrollPattern, } from "../index.js";
11
+ import { captureElementTree, clearEmbeddedFonts, clearWebfonts, composeScrollSvg, cullElementsOutsideViewBox, elementTreeToSvg, executeScrollPattern, generateAnimatedSvg, getEmbeddedFontFaceCss, launchChromium, optimizeSvg, parseScrollPattern, } from "../index.js";
12
12
  import { attachWebfontTracker, discoverAndRegisterWebfonts } from "../capture/index.js";
13
13
  import { applyReadyWaits, isSvgzPath, loadInputIntoPage, makeLogger, resolveOutputPath, timed, writeOutput, } from "./common.js";
14
14
  export async function runAnimate(args, help) {
@@ -61,6 +61,11 @@ export async function runAnimate(args, help) {
61
61
  // same registry. Multiple frames declaring the same family register
62
62
  // multiple variants and the resolver picks the closest weight/style.
63
63
  clearWebfonts();
64
+ // DM-839: embedded-font is the default render mode. Reset the builder once
65
+ // here; each frame renders with includeEmbeddedFontCss=false (below) and we
66
+ // collect the deduped @font-face block once into the animator's top-level
67
+ // <style> after the loop — so the base64 font bytes appear once, not per frame.
68
+ clearEmbeddedFonts();
64
69
  // One tracker for the whole animate run — fonts fetched by any frame
65
70
  // get accumulated, and we deduplicate URLs inside discoverAndRegister.
66
71
  const tracker = attachWebfontTracker(page);
@@ -156,7 +161,7 @@ export async function runAnimate(args, help) {
156
161
  const totalDurationMs = cfg.frames.reduce((sum, f) => sum + f.duration + (f.transition?.type === "cut" ? 0 : (f.transition?.duration ?? 300)), 0);
157
162
  const result = cullElementsOutsideViewBox(tree, cfg.width, cfg.height, resolvedAnimations, frameStartMs, totalDurationMs);
158
163
  frameCullCss = result.css;
159
- svgContent = elementTreeToSvg(tree, cfg.width, cfg.height, `f${i}-`);
164
+ svgContent = elementTreeToSvg(tree, cfg.width, cfg.height, `f${i}-`, true, 2, false);
160
165
  }
161
166
  // Resolve SVG-kind overlays: read each `src` from disk, namespace its
162
167
  // ids, and replace with `innerSvg`. Other overlay kinds pass through
@@ -172,7 +177,10 @@ export async function runAnimate(args, help) {
172
177
  });
173
178
  }
174
179
  tracker.detach();
175
- let svg = await timed(log, `Composed animated SVG (${cfg.frames.length} frames)`, () => Promise.resolve(generateAnimatedSvg({ width: cfg.width, height: cfg.height, frames })));
180
+ // DM-839: collect the embedded-font @font-face rules accumulated across all
181
+ // frames once, for the animator's top-level <style>.
182
+ const fontFaceCss = getEmbeddedFontFaceCss();
183
+ let svg = await timed(log, `Composed animated SVG (${cfg.frames.length} frames)`, () => Promise.resolve(generateAnimatedSvg({ width: cfg.width, height: cfg.height, frames, fontFaceCss })));
176
184
  // svgz is auto-detected from the output filename; implies --optimize
177
185
  // unless --no-optimize was passed.
178
186
  const outputArg = values.output ?? cfg.output;
@@ -6,7 +6,7 @@
6
6
  * scroll machinery.
7
7
  */
8
8
  import { parseArgs } from "node:util";
9
- import { captureElementTree, clearWebfonts, composeScrollSvg, cullElementsOutsideViewBox, elementTreeToSvg, executeScrollPattern, launchChromium, logCaptureWarnings, optimizeSvg, parseScrollPattern, wrapSvg, } from "../index.js";
9
+ import { captureElementTree, clearEmbeddedFonts, clearWebfonts, composeScrollSvg, cullElementsOutsideViewBox, elementTreeToSvg, executeScrollPattern, launchChromium, logCaptureWarnings, optimizeSvg, parseScrollPattern, wrapSvg, } from "../index.js";
10
10
  import { attachWebfontTracker, discoverAndRegisterWebfonts } from "../capture/index.js";
11
11
  import { applyReadyWaits, isSvgzPath, loadInputIntoPage, makeLogger, parseColorScheme, parseIntFlag, parseTuple, resolveOutputPath, timed, writeOutput, } from "./common.js";
12
12
  export async function runCapture(args, help) {
@@ -139,6 +139,7 @@ export async function runCapture(args, help) {
139
139
  // so this is a defense-in-depth pass for the position:fixed-descendant
140
140
  // escape cases where an off-viewport ancestor still gets captured.
141
141
  cullElementsOutsideViewBox(tree, clip[2], clip[3], undefined, 0, 1);
142
+ clearEmbeddedFonts(); // DM-839: reset embedded-font builder before this single-frame render
142
143
  const inner = elementTreeToSvg(tree, clip[2], clip[3]);
143
144
  svg = wrapSvg(inner, clip[2], clip[3]);
144
145
  }
@@ -67,7 +67,17 @@ includeGlyphDefs?: boolean,
67
67
  * `resizeEmbeddedImages` for the same tree, or the lookup misses and
68
68
  * the renderer falls back to the source-resolution data URI. Default 2.
69
69
  */
70
- hiDPIFactor?: number): string;
70
+ hiDPIFactor?: number,
71
+ /**
72
+ * DM-839: when true, emit the embedded-font `@font-face` rules
73
+ * (`getEmbeddedFontFaceCss()`) as a `<style>` inside this frame's `<defs>`.
74
+ * Defaults to `includeGlyphDefs` — single-frame standalone producers
75
+ * (capture, CLI, the test harness) own their top-level defs and so should
76
+ * carry their font CSS here. Multi-frame producers (animator, scroll
77
+ * composer) pass `false` and collect the CSS once at the top level instead,
78
+ * so the (potentially large) base64 font bytes aren't duplicated per frame.
79
+ */
80
+ includeEmbeddedFontCss?: boolean): string;
71
81
  /**
72
82
  * Rewrite a captured `<mask>` element's `outerHTML` so it can be safely
73
83
  * inlined in the output SVG's `<defs>`. The mask's own `id` becomes
@@ -4,7 +4,7 @@
4
4
  * Uses Playwright to inspect DOM elements and recreate them as native SVG.
5
5
  */
6
6
  import { renderSingleLineText, renderMultiSegmentText, renderMultiLineText, renderInputText } from "./text.js";
7
- import { getGlyphDefs, measureLastGlyphRsb } from "./text-to-path.js";
7
+ import { getEmbeddedFontFaceCss, getGlyphDefs, measureLastGlyphRsb } from "./text-to-path.js";
8
8
  import { renderFormControl } from "./form-controls.js";
9
9
  import { r, esc, stopFmt } from "./format.js";
10
10
  import { parseColor, colorStr, sameColor } from "./colors.js";
@@ -104,7 +104,17 @@ includeGlyphDefs = true,
104
104
  * `resizeEmbeddedImages` for the same tree, or the lookup misses and
105
105
  * the renderer falls back to the source-resolution data URI. Default 2.
106
106
  */
107
- hiDPIFactor = 2) {
107
+ hiDPIFactor = 2,
108
+ /**
109
+ * DM-839: when true, emit the embedded-font `@font-face` rules
110
+ * (`getEmbeddedFontFaceCss()`) as a `<style>` inside this frame's `<defs>`.
111
+ * Defaults to `includeGlyphDefs` — single-frame standalone producers
112
+ * (capture, CLI, the test harness) own their top-level defs and so should
113
+ * carry their font CSS here. Multi-frame producers (animator, scroll
114
+ * composer) pass `false` and collect the CSS once at the top level instead,
115
+ * so the (potentially large) base64 font bytes aren't duplicated per frame.
116
+ */
117
+ includeEmbeddedFontCss = includeGlyphDefs) {
108
118
  setActiveHiDPIFactor(hiDPIFactor);
109
119
  const svgParts = [];
110
120
  const defsParts = [];
@@ -2956,7 +2966,13 @@ hiDPIFactor = 2) {
2956
2966
  // `getEmbeddedFontFaceCss()` themselves once at the top level (see how
2957
2967
  // `composeScrollSvg` injects it into the outer <style>).
2958
2968
  const glyphDefsMarkup = includeGlyphDefs ? getGlyphDefs() : "";
2959
- const allDefs = defsParts.join("") + glyphDefsMarkup;
2969
+ // DM-839: embedded-font `@font-face` rules for the text runs rendered above
2970
+ // (empty in paths mode, or when no run was embeddable). A `<style>` inside
2971
+ // `<defs>` is valid SVG. Single-frame producers emit it here; multi-frame
2972
+ // producers pass includeEmbeddedFontCss=false and inject once at the top.
2973
+ const embeddedFontCss = includeEmbeddedFontCss ? getEmbeddedFontFaceCss() : "";
2974
+ const fontStyleMarkup = embeddedFontCss !== "" ? `<style>${embeddedFontCss}</style>` : "";
2975
+ const allDefs = defsParts.join("") + glyphDefsMarkup + fontStyleMarkup;
2960
2976
  const defs = allDefs !== "" ? ` <defs>${allDefs}</defs>\n` : "";
2961
2977
  return defs + svgParts.join("\n");
2962
2978
  }
@@ -1,2 +1,2 @@
1
1
  export { elementTreeToSvg, wrapSvg, } from "./element-tree-to-svg.js";
2
- export { getGlyphDefs, clearGlyphDefs, registerWebfont, clearWebfonts, } from "./text-to-path.js";
2
+ export { getGlyphDefs, clearGlyphDefs, registerWebfont, clearWebfonts, setRenderTextMode, getRenderTextMode, clearEmbeddedFonts, getEmbeddedFontFaceCss, type RenderTextMode, } from "./text-to-path.js";
@@ -1,4 +1,8 @@
1
1
  // Render-side public surface. Pure functions that take a captured element
2
2
  // tree (or fragments of one) and emit SVG markup.
3
3
  export { elementTreeToSvg, wrapSvg, } from "./element-tree-to-svg.js";
4
- export { getGlyphDefs, clearGlyphDefs, registerWebfont, clearWebfonts, } from "./text-to-path.js";
4
+ export { getGlyphDefs, clearGlyphDefs, registerWebfont, clearWebfonts,
5
+ // DM-839: embedded-font is the default render mode; expose the controls so
6
+ // consumers can opt back into per-pixel-faithful `paths` mode and manage the
7
+ // embedded-font lifecycle.
8
+ setRenderTextMode, getRenderTextMode, clearEmbeddedFonts, getEmbeddedFontFaceCss, } from "./text-to-path.js";
@@ -109,6 +109,12 @@ export declare function __pickWebfontVariantMetaForCodepointForTest(family: stri
109
109
  /** Drop all registered webfonts. Call at the start of a fresh capture run. */
110
110
  export declare function clearWebfonts(): void;
111
111
  export declare function registerLocalFontAlias(family: string, resolvedKey: string, weight?: number, italic?: boolean): void;
112
+ /** Test-only window into the platform path resolver (DM-258). */
113
+ export declare function __resolveFontSpecForTest(key: string): {
114
+ path: string;
115
+ postscriptName?: string;
116
+ extractor?: string;
117
+ } | null;
112
118
  /**
113
119
  * Ordered list of fallback font keys to try when the primary font lacks a
114
120
  * glyph for `codepoint`. Caller iterates the chain and picks the first font
@@ -139,7 +145,27 @@ export declare function registerLocalFontAlias(family: string, resolvedKey: stri
139
145
  * "ja" / "ja-JP" → hiragino-jp (PingFang has no JP subfont on macOS)
140
146
  */
141
147
  export declare function pingfangKeyForLang(lang: string | undefined): string | null;
148
+ /**
149
+ * Linux fallback chain (DM-259) — calibrated to what Chromium-on-Linux paints
150
+ * in the Playwright `*-noble` CI image, measured via CDP
151
+ * `CSS.getPlatformFontsForNode` (tools/probe-fallbacks-linux.mjs). Returns
152
+ * logical keys that `LINUX_FONT_PATHS` maps to the image's real faces
153
+ * (Liberation / WenQuanYi / FreeFont / Loma / IPAGothic). As on macOS, the
154
+ * caller has already tried the primary font, so what reaches here is the
155
+ * residue the primary lacks. The comment after each branch names the face the
156
+ * probe showed Chromium using for that block.
157
+ */
158
+ export declare function linuxFallbackChain(codepoint: number, primaryKey?: string, _lang?: string): string[];
142
159
  export declare function fallbackFontChain(codepoint: number, primaryKey?: string, lang?: string): string[];
160
+ /**
161
+ * macOS (CoreText) fallback chain — reverse-engineered from Chromium-on-macOS
162
+ * painted widths (DM-241 / DM-256 / DM-257 / …). Exported so the macOS-
163
+ * calibration unit tests assert it directly: the suite runs on Linux in CI,
164
+ * where `fallbackFontChain` dispatches to `linuxFallbackChain`, so those tests
165
+ * must call this function (not `fallbackFontChain`) to validate macOS routing
166
+ * regardless of the host platform (DM-842).
167
+ */
168
+ export declare function darwinFallbackChain(codepoint: number, primaryKey?: string, lang?: string): string[];
143
169
  /** @deprecated Single-key wrapper for back-compat — prefer `fallbackFontChain`. */
144
170
  export declare function fallbackFontKey(codepoint: number): string | null;
145
171
  export declare function resolveFontKey(fontFamily: string): string;