imgtune 0.1.0 → 0.3.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.
Files changed (63) hide show
  1. package/CHANGELOG.md +172 -0
  2. package/README.md +109 -5
  3. package/dist/animated.js +176 -0
  4. package/dist/audit.js +1 -0
  5. package/dist/backgrounds.js +42 -7
  6. package/dist/broken.js +5 -1
  7. package/dist/browser.js +4 -4
  8. package/dist/candidates.js +25 -0
  9. package/dist/check-report.js +56 -6
  10. package/dist/check.js +26 -5
  11. package/dist/cli.js +86 -8
  12. package/dist/egress-proxy.js +2 -1
  13. package/dist/fetch.js +9 -4
  14. package/dist/ffmpeg.js +530 -0
  15. package/dist/gpu.js +12 -0
  16. package/dist/hints.js +704 -16
  17. package/dist/image-file.js +154 -34
  18. package/dist/image-open.js +9 -0
  19. package/dist/images.js +142 -8
  20. package/dist/layout.js +7 -7
  21. package/dist/lcp.js +2 -1
  22. package/dist/locate-join.js +86 -0
  23. package/dist/locate-only.js +14 -3
  24. package/dist/locate.js +6 -2
  25. package/dist/measure.js +39 -1
  26. package/dist/media-encode.js +1054 -0
  27. package/dist/media-file.js +284 -0
  28. package/dist/media-limits.js +31 -0
  29. package/dist/media-model.js +251 -0
  30. package/dist/media-phase.js +675 -0
  31. package/dist/media-prepare.js +31 -0
  32. package/dist/px.js +11 -0
  33. package/dist/recheck.js +12 -4
  34. package/dist/remote.js +1065 -107
  35. package/dist/replay.js +20 -0
  36. package/dist/resources/audio.js +248 -0
  37. package/dist/resources/brotli.js +22 -0
  38. package/dist/resources/cache.js +59 -0
  39. package/dist/resources/capture.js +225 -0
  40. package/dist/resources/compression.js +122 -0
  41. package/dist/resources/favicon.js +130 -0
  42. package/dist/resources/font.js +440 -0
  43. package/dist/resources/index.js +8 -0
  44. package/dist/resources/lottie.js +491 -0
  45. package/dist/resources/model.js +478 -0
  46. package/dist/resources/phase.js +171 -0
  47. package/dist/resources/pool.js +47 -0
  48. package/dist/resources/report.js +57 -0
  49. package/dist/resources/sfnt.js +305 -0
  50. package/dist/resources/svg.js +178 -0
  51. package/dist/resources/types.js +1 -0
  52. package/dist/resources/zip.js +67 -0
  53. package/dist/result-cache.js +56 -3
  54. package/dist/run.js +32 -8
  55. package/dist/sizes.js +7 -5
  56. package/dist/summary.js +69 -3
  57. package/dist/vector-art.js +294 -0
  58. package/dist/vector-phase.js +222 -0
  59. package/dist/video-analysis.js +371 -0
  60. package/dist/webp-animation.js +283 -0
  61. package/package.json +25 -4
  62. package/skills/imgtune/SKILL.md +8 -4
  63. package/skills/imgtune/reference.md +1 -1
