@pptx-studio/cli 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.
@@ -0,0 +1,1570 @@
1
+ import { mkdirSync, mkdtempSync, readFileSync, readdirSync, rmSync, statSync, writeFileSync } from "node:fs";
2
+ import { parseArgs } from "node:util";
3
+ import { PartStore, isOpcError, readZip } from "@pptx-studio/opc";
4
+ import { formatReport, isValidateError, validatePackage } from "@pptx-studio/validate";
5
+ import { spawnSync } from "node:child_process";
6
+ import { homedir, tmpdir } from "node:os";
7
+ import { dirname, join } from "node:path";
8
+ import { fileURLToPath } from "node:url";
9
+ import { firstDifference } from "@pptx-studio/xml";
10
+ import { bisectPackages, describeChange, flattenChanges, roundTripPackage, summarizeBisect, summarizeRoundTrip } from "@pptx-studio/writer";
11
+ import { censusPackage, formatCensus } from "@pptx-studio/census";
12
+ import { loadDocument } from "@pptx-studio/model";
13
+ import { renderSlide } from "@pptx-studio/render-svg";
14
+ import { LAST_RESORT_FAMILIES, kerningEnabled, substituteFor } from "@pptx-studio/text";
15
+ //#region src/bisect.ts
16
+ const BISECT_DEFAULTS = {
17
+ oracle: "validate",
18
+ command: null,
19
+ maxRuns: 2e3,
20
+ timeout: 12e4,
21
+ write: null,
22
+ json: false,
23
+ quiet: false,
24
+ out: null,
25
+ progress: false
26
+ };
27
+ /** Where `powerpoint-oracle.ps1` lives, from `src/` and from `dist/` alike. */
28
+ function oracleScriptPath() {
29
+ return fileURLToPath(new URL("../scripts/powerpoint-oracle.ps1", import.meta.url));
30
+ }
31
+ /**
32
+ * The twenty-nine rules, asked of one candidate.
33
+ *
34
+ * No baseline is supplied and none could be: a candidate is a package that
35
+ * never existed until this run, and it has no history. That makes every fatal
36
+ * finding count as ours, which is the safe direction here for the same reason
37
+ * it is in `validate` - the question is "is this package broken", not "who
38
+ * broke it".
39
+ */
40
+ function validateOracle() {
41
+ return (bytes) => {
42
+ try {
43
+ return validatePackage({ bytes }).ok ? "passes" : "fails";
44
+ } catch {
45
+ return "unresolved";
46
+ }
47
+ };
48
+ }
49
+ /** Write the candidate somewhere, run something over it, read the exit code. */
50
+ function spawningOracle(run, options) {
51
+ const path = join(options.directory, "candidate.pptx");
52
+ return (bytes) => {
53
+ writeFileSync(path, bytes);
54
+ const result = run(path);
55
+ if (result.signal !== null || result.status === null) {
56
+ options.onSpawn?.("unresolved", result.signal ?? "no exit status");
57
+ return "unresolved";
58
+ }
59
+ const verdict = result.status === 0 ? "passes" : result.status === 1 ? "fails" : "unresolved";
60
+ options.onSpawn?.(verdict, "exit " + String(result.status));
61
+ return verdict;
62
+ };
63
+ }
64
+ function powerPointOracle(options) {
65
+ const script = oracleScriptPath();
66
+ return spawningOracle((path) => {
67
+ const result = spawnSync("powershell.exe", [
68
+ "-NoProfile",
69
+ "-ExecutionPolicy",
70
+ "Bypass",
71
+ "-File",
72
+ script,
73
+ "-File",
74
+ path
75
+ ], {
76
+ timeout: options.timeout,
77
+ encoding: "utf8",
78
+ windowsHide: true
79
+ });
80
+ return {
81
+ status: result.status,
82
+ signal: result.signal,
83
+ stderr: result.stderr ?? ""
84
+ };
85
+ }, options);
86
+ }
87
+ function commandOracle(command, options) {
88
+ return spawningOracle((path) => {
89
+ const filled = command.includes("{}") ? command.replaceAll("{}", path) : command + " " + path;
90
+ const result = spawnSync(filled, {
91
+ shell: true,
92
+ timeout: options.timeout,
93
+ encoding: "utf8",
94
+ windowsHide: true
95
+ });
96
+ return {
97
+ status: result.status,
98
+ signal: result.signal,
99
+ stderr: result.stderr ?? ""
100
+ };
101
+ }, options);
102
+ }
103
+ /** Shut down a PowerPoint this run started, so a bisection leaves nothing behind. */
104
+ function quitPowerPoint(timeout) {
105
+ spawnSync("powershell.exe", [
106
+ "-NoProfile",
107
+ "-ExecutionPolicy",
108
+ "Bypass",
109
+ "-File",
110
+ oracleScriptPath(),
111
+ "-QuitOnly"
112
+ ], {
113
+ timeout,
114
+ encoding: "utf8",
115
+ windowsHide: true
116
+ });
117
+ }
118
+ /**
119
+ * A change's two sides, short enough to read and showing where they part.
120
+ *
121
+ * Truncating both from the front is the obvious thing and it is useless: two
122
+ * versions of a 4 kB element that differ at character 3 000 come out as the
123
+ * same sixty-eight characters twice, which reads as a bug in the bisector. So
124
+ * the window is centred on the first character where they actually differ.
125
+ */
126
+ function sides(change, width = 66) {
127
+ const flatten = (text) => text.replaceAll("\r", "").replaceAll("\n", "⏎");
128
+ const was = change.was === null ? null : flatten(change.was);
129
+ const text = change.text === null ? null : flatten(change.text);
130
+ let from = 0;
131
+ if (was !== null && text !== null && (was.length > width || text.length > width)) {
132
+ const at = firstDifference(was, text);
133
+ if (at > width / 2) from = Math.floor(at - width / 2);
134
+ }
135
+ const show = (value, mark) => {
136
+ if (value === null) return " " + mark + " (absent)";
137
+ if (value === "") return " " + mark + " (nothing)";
138
+ const head = from > 0 ? "…" : "";
139
+ const body = value.slice(from, from + width);
140
+ const tail = from + width < value.length ? "…" : "";
141
+ return " " + mark + " " + head + body + tail;
142
+ };
143
+ return [show(was, "-"), show(text, "+")];
144
+ }
145
+ /**
146
+ * Render a result.
147
+ *
148
+ * Exported for the same reason `formatRoundTrip` is: the branches worth reading
149
+ * are the ones a green corpus cannot produce, and they need a test of their own.
150
+ */
151
+ function formatBisect(stats, result, quiet) {
152
+ const lines = [];
153
+ if (!quiet) lines.push("bisect " + stats.original, " vs. " + stats.broken, "", " original " + String(stats.bytesOriginal) + " bytes, " + String(stats.entriesOriginal) + " entries", " broken " + String(stats.bytesBroken) + " bytes, " + String(stats.entriesBroken) + " entries", " delta " + String(flattenChanges(result.changes).length) + " change(s) in " + String(result.changes.length) + " entry(s)", " oracle " + stats.oracle + ", " + String(result.runs) + " run(s)" + (result.cached > 0 ? ", " + String(result.cached) + " from cache" : "") + (result.unresolved > 0 ? ", " + String(result.unresolved) + " unresolved" : ""), "");
154
+ switch (result.outcome) {
155
+ case "identical":
156
+ lines.push(" the two packages hold the same entries, byte for byte");
157
+ break;
158
+ case "broken-passes":
159
+ lines.push(" the second package passes the oracle, so there is nothing to look for", " (" + String(flattenChanges(result.changes).length) + " change(s) between them, none fatal)");
160
+ break;
161
+ case "original-fails":
162
+ lines.push(" the *original* fails the oracle, so the cause is not in the difference", " nothing was narrowed down; look at the original package first");
163
+ break;
164
+ case "localized":
165
+ lines.push(" " + summarizeBisect(result), "");
166
+ for (const change of result.minimal) lines.push(" " + describeChange(change), ...sides(change), "");
167
+ }
168
+ if (result.exhausted) lines.push("", " stopped at the ceiling of " + String(result.runs) + " oracle run(s).", " the answer above still fails, but it is not minimal - raise --max-runs.");
169
+ if (result.truncated) lines.push("", " the delta was too large to decompose completely, so some changes are", " whole entries rather than elements - raise --max-changes.");
170
+ return lines.join("\n") + "\n";
171
+ }
172
+ function bisectFiles(originalPath, brokenPath, options = BISECT_DEFAULTS, note = () => {}) {
173
+ const original = new Uint8Array(readFileSync(originalPath));
174
+ const broken = brokenPath === null ? roundTripPackage(original).exported.bytes : new Uint8Array(readFileSync(brokenPath));
175
+ const directory = mkdtempSync(join(tmpdir(), "pptx-bisect-"));
176
+ try {
177
+ const spawnOptions = {
178
+ timeout: options.timeout,
179
+ directory
180
+ };
181
+ const oracle = options.oracle === "validate" ? validateOracle() : options.oracle === "powerpoint" ? powerPointOracle(spawnOptions) : commandOracle(options.command ?? "", spawnOptions);
182
+ const result = bisectPackages(original, broken, {
183
+ oracle,
184
+ maxRuns: options.maxRuns,
185
+ ...options.progress ? { onRun: (run) => {
186
+ note(" run " + String(run.run).padStart(4) + " " + run.label + "\n");
187
+ } } : {}
188
+ });
189
+ const stats = {
190
+ original: originalPath,
191
+ broken: brokenPath ?? "(our own export of it)",
192
+ bytesOriginal: original.length,
193
+ bytesBroken: broken.length,
194
+ entriesOriginal: readZip(original).entries.length,
195
+ entriesBroken: readZip(broken).entries.length,
196
+ oracle: options.oracle
197
+ };
198
+ const output = options.json ? JSON.stringify({
199
+ original: originalPath,
200
+ broken: stats.broken,
201
+ oracle: options.oracle,
202
+ outcome: result.outcome,
203
+ runs: result.runs,
204
+ cached: result.cached,
205
+ unresolved: result.unresolved,
206
+ exhausted: result.exhausted,
207
+ truncated: result.truncated,
208
+ delta: flattenChanges(result.changes).length,
209
+ minimal: result.minimal.map((change) => ({
210
+ entry: change.entry,
211
+ kind: change.kind,
212
+ where: change.where,
213
+ was: change.was,
214
+ text: change.text
215
+ }))
216
+ }, null, 2) + "\n" : formatBisect(stats, result, options.quiet);
217
+ if (options.write !== null) writeFileSync(options.write, result.bytes);
218
+ return {
219
+ result,
220
+ output,
221
+ exitCode: result.outcome === "localized" || result.outcome === "original-fails" ? 1 : 0
222
+ };
223
+ } finally {
224
+ rmSync(directory, {
225
+ recursive: true,
226
+ force: true
227
+ });
228
+ if (options.oracle === "powerpoint") quitPowerPoint(options.timeout);
229
+ }
230
+ }
231
+ function runBisect(originalPath, brokenPath, options, write) {
232
+ const result = bisectFiles(originalPath, brokenPath, options, write);
233
+ if (options.out === null) write(result.output);
234
+ else writeFileSync(options.out, result.output);
235
+ return result.exitCode;
236
+ }
237
+ //#endregion
238
+ //#region src/inspect.ts
239
+ const INSPECT_DEFAULTS = {
240
+ json: false,
241
+ parts: false,
242
+ namespaces: false,
243
+ top: 15,
244
+ out: null
245
+ };
246
+ /** Run a census over one file and render it. Does no I/O of its own beyond the read. */
247
+ function inspectFile(path, options = INSPECT_DEFAULTS) {
248
+ const bytes = new Uint8Array(readFileSync(path));
249
+ const census = censusPackage(bytes);
250
+ return {
251
+ census,
252
+ output: options.json ? JSON.stringify(census, null, 2) + "\n" : formatCensus(census, {
253
+ parts: options.parts,
254
+ namespaces: options.namespaces,
255
+ top: options.top
256
+ }),
257
+ exitCode: census.problems.filter((problem) => problem.severity === "error").length > 0 ? 1 : 0
258
+ };
259
+ }
260
+ /** Run `inspectFile` and put the result where the options say. */
261
+ function runInspect(path, options, write) {
262
+ const result = inspectFile(path, options);
263
+ if (options.out === null) write(result.output);
264
+ else writeFileSync(options.out, result.output);
265
+ return result.exitCode;
266
+ }
267
+ //#endregion
268
+ //#region src/render/errors.ts
269
+ const RENDER_ERROR_CODES = [
270
+ "CLI_FONT_UNREADABLE",
271
+ "CLI_FONT_DIR",
272
+ "CLI_NO_FACE",
273
+ "CLI_FONT_SIZE",
274
+ "CLI_NO_SLIDE",
275
+ "CLI_OUTPUT_PATH"
276
+ ];
277
+ var RenderError = class extends Error {
278
+ name = "RenderError";
279
+ code;
280
+ /** The font file, deck path or typeface the failure is about. */
281
+ subject;
282
+ constructor(code, message, subject) {
283
+ super(message);
284
+ this.code = code;
285
+ this.subject = subject;
286
+ }
287
+ };
288
+ function isRenderError(error) {
289
+ return error instanceof RenderError;
290
+ }
291
+ //#endregion
292
+ //#region src/render/sfnt.ts
293
+ /**
294
+ * The four tables a measurement needs, read out of a font file.
295
+ *
296
+ * Not a font library: nothing here decodes an outline, and a CFF font is read
297
+ * as happily as a glyf one because advances live in `hmtx` either way. What it
298
+ * answers is the question the browser answers with `measureText`, which is the
299
+ * one thing `render-svg` cannot get in Node. Every reading below was measured
300
+ * against Chromium in experiment T13 rather than taken from the specification.
301
+ * ADR 0042.
302
+ */
303
+ const OTTO = 1330926671;
304
+ const TRUE_TYPE = 65536;
305
+ const TTCF = 1953784678;
306
+ /** `OS/2.fsSelection` bit 7: the typographic metrics are the ones to use. */
307
+ const USE_TYPO_METRICS = 128;
308
+ function readerOf(bytes) {
309
+ return {
310
+ bytes,
311
+ view: new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength)
312
+ };
313
+ }
314
+ function u8(r, at) {
315
+ return r.view.getUint8(at);
316
+ }
317
+ function u16(r, at) {
318
+ return r.view.getUint16(at);
319
+ }
320
+ function i16(r, at) {
321
+ return r.view.getInt16(at);
322
+ }
323
+ function u32(r, at) {
324
+ return r.view.getUint32(at);
325
+ }
326
+ function unreadable(what, subject) {
327
+ throw new RenderError("CLI_FONT_UNREADABLE", what, subject);
328
+ }
329
+ function directoryAt(bytes, start, subject) {
330
+ const r = readerOf(bytes);
331
+ if (start + 12 > bytes.length) unreadable("font directory runs past the file", subject);
332
+ const count = u16(r, start + 4);
333
+ const out = /* @__PURE__ */ new Map();
334
+ for (let i = 0; i < count; i++) {
335
+ const at = start + 12 + i * 16;
336
+ if (at + 16 > bytes.length) unreadable("table directory runs past the file", subject);
337
+ const tag = String.fromCharCode(u8(r, at), u8(r, at + 1), u8(r, at + 2), u8(r, at + 3));
338
+ const offset = u32(r, at + 8);
339
+ const length = u32(r, at + 12);
340
+ if (offset < bytes.length) out.set(tag, bytes.subarray(offset, Math.min(offset + length, bytes.length)));
341
+ }
342
+ return out;
343
+ }
344
+ /** Every font in the file: one for a plain font, several for a collection. */
345
+ function fontsIn(bytes, subject) {
346
+ if (bytes.length < 12) unreadable("too short to be a font", subject);
347
+ const r = readerOf(bytes);
348
+ const version = u32(r, 0);
349
+ if (version === TTCF) {
350
+ const count = u32(r, 8);
351
+ const out = [];
352
+ for (let i = 0; i < count; i++) out.push(directoryAt(bytes, u32(r, 12 + i * 4), subject));
353
+ return out;
354
+ }
355
+ if (version !== TRUE_TYPE && version !== OTTO) unreadable(`0x${version.toString(16).padStart(8, "0")} is not an SFNT signature`, subject);
356
+ return [directoryAt(bytes, 0, subject)];
357
+ }
358
+ function required(tables, tag, subject) {
359
+ const table = tables.get(tag);
360
+ if (table === void 0) unreadable(`no ${tag} table`, subject);
361
+ return table;
362
+ }
363
+ /**
364
+ * The names a face is known by, decoded from the Windows platform records.
365
+ *
366
+ * Platform 3 encoding 1 is UTF-16BE and is what every font shipped for Windows
367
+ * carries; platform 1 is Mac Roman and is read only where a face has no
368
+ * Windows record at all, which is rare enough to be worth the four lines.
369
+ */
370
+ function namesOf(tables, subject) {
371
+ const table = required(tables, "name", subject);
372
+ const r = readerOf(table);
373
+ if (table.length < 6) unreadable("name table is truncated", subject);
374
+ const count = u16(r, 2);
375
+ const storage = u16(r, 4);
376
+ const out = /* @__PURE__ */ new Map();
377
+ const seenWindows = /* @__PURE__ */ new Set();
378
+ for (let i = 0; i < count; i++) {
379
+ const at = 6 + i * 12;
380
+ if (at + 12 > table.length) break;
381
+ const platform = u16(r, at);
382
+ const encoding = u16(r, at + 2);
383
+ const nameId = u16(r, at + 6);
384
+ const length = u16(r, at + 8);
385
+ const offset = storage + u16(r, at + 10);
386
+ if (offset + length > table.length) continue;
387
+ const raw = table.subarray(offset, offset + length);
388
+ if (platform === 3 && (encoding === 1 || encoding === 0)) {
389
+ let text = "";
390
+ for (let j = 0; j + 1 < raw.length; j += 2) text += String.fromCharCode((raw[j] ?? 0) << 8 | (raw[j + 1] ?? 0));
391
+ out.set(nameId, text);
392
+ seenWindows.add(nameId);
393
+ } else if (platform === 1 && encoding === 0 && !seenWindows.has(nameId)) out.set(nameId, String.fromCharCode(...raw));
394
+ }
395
+ return out;
396
+ }
397
+ /**
398
+ * The best Unicode subtable in the font.
399
+ *
400
+ * Preference order is the one every shaper uses: a format 12 full-repertoire
401
+ * table beats a format 4 BMP one, because a face that has both maps astral
402
+ * code points only in the first.
403
+ */
404
+ function cmapOf(tables, subject) {
405
+ const table = required(tables, "cmap", subject);
406
+ const r = readerOf(table);
407
+ if (table.length < 4) unreadable("cmap table is truncated", subject);
408
+ const count = u16(r, 2);
409
+ let best;
410
+ for (let i = 0; i < count; i++) {
411
+ const at = 4 + i * 8;
412
+ if (at + 8 > table.length) break;
413
+ const platform = u16(r, at);
414
+ const encoding = u16(r, at + 2);
415
+ const offset = u32(r, at + 4);
416
+ if (offset + 2 > table.length) continue;
417
+ if (!(platform === 3 && (encoding === 10 || encoding === 1 || encoding === 0) || platform === 0)) continue;
418
+ const format = u16(readerOf(table.subarray(offset)), 0);
419
+ const score = format === 12 ? 3 : format === 4 ? 2 : format === 6 || format === 0 ? 1 : 0;
420
+ if (score > 0 && (best === void 0 || score > best.score)) best = {
421
+ offset,
422
+ score
423
+ };
424
+ }
425
+ if (best === void 0) unreadable("no Unicode cmap subtable", subject);
426
+ const sub = table.subarray(best.offset);
427
+ const s = readerOf(sub);
428
+ const format = u16(s, 0);
429
+ if (format === 4) {
430
+ const segCount = u16(s, 6) / 2;
431
+ const ends = 14;
432
+ const starts = ends + segCount * 2 + 2;
433
+ const deltas = starts + segCount * 2;
434
+ const ranges = deltas + segCount * 2;
435
+ return (codePoint) => {
436
+ if (codePoint > 65535) return 0;
437
+ for (let seg = 0; seg < segCount; seg++) {
438
+ if (u16(s, ends + seg * 2) < codePoint) continue;
439
+ if (u16(s, starts + seg * 2) > codePoint) return 0;
440
+ const rangeOffset = u16(s, ranges + seg * 2);
441
+ if (rangeOffset === 0) return codePoint + i16(s, deltas + seg * 2) & 65535;
442
+ const at = ranges + seg * 2 + rangeOffset + (codePoint - u16(s, starts + seg * 2)) * 2;
443
+ if (at + 2 > sub.length) return 0;
444
+ const glyph = u16(s, at);
445
+ return glyph === 0 ? 0 : glyph + i16(s, deltas + seg * 2) & 65535;
446
+ }
447
+ return 0;
448
+ };
449
+ }
450
+ if (format === 12) {
451
+ const groups = u32(s, 12);
452
+ return (codePoint) => {
453
+ let low = 0;
454
+ let high = groups - 1;
455
+ while (low <= high) {
456
+ const mid = low + high >> 1;
457
+ const at = 16 + mid * 12;
458
+ const start = u32(s, at);
459
+ const end = u32(s, at + 4);
460
+ if (codePoint < start) high = mid - 1;
461
+ else if (codePoint > end) low = mid + 1;
462
+ else return u32(s, at + 8) + (codePoint - start);
463
+ }
464
+ return 0;
465
+ };
466
+ }
467
+ if (format === 6) {
468
+ const first = u16(s, 6);
469
+ const entries = u16(s, 8);
470
+ return (codePoint) => {
471
+ const at = codePoint - first;
472
+ return at < 0 || at >= entries ? 0 : u16(s, 10 + at * 2);
473
+ };
474
+ }
475
+ return (codePoint) => codePoint > 255 ? 0 : u8(s, 6 + codePoint);
476
+ }
477
+ const NO_KERNING = () => 0;
478
+ function coverageIndex(r, at, glyph) {
479
+ const format = u16(r, at);
480
+ if (format === 1) {
481
+ const count = u16(r, at + 2);
482
+ for (let i = 0; i < count; i++) if (u16(r, at + 4 + i * 2) === glyph) return i;
483
+ return -1;
484
+ }
485
+ if (format !== 2) return -1;
486
+ const ranges = u16(r, at + 2);
487
+ for (let i = 0; i < ranges; i++) {
488
+ const record = at + 4 + i * 6;
489
+ if (glyph >= u16(r, record) && glyph <= u16(r, record + 2)) return u16(r, record + 4) + (glyph - u16(r, record));
490
+ }
491
+ return -1;
492
+ }
493
+ function classOf(r, at, glyph) {
494
+ const format = u16(r, at);
495
+ if (format === 1) {
496
+ const start = u16(r, at + 2);
497
+ const count = u16(r, at + 4);
498
+ const index = glyph - start;
499
+ return index < 0 || index >= count ? 0 : u16(r, at + 6 + index * 2);
500
+ }
501
+ if (format !== 2) return 0;
502
+ const ranges = u16(r, at + 2);
503
+ for (let i = 0; i < ranges; i++) {
504
+ const record = at + 4 + i * 6;
505
+ if (glyph >= u16(r, record) && glyph <= u16(r, record + 2)) return u16(r, record + 4);
506
+ }
507
+ return 0;
508
+ }
509
+ /** The byte width of a GPOS value record, which is one 16-bit field per set bit. */
510
+ function valueSize(format) {
511
+ let bits = 0;
512
+ for (let i = 0; i < 16; i++) if ((format & 1 << i) !== 0) bits += 1;
513
+ return bits * 2;
514
+ }
515
+ /** `XAdvance` is bit 2, and it is the only field a horizontal advance reads. */
516
+ const X_ADVANCE = 4;
517
+ function pairPosLookup(r, at) {
518
+ const format = u16(r, at);
519
+ const valueFormat1 = u16(r, at + 4);
520
+ const valueFormat2 = u16(r, at + 6);
521
+ if ((valueFormat1 & X_ADVANCE) === 0) return void 0;
522
+ const size1 = valueSize(valueFormat1);
523
+ const size2 = valueSize(valueFormat2);
524
+ const coverage = at + u16(r, at + 2);
525
+ if (format === 1) {
526
+ const setCount = u16(r, at + 8);
527
+ return (left, right) => {
528
+ const index = coverageIndex(r, coverage, left);
529
+ if (index < 0 || index >= setCount) return 0;
530
+ const set = at + u16(r, at + 10 + index * 2);
531
+ const pairs = u16(r, set);
532
+ for (let i = 0; i < pairs; i++) {
533
+ const record = set + 2 + i * (2 + size1 + size2);
534
+ if (u16(r, record) === right) return i16(r, record + 2);
535
+ }
536
+ return 0;
537
+ };
538
+ }
539
+ if (format !== 2) return void 0;
540
+ const classDef1 = at + u16(r, at + 8);
541
+ const classDef2 = at + u16(r, at + 10);
542
+ const class1Count = u16(r, at + 12);
543
+ const class2Count = u16(r, at + 14);
544
+ return (left, right) => {
545
+ if (coverageIndex(r, coverage, left) < 0) return 0;
546
+ const c1 = classOf(r, classDef1, left);
547
+ const c2 = classOf(r, classDef2, right);
548
+ if (c1 >= class1Count || c2 >= class2Count) return 0;
549
+ return i16(r, at + 16 + (c1 * class2Count + c2) * (size1 + size2));
550
+ };
551
+ }
552
+ /**
553
+ * The `kern` feature's pair adjustments, if the font has GPOS.
554
+ *
555
+ * Only the `kern` feature: a font's GPOS also carries mark attachment and
556
+ * cursive positioning, and neither changes an advance.
557
+ */
558
+ function gposKerning(tables) {
559
+ const table = tables.get("GPOS");
560
+ if (table === void 0 || table.length < 10) return void 0;
561
+ const r = readerOf(table);
562
+ const featureList = u16(r, 6);
563
+ const lookupList = u16(r, 8);
564
+ if (featureList >= table.length || lookupList >= table.length) return void 0;
565
+ const wanted = /* @__PURE__ */ new Set();
566
+ const featureCount = u16(r, featureList);
567
+ for (let i = 0; i < featureCount; i++) {
568
+ const record = featureList + 2 + i * 6;
569
+ if (String.fromCharCode(u8(r, record), u8(r, record + 1), u8(r, record + 2), u8(r, record + 3)) !== "kern") continue;
570
+ const feature = featureList + u16(r, record + 4);
571
+ const lookups = u16(r, feature + 2);
572
+ for (let j = 0; j < lookups; j++) wanted.add(u16(r, feature + 4 + j * 2));
573
+ }
574
+ if (wanted.size === 0) return void 0;
575
+ const found = [];
576
+ const lookupCount = u16(r, lookupList);
577
+ for (const index of wanted) {
578
+ if (index >= lookupCount) continue;
579
+ const lookup = lookupList + u16(r, lookupList + 2 + index * 2);
580
+ if (u16(r, lookup) !== 2) continue;
581
+ const subtables = u16(r, lookup + 4);
582
+ for (let i = 0; i < subtables; i++) {
583
+ const pairs = pairPosLookup(r, lookup + u16(r, lookup + 6 + i * 2));
584
+ if (pairs !== void 0) found.push(pairs);
585
+ }
586
+ }
587
+ if (found.length === 0) return void 0;
588
+ return (left, right) => {
589
+ for (const lookup of found) {
590
+ const adjust = lookup(left, right);
591
+ if (adjust !== 0) return adjust;
592
+ }
593
+ return 0;
594
+ };
595
+ }
596
+ /** The pre-OpenType `kern` table, format 0, horizontal subtables only. */
597
+ function legacyKerning(tables) {
598
+ const table = tables.get("kern");
599
+ if (table === void 0 || table.length < 4) return void 0;
600
+ const r = readerOf(table);
601
+ const subtables = u16(r, 2);
602
+ const pairs = /* @__PURE__ */ new Map();
603
+ let at = 4;
604
+ for (let i = 0; i < subtables && at + 14 <= table.length; i++) {
605
+ const length = u16(r, at + 2);
606
+ const coverage = u16(r, at + 4);
607
+ const horizontal = (coverage & 1) !== 0;
608
+ const format = coverage >> 8 & 255;
609
+ if (horizontal && format === 0) {
610
+ const count = u16(r, at + 6);
611
+ for (let j = 0; j < count; j++) {
612
+ const record = at + 14 + j * 6;
613
+ if (record + 6 > table.length) break;
614
+ pairs.set(u16(r, record) << 16 | u16(r, record + 2), i16(r, record + 4));
615
+ }
616
+ }
617
+ at += length === 0 ? 14 : length;
618
+ }
619
+ if (pairs.size === 0) return void 0;
620
+ return (left, right) => pairs.get(left << 16 | right) ?? 0;
621
+ }
622
+ /**
623
+ * The vertical metrics a browser reports for the face.
624
+ *
625
+ * T13 built one font whose `hhea`, `usWin` and `sTypo` pairs are all different
626
+ * and asked Chromium for `fontBoundingBoxAscent`: it answered `usWin`, and
627
+ * answered `sTypo` from the same font with `fsSelection` bit 7 set. So `hhea`
628
+ * is never the answer, and a reader that ignores bit 7 is wrong on every font
629
+ * that sets it, which is most of the ones shipped since 2015.
630
+ */
631
+ function metricsOf(tables, subject) {
632
+ const unitsPerEm = u16(readerOf(required(tables, "head", subject)), 18);
633
+ if (unitsPerEm <= 0) unreadable("head.unitsPerEm is zero", subject);
634
+ const os2 = tables.get("OS/2");
635
+ if (os2 === void 0 || os2.length < 78) {
636
+ const hhea = readerOf(required(tables, "hhea", subject));
637
+ return {
638
+ unitsPerEm,
639
+ ascent: i16(hhea, 4),
640
+ descent: -i16(hhea, 6),
641
+ source: "usWin"
642
+ };
643
+ }
644
+ const r = readerOf(os2);
645
+ if ((u16(r, 62) & USE_TYPO_METRICS) !== 0) return {
646
+ unitsPerEm,
647
+ ascent: i16(r, 68),
648
+ descent: -i16(r, 70),
649
+ source: "sTypo"
650
+ };
651
+ return {
652
+ unitsPerEm,
653
+ ascent: u16(r, 74),
654
+ descent: u16(r, 76),
655
+ source: "usWin"
656
+ };
657
+ }
658
+ function advancesOf(tables, subject) {
659
+ const metrics = u16(readerOf(required(tables, "hhea", subject)), 34);
660
+ const hmtx = required(tables, "hmtx", subject);
661
+ const r = readerOf(hmtx);
662
+ if (metrics === 0) unreadable("hhea.numberOfHMetrics is zero", subject);
663
+ return (glyph) => {
664
+ const at = Math.min(glyph, metrics - 1) * 4;
665
+ return at + 2 <= hmtx.length ? u16(r, at) : 0;
666
+ };
667
+ }
668
+ const BOLD_STYLE = /bold|black|heavy|semibold|extrabold|demibold/i;
669
+ const ITALIC_STYLE = /italic|oblique/i;
670
+ /** Read one font out of an already-located table directory. */
671
+ function faceOf(tables, subject) {
672
+ const names = namesOf(tables, subject);
673
+ const family = names.get(1);
674
+ if (family === void 0 || family === "") unreadable("no family name", subject);
675
+ const subfamily = names.get(2) ?? "Regular";
676
+ const cmap = cmapOf(tables, subject);
677
+ const advance = advancesOf(tables, subject);
678
+ const kerning = gposKerning(tables) ?? legacyKerning(tables) ?? NO_KERNING;
679
+ const macStyle = u16(readerOf(required(tables, "head", subject)), 44);
680
+ return {
681
+ family,
682
+ subfamily,
683
+ typographicFamily: names.get(16),
684
+ metrics: metricsOf(tables, subject),
685
+ bold: BOLD_STYLE.test(subfamily) || (macStyle & 1) !== 0,
686
+ italic: ITALIC_STYLE.test(subfamily) || (macStyle & 2) !== 0,
687
+ advanceOf(codePoint) {
688
+ const glyph = cmap(codePoint);
689
+ return glyph === 0 ? void 0 : advance(glyph);
690
+ },
691
+ kernBetween(left, right) {
692
+ const a = cmap(left);
693
+ const b = cmap(right);
694
+ return a === 0 || b === 0 ? 0 : kerning(a, b);
695
+ }
696
+ };
697
+ }
698
+ /** Every face in a file, which is one unless the file is a collection. */
699
+ function facesIn(bytes, subject) {
700
+ return fontsIn(bytes, subject).map((tables) => faceOf(tables, subject));
701
+ }
702
+ //#endregion
703
+ //#region src/render/faces.ts
704
+ /**
705
+ * Which file on this machine is the typeface the deck asked for.
706
+ *
707
+ * The browser answers this with a font stack and never tells us what it chose;
708
+ * here the choice is ours, so it is also reportable - `renderDeck` can say
709
+ * "Aptos was drawn in Carlito" because this module knows it was. The
710
+ * substitution table itself is `@pptx-studio/text`'s, so the two renderers
711
+ * cannot fall back differently. ADR 0042.
712
+ */
713
+ const FONT_FILE = /\.(ttf|ttc|otf|otc)$/i;
714
+ /** Deep enough for `/usr/share/fonts/truetype/dejavu`, and no deeper. */
715
+ const MAX_DEPTH = 4;
716
+ /** Case and whitespace are not part of a typeface's identity for a lookup. */
717
+ function key(family) {
718
+ return family.trim().replace(/\s+/g, " ").toLowerCase();
719
+ }
720
+ /** Where fonts live on this platform, whether or not the directories exist. */
721
+ function systemFontDirectories(platform = process.platform) {
722
+ const home = homedir();
723
+ if (platform === "win32") {
724
+ const root = process.env["SystemRoot"] ?? "C:\\Windows";
725
+ const local = process.env["LOCALAPPDATA"];
726
+ return [join(root, "Fonts"), ...local === void 0 ? [] : [join(local, "Microsoft", "Windows", "Fonts")]];
727
+ }
728
+ if (platform === "darwin") return [
729
+ "/System/Library/Fonts",
730
+ "/Library/Fonts",
731
+ join(home, "Library", "Fonts")
732
+ ];
733
+ return [
734
+ "/usr/share/fonts",
735
+ "/usr/local/share/fonts",
736
+ join(home, ".local", "share", "fonts"),
737
+ join(home, ".fonts")
738
+ ];
739
+ }
740
+ function filesUnder(directory, depth, out) {
741
+ if (depth > MAX_DEPTH) return;
742
+ let entries;
743
+ try {
744
+ entries = readdirSync(directory, { withFileTypes: true });
745
+ } catch {
746
+ return;
747
+ }
748
+ for (const entry of entries) {
749
+ const path = join(directory, entry.name);
750
+ if (entry.isDirectory()) filesUnder(path, depth + 1, out);
751
+ else if (FONT_FILE.test(entry.name)) out.push(path);
752
+ }
753
+ }
754
+ /**
755
+ * The style slot a face fills.
756
+ *
757
+ * Two bits rather than a weight axis, because DrawingML has no weight axis:
758
+ * `a:rPr/@b` is a boolean and "Roboto Light" is a typeface name, not a weight.
759
+ */
760
+ function slot(bold, italic) {
761
+ return (bold ? 1 : 0) | (italic ? 2 : 0);
762
+ }
763
+ /**
764
+ * Read every font file under the given directories, once.
765
+ *
766
+ * A file that will not parse is skipped rather than fatal: a font directory on
767
+ * a real machine holds `.ttf` files that are bitmap-only, damaged, or not fonts
768
+ * at all, and one of them must not stop a deck from rendering.
769
+ */
770
+ function indexFonts(options = {}) {
771
+ for (const directory of options.extra ?? []) try {
772
+ if (!statSync(directory).isDirectory()) throw new RenderError("CLI_FONT_DIR", `${directory} is not a directory`, directory);
773
+ } catch (error) {
774
+ if (error instanceof RenderError) throw error;
775
+ throw new RenderError("CLI_FONT_DIR", `no such directory: ${directory}`, directory);
776
+ }
777
+ const directories = [...options.extra ?? [], ...options.system === false ? [] : systemFontDirectories(options.platform)];
778
+ const files = [];
779
+ for (const directory of directories) filesUnder(directory, 0, files);
780
+ const indexed = [];
781
+ const byFamily = /* @__PURE__ */ new Map();
782
+ const claim = (family, entry) => {
783
+ const slots = byFamily.get(key(family)) ?? [
784
+ void 0,
785
+ void 0,
786
+ void 0,
787
+ void 0
788
+ ];
789
+ const at = slot(entry.face.bold, entry.face.italic);
790
+ slots[at] ??= entry;
791
+ byFamily.set(key(family), slots);
792
+ };
793
+ for (const file of files) {
794
+ let faces;
795
+ try {
796
+ faces = facesIn(new Uint8Array(readFileSync(file)), file);
797
+ } catch {
798
+ continue;
799
+ }
800
+ for (const face of faces) {
801
+ const entry = {
802
+ face,
803
+ file
804
+ };
805
+ indexed.push(entry);
806
+ claim(face.family, entry);
807
+ if (face.typographicFamily !== void 0) claim(face.typographicFamily, entry);
808
+ }
809
+ }
810
+ /** The nearest slot to the one asked for: exact, then drop italic, then bold. */
811
+ const pick = (family, bold, italic) => {
812
+ const slots = byFamily.get(key(family));
813
+ if (slots === void 0) return void 0;
814
+ const wanted = [
815
+ slot(bold, italic),
816
+ slot(bold, false),
817
+ slot(false, italic),
818
+ slot(false, false),
819
+ 0,
820
+ 1,
821
+ 2,
822
+ 3
823
+ ];
824
+ for (const at of wanted) {
825
+ const found = slots[at];
826
+ if (found !== void 0) return found;
827
+ }
828
+ };
829
+ return {
830
+ indexed,
831
+ directories,
832
+ resolve(family, bold, italic) {
833
+ const direct = pick(family, bold, italic);
834
+ if (direct !== void 0) return {
835
+ face: direct.face,
836
+ asked: family,
837
+ drawn: family,
838
+ file: direct.file,
839
+ substituted: false
840
+ };
841
+ const chain = [substituteFor(family)?.use, ...LAST_RESORT_FAMILIES].filter((name) => name !== void 0);
842
+ for (const name of chain) {
843
+ const found = pick(name, bold, italic);
844
+ if (found !== void 0) return {
845
+ face: found.face,
846
+ asked: family,
847
+ drawn: name,
848
+ file: found.file,
849
+ substituted: true
850
+ };
851
+ }
852
+ }
853
+ };
854
+ }
855
+ //#endregion
856
+ //#region src/render/measure.ts
857
+ /**
858
+ * `TextMeasurer` and `FaceBoxProbe` over font tables instead of a canvas.
859
+ *
860
+ * This is a second measurement engine, and `@pptx-studio/text` says in as many
861
+ * words that nothing inside it may measure by a second route. That rule holds:
862
+ * this is outside it, in the one package where Node exists, and it is here
863
+ * because the browser's engine is not available to a server. What makes it safe
864
+ * is that T13 measured Chromium's arithmetic rather than guessing it, so the two
865
+ * engines agree exactly on a face they both have. ADR 0042.
866
+ */
867
+ /**
868
+ * The fixed-point step Chromium reports an advance in.
869
+ *
870
+ * T13 measured 252 widths across seven fonts, six strings and six sizes. Summing
871
+ * exact floats fits 88 of them; quantising each glyph advance toward zero and
872
+ * each kern adjustment to nearest, both at 1/65536 px, fits all 216. The
873
+ * difference is never more than 1.6e-5 px and matters to nothing on a slide -
874
+ * it is here because a rule that reproduces the browser exactly can be tested
875
+ * exactly, and one that is merely close cannot.
876
+ */
877
+ const FIXED = 65536;
878
+ /**
879
+ * Every advance in this font is positive, so truncation and flooring are the
880
+ * same rule; T13 had no probe that could separate them and does not claim one.
881
+ */
882
+ function quantiseAdvance(px) {
883
+ return Math.trunc(px * FIXED) / FIXED;
884
+ }
885
+ function quantiseKern(px) {
886
+ return Math.round(px * FIXED) / FIXED;
887
+ }
888
+ /**
889
+ * A measurer bound to one library.
890
+ *
891
+ * Both probes cache by family, because a slide asks for the same handful of
892
+ * typefaces thousands of times and nothing can change between two asks.
893
+ */
894
+ function createFontMeasurer(library) {
895
+ const resolved = /* @__PURE__ */ new Map();
896
+ const missing = /* @__PURE__ */ new Set();
897
+ /** Which face draws a code point the asked-for face has no glyph for. */
898
+ const fallbacks = /* @__PURE__ */ new Map();
899
+ const faceFor = (family, bold, italic) => {
900
+ const cacheKey = `${family}${bold ? "b" : ""}${italic ? "i" : ""}`;
901
+ const cached = resolved.get(cacheKey);
902
+ if (cached !== void 0) return cached;
903
+ const found = library.resolve(family, bold, italic);
904
+ if (found === void 0) throw new RenderError("CLI_NO_FACE", `no face on this machine can stand in for ${JSON.stringify(family)}; ${String(library.indexed.length)} face(s) were indexed from ${library.directories.join(", ")}`, family);
905
+ resolved.set(cacheKey, found);
906
+ return found;
907
+ };
908
+ /**
909
+ * The advance of one code point, in font units over its own em.
910
+ *
911
+ * A face that lacks the glyph does not draw a blank: the browser would fall
912
+ * back per character, so the whole index is searched once per code point and
913
+ * the answer cached. A code point nothing has is recorded and contributes the
914
+ * face's own `.notdef` width, which is what is drawn.
915
+ */
916
+ const advanceOf = (face, codePoint) => {
917
+ const own = face.face.advanceOf(codePoint);
918
+ if (own !== void 0) return {
919
+ units: own,
920
+ em: face.face.metrics.unitsPerEm
921
+ };
922
+ const cacheKey = String(codePoint);
923
+ let stand = fallbacks.get(cacheKey);
924
+ if (stand === void 0) {
925
+ stand = null;
926
+ for (const entry of library.indexed) if (entry.face.advanceOf(codePoint) !== void 0) {
927
+ stand = {
928
+ ...face,
929
+ face: entry.face,
930
+ drawn: entry.face.family,
931
+ file: entry.file
932
+ };
933
+ break;
934
+ }
935
+ fallbacks.set(cacheKey, stand);
936
+ }
937
+ if (stand === null) {
938
+ missing.add(codePoint);
939
+ return {
940
+ units: 0,
941
+ em: face.face.metrics.unitsPerEm
942
+ };
943
+ }
944
+ return {
945
+ units: stand.face.advanceOf(codePoint) ?? 0,
946
+ em: stand.face.metrics.unitsPerEm
947
+ };
948
+ };
949
+ const measurer = { measure(text, font) {
950
+ if (!Number.isFinite(font.sz) || font.sz <= 0) throw new RenderError("CLI_FONT_SIZE", `font size ${String(font.sz)} is not a positive size`, font.family);
951
+ const face = faceFor(font.family, font.bold === true, font.italic === true);
952
+ const px = font.sz / 100;
953
+ const kerns = kerningEnabled(font.kern, font.sz);
954
+ const spacing = (font.spc ?? 0) / 100;
955
+ const points = [...text];
956
+ let width = 0;
957
+ for (let i = 0; i < points.length; i++) {
958
+ const codePoint = points[i]?.codePointAt(0) ?? 0;
959
+ const { units, em } = advanceOf(face, codePoint);
960
+ width += quantiseAdvance(units * px / em);
961
+ width += spacing;
962
+ if (kerns && i + 1 < points.length) {
963
+ const next = points[i + 1]?.codePointAt(0) ?? 0;
964
+ const adjust = face.face.kernBetween(codePoint, next);
965
+ if (adjust !== 0) width += quantiseKern(adjust * px / face.face.metrics.unitsPerEm);
966
+ }
967
+ }
968
+ return { width };
969
+ } };
970
+ const boxes = /* @__PURE__ */ new Map();
971
+ return {
972
+ measurer,
973
+ faceBox: { box(family) {
974
+ const cached = boxes.get(family);
975
+ if (cached !== void 0) return cached;
976
+ const { metrics } = faceFor(family, false, false).face;
977
+ const box = {
978
+ ascent: metrics.ascent / metrics.unitsPerEm,
979
+ descent: metrics.descent / metrics.unitsPerEm,
980
+ ideographic: metrics.descent / metrics.unitsPerEm
981
+ };
982
+ boxes.set(family, box);
983
+ return box;
984
+ } },
985
+ used() {
986
+ const seen = /* @__PURE__ */ new Map();
987
+ for (const face of resolved.values()) seen.set(face.asked, {
988
+ asked: face.asked,
989
+ drawn: face.drawn,
990
+ file: face.file,
991
+ substituted: face.substituted
992
+ });
993
+ return [...seen.values()].sort((a, b) => a.asked < b.asked ? -1 : 1);
994
+ },
995
+ missing() {
996
+ return [...missing].sort((a, b) => a - b);
997
+ }
998
+ };
999
+ }
1000
+ //#endregion
1001
+ //#region src/render/render.ts
1002
+ /**
1003
+ * `pptx-studio render` - slides to SVG, with no browser anywhere.
1004
+ *
1005
+ * Every part of the picture except text was already reachable from Node:
1006
+ * geometry, fills, strokes, effects and images are pure functions over the
1007
+ * model, and an image's size comes out of its own header rather than a decoder.
1008
+ * Text was the one thing that needed a canvas, and `measure.ts` is what replaces
1009
+ * it. ADR 0042.
1010
+ */
1011
+ /** What a slide is drawn at when the caller says nothing. PowerPoint's own. */
1012
+ const DEFAULT_WIDTH = 1920;
1013
+ const RENDER_DEFAULTS = {
1014
+ width: DEFAULT_WIDTH,
1015
+ systemFonts: true,
1016
+ text: true
1017
+ };
1018
+ /** The media resolver: an rId means the rels of the part the fill was written in. */
1019
+ function mediaFrom(store) {
1020
+ return (embed, part) => {
1021
+ const target = store.relationships(part).targetOf(embed);
1022
+ if (target === void 0) return void 0;
1023
+ const contentType = store.contentTypeOf(target);
1024
+ if (contentType === void 0) return void 0;
1025
+ return {
1026
+ bytes: store.read(target),
1027
+ contentType
1028
+ };
1029
+ };
1030
+ }
1031
+ /**
1032
+ * Render a deck that is already in memory.
1033
+ *
1034
+ * Separate from `runRender` so that the whole pipeline is exercised by the test
1035
+ * suite without a file system, a process or a captured stdout.
1036
+ */
1037
+ function renderDeck(bytes, options) {
1038
+ const store = PartStore.open(bytes);
1039
+ const document = loadDocument(store);
1040
+ const size = document.slideSize;
1041
+ const height = Math.round(options.width * size.cy / size.cx);
1042
+ if (options.slide !== null) {
1043
+ const count = document.slides.length;
1044
+ if (!Number.isInteger(options.slide) || options.slide < 1 || options.slide > count) throw new RenderError("CLI_NO_SLIDE", `--slide ${String(options.slide)}: the deck has ${String(count)} slide(s)`, String(options.slide));
1045
+ }
1046
+ let library = null;
1047
+ const fonts = options.text ? createFontMeasurer(library = indexFonts({
1048
+ extra: options.fontDirs,
1049
+ system: options.systemFonts
1050
+ })) : null;
1051
+ const media = mediaFrom(store);
1052
+ return {
1053
+ slides: (options.slide === null ? document.slides.map((sheet, at) => ({
1054
+ sheet,
1055
+ number: at + 1
1056
+ })) : [{
1057
+ sheet: document.slides[options.slide - 1],
1058
+ number: options.slide
1059
+ }]).map(({ sheet, number }) => ({
1060
+ number,
1061
+ svg: renderSlide(sheet, size, {
1062
+ width: options.width,
1063
+ height,
1064
+ idPrefix: `s${String(number)}`,
1065
+ media,
1066
+ text: fonts === null ? false : {
1067
+ defaultTextStyle: document.defaultTextStyle,
1068
+ measurer: fonts.measurer,
1069
+ faceBox: fonts.faceBox
1070
+ }
1071
+ })
1072
+ })),
1073
+ width: options.width,
1074
+ height,
1075
+ fonts: fonts?.used() ?? [],
1076
+ missing: fonts?.missing() ?? [],
1077
+ fontDirectories: library?.directories ?? [],
1078
+ facesIndexed: library?.indexed.length ?? 0
1079
+ };
1080
+ }
1081
+ /** `slide-03.svg`, so a directory listing sorts the way the deck reads. */
1082
+ function fileNameFor(number, count) {
1083
+ const width = Math.max(2, String(count).length);
1084
+ return `slide-${String(number).padStart(width, "0")}.svg`;
1085
+ }
1086
+ function isDirectory(path) {
1087
+ try {
1088
+ return statSync(path).isDirectory();
1089
+ } catch {
1090
+ return false;
1091
+ }
1092
+ }
1093
+ /**
1094
+ * Where each slide's markup goes.
1095
+ *
1096
+ * One slide to a path that is not an existing directory writes that file;
1097
+ * anything else writes a file per slide into a directory, created if needed.
1098
+ * A path ending in `.svg` with more than one slide is a mistake worth naming
1099
+ * rather than silently turning into a directory.
1100
+ */
1101
+ function write(result, out) {
1102
+ if (result.slides.length === 1 && !isDirectory(out)) {
1103
+ const first = result.slides[0];
1104
+ mkdirSync(dirname(out), { recursive: true });
1105
+ writeFileSync(out, first.svg, "utf8");
1106
+ return [out];
1107
+ }
1108
+ if (out.toLowerCase().endsWith(".svg")) throw new RenderError("CLI_OUTPUT_PATH", `--out ${out} names one file but ${String(result.slides.length)} slides were rendered; name a directory, or pass --slide`, out);
1109
+ mkdirSync(out, { recursive: true });
1110
+ return result.slides.map((slide) => {
1111
+ const path = join(out, fileNameFor(slide.number, result.slides.length));
1112
+ writeFileSync(path, slide.svg, "utf8");
1113
+ return path;
1114
+ });
1115
+ }
1116
+ function report(result, written, options) {
1117
+ const lines = [];
1118
+ const slides = `${String(result.slides.length)} slide(s) at ${String(result.width)}x${String(result.height)}`;
1119
+ lines.push(written.length === 0 ? slides : `${slides} -> ${written.length === 1 ? written[0] : `${String(written.length)} files`}`);
1120
+ if (options.text) {
1121
+ const substituted = result.fonts.filter((font) => font.substituted);
1122
+ lines.push(`${String(result.fonts.length)} typeface(s) from ${String(result.facesIndexed)} indexed face(s)` + (substituted.length === 0 ? "" : `, ${String(substituted.length)} substituted`));
1123
+ for (const font of substituted) lines.push(` ${font.asked} -> ${font.drawn}`);
1124
+ if (result.missing.length > 0) {
1125
+ const shown = result.missing.slice(0, 8).map((code) => `U+${code.toString(16).toUpperCase().padStart(4, "0")}`);
1126
+ lines.push(` no face has ${String(result.missing.length)} code point(s): ${shown.join(" ")}` + (result.missing.length > shown.length ? " ..." : ""));
1127
+ }
1128
+ }
1129
+ return `${lines.join("\n")}\n`;
1130
+ }
1131
+ /** Read, render, write, and return an exit code. Never calls `process.exit`. */
1132
+ function runRender(file, options, out) {
1133
+ const result = renderDeck(new Uint8Array(readFileSync(file)), options);
1134
+ if (options.json) {
1135
+ const written = options.out === null ? [] : write(result, options.out);
1136
+ out(`${JSON.stringify({
1137
+ slides: result.slides.map((slide) => ({
1138
+ number: slide.number,
1139
+ bytes: slide.svg.length
1140
+ })),
1141
+ width: result.width,
1142
+ height: result.height,
1143
+ fonts: result.fonts,
1144
+ missing: result.missing.map((code) => `U+${code.toString(16).toUpperCase()}`),
1145
+ fontDirectories: result.fontDirectories,
1146
+ facesIndexed: result.facesIndexed,
1147
+ written
1148
+ }, null, 2)}\n`);
1149
+ return 0;
1150
+ }
1151
+ if (options.out === null) {
1152
+ for (const slide of result.slides) out(slide.svg);
1153
+ return 0;
1154
+ }
1155
+ const written = write(result, options.out);
1156
+ if (!options.quiet) out(report(result, written, options));
1157
+ return 0;
1158
+ }
1159
+ //#endregion
1160
+ //#region src/roundtrip.ts
1161
+ const ROUNDTRIP_DEFAULTS = {
1162
+ json: false,
1163
+ quiet: false,
1164
+ out: null,
1165
+ write: null
1166
+ };
1167
+ function describeDifference(difference) {
1168
+ const body = difference.detail.split("\n").map((line) => " " + line.trimEnd()).join("\n");
1169
+ return " " + difference.kind.padEnd(13) + difference.part + "\n" + body;
1170
+ }
1171
+ /**
1172
+ * Render a comparison.
1173
+ *
1174
+ * Exported so that the branch that lists differences can be tested, which it
1175
+ * otherwise could not be: no deck in the corpus makes this writer produce one,
1176
+ * which is the point of the whole phase and leaves the most important output in
1177
+ * the file as the only output nothing exercises.
1178
+ */
1179
+ function formatRoundTrip(path, stats, comparison, quiet) {
1180
+ const lines = [];
1181
+ if (!quiet) lines.push("roundtrip " + path, "", " read " + String(stats.bytesIn) + " bytes, " + String(stats.entriesIn) + " entries", " written " + String(stats.bytesOut) + " bytes, " + String(stats.entriesOut) + " entries", " export " + String(stats.rewritten) + " part(s) re-serialized, " + String(stats.streamed) + " streamed", " compared " + summarizeRoundTrip(comparison), "");
1182
+ if (comparison.ok) lines.push(" no differences");
1183
+ else {
1184
+ lines.push(" " + String(comparison.differences.length) + " difference(s)", "");
1185
+ for (const difference of comparison.differences) lines.push(describeDifference(difference), "");
1186
+ }
1187
+ if (!quiet && comparison.relabelled.length > 0) lines.push("", " " + String(comparison.relabelled.length) + " relationship id(s) renamed:", ...comparison.relabelled.slice(0, 10).map((entry) => " " + entry.source + " " + entry.from + " -> " + entry.to));
1188
+ return lines.join("\n") + "\n";
1189
+ }
1190
+ function roundTripFile(path, options = ROUNDTRIP_DEFAULTS) {
1191
+ const original = new Uint8Array(readFileSync(path));
1192
+ const result = roundTripPackage(original);
1193
+ const { comparison } = result;
1194
+ const stats = {
1195
+ bytesIn: original.length,
1196
+ bytesOut: result.exported.bytes.length,
1197
+ entriesIn: readZip(original).entries.length,
1198
+ entriesOut: readZip(result.exported.bytes).entries.length,
1199
+ rewritten: result.exported.rewritten.length,
1200
+ streamed: result.exported.streamed
1201
+ };
1202
+ return {
1203
+ comparison,
1204
+ output: options.json ? JSON.stringify({
1205
+ file: path,
1206
+ ok: comparison.ok,
1207
+ bytesIn: stats.bytesIn,
1208
+ bytesOut: stats.bytesOut,
1209
+ rewritten: result.exported.rewritten,
1210
+ counts: comparison.counts,
1211
+ relabelled: comparison.relabelled,
1212
+ differences: comparison.differences,
1213
+ parts: comparison.parts
1214
+ }, null, 2) + "\n" : formatRoundTrip(path, stats, comparison, options.quiet),
1215
+ bytes: result.exported.bytes,
1216
+ exitCode: comparison.ok ? 0 : 1
1217
+ };
1218
+ }
1219
+ /** Run `roundTripFile` and put the results where the options say. */
1220
+ function runRoundTrip(path, options, write) {
1221
+ const result = roundTripFile(path, options);
1222
+ if (options.write !== null) writeFileSync(options.write, result.bytes);
1223
+ if (options.out === null) write(result.output);
1224
+ else writeFileSync(options.out, result.output);
1225
+ return result.exitCode;
1226
+ }
1227
+ //#endregion
1228
+ //#region src/validate.ts
1229
+ const VALIDATE_DEFAULTS = {
1230
+ json: false,
1231
+ explain: false,
1232
+ quiet: false,
1233
+ out: null
1234
+ };
1235
+ function validateFile(path, options = VALIDATE_DEFAULTS) {
1236
+ const bytes = new Uint8Array(readFileSync(path));
1237
+ const report = validatePackage({ bytes });
1238
+ return {
1239
+ report,
1240
+ output: options.json ? JSON.stringify(report, null, 2) + "\n" : formatReport(report, {
1241
+ explain: options.explain,
1242
+ warnings: !options.quiet
1243
+ }) + "\n",
1244
+ exitCode: report.findings.filter((finding) => finding.severity === "fatal").length > 0 ? 1 : 0
1245
+ };
1246
+ }
1247
+ /** Run `validateFile` and put the result where the options say. */
1248
+ function runValidate(path, options, write) {
1249
+ const result = validateFile(path, options);
1250
+ if (options.out === null) write(result.output);
1251
+ else writeFileSync(options.out, result.output);
1252
+ return result.exitCode;
1253
+ }
1254
+ //#endregion
1255
+ //#region src/main.ts
1256
+ const PLANNED = [{
1257
+ name: "resolve",
1258
+ summary: "show where a resolved property came from",
1259
+ phase: "7.x"
1260
+ }];
1261
+ function version() {
1262
+ const manifest = JSON.parse(readFileSync(new URL("../package.json", import.meta.url), "utf8"));
1263
+ return typeof manifest === "object" && manifest !== null && "version" in manifest ? String(manifest.version) : "0.0.0";
1264
+ }
1265
+ function usage() {
1266
+ const planned = PLANNED.map((verb) => " " + verb.name.padEnd(12) + verb.summary + " (sub-phase " + verb.phase + ")").join("\n");
1267
+ return [
1268
+ "pptx-studio " + version(),
1269
+ "",
1270
+ "Usage: pptx-studio <command> <deck.pptx> [options]",
1271
+ "",
1272
+ "Commands:",
1273
+ " inspect what is inside a package: parts, relationships, features",
1274
+ " validate the must-not-break rules, with the part and the XPath",
1275
+ " roundtrip read a deck, write it back, and prove nothing moved",
1276
+ " render draw slides as SVG, with no browser and no LibreOffice",
1277
+ " bisect narrow a broken deck to the change that breaks it",
1278
+ "",
1279
+ "render options:",
1280
+ " --slide <n> one slide, 1-based; every slide by default",
1281
+ " --width <px> the SVG width attribute; the height follows the aspect",
1282
+ " --out <path> a directory, or a file when rendering one slide",
1283
+ " --font-dir <d> look for fonts here first; repeatable",
1284
+ " --no-system-fonts do not look in this platform own font directories",
1285
+ " --no-text draw geometry only, and ask no font questions",
1286
+ " --json what was drawn, and which face drew each typeface",
1287
+ " --quiet no summary after writing",
1288
+ "",
1289
+ " With no --out the markup goes to stdout, which is one slide worth doing.",
1290
+ "",
1291
+ "inspect options:",
1292
+ " --json the census as JSON, for a script or for committing as a fixture",
1293
+ " --parts include the per-part table",
1294
+ " --namespaces include the namespace histogram",
1295
+ " --top <n> rows per histogram before truncating (default 15)",
1296
+ " --out <file> write to a file instead of stdout",
1297
+ "",
1298
+ "validate options:",
1299
+ " --json the report as JSON",
1300
+ " --explain append the rationale for every rule that fired",
1301
+ " --quiet fatal findings only",
1302
+ " --out <file> write to a file instead of stdout",
1303
+ "",
1304
+ "roundtrip options:",
1305
+ " --json the comparison as JSON, with a digest per part",
1306
+ " --quiet the differences only, without the tally",
1307
+ " --write <file> also save the package that was written, to open in PowerPoint",
1308
+ " --out <file> write the report to a file instead of stdout",
1309
+ "",
1310
+ "bisect <deck.pptx> [broken.pptx]",
1311
+ " With one deck the broken package is our own export of it; with two they",
1312
+ " are the original and the broken package, in that order.",
1313
+ "",
1314
+ " --oracle <name> validate (default), powerpoint, or command",
1315
+ " --command <cmd> for --oracle command; {} becomes the candidate path",
1316
+ " --max-runs <n> ceiling on oracle runs (default 2000)",
1317
+ " --timeout <ms> per run, for the oracles that spawn something",
1318
+ " --progress a line per oracle run; a bisection is not quick",
1319
+ " --write <file> save the smallest package that still fails",
1320
+ " --json the result as JSON",
1321
+ " --quiet the changes that matter, without the tally",
1322
+ " --out <file> write the report to a file instead of stdout",
1323
+ "",
1324
+ " -h, --help this text",
1325
+ " -v, --version print the version",
1326
+ "",
1327
+ "Exit status is 1 when inspect finds a structural error, when validate finds",
1328
+ "anything fatal, when roundtrip finds a difference, when render cannot draw,",
1329
+ "or when bisect localizes one. 0 otherwise. Warnings and notes never fail a",
1330
+ "command.",
1331
+ "",
1332
+ "Not built yet:",
1333
+ planned,
1334
+ ""
1335
+ ].join("\n");
1336
+ }
1337
+ const CONSOLE_STREAMS = {
1338
+ out: (text) => process.stdout.write(text),
1339
+ err: (text) => process.stderr.write(text)
1340
+ };
1341
+ /** Parse, dispatch, and return an exit code. Never calls `process.exit`. */
1342
+ function main(argv, streams = CONSOLE_STREAMS) {
1343
+ let parsed;
1344
+ try {
1345
+ parsed = parseArgs({
1346
+ args: [...argv],
1347
+ allowPositionals: true,
1348
+ allowNegative: true,
1349
+ options: {
1350
+ json: {
1351
+ type: "boolean",
1352
+ default: false
1353
+ },
1354
+ explain: {
1355
+ type: "boolean",
1356
+ default: false
1357
+ },
1358
+ quiet: {
1359
+ type: "boolean",
1360
+ default: false
1361
+ },
1362
+ parts: {
1363
+ type: "boolean",
1364
+ default: false
1365
+ },
1366
+ namespaces: {
1367
+ type: "boolean",
1368
+ default: false
1369
+ },
1370
+ top: { type: "string" },
1371
+ out: { type: "string" },
1372
+ write: { type: "string" },
1373
+ oracle: { type: "string" },
1374
+ slide: { type: "string" },
1375
+ width: { type: "string" },
1376
+ "font-dir": {
1377
+ type: "string",
1378
+ multiple: true
1379
+ },
1380
+ "system-fonts": {
1381
+ type: "boolean",
1382
+ default: true
1383
+ },
1384
+ text: {
1385
+ type: "boolean",
1386
+ default: true
1387
+ },
1388
+ command: { type: "string" },
1389
+ "max-runs": { type: "string" },
1390
+ timeout: { type: "string" },
1391
+ progress: {
1392
+ type: "boolean",
1393
+ default: false
1394
+ },
1395
+ help: {
1396
+ type: "boolean",
1397
+ short: "h",
1398
+ default: false
1399
+ },
1400
+ version: {
1401
+ type: "boolean",
1402
+ short: "v",
1403
+ default: false
1404
+ }
1405
+ }
1406
+ });
1407
+ } catch (error) {
1408
+ streams.err(describe(error) + "\n\n" + usage());
1409
+ return 2;
1410
+ }
1411
+ const { values, positionals } = parsed;
1412
+ if (values.version === true) {
1413
+ streams.out(version() + "\n");
1414
+ return 0;
1415
+ }
1416
+ const command = positionals[0];
1417
+ if (values.help === true || command === void 0) {
1418
+ streams.out(usage());
1419
+ return command === void 0 && values.help !== true ? 2 : 0;
1420
+ }
1421
+ const planned = PLANNED.find((verb) => verb.name === command);
1422
+ if (planned !== void 0) {
1423
+ streams.err("pptx-studio " + command + " is not built yet: it arrives with sub-phase " + planned.phase + ".\n " + planned.summary + "\n");
1424
+ return 2;
1425
+ }
1426
+ if (command !== "inspect" && command !== "validate" && command !== "roundtrip" && command !== "render" && command !== "bisect") {
1427
+ streams.err("unknown command: " + command + "\n\n" + usage());
1428
+ return 2;
1429
+ }
1430
+ const file = positionals[1];
1431
+ if (file === void 0) {
1432
+ streams.err(command + " needs a path to a .pptx\n\n" + usage());
1433
+ return 2;
1434
+ }
1435
+ if (command === "bisect") {
1436
+ const oracle = values.oracle ?? BISECT_DEFAULTS.oracle;
1437
+ if (oracle !== "validate" && oracle !== "powerpoint" && oracle !== "command") {
1438
+ streams.err("--oracle wants validate, powerpoint or command, got " + oracle + "\n");
1439
+ return 2;
1440
+ }
1441
+ if (oracle === "command" && values.command === void 0) {
1442
+ streams.err("--oracle command needs --command \"<what to run>\"\n");
1443
+ return 2;
1444
+ }
1445
+ const maxRuns = positiveInteger(values["max-runs"], BISECT_DEFAULTS.maxRuns);
1446
+ const timeout = positiveInteger(values.timeout, BISECT_DEFAULTS.timeout);
1447
+ if (maxRuns === null || timeout === null) {
1448
+ streams.err("--max-runs and --timeout want positive integers\n");
1449
+ return 2;
1450
+ }
1451
+ const options = {
1452
+ oracle,
1453
+ command: values.command ?? null,
1454
+ maxRuns,
1455
+ timeout,
1456
+ write: values.write ?? null,
1457
+ json: values.json === true,
1458
+ quiet: values.quiet === true,
1459
+ out: values.out ?? null,
1460
+ progress: values.progress === true
1461
+ };
1462
+ try {
1463
+ return runBisect(file, positionals[2] ?? null, options, streams.out);
1464
+ } catch (error) {
1465
+ if (isValidateError(error)) streams.err("pptx-studio: the export was refused, so there was no broken package to compare.\n " + error.message + "\n");
1466
+ else if (isOpcError(error)) streams.err("pptx-studio: " + error.code + ": " + error.message + "\n");
1467
+ else streams.err("pptx-studio: " + describe(error) + "\n");
1468
+ return 1;
1469
+ }
1470
+ }
1471
+ if (command === "render") {
1472
+ const slide = values.slide === void 0 ? null : Number.parseInt(values.slide, 10);
1473
+ if (slide !== null && (!Number.isFinite(slide) || slide < 1)) {
1474
+ streams.err("--slide wants a positive integer, got " + String(values.slide) + "\n");
1475
+ return 2;
1476
+ }
1477
+ const width = positiveInteger(values.width, RENDER_DEFAULTS.width);
1478
+ if (width === null) {
1479
+ streams.err("--width wants a positive integer, got " + String(values.width) + "\n");
1480
+ return 2;
1481
+ }
1482
+ const options = {
1483
+ slide,
1484
+ width,
1485
+ out: values.out ?? null,
1486
+ fontDirs: values["font-dir"] ?? [],
1487
+ systemFonts: values["system-fonts"] !== false,
1488
+ text: values.text !== false,
1489
+ json: values.json === true,
1490
+ quiet: values.quiet === true
1491
+ };
1492
+ try {
1493
+ return runRender(file, options, streams.out);
1494
+ } catch (error) {
1495
+ if (isRenderError(error)) streams.err("pptx-studio: " + error.code + ": " + error.message + "\n");
1496
+ else if (isOpcError(error)) streams.err("pptx-studio: " + error.code + ": " + error.message + "\n");
1497
+ else streams.err("pptx-studio: " + describe(error) + "\n");
1498
+ return 1;
1499
+ }
1500
+ }
1501
+ if (command === "roundtrip") {
1502
+ const options = {
1503
+ json: values.json === true,
1504
+ quiet: values.quiet === true,
1505
+ out: values.out ?? null,
1506
+ write: values.write ?? null
1507
+ };
1508
+ try {
1509
+ return runRoundTrip(file, options, streams.out);
1510
+ } catch (error) {
1511
+ if (isValidateError(error)) streams.err("pptx-studio: the export was refused, so there was nothing to compare.\n " + error.message + "\n");
1512
+ else if (isOpcError(error)) streams.err("pptx-studio: " + error.code + ": " + error.message + "\n");
1513
+ else streams.err("pptx-studio: " + describe(error) + "\n");
1514
+ return 1;
1515
+ }
1516
+ }
1517
+ if (command === "validate") {
1518
+ const options = {
1519
+ json: values.json === true,
1520
+ explain: values.explain === true,
1521
+ quiet: values.quiet === true,
1522
+ out: values.out ?? null
1523
+ };
1524
+ try {
1525
+ return runValidate(file, options, streams.out);
1526
+ } catch (error) {
1527
+ if (isOpcError(error)) {
1528
+ streams.err("pptx-studio: " + error.code + ": " + error.message + "\n");
1529
+ return 1;
1530
+ }
1531
+ streams.err("pptx-studio: " + describe(error) + "\n");
1532
+ return 1;
1533
+ }
1534
+ }
1535
+ const top = values.top === void 0 ? INSPECT_DEFAULTS.top : Number.parseInt(values.top, 10);
1536
+ if (!Number.isFinite(top) || top < 1) {
1537
+ streams.err("--top wants a positive integer, got " + String(values.top) + "\n");
1538
+ return 2;
1539
+ }
1540
+ const options = {
1541
+ json: values.json === true,
1542
+ parts: values.parts === true,
1543
+ namespaces: values.namespaces === true,
1544
+ top,
1545
+ out: values.out ?? null
1546
+ };
1547
+ try {
1548
+ return runInspect(file, options, streams.out);
1549
+ } catch (error) {
1550
+ if (isOpcError(error)) {
1551
+ streams.err("pptx-studio: " + error.code + ": " + error.message + "\n");
1552
+ return 1;
1553
+ }
1554
+ streams.err("pptx-studio: " + describe(error) + "\n");
1555
+ return 1;
1556
+ }
1557
+ }
1558
+ function describe(error) {
1559
+ return error instanceof Error ? error.message : String(error);
1560
+ }
1561
+ /** A numeric flag, or null when what was given is not one. */
1562
+ function positiveInteger(value, fallback) {
1563
+ if (value === void 0) return fallback;
1564
+ const parsed = Number.parseInt(value, 10);
1565
+ return Number.isFinite(parsed) && parsed > 0 ? parsed : null;
1566
+ }
1567
+ //#endregion
1568
+ export { runRender as a, systemFontDirectories as c, RenderError as d, isRenderError as f, runInspect as h, renderDeck as i, facesIn as l, inspectFile as m, DEFAULT_WIDTH as n, createFontMeasurer as o, INSPECT_DEFAULTS as p, RENDER_DEFAULTS as r, indexFonts as s, main as t, RENDER_ERROR_CODES as u };
1569
+
1570
+ //# sourceMappingURL=main-DLx2onii.js.map