@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.
- package/CHANGELOG.md +29 -0
- package/LICENSE +202 -0
- package/NOTICE +43 -0
- package/README.md +263 -0
- package/dist/cli.d.ts +1 -0
- package/dist/cli.js +16 -0
- package/dist/cli.js.map +1 -0
- package/dist/index.d.ts +232 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +2 -0
- package/dist/main-DLx2onii.js +1570 -0
- package/dist/main-DLx2onii.js.map +1 -0
- package/package.json +73 -0
- package/scripts/powerpoint-oracle.ps1 +162 -0
|
@@ -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
|