@@ -0,0 +1,47 @@
1
+ // A bounded pool for the resource jobs of a scan. A job is started in phase 1 (while the next pages are measured) and read
2
+ // back when its page is analysed. `admit` waits until fewer than `limit` jobs are pending, so the jobs in flight, and the
3
+ // page data they hold, stay bounded. A job that throws is reported and gives undefined: it never fails its page or the others.
4
+ export class ResourcePool {
5
+ limit;
6
+ onError;
7
+ pending = new Set();
8
+ waiters = [];
9
+ constructor(limit, onError = () => undefined) {
10
+ this.limit = limit;
11
+ this.onError = onError;
12
+ if (!Number.isInteger(limit) || limit < 1)
13
+ throw new RangeError(`ResourcePool limit must be a positive integer, got ${limit}`);
14
+ }
15
+ /** Resolves when a slot is free. Call before start, so the number of pending jobs never exceeds the limit. */
16
+ async admit() {
17
+ while (this.pending.size >= this.limit)
18
+ await new Promise((resolve) => this.waiters.push(resolve));
19
+ }
20
+ /** Starts a job now (after admit). The returned promise resolves to the job's value, or undefined when it throws. */
21
+ start(job) {
22
+ const run = (async () => {
23
+ try {
24
+ return await job();
25
+ }
26
+ catch (error) {
27
+ this.onError(error);
28
+ return undefined;
29
+ }
30
+ })();
31
+ this.pending.add(run);
32
+ void run.finally(() => {
33
+ this.pending.delete(run);
34
+ this.waiters.shift()?.();
35
+ });
36
+ return run;
37
+ }
38
+ /** Resolves once every started job has settled. */
39
+ async drain() {
40
+ while (this.pending.size > 0)
41
+ await Promise.all([...this.pending]);
42
+ }
43
+ /** The number of jobs not yet settled. */
44
+ get size() {
45
+ return this.pending.size;
46
+ }
47
+ }
@@ -0,0 +1,57 @@
1
+ /** Rows shown per page in the text output; the JSON has all of them. */
2
+ export const RESOURCE_TEXT_ROWS = 8;
3
+ const kb = (bytes) => `${(bytes / 1024).toFixed(0)} KB`;
4
+ /** A finding's saving smaller than this, for a kind that is weak for its cost, is a lead (see labelFinding in phase.ts). */
5
+ export const LEAD_BYTES = 5 * 1024;
6
+ /**
7
+ * The order and the totals of a finding: 0 a first-party saving (the headline), 1 a third-party saving (ask the vendor),
8
+ * 2 a lead (a small saving of a weak-for-cost kind), 3 informational (no saving). Sorting and totals both read this.
9
+ */
10
+ export function findingTier(f) {
11
+ if (f.savedBytes <= 0)
12
+ return 3;
13
+ if (f.details?.lead === true)
14
+ return 2;
15
+ return f.details?.thirdParty === true ? 1 : 0;
16
+ }
17
+ /**
18
+ * The totals of a page's findings: first-party savings (their count and bytes per view, marked when part of it is an
19
+ * estimate), third-party savings apart, leads and informational findings counted apart. None of the rest adds to the
20
+ * headline saving.
21
+ */
22
+ export function resourceTotalsLine(findings) {
23
+ if (findings.length === 0)
24
+ return undefined;
25
+ const of = (tier) => findings.filter((f) => findingTier(f) === tier);
26
+ const first = of(0);
27
+ const third = of(1);
28
+ const leads = of(2);
29
+ const informational = of(3).length;
30
+ const sumOf = (list) => list.reduce((sum, f) => sum + f.savedBytes, 0);
31
+ const estimated = first.some((f) => f.estimate);
32
+ let head;
33
+ if (first.length > 0)
34
+ head = `resources: ${first.length} finding(s), saves ~${kb(sumOf(first))}/view${estimated ? " (est. in part)" : ""}`;
35
+ else
36
+ head = third.length > 0 || leads.length > 0 ? "resources: no first-party saving" : "resources: no saving";
37
+ const thirdPart = third.length > 0 ? `; third-party ${third.length} finding(s) ~${kb(sumOf(third))}/view (ask the vendor)` : "";
38
+ const leadPart = leads.length > 0 ? `; ${leads.length} lead(s) ~${kb(sumOf(leads))}/view (small)` : "";
39
+ const info = informational > 0 ? `, ${informational} informational` : "";
40
+ return `${head}${thirdPart}${leadPart}${info}, not in the image totals`;
41
+ }
42
+ /** Text lines for a page's resource findings: the totals, then one row per finding and its first fix line. */
43
+ export function resourceLinesOf(findings, text) {
44
+ const total = resourceTotalsLine(findings);
45
+ if (total === undefined)
46
+ return [];
47
+ const lines = [` ${total}`];
48
+ for (const finding of findings.slice(0, RESOURCE_TEXT_ROWS)) {
49
+ const saved = finding.savedBytes > 0 ? ` saves ${kb(finding.savedBytes)}/view${finding.estimate ? " (est.)" : ""}` : "";
50
+ lines.push(` ${finding.kind}: ${text(finding.title)}${saved}`);
51
+ if (finding.fix[0] !== undefined)
52
+ lines.push(` fix: ${text(finding.fix[0])}`);
53
+ }
54
+ if (findings.length > RESOURCE_TEXT_ROWS)
55
+ lines.push(` ... ${findings.length - RESOURCE_TEXT_ROWS} more in the JSON (resources)`);
56
+ return lines;
57
+ }
@@ -0,0 +1,305 @@
1
+ // Reads just enough of a font file for the font analyzer: its container format, a WOFF/WOFF2/EOT unwrap to plain sfnt
2
+ // bytes, and the tables the checks need (maxp, head, OS/2, name, cmap, fvar). Nothing is rendered or fully parsed.
3
+ // Every function returns null (or an empty result) on malformed input; none of them throws.
4
+ import { inflateSync } from "node:zlib";
5
+ import * as wawoff2 from "wawoff2";
6
+ export function detectFormat(buf) {
7
+ if (buf.length < 4)
8
+ return "unknown";
9
+ const tag = buf.toString("latin1", 0, 4);
10
+ if (tag === "wOF2")
11
+ return "woff2";
12
+ if (tag === "wOFF")
13
+ return "woff";
14
+ if (tag === "ttcf")
15
+ return "collection";
16
+ if (tag === "OTTO" || tag === "true" || buf.readUInt32BE(0) === 0x00010000)
17
+ return "sfnt";
18
+ if (buf.length >= 36 && buf.readUInt16LE(34) === 0x504c)
19
+ return "eot";
20
+ return "unknown";
21
+ }
22
+ /** Plain sfnt bytes for a font file, or null when it cannot be unwrapped. */
23
+ export async function toSfnt(buf, format) {
24
+ try {
25
+ switch (format) {
26
+ case "sfnt":
27
+ return buf;
28
+ case "woff2":
29
+ return Buffer.from(await wawoff2.decompress(buf));
30
+ case "woff":
31
+ return woff1ToSfnt(buf);
32
+ case "eot":
33
+ return eotToSfnt(buf);
34
+ default:
35
+ return null;
36
+ }
37
+ }
38
+ catch {
39
+ return null;
40
+ }
41
+ }
42
+ /** WOFF 1.0: each table is zlib-deflated when its compressed length is smaller. Rebuild the sfnt table directory. */
43
+ function woff1ToSfnt(buf) {
44
+ const flavor = buf.readUInt32BE(4);
45
+ const numTables = buf.readUInt16BE(12);
46
+ const tables = [];
47
+ for (let i = 0; i < numTables; i++) {
48
+ const at = 44 + i * 20;
49
+ const offset = buf.readUInt32BE(at + 4);
50
+ const compLength = buf.readUInt32BE(at + 8);
51
+ const origLength = buf.readUInt32BE(at + 12);
52
+ const raw = buf.subarray(offset, offset + compLength);
53
+ if (raw.length !== compLength)
54
+ return null;
55
+ const data = compLength < origLength ? inflateSync(raw) : raw;
56
+ if (data.length !== origLength)
57
+ return null;
58
+ tables.push({ tag: buf.subarray(at, at + 4), data });
59
+ }
60
+ return buildSfnt(flavor, tables);
61
+ }
62
+ /** EOT without MTX compression or XOR obfuscation is the sfnt font data at the end of the header. */
63
+ function eotToSfnt(buf) {
64
+ const eotSize = buf.readUInt32LE(0);
65
+ const fontDataSize = buf.readUInt32LE(4);
66
+ const flags = buf.readUInt32LE(12);
67
+ if (flags & 0x4)
68
+ return null; // MicroType Express compressed
69
+ if (eotSize > buf.length || fontDataSize > eotSize)
70
+ return null;
71
+ const data = Buffer.from(buf.subarray(eotSize - fontDataSize, eotSize));
72
+ if (flags & 0x10000000)
73
+ for (let i = 0; i < data.length; i++)
74
+ data[i] ^= 0x50;
75
+ return detectFormat(data) === "sfnt" ? data : null;
76
+ }
77
+ function buildSfnt(flavor, tables) {
78
+ const n = tables.length;
79
+ const entrySelector = Math.floor(Math.log2(n));
80
+ const searchRange = 2 ** entrySelector * 16;
81
+ const header = Buffer.alloc(12 + n * 16);
82
+ header.writeUInt32BE(flavor, 0);
83
+ header.writeUInt16BE(n, 4);
84
+ header.writeUInt16BE(searchRange, 6);
85
+ header.writeUInt16BE(entrySelector, 8);
86
+ header.writeUInt16BE(n * 16 - searchRange, 10);
87
+ const body = [];
88
+ let offset = header.length;
89
+ tables.forEach((t, i) => {
90
+ const at = 12 + i * 16;
91
+ t.tag.copy(header, at);
92
+ header.writeUInt32BE(0, at + 4); // checksum: not verified by any reader used here
93
+ header.writeUInt32BE(offset, at + 8);
94
+ header.writeUInt32BE(t.data.length, at + 12);
95
+ const padded = Buffer.alloc((t.data.length + 3) & ~3);
96
+ t.data.copy(padded);
97
+ body.push(padded);
98
+ offset += padded.length;
99
+ });
100
+ return Buffer.concat([header, ...body]);
101
+ }
102
+ function readTables(buf) {
103
+ if (buf.length < 12)
104
+ return null;
105
+ const numTables = buf.readUInt16BE(4);
106
+ const tables = new Map();
107
+ for (let i = 0; i < numTables; i++) {
108
+ const at = 12 + i * 16;
109
+ const tag = buf.toString("latin1", at, at + 4);
110
+ const offset = buf.readUInt32BE(at + 8);
111
+ const length = buf.readUInt32BE(at + 12);
112
+ if (offset + length <= buf.length)
113
+ tables.set(tag, { offset, length });
114
+ }
115
+ return tables;
116
+ }
117
+ /** Reads the tables the checks need from a plain sfnt. Returns null for a malformed or unsupported file. */
118
+ export function parseSfnt(buf) {
119
+ try {
120
+ if (detectFormat(buf) !== "sfnt")
121
+ return null;
122
+ const tables = readTables(buf);
123
+ if (!tables)
124
+ return null;
125
+ const maxp = tables.get("maxp");
126
+ const cmap = tables.get("cmap");
127
+ if (!maxp || !cmap)
128
+ return null;
129
+ const numGlyphs = buf.readUInt16BE(maxp.offset + 4);
130
+ const head = tables.get("head");
131
+ const macStyle = head ? buf.readUInt16BE(head.offset + 44) : 0;
132
+ const os2 = tables.get("OS/2");
133
+ const weightClass = os2 ? buf.readUInt16BE(os2.offset + 4) : undefined;
134
+ const fsSelection = os2 && os2.length >= 64 ? buf.readUInt16BE(os2.offset + 62) : 0;
135
+ const ranges = readCmap(buf, cmap);
136
+ const codepoints = ranges.reduce((sum, [a, b]) => sum + (b - a + 1), 0);
137
+ const fvar = tables.get("fvar");
138
+ const name = tables.get("name");
139
+ return {
140
+ numGlyphs,
141
+ families: name ? familyNames(buf, name) : [],
142
+ weightClass,
143
+ italic: Boolean(fsSelection & 1) || Boolean(macStyle & 2),
144
+ axes: fvar ? readAxes(buf, fvar) : [],
145
+ ranges,
146
+ codepoints,
147
+ };
148
+ }
149
+ catch {
150
+ return null;
151
+ }
152
+ }
153
+ function readAxes(buf, fvar) {
154
+ const axesOffset = buf.readUInt16BE(fvar.offset + 4);
155
+ const axisCount = buf.readUInt16BE(fvar.offset + 8);
156
+ const axisSize = buf.readUInt16BE(fvar.offset + 10);
157
+ const axes = [];
158
+ for (let i = 0; i < axisCount; i++) {
159
+ const at = fvar.offset + axesOffset + i * axisSize;
160
+ axes.push({
161
+ tag: buf.toString("latin1", at, at + 4),
162
+ min: buf.readInt32BE(at + 4) / 65536,
163
+ default: buf.readInt32BE(at + 8) / 65536,
164
+ max: buf.readInt32BE(at + 12) / 65536,
165
+ });
166
+ }
167
+ return axes;
168
+ }
169
+ /** Name IDs 16 then 1, from the Windows (UTF-16BE) records, else the Mac (Latin-1) records. */
170
+ function familyNames(buf, name) {
171
+ const count = buf.readUInt16BE(name.offset + 2);
172
+ const stringOffset = name.offset + buf.readUInt16BE(name.offset + 4);
173
+ const found = new Map();
174
+ for (let i = 0; i < count; i++) {
175
+ const at = name.offset + 6 + i * 12;
176
+ const platform = buf.readUInt16BE(at);
177
+ const nameId = buf.readUInt16BE(at + 6);
178
+ if (nameId !== 1 && nameId !== 16)
179
+ continue;
180
+ const length = buf.readUInt16BE(at + 8);
181
+ const start = stringOffset + buf.readUInt16BE(at + 10);
182
+ const raw = buf.subarray(start, start + length);
183
+ const text = decodeName(platform, raw);
184
+ // A Windows record wins over a Mac one for the same ID.
185
+ if (!found.has(nameId) || platform === 3)
186
+ found.set(nameId, text.trim());
187
+ }
188
+ return [found.get(16), found.get(1)].filter((s) => Boolean(s));
189
+ }
190
+ function decodeName(platform, raw) {
191
+ if (platform === 3 || platform === 0) {
192
+ // UTF-16BE: copy the even-length prefix, swap to little-endian for Node's decoder.
193
+ return Buffer.from(raw.subarray(0, raw.length & ~1)).swap16().toString("utf16le");
194
+ }
195
+ return raw.toString("latin1");
196
+ }
197
+ /** Unicode code point ranges from the best Unicode cmap subtable (3,10 then 3,1 then any platform 0, then 3,0). */
198
+ function readCmap(buf, cmap) {
199
+ const count = buf.readUInt16BE(cmap.offset + 2);
200
+ const subtables = [];
201
+ for (let i = 0; i < count; i++) {
202
+ const at = cmap.offset + 4 + i * 8;
203
+ subtables.push({
204
+ platform: buf.readUInt16BE(at),
205
+ encoding: buf.readUInt16BE(at + 2),
206
+ offset: cmap.offset + buf.readUInt32BE(at + 4),
207
+ });
208
+ }
209
+ const pick = subtables.find((s) => s.platform === 3 && s.encoding === 10) ??
210
+ subtables.find((s) => s.platform === 3 && s.encoding === 1) ??
211
+ subtables.find((s) => s.platform === 0) ??
212
+ subtables.find((s) => s.platform === 3 && s.encoding === 0);
213
+ if (!pick)
214
+ return [];
215
+ const format = buf.readUInt16BE(pick.offset);
216
+ const raw = format === 4 ? format4Codes(buf, pick.offset) : format === 12 ? format12Groups(buf, pick.offset) : [];
217
+ return mergeCodes(raw);
218
+ }
219
+ function format4Codes(buf, sub) {
220
+ const segCount = buf.readUInt16BE(sub + 6) / 2;
221
+ const endAt = sub + 14;
222
+ const startAt = endAt + segCount * 2 + 2;
223
+ const deltaAt = startAt + segCount * 2;
224
+ const rangeAt = deltaAt + segCount * 2;
225
+ const out = [];
226
+ for (let s = 0; s < segCount; s++) {
227
+ const end = buf.readUInt16BE(endAt + s * 2);
228
+ const start = buf.readUInt16BE(startAt + s * 2);
229
+ const delta = buf.readUInt16BE(deltaAt + s * 2);
230
+ const rangeOffset = buf.readUInt16BE(rangeAt + s * 2);
231
+ for (let c = start; c <= end && c < 0xffff; c++) {
232
+ let glyph;
233
+ if (rangeOffset === 0) {
234
+ glyph = (c + delta) & 0xffff;
235
+ }
236
+ else {
237
+ glyph = buf.readUInt16BE(rangeAt + s * 2 + rangeOffset + (c - start) * 2);
238
+ if (glyph !== 0)
239
+ glyph = (glyph + delta) & 0xffff;
240
+ }
241
+ if (glyph !== 0)
242
+ out.push([c, c]);
243
+ }
244
+ }
245
+ return out;
246
+ }
247
+ function format12Groups(buf, sub) {
248
+ const groups = buf.readUInt32BE(sub + 12);
249
+ const out = [];
250
+ for (let g = 0; g < groups; g++) {
251
+ const at = sub + 16 + g * 12;
252
+ out.push([buf.readUInt32BE(at), buf.readUInt32BE(at + 4)]);
253
+ }
254
+ return out;
255
+ }
256
+ /** Sorts and merges inclusive ranges, and drops anything above U+10FFFF. */
257
+ export function mergeCodes(input) {
258
+ const sorted = input
259
+ .map(([a, b]) => [a, Math.min(b, 0x10ffff)])
260
+ .filter(([a, b]) => a <= b)
261
+ .sort((x, y) => x[0] - y[0]);
262
+ const out = [];
263
+ for (const [a, b] of sorted) {
264
+ const last = out[out.length - 1];
265
+ if (last && a <= last[1] + 1)
266
+ last[1] = Math.max(last[1], b);
267
+ else
268
+ out.push([a, b]);
269
+ }
270
+ return out;
271
+ }
272
+ /** Intersection of two sorted, merged range lists. */
273
+ export function intersectRanges(a, b) {
274
+ const out = [];
275
+ let i = 0;
276
+ let j = 0;
277
+ while (i < a.length && j < b.length) {
278
+ const lo = Math.max(a[i][0], b[j][0]);
279
+ const hi = Math.min(a[i][1], b[j][1]);
280
+ if (lo <= hi)
281
+ out.push([lo, hi]);
282
+ if (a[i][1] < b[j][1])
283
+ i++;
284
+ else
285
+ j++;
286
+ }
287
+ return out;
288
+ }
289
+ export function countRanges(ranges) {
290
+ return ranges.reduce((sum, [a, b]) => sum + (b - a + 1), 0);
291
+ }
292
+ export function inRanges(ranges, cp) {
293
+ let lo = 0;
294
+ let hi = ranges.length - 1;
295
+ while (lo <= hi) {
296
+ const mid = (lo + hi) >> 1;
297
+ if (cp < ranges[mid][0])
298
+ hi = mid - 1;
299
+ else if (cp > ranges[mid][1])
300
+ lo = mid + 1;
301
+ else
302
+ return true;
303
+ }
304
+ return false;
305
+ }
@@ -0,0 +1,178 @@
1
+ import { createHash } from "node:crypto";
2
+ import sharp from "sharp";
3
+ import { optimize } from "svgo";
4
+ import { brotliSize } from "./brotli.js";
5
+ import { digestOf, heavyValue } from "./cache.js";
6
+ import { score } from "../metric.js";
7
+ /** SVGs under this size are not worth an optimiser pass. */
8
+ const MIN_BYTES = 2048;
9
+ /** SVGs over this size are skipped: the pass and the render would take too long for the phase budget. */
10
+ const MAX_BYTES = 5 * 1024 * 1024;
11
+ /** A finding needs at least this share of the brotli size saved by SVGO. */
12
+ const MIN_SAVING_SHARE = 0.2;
13
+ /** Fidelity bar: SSIMULACRA2 of the optimised render against the original, both at half of RENDER_PX. */
14
+ export const FIDELITY_BAR = 70;
15
+ const RENDER_PX = 512;
16
+ const SCORE_PX = 256;
17
+ /** Embedded rasters: `<image href="data:image/…;base64,…">`. */
18
+ const EMBEDDED_RASTER = /<image\b[^>]*?\s(?:xlink:)?href\s*=\s*["']data:image\/([a-z0-9.+-]+);base64,([^"']*)["']/gi;
19
+ const kb = (n) => `${(n / 1024).toFixed(n < 10 * 1024 ? 1 : 0)} KB`;
20
+ const shortHash = (url) => createHash("sha1").update(url).digest("hex").slice(0, 10);
21
+ const basename = (url) => {
22
+ const path = url.replace(/[?#].*$/, "");
23
+ const raw = path.slice(path.lastIndexOf("/") + 1) || url;
24
+ try {
25
+ return decodeURIComponent(raw);
26
+ }
27
+ catch {
28
+ return raw;
29
+ }
30
+ };
31
+ /**
32
+ * Above this size mergePaths is switched off: on a 700 KB export it takes ~7 s and gains about 1% of the brotli size.
33
+ */
34
+ export const MERGE_PATHS_MAX_BYTES = 256 * 1024;
35
+ /** Optimises with the default preset at floatPrecision 2 (the plan's setting; the fidelity check guards it). */
36
+ export function optimizeSvg(text) {
37
+ const overrides = text.length > MERGE_PATHS_MAX_BYTES ? { mergePaths: false } : {};
38
+ const result = optimize(text, {
39
+ multipass: false,
40
+ plugins: [{ name: "preset-default", params: { floatPrecision: 2, overrides } }],
41
+ });
42
+ return result.data;
43
+ }
44
+ function scaleTo(w, h, px) {
45
+ const k = px / Math.max(w, h);
46
+ return { width: Math.max(1, Math.round(w * k)), height: Math.max(1, Math.round(h * k)) };
47
+ }
48
+ /** Rasterises the SVG so its longer side is `px` pixels (aspect kept), RGBA, non-premultiplied. */
49
+ async function render(svg, px, size) {
50
+ const meta = await sharp(svg, { density: 72 }).metadata();
51
+ const intrinsicW = meta.width && meta.width > 0 ? meta.width : 300;
52
+ const intrinsicH = meta.height && meta.height > 0 ? meta.height : 300;
53
+ const target = size ?? scaleTo(intrinsicW, intrinsicH, px);
54
+ // The density makes the SVG rasterise at the target width directly, so the downscale is small.
55
+ const density = Math.min(3000, Math.max(1, Math.round((72 * target.width) / intrinsicW)));
56
+ const { data, info } = await sharp(svg, { density })
57
+ .resize(target.width, target.height, { fit: "fill", background: { r: 0, g: 0, b: 0, alpha: 0 } })
58
+ .ensureAlpha()
59
+ .raw()
60
+ .toBuffer({ resolveWithObject: true });
61
+ return { data: new Uint8Array(data.buffer, data.byteOffset, data.byteLength), width: info.width, height: info.height };
62
+ }
63
+ /** Downscales a raw RGBA image to `px` on the longer side. */
64
+ async function half(img, px) {
65
+ const size = scaleTo(img.width, img.height, px);
66
+ const { data, info } = await sharp(Buffer.from(img.data), { raw: { width: img.width, height: img.height, channels: 4 } })
67
+ .resize(size.width, size.height, { fit: "fill" })
68
+ .raw()
69
+ .toBuffer({ resolveWithObject: true });
70
+ return { data: new Uint8Array(data.buffer, data.byteOffset, data.byteLength), width: info.width, height: info.height };
71
+ }
72
+ /** SSIMULACRA2 of the optimised SVG against the original, both rendered at RENDER_PX and compared at SCORE_PX. */
73
+ export async function fidelity(original, optimised) {
74
+ const ref = await render(original, RENDER_PX);
75
+ const test = await render(optimised, RENDER_PX, { width: ref.width, height: ref.height });
76
+ const [a, b] = await Promise.all([half(ref, SCORE_PX), half(test, SCORE_PX)]);
77
+ return score("ssimulacra2", a, b);
78
+ }
79
+ function embeddedRasters(text) {
80
+ const out = [];
81
+ for (const m of text.matchAll(EMBEDDED_RASTER)) {
82
+ const b64 = m[2] ?? "";
83
+ const padding = b64.endsWith("==") ? 2 : b64.endsWith("=") ? 1 : 0;
84
+ out.push({ format: (m[1] ?? "").toLowerCase(), bytes: Math.floor((b64.length * 3) / 4) - padding });
85
+ }
86
+ return out;
87
+ }
88
+ /** The measurement of one SVG: its optimised length and both brotli sizes, and the render's fidelity when the saving is worth a look. */
89
+ async function measureSvg(body, text) {
90
+ const optimised = optimizeSvg(text);
91
+ const before = brotliSize(body);
92
+ const after = brotliSize(Buffer.from(optimised));
93
+ const saved = before.bytes - after.bytes;
94
+ // A saving under the bar is not worth the render: the fidelity is not computed (quality 0 reads as no claim).
95
+ const quality = saved < before.bytes * MIN_SAVING_SHARE ? 0 : await fidelity(body, Buffer.from(optimised));
96
+ return { before, after, quality, optimisedBytes: optimised.length, saved };
97
+ }
98
+ async function analyzeOne(record, body) {
99
+ const url = record.url;
100
+ const name = basename(url);
101
+ const id = `svg:${shortHash(url)}`;
102
+ const findings = [];
103
+ const text = body.toString("utf8");
104
+ const rasters = embeddedRasters(text);
105
+ if (rasters.length > 0) {
106
+ const total = rasters.reduce((s, r) => s + r.bytes, 0);
107
+ findings.push({
108
+ id: `${id}:raster`,
109
+ kind: "svg",
110
+ url,
111
+ bytes: body.length,
112
+ savedBytes: 0,
113
+ estimate: false,
114
+ title: `svg ${name}: ${rasters.length} embedded raster${rasters.length === 1 ? "" : "s"}, ${kb(total)} decoded`,
115
+ fix: [
116
+ "replace each embedded raster with a separate file at its display size (AVIF or WebP, see the image hints), or redraw it as vector",
117
+ "an embedded data URI costs about a third more than the file it holds",
118
+ ],
119
+ details: { rasters: rasters.map((r) => ({ format: r.format, bytes: r.bytes })) },
120
+ });
121
+ }
122
+ // The measurement (SVGO, both brotli sizes, the render) is heavy and depends only on the content: once per run, and not at
123
+ // all once the run's resource budget is spent (then the file is not measured and claims no saving).
124
+ const measured = await heavyValue(`svg:measure:${digestOf(body)}`, () => measureSvg(body, text));
125
+ if (measured === undefined)
126
+ return findings;
127
+ const { before, after, quality, optimisedBytes } = measured;
128
+ const saved = before.bytes - after.bytes;
129
+ if (saved < before.bytes * MIN_SAVING_SHARE || !(quality >= FIDELITY_BAR))
130
+ return findings;
131
+ findings.unshift({
132
+ id,
133
+ kind: "svg",
134
+ url,
135
+ bytes: body.length,
136
+ savedBytes: saved,
137
+ estimate: false,
138
+ title: `svg ${name}: SVGO (floatPrecision 2) ${kb(body.length)} → ${kb(optimisedBytes)}, brotli ${kb(before.bytes)} → ${kb(after.bytes)}, render SSIMULACRA2 ${quality.toFixed(1)}`,
139
+ fix: [
140
+ `npx svgo --precision=2 <file>.svg -o <file>.min.svg (default preset; check the render at display size)`,
141
+ `expected brotli ${kb(after.bytes)} (now ${kb(before.bytes)}); serve the optimised file, keep the original under version control`,
142
+ ],
143
+ details: {
144
+ rawBefore: body.length,
145
+ rawAfter: optimisedBytes,
146
+ brotliBefore: before.bytes,
147
+ brotliAfter: after.bytes,
148
+ ssimulacra2: quality,
149
+ ...(before.estimate || after.estimate ? { brotliEstimate: true } : {}),
150
+ },
151
+ });
152
+ return findings;
153
+ }
154
+ export const analyzer = {
155
+ kind: "svg",
156
+ wants(r) {
157
+ // The size cap is applied on the body in analyze(): a chunked response has no length before it is read.
158
+ if (r.bytes < MIN_BYTES)
159
+ return false;
160
+ return /image\/svg\+xml/.test(r.contentType) || (/\.svg(?:[?#]|$)/i.test(r.url) && /xml|octet-stream/.test(r.contentType));
161
+ },
162
+ async analyze(records, _ctx) {
163
+ const out = [];
164
+ const seen = new Set();
165
+ for (const r of records) {
166
+ if (r.body === undefined || seen.has(r.url) || r.body.length > MAX_BYTES || r.body.length < MIN_BYTES)
167
+ continue;
168
+ seen.add(r.url);
169
+ try {
170
+ out.push(...(await analyzeOne(r, r.body)));
171
+ }
172
+ catch {
173
+ // Never throws: an SVG that svgo or sharp cannot handle yields no findings.
174
+ }
175
+ }
176
+ return out;
177
+ },
178
+ };
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,67 @@
1
+ import { inflateRawSync } from "node:zlib";
2
+ /** Largest entry the reader will inflate (a lottie animation or a raster). */
3
+ export const ZIP_MAX_ENTRY_BYTES = 50 * 1024 * 1024;
4
+ /**
5
+ * Reads the central directory of a zip archive (no zip64). Never throws: returns [] for anything it does not understand.
6
+ */
7
+ export function readZip(buf) {
8
+ try {
9
+ const eocd = findEocd(buf);
10
+ if (eocd < 0)
11
+ return [];
12
+ const count = buf.readUInt16LE(eocd + 10);
13
+ let p = buf.readUInt32LE(eocd + 16);
14
+ const entries = [];
15
+ for (let i = 0; i < count; i++) {
16
+ if (p + 46 > buf.length || buf.readUInt32LE(p) !== 0x02014b50)
17
+ break;
18
+ const method = buf.readUInt16LE(p + 10);
19
+ const compressedSize = buf.readUInt32LE(p + 20);
20
+ const size = buf.readUInt32LE(p + 24);
21
+ const nameLen = buf.readUInt16LE(p + 28);
22
+ const extraLen = buf.readUInt16LE(p + 30);
23
+ const commentLen = buf.readUInt16LE(p + 32);
24
+ const local = buf.readUInt32LE(p + 42);
25
+ const name = buf.toString("utf8", p + 46, p + 46 + nameLen);
26
+ entries.push({
27
+ name,
28
+ method,
29
+ compressedSize,
30
+ size,
31
+ data: () => readEntry(buf, local, method, compressedSize, size),
32
+ });
33
+ p += 46 + nameLen + extraLen + commentLen;
34
+ }
35
+ return entries;
36
+ }
37
+ catch {
38
+ return [];
39
+ }
40
+ }
41
+ function findEocd(buf) {
42
+ const min = Math.max(0, buf.length - 22 - 0xffff);
43
+ for (let i = buf.length - 22; i >= min; i--) {
44
+ if (buf.readUInt32LE(i) === 0x06054b50)
45
+ return i;
46
+ }
47
+ return -1;
48
+ }
49
+ function readEntry(buf, local, method, compressedSize, size) {
50
+ if (size > ZIP_MAX_ENTRY_BYTES || local + 30 > buf.length || buf.readUInt32LE(local) !== 0x04034b50)
51
+ return undefined;
52
+ const start = local + 30 + buf.readUInt16LE(local + 26) + buf.readUInt16LE(local + 28);
53
+ const raw = buf.subarray(start, start + compressedSize);
54
+ if (raw.length !== compressedSize)
55
+ return undefined;
56
+ if (method === 0)
57
+ return raw;
58
+ if (method === 8) {
59
+ try {
60
+ return inflateRawSync(raw, { maxOutputLength: ZIP_MAX_ENTRY_BYTES });
61
+ }
62
+ catch {
63
+ return undefined;
64
+ }
65
+ }
66
+ return undefined;
67
+ }