domotion-svg 0.2.2 → 0.3.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/FEATURES.md +1 -0
- package/README.md +29 -0
- package/dist/animation/animator.js +25 -14
- package/dist/animation/animator.test.js +54 -21
- package/dist/animation/cursor-overlay.js +0 -2
- package/dist/capture/emoji.js +29 -18
- package/dist/capture/index.js +5 -4
- package/dist/capture/script/color-norm.d.ts +1 -0
- package/dist/capture/script/color-norm.js +43 -1
- package/dist/capture/script/emoji-detect.js +14 -0
- package/dist/capture/script/index.js +593 -65
- package/dist/capture/script/walker/borders-backgrounds.d.ts +24 -17
- package/dist/capture/script/walker/borders-backgrounds.js +123 -7
- package/dist/capture/script/walker/counter-style-resolver.d.ts +7 -0
- package/dist/capture/script/walker/counter-style-resolver.js +218 -0
- package/dist/capture/script/walker/input-value.js +14 -1
- package/dist/capture/script/walker/lists-counters.d.ts +3 -1
- package/dist/capture/script/walker/lists-counters.js +22 -2
- package/dist/capture/script/walker/masks-clips.d.ts +2 -0
- package/dist/capture/script/walker/masks-clips.js +41 -1
- package/dist/capture/script/walker/pseudo-content.d.ts +14 -1
- package/dist/capture/script/walker/pseudo-content.js +301 -61
- package/dist/capture/script/walker/pseudo-inject.js +20 -0
- package/dist/capture/script/walker/text-segments.js +98 -4
- package/dist/capture/script/walker/transforms.d.ts +1 -0
- package/dist/capture/script/walker/transforms.js +16 -0
- package/dist/capture/script.generated.js +1 -1
- package/dist/capture/types.d.ts +213 -2
- package/dist/cli/animate.js +151 -15
- package/dist/mask.test.js +12 -7
- package/dist/render/borders.d.ts +9 -13
- package/dist/render/borders.js +379 -14
- package/dist/render/element-tree-to-svg.d.ts +11 -12
- package/dist/render/element-tree-to-svg.js +2046 -241
- package/dist/render/embedded-font-builder.d.ts +49 -0
- package/dist/render/embedded-font-builder.js +149 -0
- package/dist/render/form-controls.js +45 -24
- package/dist/render/gradients.d.ts +15 -0
- package/dist/render/gradients.js +103 -2
- package/dist/render/gradients.test.js +34 -0
- package/dist/render/text-to-path.d.ts +38 -1
- package/dist/render/text-to-path.js +654 -29
- package/dist/render/text-to-path.test.js +230 -9
- package/dist/render/text.d.ts +14 -0
- package/dist/render/text.js +344 -40
- package/dist/scroll/composer.d.ts +26 -0
- package/dist/scroll/composer.js +199 -11
- package/dist/scroll/composer.test.js +293 -16
- package/dist/scroll/executor.d.ts +3 -1
- package/dist/scroll/executor.js +15 -6
- package/dist/scroll/executor.test.js +25 -0
- package/dist/scroll/hoist-fixed.d.ts +48 -0
- package/dist/scroll/hoist-fixed.js +85 -0
- package/dist/scroll/hoist-fixed.test.d.ts +1 -0
- package/dist/scroll/hoist-fixed.test.js +103 -0
- package/dist/scroll/hoist-sticky.d.ts +45 -0
- package/dist/scroll/hoist-sticky.js +157 -0
- package/dist/scroll/hoist-sticky.test.d.ts +1 -0
- package/dist/scroll/hoist-sticky.test.js +154 -0
- package/dist/scroll/pattern.d.ts +22 -5
- package/dist/scroll/pattern.js +55 -7
- package/dist/scroll/pattern.test.js +48 -1
- package/dist/tree-ops/frame-merge.d.ts +10 -0
- package/dist/tree-ops/frame-merge.js +23 -5
- package/dist/tree-ops/frame-merge.test.js +45 -0
- package/dist/tree-ops/tree-diff.js +1 -1
- package/dist/tree-ops/viewbox-culling.js +32 -18
- package/dist/tree-ops/viewbox-culling.test.js +40 -6
- package/package.json +8 -2
- package/src/animation/animator.test.ts +56 -21
- package/src/animation/animator.ts +25 -14
- package/src/animation/cursor-overlay.ts +0 -2
- package/src/capture/emoji.ts +28 -18
- package/src/capture/index.ts +15 -14
- package/src/capture/script/color-norm.ts +38 -1
- package/src/capture/script/emoji-detect.ts +14 -0
- package/src/capture/script/index.ts +555 -48
- package/src/capture/script/walker/borders-backgrounds.ts +114 -7
- package/src/capture/script/walker/counter-style-resolver.ts +184 -0
- package/src/capture/script/walker/input-value.ts +14 -1
- package/src/capture/script/walker/lists-counters.ts +24 -2
- package/src/capture/script/walker/masks-clips.ts +40 -1
- package/src/capture/script/walker/pseudo-content.ts +297 -55
- package/src/capture/script/walker/pseudo-inject.ts +20 -0
- package/src/capture/script/walker/text-segments.ts +93 -4
- package/src/capture/script/walker/transforms.ts +14 -0
- package/src/capture/script.generated.ts +1 -1
- package/src/capture/types.ts +202 -2
- package/src/cli/animate.ts +135 -15
- package/src/mask.test.ts +12 -7
- package/src/render/borders.ts +383 -17
- package/src/render/element-tree-to-svg.ts +2051 -238
- package/src/render/embedded-font-builder.ts +221 -0
- package/src/render/form-controls.ts +45 -24
- package/src/render/gradients.test.ts +46 -0
- package/src/render/gradients.ts +94 -2
- package/src/render/opentype.js.d.ts +7 -0
- package/src/render/text-to-path.test.ts +246 -9
- package/src/render/text-to-path.ts +702 -31
- package/src/render/text.ts +344 -40
- package/src/scroll/composer.test.ts +322 -16
- package/src/scroll/composer.ts +246 -13
- package/src/scroll/executor.test.ts +27 -0
- package/src/scroll/executor.ts +19 -10
- package/src/scroll/hoist-fixed.test.ts +117 -0
- package/src/scroll/hoist-fixed.ts +95 -0
- package/src/scroll/hoist-sticky.test.ts +173 -0
- package/src/scroll/hoist-sticky.ts +193 -0
- package/src/scroll/pattern.test.ts +58 -1
- package/src/scroll/pattern.ts +71 -8
- package/src/tree-ops/frame-merge.test.ts +51 -0
- package/src/tree-ops/frame-merge.ts +24 -6
- package/src/tree-ops/tree-diff.ts +3 -1
- package/src/tree-ops/viewbox-culling.test.ts +42 -6
- package/src/tree-ops/viewbox-culling.ts +32 -18
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* One path command from fontkit. Mirrors fontkit's internal `Path.commands[]`
|
|
3
|
+
* shape so callers can hand the raw fontkit output across without converting.
|
|
4
|
+
*
|
|
5
|
+
* moveTo (x, y)
|
|
6
|
+
* lineTo (x, y)
|
|
7
|
+
* quadraticCurveTo (cx, cy, x, y)
|
|
8
|
+
* bezierCurveTo (c1x, c1y, c2x, c2y, x, y)
|
|
9
|
+
* closePath ()
|
|
10
|
+
*/
|
|
11
|
+
export interface PathCommand {
|
|
12
|
+
command: string;
|
|
13
|
+
args: number[];
|
|
14
|
+
}
|
|
15
|
+
/**
|
|
16
|
+
* Reset per-composition state. Call alongside `clearEmbeddedFonts` at the
|
|
17
|
+
* start of every `composeScrollSvg` / `elementTreeToSvg` invocation so
|
|
18
|
+
* glyphs from a prior composition don't leak into the new one.
|
|
19
|
+
*/
|
|
20
|
+
export declare function clearEmbeddedFontBuilder(): void;
|
|
21
|
+
/**
|
|
22
|
+
* Record that a glyph is used by the current composition and return its
|
|
23
|
+
* placement coordinates: which CSS family to reference and which PUA
|
|
24
|
+
* codepoint to emit in the `<text>` content.
|
|
25
|
+
*
|
|
26
|
+
* Idempotent on (`instanceKey`, `glyphId`): repeated calls return the same
|
|
27
|
+
* cssFamily + puaCodepoint, so the same glyph can be referenced many times
|
|
28
|
+
* across the SVG and collapses to a single entry in the custom font.
|
|
29
|
+
*
|
|
30
|
+
* `instanceKey` must be stable per (font, axes-tuple). Two text runs that
|
|
31
|
+
* resolve to "Inter Variable" at `wght=450 opsz=30` share an instance key;
|
|
32
|
+
* a third run at `wght=540 opsz=24` gets its own.
|
|
33
|
+
*/
|
|
34
|
+
export declare function trackGlyphInEmbedFont(instanceKey: string, unitsPerEm: number, ascender: number, descender: number, glyphId: number, pathCommands: PathCommand[], advanceWidth: number, variant?: {
|
|
35
|
+
italic: boolean;
|
|
36
|
+
weight: number;
|
|
37
|
+
}): {
|
|
38
|
+
cssFamily: string;
|
|
39
|
+
puaCodepoint: number;
|
|
40
|
+
} | null;
|
|
41
|
+
/**
|
|
42
|
+
* Serialise every tracked custom font as `@font-face` rules with embedded
|
|
43
|
+
* TTF bytes. Returns the joined CSS ready to drop into the SVG's `<style>`
|
|
44
|
+
* block. Empty string when no glyphs were registered.
|
|
45
|
+
*/
|
|
46
|
+
export declare function getBuiltEmbeddedFontFaceCss(): string;
|
|
47
|
+
/** Test-only: inspect builder state for assertions. */
|
|
48
|
+
export declare function _builderRegistrySize(): number;
|
|
49
|
+
export declare function _builderGlyphsFor(instanceKey: string): number;
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
// DM-655: build custom standalone TTFs from extracted glyph outlines so the
|
|
2
|
+
// embedded-font render mode can emit `<text>` against any font Chrome paints
|
|
3
|
+
// with — webfonts, system fonts, variable-axis instances — not just CDN-
|
|
4
|
+
// fetched webfonts. Each tracked font becomes one `@font-face` whose `src:`
|
|
5
|
+
// is a `data:font/ttf;base64,…` URI containing JUST the glyphs the SVG uses,
|
|
6
|
+
// at their captured outlines and advances.
|
|
7
|
+
//
|
|
8
|
+
// Glyph addressing: every shaped glyph is assigned a sequential PUA codepoint
|
|
9
|
+
// (U+E000+). The `<text>` we emit contains the PUA stream, NOT the original
|
|
10
|
+
// codepoints — so the consumer browser performs zero shaping / kerning /
|
|
11
|
+
// ligature substitution and renders each glyph at its declared advance.
|
|
12
|
+
// fontkit already did the shaping at capture time, so this preserves
|
|
13
|
+
// contextual joining (Arabic init/medi/fina), ligatures (fi, ffi), and
|
|
14
|
+
// cluster reordering (Devanagari i-matra) without us having to ship any
|
|
15
|
+
// GSUB/GPOS rules in the custom font.
|
|
16
|
+
// opentype.js ships no type declarations; declare the minimal surface we
|
|
17
|
+
// use so callers stay strict-typed at the boundary.
|
|
18
|
+
import opentype from "opentype.js";
|
|
19
|
+
const ot = opentype;
|
|
20
|
+
const builderRegistry = new Map();
|
|
21
|
+
let builderIdCounter = 0;
|
|
22
|
+
/** PUA-A block: U+E000..U+F8FF (6400 codepoints). Plenty for typical SVGs. */
|
|
23
|
+
const PUA_START = 0xE000;
|
|
24
|
+
const PUA_END = 0xF8FF;
|
|
25
|
+
/**
|
|
26
|
+
* Reset per-composition state. Call alongside `clearEmbeddedFonts` at the
|
|
27
|
+
* start of every `composeScrollSvg` / `elementTreeToSvg` invocation so
|
|
28
|
+
* glyphs from a prior composition don't leak into the new one.
|
|
29
|
+
*/
|
|
30
|
+
export function clearEmbeddedFontBuilder() {
|
|
31
|
+
builderRegistry.clear();
|
|
32
|
+
builderIdCounter = 0;
|
|
33
|
+
}
|
|
34
|
+
/**
|
|
35
|
+
* Record that a glyph is used by the current composition and return its
|
|
36
|
+
* placement coordinates: which CSS family to reference and which PUA
|
|
37
|
+
* codepoint to emit in the `<text>` content.
|
|
38
|
+
*
|
|
39
|
+
* Idempotent on (`instanceKey`, `glyphId`): repeated calls return the same
|
|
40
|
+
* cssFamily + puaCodepoint, so the same glyph can be referenced many times
|
|
41
|
+
* across the SVG and collapses to a single entry in the custom font.
|
|
42
|
+
*
|
|
43
|
+
* `instanceKey` must be stable per (font, axes-tuple). Two text runs that
|
|
44
|
+
* resolve to "Inter Variable" at `wght=450 opsz=30` share an instance key;
|
|
45
|
+
* a third run at `wght=540 opsz=24` gets its own.
|
|
46
|
+
*/
|
|
47
|
+
export function trackGlyphInEmbedFont(instanceKey, unitsPerEm, ascender, descender, glyphId, pathCommands, advanceWidth, variant = { italic: false, weight: 400 }) {
|
|
48
|
+
let entry = builderRegistry.get(instanceKey);
|
|
49
|
+
if (entry == null) {
|
|
50
|
+
entry = {
|
|
51
|
+
cssFamily: `dmf${builderIdCounter++}`,
|
|
52
|
+
unitsPerEm,
|
|
53
|
+
ascender,
|
|
54
|
+
descender,
|
|
55
|
+
glyphs: new Map(),
|
|
56
|
+
puaForGlyphId: new Map(),
|
|
57
|
+
nextPua: PUA_START,
|
|
58
|
+
italic: variant.italic,
|
|
59
|
+
weight: variant.weight,
|
|
60
|
+
};
|
|
61
|
+
builderRegistry.set(instanceKey, entry);
|
|
62
|
+
}
|
|
63
|
+
const cached = entry.puaForGlyphId.get(glyphId);
|
|
64
|
+
if (cached != null)
|
|
65
|
+
return { cssFamily: entry.cssFamily, puaCodepoint: cached };
|
|
66
|
+
if (entry.nextPua > PUA_END) {
|
|
67
|
+
// Out of PUA-A slots. Caller falls through to paths-mode emission for
|
|
68
|
+
// this glyph; the (rare) over-6400-glyph case keeps rendering, just
|
|
69
|
+
// without the embedded-font fast path for the run that exceeded.
|
|
70
|
+
return null;
|
|
71
|
+
}
|
|
72
|
+
const pua = entry.nextPua++;
|
|
73
|
+
entry.puaForGlyphId.set(glyphId, pua);
|
|
74
|
+
const otPath = new ot.Path();
|
|
75
|
+
for (const cmd of pathCommands) {
|
|
76
|
+
switch (cmd.command) {
|
|
77
|
+
case "moveTo":
|
|
78
|
+
otPath.moveTo(cmd.args[0], cmd.args[1]);
|
|
79
|
+
break;
|
|
80
|
+
case "lineTo":
|
|
81
|
+
otPath.lineTo(cmd.args[0], cmd.args[1]);
|
|
82
|
+
break;
|
|
83
|
+
case "quadraticCurveTo":
|
|
84
|
+
otPath.quadraticCurveTo(cmd.args[0], cmd.args[1], cmd.args[2], cmd.args[3]);
|
|
85
|
+
break;
|
|
86
|
+
case "bezierCurveTo":
|
|
87
|
+
otPath.curveTo(cmd.args[0], cmd.args[1], cmd.args[2], cmd.args[3], cmd.args[4], cmd.args[5]);
|
|
88
|
+
break;
|
|
89
|
+
case "closePath":
|
|
90
|
+
otPath.close();
|
|
91
|
+
break;
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
entry.glyphs.set(glyphId, new ot.Glyph({
|
|
95
|
+
name: `g${glyphId}`,
|
|
96
|
+
unicode: pua,
|
|
97
|
+
advanceWidth,
|
|
98
|
+
path: otPath,
|
|
99
|
+
}));
|
|
100
|
+
return { cssFamily: entry.cssFamily, puaCodepoint: pua };
|
|
101
|
+
}
|
|
102
|
+
/**
|
|
103
|
+
* Serialise every tracked custom font as `@font-face` rules with embedded
|
|
104
|
+
* TTF bytes. Returns the joined CSS ready to drop into the SVG's `<style>`
|
|
105
|
+
* block. Empty string when no glyphs were registered.
|
|
106
|
+
*/
|
|
107
|
+
export function getBuiltEmbeddedFontFaceCss() {
|
|
108
|
+
if (builderRegistry.size === 0)
|
|
109
|
+
return "";
|
|
110
|
+
const rules = [];
|
|
111
|
+
for (const entry of builderRegistry.values()) {
|
|
112
|
+
// Glyph 0 must be .notdef per OpenType spec — opentype.js enforces this
|
|
113
|
+
// by adding the first glyph as .notdef. Provide an empty .notdef so any
|
|
114
|
+
// codepoint the consumer might query that we DIDN'T embed (shouldn't
|
|
115
|
+
// happen in practice since we emit only PUA codepoints we registered)
|
|
116
|
+
// renders as a zero-width invisible glyph rather than tofu.
|
|
117
|
+
const notdef = new ot.Glyph({
|
|
118
|
+
name: ".notdef",
|
|
119
|
+
unicode: 0,
|
|
120
|
+
advanceWidth: Math.round(entry.unitsPerEm / 2),
|
|
121
|
+
path: new ot.Path(),
|
|
122
|
+
});
|
|
123
|
+
const allGlyphs = [notdef, ...entry.glyphs.values()];
|
|
124
|
+
const font = new ot.Font({
|
|
125
|
+
familyName: entry.cssFamily,
|
|
126
|
+
styleName: "Regular",
|
|
127
|
+
unitsPerEm: entry.unitsPerEm,
|
|
128
|
+
ascender: entry.ascender,
|
|
129
|
+
descender: entry.descender,
|
|
130
|
+
glyphs: allGlyphs,
|
|
131
|
+
});
|
|
132
|
+
const ttfBytes = Buffer.from(font.toArrayBuffer());
|
|
133
|
+
const b64 = ttfBytes.toString("base64");
|
|
134
|
+
// Emit explicit font-style / font-weight descriptors so the consumer
|
|
135
|
+
// browser matches this @font-face EXACTLY when the `<text>` element
|
|
136
|
+
// requests italic / bold. Without these the rule defaults to
|
|
137
|
+
// `font-style: normal; font-weight: 400` and Chromium synthesizes faux
|
|
138
|
+
// italic / faux bold on top of glyphs whose italic / bold shape is
|
|
139
|
+
// already baked into the custom TTF.
|
|
140
|
+
const styleDesc = entry.italic ? "italic" : "normal";
|
|
141
|
+
rules.push(`@font-face { font-family: "${entry.cssFamily}"; font-style: ${styleDesc}; font-weight: ${entry.weight}; src: url("data:font/ttf;base64,${b64}"); }`);
|
|
142
|
+
}
|
|
143
|
+
return rules.join("\n");
|
|
144
|
+
}
|
|
145
|
+
/** Test-only: inspect builder state for assertions. */
|
|
146
|
+
export function _builderRegistrySize() { return builderRegistry.size; }
|
|
147
|
+
export function _builderGlyphsFor(instanceKey) {
|
|
148
|
+
return builderRegistry.get(instanceKey)?.glyphs.size ?? 0;
|
|
149
|
+
}
|
|
@@ -757,28 +757,41 @@ function renderDatePicker(el, indent) {
|
|
|
757
757
|
const parts = [];
|
|
758
758
|
const t = el.styles.inputType ?? "date";
|
|
759
759
|
const val = el.styles.inputValue ?? "";
|
|
760
|
+
// DM-723: Chrome's UA stylesheet sets date/time/month/week/datetime-local
|
|
761
|
+
// inputs to `font-family: monospace; font-size: ~13.333px` (small-control
|
|
762
|
+
// form-control metric). Read the captured font-size so we match Chrome's
|
|
763
|
+
// resolved metric instead of an under-scaled 11px literal — the previous
|
|
764
|
+
// hardcoded size emitted glyphs noticeably narrower than the expected paint.
|
|
765
|
+
const fontSize = parseFloat(el.styles.fontSize ?? "") || 13.333;
|
|
760
766
|
const tx = el.x + 6;
|
|
761
|
-
const ty = el.y + el.height / 2 +
|
|
767
|
+
const ty = el.y + el.height / 2 + fontSize * 0.35;
|
|
768
|
+
// DM-731: pick up the input's resolved color so the value text matches
|
|
769
|
+
// the active color-scheme. Hardcoding `rgb(0,0,0)` made dark-mode date
|
|
770
|
+
// inputs render with invisible black text on a dark background. Falls
|
|
771
|
+
// back to black when the capture didn't supply a color.
|
|
772
|
+
const textFill = (el.styles.color != null && el.styles.color !== "")
|
|
773
|
+
? el.styles.color
|
|
774
|
+
: "rgb(0,0,0)";
|
|
762
775
|
// Chrome renders date inputs with an en-US-formatted display value: dates
|
|
763
776
|
// as MM/DD/YYYY, times as hh:mm AM/PM, etc. The captured `inputValue` is
|
|
764
777
|
// the canonical ISO form (`2026-04-21`). DM-263.
|
|
765
778
|
const display = formatDateInputDisplay(t, val);
|
|
766
779
|
if (display !== "") {
|
|
767
|
-
|
|
768
|
-
// through the system mono fallback so the segments don't kern.
|
|
769
|
-
parts.push(`${indent}<text x="${r(tx)}" y="${r(ty)}" font-size="11" font-family="ui-monospace, Menlo, monospace" fill="rgb(0,0,0)">${display.replace(/[&<>]/g, (c) => ({ "&": "&", "<": "<", ">": ">" }[c]))}</text>`);
|
|
780
|
+
parts.push(`${indent}<text x="${r(tx)}" y="${r(ty)}" font-size="${r(fontSize)}" font-family="ui-monospace, Menlo, monospace" fill="${textFill}">${display.replace(/[&<>]/g, (c) => ({ "&": "&", "<": "<", ">": ">" }[c]))}</text>`);
|
|
770
781
|
}
|
|
771
782
|
// Picker icon on the right edge: calendar for date / month / week / datetime-local,
|
|
772
783
|
// clock for time. Chrome paints these monochrome at ~14px in the input's
|
|
773
784
|
// line-height. DM-263.
|
|
785
|
+
// DM-731: pass the input's text color through so the icon picks up the
|
|
786
|
+
// dark-mode color (was hardcoded to TRACK_FG light-mode constant).
|
|
774
787
|
const cx = el.x + el.width - 12;
|
|
775
788
|
const cy = el.y + el.height / 2;
|
|
776
789
|
const iconSize = Math.min(11, el.height - 6);
|
|
777
790
|
if (t === "time") {
|
|
778
|
-
parts.push(renderClockIcon(indent, cx, cy, iconSize));
|
|
791
|
+
parts.push(renderClockIcon(indent, cx, cy, iconSize, textFill));
|
|
779
792
|
}
|
|
780
793
|
else {
|
|
781
|
-
parts.push(renderCalendarIcon(indent, cx, cy, iconSize));
|
|
794
|
+
parts.push(renderCalendarIcon(indent, cx, cy, iconSize, textFill));
|
|
782
795
|
}
|
|
783
796
|
return parts.join("\n");
|
|
784
797
|
}
|
|
@@ -837,19 +850,21 @@ function formatDateInputDisplay(type, val) {
|
|
|
837
850
|
}
|
|
838
851
|
return val;
|
|
839
852
|
}
|
|
840
|
-
function renderCalendarIcon(indent, cx, cy, size) {
|
|
853
|
+
function renderCalendarIcon(indent, cx, cy, size, strokeOverride) {
|
|
841
854
|
// Simple calendar glyph: rounded rect with two top "binders" and a grid line.
|
|
842
855
|
const w = size;
|
|
843
856
|
const h = size;
|
|
844
857
|
const x = cx - w / 2;
|
|
845
858
|
const y = cy - h / 2;
|
|
846
|
-
|
|
859
|
+
// DM-731: caller supplies the stroke color so the icon picks up the
|
|
860
|
+
// active color-scheme (was hardcoded to the light-mode `TRACK_FG`).
|
|
861
|
+
const stroke = strokeOverride ?? TRACK_FG;
|
|
847
862
|
return `${indent}<g fill="none" stroke="${stroke}" stroke-width="1" stroke-linecap="round"><rect x="${r(x + 0.5)}" y="${r(y + 1.5)}" width="${r(w - 1)}" height="${r(h - 2)}" rx="1" /><line x1="${r(x + 0.5)}" y1="${r(y + 4)}" x2="${r(x + w - 0.5)}" y2="${r(y + 4)}" /><line x1="${r(x + 3)}" y1="${r(y + 0.5)}" x2="${r(x + 3)}" y2="${r(y + 2.5)}" /><line x1="${r(x + w - 3)}" y1="${r(y + 0.5)}" x2="${r(x + w - 3)}" y2="${r(y + 2.5)}" /></g>`;
|
|
848
863
|
}
|
|
849
|
-
function renderClockIcon(indent, cx, cy, size) {
|
|
864
|
+
function renderClockIcon(indent, cx, cy, size, strokeOverride) {
|
|
850
865
|
// Simple clock glyph: circle with two hands.
|
|
851
866
|
const r1 = size / 2;
|
|
852
|
-
const stroke = TRACK_FG;
|
|
867
|
+
const stroke = strokeOverride ?? TRACK_FG;
|
|
853
868
|
return `${indent}<g fill="none" stroke="${stroke}" stroke-width="1" stroke-linecap="round"><circle cx="${r(cx)}" cy="${r(cy)}" r="${r(r1 - 0.5)}" /><line x1="${r(cx)}" y1="${r(cy)}" x2="${r(cx)}" y2="${r(cy - r1 * 0.55)}" /><line x1="${r(cx)}" y1="${r(cy)}" x2="${r(cx + r1 * 0.4)}" y2="${r(cy)}" /></g>`;
|
|
854
869
|
}
|
|
855
870
|
/**
|
|
@@ -1120,24 +1135,30 @@ function renderDetailsMarker(el, indent) {
|
|
|
1120
1135
|
// previous 0.6em multiplier produced a triangle that read visibly small
|
|
1121
1136
|
// vs Chrome's painted output.
|
|
1122
1137
|
const size = Math.max(8, fontSizePx * 0.7);
|
|
1123
|
-
|
|
1124
|
-
//
|
|
1125
|
-
//
|
|
1126
|
-
//
|
|
1127
|
-
//
|
|
1138
|
+
// DM-746: prefer the captured <summary> child's actual y / height when
|
|
1139
|
+
// available — that's the line box Chrome paints the marker into. The
|
|
1140
|
+
// previous fallback computed `cy` from `el.y + paddingT + borderT +
|
|
1141
|
+
// lineH/2` with `lineH = parseFloat(lineHeight) || fontSize*1.5`, which
|
|
1142
|
+
// overshoots by ~3 px when lineHeight is "normal" (Chrome's "normal"
|
|
1143
|
+
// resolves to ~1.15× fontSize via the font's intrinsic ascent+descent
|
|
1144
|
+
// +linegap, not the 1.5× literal). The drift was visible as a downward
|
|
1145
|
+
// shift of the disclosure triangle on `niche-command-invokers`.
|
|
1146
|
+
const summaryChild = el.children?.find((c) => c.tag === "summary");
|
|
1128
1147
|
const padL = parseFloat(el.styles.paddingLeft ?? "") || 0;
|
|
1129
1148
|
const brL = parseFloat(el.styles.borderLeftWidth ?? "") || 0;
|
|
1130
1149
|
const padT = parseFloat(el.styles.paddingTop ?? "") || 0;
|
|
1131
1150
|
const brT = parseFloat(el.styles.borderTopWidth ?? "") || 0;
|
|
1132
|
-
|
|
1133
|
-
//
|
|
1134
|
-
//
|
|
1135
|
-
//
|
|
1136
|
-
|
|
1137
|
-
// the
|
|
1138
|
-
//
|
|
1139
|
-
//
|
|
1140
|
-
const cy =
|
|
1151
|
+
// Position: marker sits inside the summary at its content-start, which
|
|
1152
|
+
// is el.x + paddingL + borderL. Offset by half the marker size so the
|
|
1153
|
+
// glyph's center sits ~half-marker-width past the summary's left edge,
|
|
1154
|
+
// matching Chrome's painted offset (DM-448).
|
|
1155
|
+
const cx = (summaryChild != null ? summaryChild.x : el.x + padL + brL) + size / 2;
|
|
1156
|
+
// Vertical center: the summary is the first child of <details>; use its
|
|
1157
|
+
// captured line-box center when available. Fallback computes from the
|
|
1158
|
+
// details' padding/border + a corrected line-height ratio.
|
|
1159
|
+
const cy = summaryChild != null
|
|
1160
|
+
? summaryChild.y + summaryChild.height / 2
|
|
1161
|
+
: el.y + padT + brT + (parseFloat(el.styles.lineHeight ?? "") || fontSizePx * 1.15) / 2;
|
|
1141
1162
|
const open = el.styles.detailsOpen === true;
|
|
1142
1163
|
// Use the summary's text color when captured, else dark gray.
|
|
1143
1164
|
const fill = (el.styles.color != null && el.styles.color !== "")
|
|
@@ -104,6 +104,21 @@ export interface ConicGradient {
|
|
|
104
104
|
export type AnyGradient = LinearGradient | RadialGradient | ConicGradient;
|
|
105
105
|
/** Try every supported gradient type. Returns the first that parses or null. */
|
|
106
106
|
export declare function parseGradient(text: string | undefined | null): AnyGradient | null;
|
|
107
|
+
/**
|
|
108
|
+
* Normalize legacy `-webkit-gradient(linear, ...)` syntax (still emitted by
|
|
109
|
+
* Chromium's computed-style serializer for old CSS that uses it — e.g. the
|
|
110
|
+
* Slashdot mobile header) into the modern `linear-gradient(...)` form so the
|
|
111
|
+
* regular parser can consume it. Returns null when the input isn't a legacy
|
|
112
|
+
* linear webkit-gradient.
|
|
113
|
+
*
|
|
114
|
+
* Grammar (legacy):
|
|
115
|
+
* -webkit-gradient(linear, <p1>, <p2>, from(<c>), [color-stop(<pos>, <c>)...], to(<c>))
|
|
116
|
+
* where <pN> is either a percentage pair `0% 0%` or a side keyword pair like
|
|
117
|
+
* `left top` / `right bottom`. Only axis-aligned cases (vertical / horizontal)
|
|
118
|
+
* are handled — diagonal legacy-syntax gradients are rare and would need a
|
|
119
|
+
* separate angle solve.
|
|
120
|
+
*/
|
|
121
|
+
export declare function convertLegacyWebkitGradient(text: string | undefined | null): string | null;
|
|
107
122
|
/** Parse `linear-gradient(...)` or `repeating-linear-gradient(...)` text. */
|
|
108
123
|
export declare function parseLinearGradient(text: string | undefined | null): LinearGradient | null;
|
|
109
124
|
/**
|
package/dist/render/gradients.js
CHANGED
|
@@ -14,7 +14,108 @@
|
|
|
14
14
|
*/
|
|
15
15
|
/** Try every supported gradient type. Returns the first that parses or null. */
|
|
16
16
|
export function parseGradient(text) {
|
|
17
|
-
|
|
17
|
+
const normalized = convertLegacyWebkitGradient(text) ?? text;
|
|
18
|
+
return parseLinearGradient(normalized) ?? parseRadialGradient(normalized) ?? parseConicGradient(normalized);
|
|
19
|
+
}
|
|
20
|
+
/**
|
|
21
|
+
* Normalize legacy `-webkit-gradient(linear, ...)` syntax (still emitted by
|
|
22
|
+
* Chromium's computed-style serializer for old CSS that uses it — e.g. the
|
|
23
|
+
* Slashdot mobile header) into the modern `linear-gradient(...)` form so the
|
|
24
|
+
* regular parser can consume it. Returns null when the input isn't a legacy
|
|
25
|
+
* linear webkit-gradient.
|
|
26
|
+
*
|
|
27
|
+
* Grammar (legacy):
|
|
28
|
+
* -webkit-gradient(linear, <p1>, <p2>, from(<c>), [color-stop(<pos>, <c>)...], to(<c>))
|
|
29
|
+
* where <pN> is either a percentage pair `0% 0%` or a side keyword pair like
|
|
30
|
+
* `left top` / `right bottom`. Only axis-aligned cases (vertical / horizontal)
|
|
31
|
+
* are handled — diagonal legacy-syntax gradients are rare and would need a
|
|
32
|
+
* separate angle solve.
|
|
33
|
+
*/
|
|
34
|
+
export function convertLegacyWebkitGradient(text) {
|
|
35
|
+
if (text == null)
|
|
36
|
+
return null;
|
|
37
|
+
const trimmed = text.trim();
|
|
38
|
+
const m = /^-webkit-gradient\s*\(\s*linear\s*,\s*([\s\S]+)\)\s*$/i.exec(trimmed);
|
|
39
|
+
if (m == null)
|
|
40
|
+
return null;
|
|
41
|
+
const parts = splitTopLevelCommas(m[1]).map((t) => t.trim()).filter((t) => t !== "");
|
|
42
|
+
if (parts.length < 4)
|
|
43
|
+
return null;
|
|
44
|
+
const p1 = parsePointToFracPair(parts[0]);
|
|
45
|
+
const p2 = parsePointToFracPair(parts[1]);
|
|
46
|
+
if (p1 == null || p2 == null)
|
|
47
|
+
return null;
|
|
48
|
+
const dx = p2.x - p1.x;
|
|
49
|
+
const dy = p2.y - p1.y;
|
|
50
|
+
// Only axis-aligned axes (vertical or horizontal). Skip diagonal cases.
|
|
51
|
+
let angleDeg;
|
|
52
|
+
if (Math.abs(dx) < 1e-6 && Math.abs(dy) > 1e-6) {
|
|
53
|
+
angleDeg = dy > 0 ? 180 : 0;
|
|
54
|
+
}
|
|
55
|
+
else if (Math.abs(dy) < 1e-6 && Math.abs(dx) > 1e-6) {
|
|
56
|
+
angleDeg = dx > 0 ? 90 : 270;
|
|
57
|
+
}
|
|
58
|
+
else {
|
|
59
|
+
return null;
|
|
60
|
+
}
|
|
61
|
+
const stopExprs = [];
|
|
62
|
+
for (let i = 2; i < parts.length; i++) {
|
|
63
|
+
const t = parts[i];
|
|
64
|
+
const fromMatch = /^from\s*\(\s*([\s\S]+?)\s*\)\s*$/i.exec(t);
|
|
65
|
+
const toMatch = /^to\s*\(\s*([\s\S]+?)\s*\)\s*$/i.exec(t);
|
|
66
|
+
const csMatch = /^color-stop\s*\(\s*([^,]+)\s*,\s*([\s\S]+?)\s*\)\s*$/i.exec(t);
|
|
67
|
+
if (fromMatch != null) {
|
|
68
|
+
stopExprs.push(`${fromMatch[1].trim()} 0%`);
|
|
69
|
+
}
|
|
70
|
+
else if (toMatch != null) {
|
|
71
|
+
stopExprs.push(`${toMatch[1].trim()} 100%`);
|
|
72
|
+
}
|
|
73
|
+
else if (csMatch != null) {
|
|
74
|
+
const pos = csMatch[1].trim();
|
|
75
|
+
// CSS color-stop accepts a 0..1 number or a percentage; normalize to %.
|
|
76
|
+
const pct = /^[0-9.]+%$/.test(pos) ? pos : `${parseFloat(pos) * 100}%`;
|
|
77
|
+
stopExprs.push(`${csMatch[2].trim()} ${pct}`);
|
|
78
|
+
}
|
|
79
|
+
else {
|
|
80
|
+
return null;
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
if (stopExprs.length < 2)
|
|
84
|
+
return null;
|
|
85
|
+
return `linear-gradient(${angleDeg}deg, ${stopExprs.join(", ")})`;
|
|
86
|
+
}
|
|
87
|
+
/**
|
|
88
|
+
* Parse a legacy webkit-gradient point token like `0% 0%`, `left top`, or
|
|
89
|
+
* `right bottom` into fractional 0..1 coordinates. Returns null on diagonal
|
|
90
|
+
* or unrecognised inputs (caller fails fast in that case).
|
|
91
|
+
*/
|
|
92
|
+
function parsePointToFracPair(tok) {
|
|
93
|
+
const lower = tok.toLowerCase().trim();
|
|
94
|
+
// Keyword form: "left top", "right", "center bottom", etc.
|
|
95
|
+
const KW = { left: 0, top: 0, center: 0.5, right: 1, bottom: 1 };
|
|
96
|
+
const words = lower.split(/\s+/);
|
|
97
|
+
if (words.length > 0 && words.every((w) => w in KW)) {
|
|
98
|
+
// Determine which keyword maps to x vs y. "top/bottom" → y, "left/right" → x.
|
|
99
|
+
let x = 0.5, y = 0.5;
|
|
100
|
+
for (const w of words) {
|
|
101
|
+
if (w === "left" || w === "right")
|
|
102
|
+
x = KW[w];
|
|
103
|
+
else if (w === "top" || w === "bottom")
|
|
104
|
+
y = KW[w];
|
|
105
|
+
else if (w === "center") { /* leave default */ }
|
|
106
|
+
}
|
|
107
|
+
return { x, y };
|
|
108
|
+
}
|
|
109
|
+
// Percentage / pixel pair: "0% 0%", "50% 100%".
|
|
110
|
+
const m = /^([\d.]+)(%|px)\s+([\d.]+)(%|px)$/.exec(lower);
|
|
111
|
+
if (m != null) {
|
|
112
|
+
const ux = m[2] === "%" ? parseFloat(m[1]) / 100 : NaN;
|
|
113
|
+
const uy = m[4] === "%" ? parseFloat(m[3]) / 100 : NaN;
|
|
114
|
+
if (Number.isFinite(ux) && Number.isFinite(uy))
|
|
115
|
+
return { x: ux, y: uy };
|
|
116
|
+
// px-pair: can't resolve without rect dims. Skip — uncommon in real CSS.
|
|
117
|
+
}
|
|
118
|
+
return null;
|
|
18
119
|
}
|
|
19
120
|
/** Parse `linear-gradient(...)` or `repeating-linear-gradient(...)` text. */
|
|
20
121
|
export function parseLinearGradient(text) {
|
|
@@ -709,7 +810,7 @@ function resolveStops(stops, gradientLineLength, opts) {
|
|
|
709
810
|
function tileRepeatingStops(stops) {
|
|
710
811
|
if (stops.length < 2)
|
|
711
812
|
return stops;
|
|
712
|
-
const sorted = stops.filter((s) => s.offset
|
|
813
|
+
const sorted = stops.filter((s) => typeof s.offset === "number" && Number.isFinite(s.offset));
|
|
713
814
|
if (sorted.length < 2)
|
|
714
815
|
return stops;
|
|
715
816
|
const tileStart = sorted[0].offset;
|
|
@@ -1,5 +1,39 @@
|
|
|
1
1
|
import { describe, expect, it } from "vitest";
|
|
2
2
|
import { buildLinearGradientDef, parseConicGradient, parseGradient, parseLinearGradient } from "./gradients.js";
|
|
3
|
+
describe("convertLegacyWebkitGradient: legacy -webkit-gradient(linear, ...)", () => {
|
|
4
|
+
it("vertical top-to-bottom from()/to() form (slashdot mobile header)", () => {
|
|
5
|
+
// Slashdot's mobile header background, as Chromium serializes it.
|
|
6
|
+
const g = parseGradient("-webkit-gradient(linear, 0% 0%, 0% 100%, from(rgb(0, 0, 0)), to(rgb(32, 32, 32)))");
|
|
7
|
+
expect(g).not.toBeNull();
|
|
8
|
+
expect(g.kind).toBe("linear");
|
|
9
|
+
const lg = g;
|
|
10
|
+
expect(lg.angleDeg).toBe(180);
|
|
11
|
+
expect(lg.stops).toHaveLength(2);
|
|
12
|
+
expect(lg.stops[0].color).toBe("rgb(0, 0, 0)");
|
|
13
|
+
expect(lg.stops[0].offset).toBe(0);
|
|
14
|
+
expect(lg.stops[1].color).toBe("rgb(32, 32, 32)");
|
|
15
|
+
expect(lg.stops[1].offset).toBe(1);
|
|
16
|
+
});
|
|
17
|
+
it("horizontal with side keyword endpoints", () => {
|
|
18
|
+
const g = parseGradient("-webkit-gradient(linear, left top, right top, from(red), to(blue))");
|
|
19
|
+
expect(g).not.toBeNull();
|
|
20
|
+
expect(g.kind).toBe("linear");
|
|
21
|
+
expect(g.angleDeg).toBe(90);
|
|
22
|
+
});
|
|
23
|
+
it("intermediate color-stop() entries pass through", () => {
|
|
24
|
+
const g = parseGradient("-webkit-gradient(linear, 0% 0%, 0% 100%, from(red), color-stop(0.5, green), to(blue))");
|
|
25
|
+
expect(g).not.toBeNull();
|
|
26
|
+
const lg = g;
|
|
27
|
+
expect(lg.stops).toHaveLength(3);
|
|
28
|
+
expect(lg.stops[1].color).toBe("green");
|
|
29
|
+
expect(lg.stops[1].offset).toBe(0.5);
|
|
30
|
+
});
|
|
31
|
+
it("diagonal legacy webkit-gradient is skipped (returns null)", () => {
|
|
32
|
+
// Diagonal endpoints aren't axis-aligned; rare in real CSS — we don't
|
|
33
|
+
// try to solve a general angle from the endpoint pair.
|
|
34
|
+
expect(parseGradient("-webkit-gradient(linear, 0% 0%, 100% 100%, from(red), to(blue))")).toBeNull();
|
|
35
|
+
});
|
|
36
|
+
});
|
|
3
37
|
describe("parseLinearGradient: repeating support (DM-275)", () => {
|
|
4
38
|
it("parses repeating-linear-gradient with the repeating flag set", () => {
|
|
5
39
|
const g = parseLinearGradient("repeating-linear-gradient(90deg, red 0%, blue 10%)");
|
|
@@ -39,6 +39,17 @@ interface FontInstance {
|
|
|
39
39
|
yStrikeoutSize?: number;
|
|
40
40
|
};
|
|
41
41
|
}
|
|
42
|
+
export type RenderTextMode = "paths" | "embedded-font";
|
|
43
|
+
export declare function setRenderTextMode(mode: RenderTextMode): void;
|
|
44
|
+
export declare function getRenderTextMode(): RenderTextMode;
|
|
45
|
+
export declare function clearEmbeddedFonts(): void;
|
|
46
|
+
/**
|
|
47
|
+
* Emit one `@font-face` rule per font the embedded-font path registered
|
|
48
|
+
* during this render pass. Returns the CSS to inject into the SVG's
|
|
49
|
+
* `<style>` block (or `<defs><style>`). Empty string when no fonts were
|
|
50
|
+
* registered (e.g. `renderText: "paths"`).
|
|
51
|
+
*/
|
|
52
|
+
export declare function getEmbeddedFontFaceCss(): string;
|
|
42
53
|
/**
|
|
43
54
|
* Open a webfont buffer with fontkit and register it under the given family
|
|
44
55
|
* name (case-insensitive). `weight` is a CSS numeric weight (100-900); 400
|
|
@@ -210,7 +221,16 @@ features?: string[],
|
|
|
210
221
|
* (PingFang TC / HK / MO, or Hiragino Kaku for `ja`). DM-394. */
|
|
211
222
|
lang?: string,
|
|
212
223
|
/** Author-set `font-variation-settings` axis overrides. DM-578. */
|
|
213
|
-
variationSettings?: Record<string, number
|
|
224
|
+
variationSettings?: Record<string, number>,
|
|
225
|
+
/** DM-719: `-webkit-text-stroke-width` (px). When > 0, the emitted text
|
|
226
|
+
* group gets a `stroke` attribute so each glyph paints with an outline. */
|
|
227
|
+
textStrokeWidth?: number,
|
|
228
|
+
/** DM-719: `-webkit-text-stroke-color`. Required when `textStrokeWidth > 0`. */
|
|
229
|
+
textStrokeColor?: string,
|
|
230
|
+
/** DM-719: `paint-order` (e.g. "stroke fill"). When `stroke fill`, the
|
|
231
|
+
* stroke paints UNDER the fill so half the stroke is covered, eliminating
|
|
232
|
+
* the chunky fill-on-stroke look at large widths. */
|
|
233
|
+
paintOrder?: string): string | null;
|
|
214
234
|
/**
|
|
215
235
|
* Check if text-to-path conversion is available for a font family.
|
|
216
236
|
*/
|
|
@@ -263,6 +283,23 @@ underlineOffsetCss?: string): DecorationMetrics;
|
|
|
263
283
|
* below baseline). Returns `[]` when font isn't resolvable, no glyphs cross
|
|
264
284
|
* the rect, or shaping throws. (DM-446.)
|
|
265
285
|
*/
|
|
286
|
+
/**
|
|
287
|
+
* Measure the right-side-bearing (rsb) of the last non-whitespace glyph in
|
|
288
|
+
* `text` when shaped through fontkit at the given font / size / weight /
|
|
289
|
+
* style. rsb = `advanceWidth − bbox.maxX`, in CSS px (already scaled by
|
|
290
|
+
* `fontSize / unitsPerEm`).
|
|
291
|
+
*
|
|
292
|
+
* Used by the list-marker emit path (DM-790): SVG `<text text-anchor="end">`
|
|
293
|
+
* places the anchor at the last glyph's advance-end, not its visible-right
|
|
294
|
+
* edge. To land the visible right at the target (Chromium's `el.x − 7` per
|
|
295
|
+
* `kCMarkerPaddingPx`), we have to shift the anchor right by `rsb`.
|
|
296
|
+
*
|
|
297
|
+
* Returns 0 when the font isn't resolvable, when shaping fails, when the
|
|
298
|
+
* text is empty / whitespace-only, or when the last glyph has no bbox. The
|
|
299
|
+
* built-in marker path's previous empirical 3 px offset for `.` is now
|
|
300
|
+
* font-metric-derived through this helper too.
|
|
301
|
+
*/
|
|
302
|
+
export declare function measureLastGlyphRsb(text: string, fontSize: number, fontFamily: string, fontWeight: string | number, fontStyle?: string): number;
|
|
266
303
|
export declare function computeSkipInkGaps(text: string, fontSize: number, fontFamily: string, fontWeight: string | number, fontStyle?: string, decorationCenterYRel?: number, decorationThickness?: number, features?: string[],
|
|
267
304
|
/** Chromium-measured run width — when set, intercepts are scaled to match
|
|
268
305
|
* so gaps line up with the painted glyph positions even when fontkit's
|