freshcoat 0.1.0
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/LICENSE +202 -0
- package/NOTICE +17 -0
- package/README.md +110 -0
- package/package.json +53 -0
- package/src/adjust.d.ts +18 -0
- package/src/adjust.js +153 -0
- package/src/approx-layout.d.ts +2 -0
- package/src/approx-layout.js +91 -0
- package/src/bake-text.d.ts +12 -0
- package/src/bake-text.js +321 -0
- package/src/browser.d.ts +7 -0
- package/src/browser.js +79 -0
- package/src/canvaskit.d.ts +4 -0
- package/src/canvaskit.js +1674 -0
- package/src/compile-scene.d.ts +24 -0
- package/src/compile-scene.js +325 -0
- package/src/css.d.ts +5 -0
- package/src/css.js +153 -0
- package/src/decode.d.ts +22 -0
- package/src/decode.js +112 -0
- package/src/export-scale.d.ts +25 -0
- package/src/export-scale.js +74 -0
- package/src/font-bytes.d.ts +4 -0
- package/src/font-bytes.js +165 -0
- package/src/font-metrics.d.ts +5 -0
- package/src/font-metrics.js +62 -0
- package/src/headless.d.ts +24 -0
- package/src/headless.js +58 -0
- package/src/index.d.ts +25 -0
- package/src/index.js +29 -0
- package/src/jpeg.d.ts +5 -0
- package/src/jpeg.js +20 -0
- package/src/line-height.d.ts +7 -0
- package/src/line-height.js +80 -0
- package/src/node.d.ts +139 -0
- package/src/node.js +47 -0
- package/src/paint-cache.d.ts +49 -0
- package/src/paint-cache.js +109 -0
- package/src/paint-helpers.d.ts +21 -0
- package/src/paint-helpers.js +71 -0
- package/src/paragraph-layout.d.ts +6 -0
- package/src/paragraph-layout.js +283 -0
- package/src/path-data.d.ts +5 -0
- package/src/path-data.js +101 -0
- package/src/png.d.ts +23 -0
- package/src/png.js +189 -0
- package/src/resolve-layout.d.ts +9 -0
- package/src/resolve-layout.js +657 -0
- package/src/runtime.d.ts +6 -0
- package/src/runtime.js +44 -0
- package/src/squircle.d.ts +1 -0
- package/src/squircle.js +60 -0
- package/src/text-cache.d.ts +14 -0
- package/src/text-cache.js +46 -0
- package/src/text-engine.d.ts +26 -0
- package/src/text-engine.js +1 -0
- package/src/text-types.d.ts +30 -0
- package/src/text-types.js +1 -0
- package/src/types.d.ts +406 -0
- package/src/types.js +13 -0
- package/src/validate-commands.d.ts +8 -0
- package/src/validate-commands.js +206 -0
|
@@ -0,0 +1,283 @@
|
|
|
1
|
+
// A TextEngine backed by CanvasKit's native Paragraph API. It does line
|
|
2
|
+
// breaking AND measurement in the same engine the CanvasKit painter shapes with,
|
|
3
|
+
// so layout and paint agree.
|
|
4
|
+
//
|
|
5
|
+
// Whitespace runs are collapsed before shaping (CSS white-space: normal), which
|
|
6
|
+
// Paragraph on its own would preserve. Break opportunities follow Skia's UAX-14
|
|
7
|
+
// rules, so breaks inside URLs and punctuation-dense runs can differ from a
|
|
8
|
+
// browser's.
|
|
9
|
+
//
|
|
10
|
+
// getLineMetrics start/end indices are UTF-16 code-unit offsets (JS string
|
|
11
|
+
// indices), so per-line text is a direct string slice. Checked for Latin + BMP
|
|
12
|
+
// punctuation (em dash etc.); astral/emoji (surrogate pairs) and CJK line-break
|
|
13
|
+
// rules are untested.
|
|
14
|
+
import { fontVariationList } from "./paint-helpers.js";
|
|
15
|
+
const WEIGHTS = {
|
|
16
|
+
100: "Thin",
|
|
17
|
+
200: "ExtraLight",
|
|
18
|
+
300: "Light",
|
|
19
|
+
400: "Normal",
|
|
20
|
+
500: "Medium",
|
|
21
|
+
600: "SemiBold",
|
|
22
|
+
700: "Bold",
|
|
23
|
+
800: "ExtraBold",
|
|
24
|
+
900: "Black",
|
|
25
|
+
};
|
|
26
|
+
const SHRINK_FLOOR_PX = 8;
|
|
27
|
+
const NATURAL_WIDTH = 1e7; // effectively unbounded — single-line advance
|
|
28
|
+
// Font size the metrics probe shapes at: big enough that the reported line box
|
|
29
|
+
// divides back to a per-em ratio without rounding noise.
|
|
30
|
+
const PROBE_EM = 1000;
|
|
31
|
+
// CSS white-space: normal: collapse every run of whitespace (spaces, tabs,
|
|
32
|
+
// newlines) to a single space. Line-edge trimming is handled
|
|
33
|
+
// per line via endExcludingWhitespaces.
|
|
34
|
+
function collapse(text) {
|
|
35
|
+
return text.replace(/\s+/g, " ");
|
|
36
|
+
}
|
|
37
|
+
export function createParagraphEngine(ck, fonts) {
|
|
38
|
+
const provider = ck.TypefaceFontProvider.Make();
|
|
39
|
+
for (const [family, list] of fonts) {
|
|
40
|
+
for (const bytes of list) {
|
|
41
|
+
provider.registerFont(bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength), family);
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
// Every registered family, in insertion order, used as the per-glyph fallback
|
|
45
|
+
// chain: a span names one family, but CanvasKit only falls back to families it
|
|
46
|
+
// sees in `fontFamilies`, so append the rest. Without this, any glyph the span's
|
|
47
|
+
// font lacks (emoji, CJK, Arabic…) renders as tofu even when a covering font is
|
|
48
|
+
// registered. Callers order the map so broader fallbacks come after the primary.
|
|
49
|
+
const fallbackFamilies = [...fonts.keys()];
|
|
50
|
+
function ckWeight(weight) {
|
|
51
|
+
return ck.FontWeight[WEIGHTS[Math.round((weight || 400) / 100) * 100] ?? "Normal"];
|
|
52
|
+
}
|
|
53
|
+
// The CanvasKit TextStyle for a span — the same shape canvaskit.ts paints
|
|
54
|
+
// with. CanvasKit adds letterSpacing after each glyph, so measuring/breaking
|
|
55
|
+
// with it here matches the render exactly.
|
|
56
|
+
function spanTextStyle(font) {
|
|
57
|
+
return {
|
|
58
|
+
fontFamilies: [
|
|
59
|
+
font.family,
|
|
60
|
+
...fallbackFamilies.filter((f) => f !== font.family),
|
|
61
|
+
],
|
|
62
|
+
fontSize: font.size,
|
|
63
|
+
fontStyle: {
|
|
64
|
+
weight: ckWeight(font.weight),
|
|
65
|
+
slant: font.style === "italic" ? ck.FontSlant.Italic : ck.FontSlant.Upright,
|
|
66
|
+
},
|
|
67
|
+
// The same weight again as a variation axis, plus any other axes the
|
|
68
|
+
// span sets. A family delivered as one variable file (what Google Fonts
|
|
69
|
+
// serves a browser, one woff2 per subset reused across every weight row)
|
|
70
|
+
// would otherwise shape a 700 span as the 400 instance under synthetic
|
|
71
|
+
// bold: lighter strokes and 400's advances, so thin and mis-wrapped.
|
|
72
|
+
fontVariations: fontVariationList(font.weight, font.variations),
|
|
73
|
+
...(font.letterSpacing ? { letterSpacing: font.letterSpacing } : {}),
|
|
74
|
+
};
|
|
75
|
+
}
|
|
76
|
+
function build(text, font) {
|
|
77
|
+
const style = new ck.ParagraphStyle({ textStyle: spanTextStyle(font) });
|
|
78
|
+
const builder = ck.ParagraphBuilder.MakeFromFontProvider(style, provider);
|
|
79
|
+
builder.addText(text);
|
|
80
|
+
return { para: builder.build(), builder };
|
|
81
|
+
}
|
|
82
|
+
// Shape every styled span as ONE paragraph and wrap at maxWidth, so cross-span
|
|
83
|
+
// shaping, per-fragment geometry, and wrapping of mixed-style text all match
|
|
84
|
+
// what canvaskit.ts paints. Spans are added verbatim
|
|
85
|
+
// — the painter does the same — and each line's fragments are sliced by the
|
|
86
|
+
// intersection of the span's text range with the line, positioned via
|
|
87
|
+
// getRectsForRange (advance-based selection rects) relative to the line's left.
|
|
88
|
+
function layoutInline(spans, maxWidth) {
|
|
89
|
+
if (spans.length === 0)
|
|
90
|
+
return { lines: [] };
|
|
91
|
+
const pstyle = new ck.ParagraphStyle({
|
|
92
|
+
textStyle: spanTextStyle(spans[0].font),
|
|
93
|
+
});
|
|
94
|
+
const builder = ck.ParagraphBuilder.MakeFromFontProvider(pstyle, provider);
|
|
95
|
+
const ranges = [];
|
|
96
|
+
let cursor = 0;
|
|
97
|
+
let full = "";
|
|
98
|
+
spans.forEach((s, i) => {
|
|
99
|
+
builder.pushStyle(ck.TextStyle(spanTextStyle(s.font)));
|
|
100
|
+
builder.addText(s.text);
|
|
101
|
+
builder.pop();
|
|
102
|
+
ranges.push({ start: cursor, end: cursor + s.text.length, spanIndex: i });
|
|
103
|
+
cursor += s.text.length;
|
|
104
|
+
full += s.text;
|
|
105
|
+
});
|
|
106
|
+
const para = builder.build();
|
|
107
|
+
try {
|
|
108
|
+
para.layout(maxWidth);
|
|
109
|
+
const lines = para
|
|
110
|
+
.getLineMetrics()
|
|
111
|
+
.map((lm) => {
|
|
112
|
+
const fragments = [];
|
|
113
|
+
for (const r of ranges) {
|
|
114
|
+
const fs = Math.max(r.start, lm.startIndex);
|
|
115
|
+
const fe = Math.min(r.end, lm.endExcludingWhitespaces);
|
|
116
|
+
if (fe <= fs)
|
|
117
|
+
continue; // span absent from this line (or only ws)
|
|
118
|
+
const rects = para.getRectsForRange(fs, fe, ck.RectHeightStyle.Tight, ck.RectWidthStyle.Tight);
|
|
119
|
+
if (!rects.length)
|
|
120
|
+
continue;
|
|
121
|
+
let left = Number.POSITIVE_INFINITY;
|
|
122
|
+
let right = Number.NEGATIVE_INFINITY;
|
|
123
|
+
for (const rd of rects) {
|
|
124
|
+
left = Math.min(left, rd.rect[0]);
|
|
125
|
+
right = Math.max(right, rd.rect[2]);
|
|
126
|
+
}
|
|
127
|
+
fragments.push({
|
|
128
|
+
spanIndex: r.spanIndex,
|
|
129
|
+
text: full.slice(fs, fe),
|
|
130
|
+
x: left - lm.left,
|
|
131
|
+
width: right - left,
|
|
132
|
+
});
|
|
133
|
+
}
|
|
134
|
+
return { fragments, width: lm.width };
|
|
135
|
+
});
|
|
136
|
+
return { lines };
|
|
137
|
+
}
|
|
138
|
+
finally {
|
|
139
|
+
para.delete();
|
|
140
|
+
builder.delete();
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
function breakLines(text, font, maxWidth) {
|
|
144
|
+
const norm = collapse(text);
|
|
145
|
+
const { para, builder } = build(norm, font);
|
|
146
|
+
try {
|
|
147
|
+
para.layout(maxWidth);
|
|
148
|
+
return para
|
|
149
|
+
.getLineMetrics()
|
|
150
|
+
.map((m) => ({
|
|
151
|
+
// UTF-16 code-unit offsets → direct string slice.
|
|
152
|
+
text: norm.slice(m.startIndex, m.endExcludingWhitespaces).trim(),
|
|
153
|
+
width: m.width,
|
|
154
|
+
}));
|
|
155
|
+
}
|
|
156
|
+
finally {
|
|
157
|
+
para.delete();
|
|
158
|
+
builder.delete();
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
function naturalWidth(text, font) {
|
|
162
|
+
const { para, builder } = build(collapse(text), font);
|
|
163
|
+
try {
|
|
164
|
+
para.layout(NATURAL_WIDTH);
|
|
165
|
+
return para.getMaxIntrinsicWidth();
|
|
166
|
+
}
|
|
167
|
+
finally {
|
|
168
|
+
para.delete();
|
|
169
|
+
builder.delete();
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
const measureText = (text, font, maxWidth) => {
|
|
173
|
+
const lineHeightPx = font.size * font.lineHeight;
|
|
174
|
+
if (text.length === 0)
|
|
175
|
+
return { width: 0, height: lineHeightPx };
|
|
176
|
+
if (maxWidth === null) {
|
|
177
|
+
// Round the single-line (hug) width UP to a whole pixel: laying a
|
|
178
|
+
// paragraph out at exactly getMaxIntrinsicWidth wraps (CanvasKit breaks on
|
|
179
|
+
// `>=`, not `>`), so a hug box sized to the raw width would wrap/ellipsize.
|
|
180
|
+
return {
|
|
181
|
+
width: Math.ceil(naturalWidth(text, font)),
|
|
182
|
+
height: lineHeightPx,
|
|
183
|
+
};
|
|
184
|
+
}
|
|
185
|
+
const lines = breakLines(text, font, maxWidth);
|
|
186
|
+
const width = lines.reduce((max, l) => Math.max(max, l.width), 0);
|
|
187
|
+
const height = Math.max(1, lines.length) * lineHeightPx;
|
|
188
|
+
return { width, height };
|
|
189
|
+
};
|
|
190
|
+
const measureSpanWidth = (text, font) => {
|
|
191
|
+
if (text.length === 0)
|
|
192
|
+
return 0;
|
|
193
|
+
return naturalWidth(text, font);
|
|
194
|
+
};
|
|
195
|
+
// Mirrors text-layout.ts's layoutText/shrinkToFit so the two engines are
|
|
196
|
+
// interchangeable inside compile.
|
|
197
|
+
const layoutText = (input) => {
|
|
198
|
+
const lh = input.lineHeight;
|
|
199
|
+
const once = (size) => {
|
|
200
|
+
const lines = breakLines(input.value, { ...input.font, size }, input.maxWidth);
|
|
201
|
+
return {
|
|
202
|
+
lines,
|
|
203
|
+
totalHeight: lines.length * size * lh,
|
|
204
|
+
effectiveFontSize: size,
|
|
205
|
+
};
|
|
206
|
+
};
|
|
207
|
+
if (input.fit !== "shrink")
|
|
208
|
+
return { ...once(input.font.size), shrinkApplied: false };
|
|
209
|
+
if (input.font.size < SHRINK_FLOOR_PX)
|
|
210
|
+
return { ...once(input.font.size), shrinkApplied: false };
|
|
211
|
+
const full = once(input.font.size);
|
|
212
|
+
if (full.totalHeight <= input.maxHeight)
|
|
213
|
+
return { ...full, shrinkApplied: false };
|
|
214
|
+
let lo = SHRINK_FLOOR_PX;
|
|
215
|
+
let hi = input.font.size;
|
|
216
|
+
let best = null;
|
|
217
|
+
while (lo <= hi) {
|
|
218
|
+
const mid = Math.floor((lo + hi) / 2);
|
|
219
|
+
const trial = once(mid);
|
|
220
|
+
if (trial.totalHeight <= input.maxHeight) {
|
|
221
|
+
best = trial;
|
|
222
|
+
lo = mid + 1;
|
|
223
|
+
}
|
|
224
|
+
else {
|
|
225
|
+
hi = mid - 1;
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
if (best)
|
|
229
|
+
return { ...best, shrinkApplied: true };
|
|
230
|
+
return { ...once(SHRINK_FLOOR_PX), shrinkApplied: true };
|
|
231
|
+
};
|
|
232
|
+
// A family's vertical metrics as Skia reads them from the decoded face, for
|
|
233
|
+
// the AUTO line height (see ./line-height). This exists because the sfnt
|
|
234
|
+
// reader parses ttf/otf only and a browser is served woff2 — the bytes are
|
|
235
|
+
// already decoded here, so ask the engine that holds them. Measured off a
|
|
236
|
+
// probe paragraph at PROBE_EM, which reports the line box the font itself
|
|
237
|
+
// declares; cached per family, since it costs a shape.
|
|
238
|
+
const metricsCache = new Map();
|
|
239
|
+
function metricsFor(family) {
|
|
240
|
+
const hit = metricsCache.get(family);
|
|
241
|
+
if (hit !== undefined || metricsCache.has(family))
|
|
242
|
+
return hit;
|
|
243
|
+
let out;
|
|
244
|
+
if (fonts.has(family)) {
|
|
245
|
+
const { para, builder } = build("Hg", {
|
|
246
|
+
family,
|
|
247
|
+
size: PROBE_EM,
|
|
248
|
+
weight: 400,
|
|
249
|
+
lineHeight: 1,
|
|
250
|
+
});
|
|
251
|
+
try {
|
|
252
|
+
para.layout(NATURAL_WIDTH);
|
|
253
|
+
const lm = para.getLineMetrics()[0];
|
|
254
|
+
if (lm) {
|
|
255
|
+
out = {
|
|
256
|
+
ascent: lm.ascent / PROBE_EM,
|
|
257
|
+
descent: lm.descent / PROBE_EM,
|
|
258
|
+
// Skia folds the font's leading into the line's ascent/descent, so
|
|
259
|
+
// counting it again here would double it.
|
|
260
|
+
lineGap: 0,
|
|
261
|
+
// Not what the paragraph reports; a caller that needs cap height
|
|
262
|
+
// (leading trim) still goes through readFontMetrics.
|
|
263
|
+
capHeight: 0,
|
|
264
|
+
};
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
finally {
|
|
268
|
+
para.delete();
|
|
269
|
+
builder.delete();
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
metricsCache.set(family, out);
|
|
273
|
+
return out;
|
|
274
|
+
}
|
|
275
|
+
return {
|
|
276
|
+
measureText,
|
|
277
|
+
measureSpanWidth,
|
|
278
|
+
layoutText,
|
|
279
|
+
layoutInline,
|
|
280
|
+
metricsFor,
|
|
281
|
+
dispose: () => provider.delete(),
|
|
282
|
+
};
|
|
283
|
+
}
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
/** Scale path data by `sx` horizontally and `sy` vertically (default: `sx`).
|
|
2
|
+
* Exact for a uniform scale; with `sx !== sy` a rotated arc's radii are scaled
|
|
3
|
+
* per axis, which approximates it. Throws on data it cannot read rather than
|
|
4
|
+
* emitting a different shape. */
|
|
5
|
+
export declare function scalePathData(d: string, sx: number, sy?: number): string;
|
package/src/path-data.js
ADDED
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
// Scaling SVG path data. Multiplying every number in a `d` string is only right
|
|
2
|
+
// for the commands whose arguments are all coordinates: an arc's x-axis rotation
|
|
3
|
+
// and its two flags are not lengths, and scaling them turns a quarter circle into
|
|
4
|
+
// something else entirely. This tokenizes the path and scales only the arguments
|
|
5
|
+
// that are lengths.
|
|
6
|
+
// Arguments per command, and which of them are x / y lengths. Anything not listed
|
|
7
|
+
// (an arc's rotation and flags) passes through unchanged.
|
|
8
|
+
const ARITY = {
|
|
9
|
+
M: 2,
|
|
10
|
+
L: 2,
|
|
11
|
+
T: 2,
|
|
12
|
+
H: 1,
|
|
13
|
+
V: 1,
|
|
14
|
+
C: 6,
|
|
15
|
+
S: 4,
|
|
16
|
+
Q: 4,
|
|
17
|
+
A: 7,
|
|
18
|
+
Z: 0,
|
|
19
|
+
};
|
|
20
|
+
const AXIS = {
|
|
21
|
+
M: ["x", "y"],
|
|
22
|
+
L: ["x", "y"],
|
|
23
|
+
T: ["x", "y"],
|
|
24
|
+
H: ["x"],
|
|
25
|
+
V: ["y"],
|
|
26
|
+
C: ["x", "y", "x", "y", "x", "y"],
|
|
27
|
+
S: ["x", "y", "x", "y"],
|
|
28
|
+
Q: ["x", "y", "x", "y"],
|
|
29
|
+
A: ["x", "y", null, null, null, "x", "y"],
|
|
30
|
+
Z: [],
|
|
31
|
+
};
|
|
32
|
+
const NUMBER = /^[-+]?(?:\d+\.?\d*|\.\d+)(?:[eE][-+]?\d+)?/;
|
|
33
|
+
/** Scale path data by `sx` horizontally and `sy` vertically (default: `sx`).
|
|
34
|
+
* Exact for a uniform scale; with `sx !== sy` a rotated arc's radii are scaled
|
|
35
|
+
* per axis, which approximates it. Throws on data it cannot read rather than
|
|
36
|
+
* emitting a different shape. */
|
|
37
|
+
export function scalePathData(d, sx, sy = sx) {
|
|
38
|
+
if (sx === 1 && sy === 1)
|
|
39
|
+
return d;
|
|
40
|
+
// Everything that is not a length is copied through verbatim, so the output
|
|
41
|
+
// keeps the input's separators and number formatting.
|
|
42
|
+
let out = "";
|
|
43
|
+
let i = 0;
|
|
44
|
+
let cmd = "";
|
|
45
|
+
let argIndex = 0;
|
|
46
|
+
const copySeparators = () => {
|
|
47
|
+
while (i < d.length && /[\s,]/.test(d[i]))
|
|
48
|
+
out += d[i++];
|
|
49
|
+
};
|
|
50
|
+
// A rewritten number can lose the boundary its original had ("1.5.5" →
|
|
51
|
+
// "3" + "1"), so one that would run into the previous digits gets a space.
|
|
52
|
+
const emitScaled = (token) => {
|
|
53
|
+
if (/[\d.]$/.test(out) && !/^[-+]/.test(token))
|
|
54
|
+
out += " ";
|
|
55
|
+
out += token;
|
|
56
|
+
};
|
|
57
|
+
copySeparators();
|
|
58
|
+
while (i < d.length) {
|
|
59
|
+
const ch = d[i];
|
|
60
|
+
if (/[a-zA-Z]/.test(ch)) {
|
|
61
|
+
const upper = ch.toUpperCase();
|
|
62
|
+
if (!(upper in ARITY))
|
|
63
|
+
throw new Error(`path: unknown command "${ch}"`);
|
|
64
|
+
cmd = ch;
|
|
65
|
+
argIndex = 0;
|
|
66
|
+
out += ch;
|
|
67
|
+
i++;
|
|
68
|
+
copySeparators();
|
|
69
|
+
continue;
|
|
70
|
+
}
|
|
71
|
+
const upper = cmd.toUpperCase();
|
|
72
|
+
const arity = ARITY[upper];
|
|
73
|
+
if (!cmd || !arity)
|
|
74
|
+
throw new Error(`path: number without a command at ${i}`);
|
|
75
|
+
const slot = argIndex % arity;
|
|
76
|
+
let token;
|
|
77
|
+
if (upper === "A" && (slot === 3 || slot === 4)) {
|
|
78
|
+
// Flags are one character and may be written without a separator
|
|
79
|
+
// ("a1 1 0 01 5 5").
|
|
80
|
+
token = ch;
|
|
81
|
+
if (token !== "0" && token !== "1")
|
|
82
|
+
throw new Error(`path: bad arc flag "${token}" at ${i}`);
|
|
83
|
+
i++;
|
|
84
|
+
}
|
|
85
|
+
else {
|
|
86
|
+
const m = NUMBER.exec(d.slice(i));
|
|
87
|
+
if (!m)
|
|
88
|
+
throw new Error(`path: unreadable number at ${i}`);
|
|
89
|
+
token = m[0];
|
|
90
|
+
i += token.length;
|
|
91
|
+
}
|
|
92
|
+
const axis = AXIS[upper][slot];
|
|
93
|
+
if (axis)
|
|
94
|
+
emitScaled(String(Number.parseFloat(token) * (axis === "x" ? sx : sy)));
|
|
95
|
+
else
|
|
96
|
+
out += token;
|
|
97
|
+
argIndex++;
|
|
98
|
+
copySeparators();
|
|
99
|
+
}
|
|
100
|
+
return out;
|
|
101
|
+
}
|
package/src/png.d.ts
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import type { DecodedPixels } from "./decode.js";
|
|
2
|
+
export type PngEffort = "fast" | "best";
|
|
3
|
+
export type EncodePngOptions = {
|
|
4
|
+
effort?: PngEffort;
|
|
5
|
+
};
|
|
6
|
+
/**
|
|
7
|
+
* What a render is encoded as.
|
|
8
|
+
*
|
|
9
|
+
* `png` is lossless and is what anything read back, re-rendered or printed
|
|
10
|
+
* wants. `webp` is Skia's lossy encoder — roughly a quarter of the bytes on
|
|
11
|
+
* card artwork, for a picture a browser displays and nothing reads back.
|
|
12
|
+
* `jpeg` is what photos are exchanged as; it has no alpha, so the frame is
|
|
13
|
+
* flattened over white first (see ./jpeg).
|
|
14
|
+
*/
|
|
15
|
+
export type EncodeFormat = "png" | "webp" | "jpeg";
|
|
16
|
+
export type EncodeOptions = EncodePngOptions & {
|
|
17
|
+
format?: EncodeFormat;
|
|
18
|
+
quality?: number;
|
|
19
|
+
};
|
|
20
|
+
export declare const DEFAULT_WEBP_QUALITY = 90;
|
|
21
|
+
export declare const DEFAULT_JPEG_QUALITY = 90;
|
|
22
|
+
export declare function encodePng(pixels: Uint8Array, width: number, height: number, opts?: EncodePngOptions): Promise<Uint8Array>;
|
|
23
|
+
export declare function encodeDecodedPng(decoded: DecodedPixels, opts?: EncodePngOptions): Promise<Uint8Array>;
|
package/src/png.js
ADDED
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
export const DEFAULT_WEBP_QUALITY = 90;
|
|
2
|
+
export const DEFAULT_JPEG_QUALITY = 90;
|
|
3
|
+
// Encode RGBA8888 pixels (row-major, length width·height·4) as a PNG.
|
|
4
|
+
export async function encodePng(pixels, width, height, opts) {
|
|
5
|
+
if (width <= 0 || height <= 0)
|
|
6
|
+
throw new Error("encodePng: empty image");
|
|
7
|
+
const expected = width * height * 4;
|
|
8
|
+
if (pixels.length < expected) {
|
|
9
|
+
throw new Error(`encodePng: expected ${expected} bytes of RGBA, got ${pixels.length}`);
|
|
10
|
+
}
|
|
11
|
+
// A fully opaque frame spends a byte per pixel on an alpha channel that says
|
|
12
|
+
// nothing — but dropping it is not always the smaller stream (long runs of an
|
|
13
|
+
// identical RGBA pixel compress better than the same runs in RGB), so it is a
|
|
14
|
+
// candidate rather than a rule.
|
|
15
|
+
const layouts = isOpaque(pixels, expected)
|
|
16
|
+
? [{ bytes: pixels, channels: 4 }, rgbLayout(pixels, width, height)]
|
|
17
|
+
: [{ bytes: pixels, channels: 4 }];
|
|
18
|
+
const filters = opts?.effort === "best" ? [false, true] : [false];
|
|
19
|
+
const plans = layouts.flatMap((layout) => filters.map((adaptive) => ({ layout, adaptive })));
|
|
20
|
+
const streams = await Promise.all(plans.map((p) => deflate(filterRows(p.layout.bytes, width, height, p.layout.channels, p.adaptive))));
|
|
21
|
+
let best = 0;
|
|
22
|
+
for (let i = 1; i < streams.length; i++) {
|
|
23
|
+
if (streams[i].length < streams[best].length)
|
|
24
|
+
best = i;
|
|
25
|
+
}
|
|
26
|
+
return assemble(streams[best], width, height, plans[best].layout.channels);
|
|
27
|
+
}
|
|
28
|
+
// Read a frame's pixels straight out of a decode and encode them. The pairing
|
|
29
|
+
// the callers actually want: decodePixels → encodePng.
|
|
30
|
+
export function encodeDecodedPng(decoded, opts) {
|
|
31
|
+
return encodePng(new Uint8Array(decoded.data), decoded.width, decoded.height, opts);
|
|
32
|
+
}
|
|
33
|
+
function isOpaque(pixels, length) {
|
|
34
|
+
for (let i = 3; i < length; i += 4)
|
|
35
|
+
if (pixels[i] !== 255)
|
|
36
|
+
return false;
|
|
37
|
+
return true;
|
|
38
|
+
}
|
|
39
|
+
function rgbLayout(pixels, width, height) {
|
|
40
|
+
const out = new Uint8Array(width * height * 3);
|
|
41
|
+
for (let src = 0, dst = 0; dst < out.length; src += 4, dst += 3) {
|
|
42
|
+
out[dst] = pixels[src];
|
|
43
|
+
out[dst + 1] = pixels[src + 1];
|
|
44
|
+
out[dst + 2] = pixels[src + 2];
|
|
45
|
+
}
|
|
46
|
+
return { bytes: out, channels: 3 };
|
|
47
|
+
}
|
|
48
|
+
// Prefix each scanline with its filter type (PNG spec §6). `adaptive` picks the
|
|
49
|
+
// type per row by the standard minimum-sum-of-absolute-differences heuristic;
|
|
50
|
+
// otherwise every row is type 0, the bytes as they are.
|
|
51
|
+
function filterRows(bytes, width, height, channels, adaptive) {
|
|
52
|
+
const stride = width * channels;
|
|
53
|
+
const out = new Uint8Array((stride + 1) * height);
|
|
54
|
+
if (!adaptive) {
|
|
55
|
+
for (let y = 0; y < height; y++) {
|
|
56
|
+
out[y * (stride + 1)] = 0;
|
|
57
|
+
out.set(bytes.subarray(y * stride, (y + 1) * stride), y * (stride + 1) + 1);
|
|
58
|
+
}
|
|
59
|
+
return out;
|
|
60
|
+
}
|
|
61
|
+
const candidate = new Uint8Array(stride);
|
|
62
|
+
const chosen = new Uint8Array(stride);
|
|
63
|
+
for (let y = 0; y < height; y++) {
|
|
64
|
+
const row = bytes.subarray(y * stride, (y + 1) * stride);
|
|
65
|
+
const prev = y > 0 ? bytes.subarray((y - 1) * stride, y * stride) : null;
|
|
66
|
+
let bestType = 0;
|
|
67
|
+
let bestScore = Number.POSITIVE_INFINITY;
|
|
68
|
+
for (let type = 0; type <= 4; type++) {
|
|
69
|
+
let score = 0;
|
|
70
|
+
for (let i = 0; i < stride; i++) {
|
|
71
|
+
const a = i >= channels ? row[i - channels] : 0;
|
|
72
|
+
const b = prev ? prev[i] : 0;
|
|
73
|
+
const c = prev && i >= channels ? prev[i - channels] : 0;
|
|
74
|
+
const v = type === 0
|
|
75
|
+
? row[i]
|
|
76
|
+
: type === 1
|
|
77
|
+
? row[i] - a
|
|
78
|
+
: type === 2
|
|
79
|
+
? row[i] - b
|
|
80
|
+
: type === 3
|
|
81
|
+
? row[i] - ((a + b) >> 1)
|
|
82
|
+
: row[i] - paeth(a, b, c);
|
|
83
|
+
candidate[i] = v & 0xff;
|
|
84
|
+
// Signed magnitude: bytes near zero are what compresses.
|
|
85
|
+
score += Math.abs((candidate[i] << 24) >> 24);
|
|
86
|
+
}
|
|
87
|
+
if (score < bestScore) {
|
|
88
|
+
bestScore = score;
|
|
89
|
+
bestType = type;
|
|
90
|
+
chosen.set(candidate);
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
out[y * (stride + 1)] = bestType;
|
|
94
|
+
out.set(chosen, y * (stride + 1) + 1);
|
|
95
|
+
}
|
|
96
|
+
return out;
|
|
97
|
+
}
|
|
98
|
+
function paeth(a, b, c) {
|
|
99
|
+
const p = a + b - c;
|
|
100
|
+
const pa = Math.abs(p - a);
|
|
101
|
+
const pb = Math.abs(p - b);
|
|
102
|
+
const pc = Math.abs(p - c);
|
|
103
|
+
if (pa <= pb && pa <= pc)
|
|
104
|
+
return a;
|
|
105
|
+
return pb <= pc ? b : c;
|
|
106
|
+
}
|
|
107
|
+
// zlib-wrapped deflate, which is what an IDAT holds — "deflate-raw" would be
|
|
108
|
+
// missing the two-byte header the spec requires.
|
|
109
|
+
//
|
|
110
|
+
// Driven through the stream's own reader/writer rather than Blob.stream() or
|
|
111
|
+
// Response: those are DOM conveniences a test environment (jsdom) may only half
|
|
112
|
+
// implement, and this needs nothing but the compression stream itself. The write
|
|
113
|
+
// is deliberately not awaited before reading starts — a large frame fills the
|
|
114
|
+
// stream's queue, and waiting for the write to settle first would deadlock.
|
|
115
|
+
async function deflate(bytes) {
|
|
116
|
+
const cs = new CompressionStream("deflate");
|
|
117
|
+
const writer = cs.writable.getWriter();
|
|
118
|
+
const written = writer
|
|
119
|
+
.write(bytes)
|
|
120
|
+
.then(() => writer.close());
|
|
121
|
+
const reader = cs.readable.getReader();
|
|
122
|
+
const chunks = [];
|
|
123
|
+
let total = 0;
|
|
124
|
+
for (;;) {
|
|
125
|
+
const { done, value } = await reader.read();
|
|
126
|
+
if (done)
|
|
127
|
+
break;
|
|
128
|
+
chunks.push(value);
|
|
129
|
+
total += value.length;
|
|
130
|
+
}
|
|
131
|
+
await written;
|
|
132
|
+
const out = new Uint8Array(total);
|
|
133
|
+
let offset = 0;
|
|
134
|
+
for (const c of chunks) {
|
|
135
|
+
out.set(c, offset);
|
|
136
|
+
offset += c.length;
|
|
137
|
+
}
|
|
138
|
+
return out;
|
|
139
|
+
}
|
|
140
|
+
const PNG_SIGNATURE = [137, 80, 78, 71, 13, 10, 26, 10];
|
|
141
|
+
function assemble(idat, width, height, channels) {
|
|
142
|
+
const ihdr = new Uint8Array(13);
|
|
143
|
+
const view = new DataView(ihdr.buffer);
|
|
144
|
+
view.setUint32(0, width);
|
|
145
|
+
view.setUint32(4, height);
|
|
146
|
+
ihdr[8] = 8; // bit depth
|
|
147
|
+
ihdr[9] = channels === 4 ? 6 : 2; // colour type: RGBA / RGB
|
|
148
|
+
// 10..12: compression 0, filter 0, interlace 0 — the only values PNG defines.
|
|
149
|
+
const chunks = [
|
|
150
|
+
chunk("IHDR", ihdr),
|
|
151
|
+
chunk("IDAT", idat),
|
|
152
|
+
chunk("IEND", new Uint8Array(0)),
|
|
153
|
+
];
|
|
154
|
+
const size = PNG_SIGNATURE.length + chunks.reduce((n, c) => n + c.length, 0);
|
|
155
|
+
const png = new Uint8Array(size);
|
|
156
|
+
png.set(PNG_SIGNATURE, 0);
|
|
157
|
+
let offset = PNG_SIGNATURE.length;
|
|
158
|
+
for (const c of chunks) {
|
|
159
|
+
png.set(c, offset);
|
|
160
|
+
offset += c.length;
|
|
161
|
+
}
|
|
162
|
+
return png;
|
|
163
|
+
}
|
|
164
|
+
function chunk(type, data) {
|
|
165
|
+
const out = new Uint8Array(12 + data.length);
|
|
166
|
+
const view = new DataView(out.buffer);
|
|
167
|
+
view.setUint32(0, data.length);
|
|
168
|
+
for (let i = 0; i < 4; i++)
|
|
169
|
+
out[4 + i] = type.charCodeAt(i);
|
|
170
|
+
out.set(data, 8);
|
|
171
|
+
view.setUint32(8 + data.length, crc32(out.subarray(4, 8 + data.length)));
|
|
172
|
+
return out;
|
|
173
|
+
}
|
|
174
|
+
const CRC_TABLE = (() => {
|
|
175
|
+
const table = new Uint32Array(256);
|
|
176
|
+
for (let n = 0; n < 256; n++) {
|
|
177
|
+
let c = n;
|
|
178
|
+
for (let k = 0; k < 8; k++)
|
|
179
|
+
c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1;
|
|
180
|
+
table[n] = c >>> 0;
|
|
181
|
+
}
|
|
182
|
+
return table;
|
|
183
|
+
})();
|
|
184
|
+
function crc32(bytes) {
|
|
185
|
+
let c = 0xffffffff;
|
|
186
|
+
for (let i = 0; i < bytes.length; i++)
|
|
187
|
+
c = CRC_TABLE[(c ^ bytes[i]) & 0xff] ^ (c >>> 8);
|
|
188
|
+
return (c ^ 0xffffffff) >>> 0;
|
|
189
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import type { Node } from "./node.js";
|
|
2
|
+
import type { MeasureText } from "./text-types.js";
|
|
3
|
+
import type { Size } from "./types.js";
|
|
4
|
+
export declare function resolveLayout(root: Node, opts: {
|
|
5
|
+
measure: MeasureText;
|
|
6
|
+
}): Node;
|
|
7
|
+
/** The axis-aligned box a turned node covers — what its container has to make
|
|
8
|
+
* room for. Identity for an unrotated node, so nothing changes for one. */
|
|
9
|
+
export declare function rotatedFootprint(size: Size, rotation: number | undefined): Size;
|