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.
- package/dist/animation/animator.d.ts +18 -0
- package/dist/animation/animator.js +99 -25
- package/dist/animation/animator.test.js +56 -0
- package/dist/capture/index.js +6 -1
- package/dist/cli/animate.js +11 -3
- package/dist/cli/capture.js +2 -1
- package/dist/render/element-tree-to-svg.d.ts +11 -1
- package/dist/render/element-tree-to-svg.js +19 -3
- package/dist/render/index.d.ts +1 -1
- package/dist/render/index.js +5 -1
- package/dist/render/text-to-path.d.ts +26 -0
- package/dist/render/text-to-path.js +386 -10
- package/dist/render/text-to-path.test.js +176 -86
- package/dist/render/text.test.js +6 -1
- package/dist/scroll/composer.js +6 -5
- package/package.json +1 -1
- package/src/animation/animator.test.ts +62 -0
- package/src/animation/animator.ts +110 -30
- package/src/capture/index.ts +6 -1
- package/src/cli/animate.ts +12 -2
- package/src/cli/capture.ts +2 -0
- package/src/render/element-tree-to-svg.ts +18 -2
- package/src/render/index.ts +8 -0
- package/src/render/text-to-path.test.ts +186 -86
- package/src/render/text-to-path.ts +419 -23
- package/src/render/text.test.ts +7 -1
- package/src/scroll/composer.ts +6 -4
|
@@ -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
|
-
|
|
258
|
-
//
|
|
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 ??
|
|
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; } ${
|
|
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
|
-
//
|
|
270
|
-
//
|
|
271
|
-
//
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
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}-
|
|
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
|
});
|
package/dist/capture/index.js
CHANGED
|
@@ -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
|
}
|
package/dist/cli/animate.js
CHANGED
|
@@ -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
|
-
|
|
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;
|
package/dist/cli/capture.js
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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
|
}
|
package/dist/render/index.d.ts
CHANGED
|
@@ -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";
|
package/dist/render/index.js
CHANGED
|
@@ -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,
|
|
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;
|