@kaydash9999/banker-plugins 0.10.0 → 0.12.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/.claude-plugin/marketplace.json +1 -1
- package/.claude-plugin/plugin.json +1 -1
- package/CHANGELOG.md +39 -0
- package/README.md +73 -11
- package/codex/manifest.json +8 -1
- package/commands/setup.md +2 -0
- package/package.json +6 -1
- package/skills/3d-intro-build/SKILL.md +214 -0
- package/skills/3d-intro-build/references/LICENSE +21 -0
- package/skills/3d-intro-build/references/NOTICE.md +32 -0
- package/skills/3d-intro-build/references/assemble.mjs +252 -0
- package/skills/3d-intro-build/references/azure-adapter.mjs +495 -0
- package/skills/3d-intro-build/references/curate.mjs +301 -0
- package/skills/3d-intro-build/references/index-template.html +73 -0
- package/skills/3d-intro-build/references/prompts.md +171 -0
- package/skills/3d-intro-build/references/scrub-engine.js +448 -0
- package/skills/3d-intro-build/references/serve.mjs +123 -0
- package/skills/3d-intro-setup/SKILL.md +196 -0
- package/skills/3d-intro-setup/references/azure-adapter.mjs +495 -0
- package/skills/lineage/SKILL.md +126 -134
- package/skills/lineage/lineage.py +1041 -354
- package/skills/motion-graphic-make/SKILL.md +57 -0
- package/skills/motion-graphic-setup/SKILL.md +141 -0
- package/skills/ralph-qa/SKILL.md +59 -32
- package/skills/refresh-readme/SKILL.md +3 -3
- package/skills/vertical-pptx/SKILL.md +409 -0
- package/skills/vertical-pptx/build_a4p.js +923 -0
- package/skills/vertical-pptx/build_a4p.py +916 -0
- package/skills/vertical-pptx/verify_a4p.mjs +2168 -0
- package/skills/vertical-pptx-setup/SKILL.md +223 -0
|
@@ -0,0 +1,2168 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/*
|
|
3
|
+
* vertical-pptx verifier: A4 portrait (210x297mm) PPTX conformance + text width prediction.
|
|
4
|
+
*
|
|
5
|
+
* Zero dependencies, node stdlib only, so every acceptance criterion that leans on it stays a
|
|
6
|
+
* standing regression gate rather than something that needs a populated node_modules.
|
|
7
|
+
*
|
|
8
|
+
* Two responsibilities, both deliberately concentrated here:
|
|
9
|
+
*
|
|
10
|
+
* 1. Spec verdicts. Reads the deck's own bytes and answers a binary question about the frozen
|
|
11
|
+
* spec: is this really 7560000 x 10692000 EMU portrait, is the sldSz type attribute honest,
|
|
12
|
+
* does any shape cross the margin or leave the slide.
|
|
13
|
+
*
|
|
14
|
+
* 2. Measurement. The builders contain zero lines of font binary parsing (plan principle 5:
|
|
15
|
+
* "builders build, the verifier measures"). Every byte of TTF/TTC/OTF parsing in this skill
|
|
16
|
+
* lives below the FONT READER banner, and so does every width prediction.
|
|
17
|
+
*
|
|
18
|
+
* What this file deliberately does NOT own:
|
|
19
|
+
*
|
|
20
|
+
* - The 8x20 grid. build_a4p.js owns it and build_a4p.py mirrors it; a third copy here would be
|
|
21
|
+
* the only one no test cross-checks (E1/E1b compare the two builders against each other).
|
|
22
|
+
* The verifier reads a:off/a:ext out of the artifact and checks bounds. It never predicts them.
|
|
23
|
+
* - Any write path. --repair / --convert-a4p / --suggest-deck land later; the reader below is
|
|
24
|
+
* already central-directory based so a writer can be layered on without reopening this layer.
|
|
25
|
+
*
|
|
26
|
+
* Honesty rules that shape the code more than anything else:
|
|
27
|
+
*
|
|
28
|
+
* - The default width label is "estimate". "measured" is earned, never assumed: a real font file
|
|
29
|
+
* must be opened, its name table family compared against the run's a:latin typeface, and its
|
|
30
|
+
* tables parsed. A path that exists but holds a different font is the one silent-wrong-answer
|
|
31
|
+
* path in the design, so --font-file is verified rather than trusted, and so is whatever
|
|
32
|
+
* fc-match hands back (fc-match ALWAYS returns something, including for names it never found).
|
|
33
|
+
* - Predicted width is a LOWER BOUND. Kerning and line-breaking rules are not reproduced, so a
|
|
34
|
+
* prediction can under-report and never over-report. That is what makes a positive overflow
|
|
35
|
+
* finding sound: if even the lower bound does not fit, the real text certainly does not.
|
|
36
|
+
* - Parse failure of a valid path degrades to "estimate". It never throws. --font-file is
|
|
37
|
+
* arbitrary user input, so every table offset is bounds-checked.
|
|
38
|
+
*
|
|
39
|
+
* Exit codes: 0 pass, 1 verification failure, 2 environment missing, 3 usage error.
|
|
40
|
+
*
|
|
41
|
+
* Usage: node verify_a4p.mjs <file.pptx> [--json] [--strict] [--font-file <path>] [--deck <deck.json>]
|
|
42
|
+
*/
|
|
43
|
+
import { spawnSync } from 'node:child_process';
|
|
44
|
+
import fs from 'node:fs';
|
|
45
|
+
import { createRequire } from 'node:module';
|
|
46
|
+
import os from 'node:os';
|
|
47
|
+
import path from 'node:path';
|
|
48
|
+
import { fileURLToPath } from 'node:url';
|
|
49
|
+
import zlib from 'node:zlib';
|
|
50
|
+
|
|
51
|
+
// ---------------------------------------------------------------------------
|
|
52
|
+
// CONSTANTS
|
|
53
|
+
// ---------------------------------------------------------------------------
|
|
54
|
+
|
|
55
|
+
export const EXIT = Object.freeze({ PASS: 0, FAIL: 1, ENV: 2, USAGE: 3 });
|
|
56
|
+
|
|
57
|
+
export const EMU_PER_MM = 36000;
|
|
58
|
+
export const EMU_PER_PT = 12700;
|
|
59
|
+
|
|
60
|
+
/** The frozen spec. 210mm x 297mm, exact, no tolerance. */
|
|
61
|
+
export const A4P = Object.freeze({ cx: 7560000, cy: 10692000 });
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* PowerPoint's "A4 Paper" preset is 10.833 x 7.5 inches: LANDSCAPE and not A4. Picking it from
|
|
65
|
+
* the page-setup dialog is the single most likely way to end up with a file that is named after
|
|
66
|
+
* A4 and is not A4, which is why it gets a verdict of its own instead of folding into NOT_A4_PORTRAIT.
|
|
67
|
+
*/
|
|
68
|
+
export const PRESET_TRAP = Object.freeze({ cx: 9906000, cy: 6858000 });
|
|
69
|
+
|
|
70
|
+
/** meta.marginMm contract (plan 3-a): integer, 10..40. 10 is the frozen-spec floor. */
|
|
71
|
+
export const MARGIN_MM = Object.freeze({ MIN: 10, MAX: 40, DEFAULT: 10 });
|
|
72
|
+
|
|
73
|
+
/** ECMA-376 a:bodyPr inset defaults, in EMU. Ignoring them overstates the text box by 5mm wide. */
|
|
74
|
+
const INSET_DEFAULT = Object.freeze({ l: 91440, t: 45720, r: 91440, b: 45720 });
|
|
75
|
+
|
|
76
|
+
/** Line height when nothing was measured. Every real font's hhea exceeds this, so it under-counts lines. */
|
|
77
|
+
const FALLBACK_LINE_EM = 1.2;
|
|
78
|
+
|
|
79
|
+
/** Run size fallback when a:rPr@sz is absent and the real value lives in an inherited list style. */
|
|
80
|
+
const FALLBACK_SZ_PT = 18;
|
|
81
|
+
|
|
82
|
+
/** Bounds on the font-directory walk, so a pathological tree cannot hang the verifier. */
|
|
83
|
+
const FONT_SCAN = Object.freeze({ MAX_DEPTH: 5, MAX_FILES: 4000, MAX_OPENS: 24 });
|
|
84
|
+
|
|
85
|
+
const FONT_EXT = /\.(ttf|ttc|otf|otc)$/i;
|
|
86
|
+
|
|
87
|
+
/** Namespace-prefix matcher. Prefixes are per-document, so nothing may hardcode "p:" or "a:". */
|
|
88
|
+
const NS = '(?:[A-Za-z_][\\w.\\-]*:)?';
|
|
89
|
+
|
|
90
|
+
class VerifyError extends Error {
|
|
91
|
+
constructor(message, code) {
|
|
92
|
+
super(message);
|
|
93
|
+
this.code = code || 'VERIFY_ERROR';
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/** Thrown only inside the font reader, and always caught: a bad font degrades, it does not fail a run. */
|
|
98
|
+
class FontError extends Error {}
|
|
99
|
+
|
|
100
|
+
const check = (cond, message, code) => {
|
|
101
|
+
if (!cond) throw new VerifyError(message, code);
|
|
102
|
+
};
|
|
103
|
+
|
|
104
|
+
export const mmOf = (emu) => emu / EMU_PER_MM;
|
|
105
|
+
const round2 = (n) => Math.round(n * 100) / 100;
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* mm for humans, with enough digits to stay honest. A 1 EMU margin deviation is a real verdict
|
|
109
|
+
* (there is no tolerance) and printing it as "0mm" would read as a tool malfunction.
|
|
110
|
+
*/
|
|
111
|
+
const mmStr = (emu) => {
|
|
112
|
+
const v = mmOf(emu);
|
|
113
|
+
return v !== 0 && Math.abs(v) < 0.01 ? v.toFixed(5) : String(round2(v));
|
|
114
|
+
};
|
|
115
|
+
|
|
116
|
+
// ---------------------------------------------------------------------------
|
|
117
|
+
// ZIP READER (central directory based)
|
|
118
|
+
// ---------------------------------------------------------------------------
|
|
119
|
+
//
|
|
120
|
+
// The central directory is the authority, never the local header. An entry written with general
|
|
121
|
+
// purpose bit 3 set carries zeros for crc/csize/usize in its local header and puts the real values
|
|
122
|
+
// in a trailing data descriptor, so a local-header reader silently reads empty parts. The input to
|
|
123
|
+
// --repair is by definition a deck someone else produced, so that case is real, not theoretical.
|
|
124
|
+
// The local header is still consulted for exactly one thing: its own name/extra lengths, which are
|
|
125
|
+
// what locate the start of the data (they legitimately differ from the central directory's).
|
|
126
|
+
|
|
127
|
+
const SIG = Object.freeze({ EOCD: 0x06054b50, EOCD64: 0x06064b50, EOCD64_LOC: 0x07064b50, CEN: 0x02014b50, LOC: 0x04034b50 });
|
|
128
|
+
|
|
129
|
+
/** General purpose bit flags we care about. */
|
|
130
|
+
const GPB = Object.freeze({ ENCRYPTED: 0x0001, DATA_DESCRIPTOR: 0x0008 });
|
|
131
|
+
|
|
132
|
+
const CRC_TABLE = (() => {
|
|
133
|
+
const t = new Int32Array(256);
|
|
134
|
+
for (let n = 0; n < 256; n++) {
|
|
135
|
+
let c = n;
|
|
136
|
+
for (let k = 0; k < 8; k++) c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1;
|
|
137
|
+
t[n] = c;
|
|
138
|
+
}
|
|
139
|
+
return t;
|
|
140
|
+
})();
|
|
141
|
+
|
|
142
|
+
/** CRC32 as zip defines it. Used to stamp rewritten entries and to re-check every entry on read-back. */
|
|
143
|
+
export function crc32(buf) {
|
|
144
|
+
let c = -1;
|
|
145
|
+
for (let i = 0; i < buf.length; i++) c = CRC_TABLE[(c ^ buf[i]) & 0xff] ^ (c >>> 8);
|
|
146
|
+
return (c ^ -1) >>> 0;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
function u16(buf, off) {
|
|
150
|
+
check(off >= 0 && off + 2 <= buf.length, `zip: read past end at ${off}`, 'NOT_A_ZIP');
|
|
151
|
+
return buf.readUInt16LE(off);
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
function u32(buf, off) {
|
|
155
|
+
check(off >= 0 && off + 4 <= buf.length, `zip: read past end at ${off}`, 'NOT_A_ZIP');
|
|
156
|
+
return buf.readUInt32LE(off);
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
function u64(buf, off) {
|
|
160
|
+
check(off >= 0 && off + 8 <= buf.length, `zip: read past end at ${off}`, 'NOT_A_ZIP');
|
|
161
|
+
return Number(buf.readBigUInt64LE(off));
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/** Backwards scan for the end-of-central-directory record (the comment may be up to 64KB). */
|
|
165
|
+
function findEocd(buf) {
|
|
166
|
+
const floor = Math.max(0, buf.length - (0xffff + 22));
|
|
167
|
+
for (let i = buf.length - 22; i >= floor; i--) {
|
|
168
|
+
if (buf.readUInt32LE(i) === SIG.EOCD) return i;
|
|
169
|
+
}
|
|
170
|
+
return -1;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/** Pull the zip64 overrides out of a central-directory extra field, in their fixed order. */
|
|
174
|
+
function zip64Extra(buf, start, len, need) {
|
|
175
|
+
let p = start;
|
|
176
|
+
const end = start + len;
|
|
177
|
+
while (p + 4 <= end) {
|
|
178
|
+
const id = u16(buf, p);
|
|
179
|
+
const size = u16(buf, p + 2);
|
|
180
|
+
if (id === 0x0001) {
|
|
181
|
+
let q = p + 4;
|
|
182
|
+
const out = {};
|
|
183
|
+
for (const field of need) {
|
|
184
|
+
if (q + 8 > p + 4 + size) break;
|
|
185
|
+
out[field] = u64(buf, q);
|
|
186
|
+
q += 8;
|
|
187
|
+
}
|
|
188
|
+
return out;
|
|
189
|
+
}
|
|
190
|
+
p += 4 + size;
|
|
191
|
+
}
|
|
192
|
+
return {};
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
/**
|
|
196
|
+
* Parse the central directory. Returns entry records only; payloads are inflated on demand so a
|
|
197
|
+
* 40MB deck does not have to be decompressed to answer a question about ppt/presentation.xml.
|
|
198
|
+
*/
|
|
199
|
+
export function readZipDirectory(buf) {
|
|
200
|
+
const eocd = findEocd(buf);
|
|
201
|
+
check(eocd >= 0, 'zip: end-of-central-directory record not found (not a pptx?)', 'NOT_A_ZIP');
|
|
202
|
+
|
|
203
|
+
let count = u16(buf, eocd + 10);
|
|
204
|
+
let cdOffset = u32(buf, eocd + 16);
|
|
205
|
+
if (count === 0xffff || cdOffset === 0xffffffff) {
|
|
206
|
+
const loc = eocd - 20;
|
|
207
|
+
if (loc >= 0 && u32(buf, loc) === SIG.EOCD64_LOC) {
|
|
208
|
+
const rec = u64(buf, loc + 8);
|
|
209
|
+
if (rec >= 0 && rec + 56 <= buf.length && u32(buf, rec) === SIG.EOCD64) {
|
|
210
|
+
count = u64(buf, rec + 32);
|
|
211
|
+
cdOffset = u64(buf, rec + 48);
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
const entries = [];
|
|
217
|
+
let p = cdOffset;
|
|
218
|
+
for (let i = 0; i < count; i++) {
|
|
219
|
+
check(p + 46 <= buf.length && u32(buf, p) === SIG.CEN, `zip: malformed central directory at ${p}`, 'NOT_A_ZIP');
|
|
220
|
+
const versionMadeBy = u16(buf, p + 4);
|
|
221
|
+
const versionNeeded = u16(buf, p + 6);
|
|
222
|
+
const flags = u16(buf, p + 8);
|
|
223
|
+
const method = u16(buf, p + 10);
|
|
224
|
+
const modTime = u16(buf, p + 12);
|
|
225
|
+
const modDate = u16(buf, p + 14);
|
|
226
|
+
const crc = u32(buf, p + 16);
|
|
227
|
+
let csize = u32(buf, p + 20);
|
|
228
|
+
let usize = u32(buf, p + 24);
|
|
229
|
+
const nameLen = u16(buf, p + 28);
|
|
230
|
+
const extraLen = u16(buf, p + 30);
|
|
231
|
+
const commentLen = u16(buf, p + 32);
|
|
232
|
+
const internalAttrs = u16(buf, p + 36);
|
|
233
|
+
const externalAttrs = u32(buf, p + 38);
|
|
234
|
+
let localOffset = u32(buf, p + 42);
|
|
235
|
+
check(p + 46 + nameLen + extraLen + commentLen <= buf.length, `zip: truncated central directory entry ${i}`, 'NOT_A_ZIP');
|
|
236
|
+
// Bit 11 declares UTF-8 names; everything a pptx contains is ASCII either way.
|
|
237
|
+
const name = buf.toString('utf8', p + 46, p + 46 + nameLen);
|
|
238
|
+
if (csize === 0xffffffff || usize === 0xffffffff || localOffset === 0xffffffff) {
|
|
239
|
+
const need = [];
|
|
240
|
+
if (usize === 0xffffffff) need.push('usize');
|
|
241
|
+
if (csize === 0xffffffff) need.push('csize');
|
|
242
|
+
if (localOffset === 0xffffffff) need.push('localOffset');
|
|
243
|
+
const over = zip64Extra(buf, p + 46 + nameLen, extraLen, need);
|
|
244
|
+
if (over.usize !== undefined) usize = over.usize;
|
|
245
|
+
if (over.csize !== undefined) csize = over.csize;
|
|
246
|
+
if (over.localOffset !== undefined) localOffset = over.localOffset;
|
|
247
|
+
}
|
|
248
|
+
entries.push({
|
|
249
|
+
name,
|
|
250
|
+
method,
|
|
251
|
+
crc,
|
|
252
|
+
csize,
|
|
253
|
+
usize,
|
|
254
|
+
localOffset,
|
|
255
|
+
flags,
|
|
256
|
+
versionMadeBy,
|
|
257
|
+
versionNeeded,
|
|
258
|
+
modTime,
|
|
259
|
+
modDate,
|
|
260
|
+
internalAttrs,
|
|
261
|
+
// External attributes carry the unix mode and the MS-DOS directory bit; dropping them turns
|
|
262
|
+
// directory entries into 0-byte files on some extractors.
|
|
263
|
+
externalAttrs,
|
|
264
|
+
extra: Buffer.from(buf.subarray(p + 46 + nameLen, p + 46 + nameLen + extraLen)),
|
|
265
|
+
comment: Buffer.from(buf.subarray(p + 46 + nameLen + extraLen, p + 46 + nameLen + extraLen + commentLen)),
|
|
266
|
+
isDir: name.endsWith('/'),
|
|
267
|
+
});
|
|
268
|
+
p += 46 + nameLen + extraLen + commentLen;
|
|
269
|
+
}
|
|
270
|
+
return entries;
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
/**
|
|
274
|
+
* Where one entry's payload sits. The SIZE comes from the central directory, never from the
|
|
275
|
+
* local header, because an entry with general purpose bit 3 set carries zeros there and the
|
|
276
|
+
* real values trail the data. Only the variable-length name and extra fields are read locally.
|
|
277
|
+
*/
|
|
278
|
+
function entryDataRange(buf, entry) {
|
|
279
|
+
const lo = entry.localOffset;
|
|
280
|
+
check(lo >= 0 && lo + 30 <= buf.length && u32(buf, lo) === SIG.LOC, `zip: local header missing for ${entry.name}`, 'NOT_A_ZIP');
|
|
281
|
+
const start = lo + 30 + u16(buf, lo + 26) + u16(buf, lo + 28);
|
|
282
|
+
const end = start + entry.csize;
|
|
283
|
+
check(start >= 0 && end <= buf.length, `zip: entry data out of range for ${entry.name}`, 'NOT_A_ZIP');
|
|
284
|
+
return { start, end };
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
/** Inflate one entry, taking its size from the central directory and its data offset from the local header. */
|
|
288
|
+
function readEntryData(buf, entry) {
|
|
289
|
+
const { start, end } = entryDataRange(buf, entry);
|
|
290
|
+
const raw = buf.subarray(start, end);
|
|
291
|
+
if (entry.method === 0) return Buffer.from(raw);
|
|
292
|
+
if (entry.method === 8) return zlib.inflateRawSync(raw);
|
|
293
|
+
throw new VerifyError(`zip: unsupported compression method ${entry.method} for ${entry.name}`, 'ZIP_UNSUPPORTED');
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
/** The entry's stored bytes exactly as they sit in the file, still compressed. */
|
|
297
|
+
function rawEntryBytes(buf, entry) {
|
|
298
|
+
const { start, end } = entryDataRange(buf, entry);
|
|
299
|
+
return Buffer.from(buf.subarray(start, end));
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
/** Strip the zip64 extended-information field. We only ever write 32-bit records, so keeping a
|
|
303
|
+
* stale 0x0001 block would leave the central directory contradicting its own size fields. */
|
|
304
|
+
function stripZip64Extra(extra) {
|
|
305
|
+
const out = [];
|
|
306
|
+
let p = 0;
|
|
307
|
+
while (p + 4 <= extra.length) {
|
|
308
|
+
const id = extra.readUInt16LE(p);
|
|
309
|
+
const size = extra.readUInt16LE(p + 2);
|
|
310
|
+
if (p + 4 + size > extra.length) break;
|
|
311
|
+
if (id !== 0x0001) out.push(extra.subarray(p, p + 4 + size));
|
|
312
|
+
p += 4 + size;
|
|
313
|
+
}
|
|
314
|
+
return Buffer.concat(out);
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
/**
|
|
318
|
+
* Assemble a zip from entry records. Each record is either passed through with its ORIGINAL
|
|
319
|
+
* compressed bytes (method, crc and both sizes reused verbatim) or carries `replacement`, a plain
|
|
320
|
+
* Buffer that gets deflated here and stamped with a fresh CRC32.
|
|
321
|
+
*
|
|
322
|
+
* Re-deflating an untouched part would be pure added risk: it changes bytes this tool has no
|
|
323
|
+
* reason to change, and every one of those changes is a chance to corrupt a part that was fine.
|
|
324
|
+
*/
|
|
325
|
+
export function writeZip(records) {
|
|
326
|
+
const chunks = [];
|
|
327
|
+
const central = [];
|
|
328
|
+
let offset = 0;
|
|
329
|
+
|
|
330
|
+
for (const rec of records) {
|
|
331
|
+
check(!(rec.flags & GPB.ENCRYPTED), `zip: ${rec.name} is encrypted; refusing to rewrite it`, 'ZIP_ENCRYPTED');
|
|
332
|
+
const nameBuf = Buffer.from(rec.name, 'utf8');
|
|
333
|
+
const replaced = rec.replacement !== undefined && rec.replacement !== null;
|
|
334
|
+
const body = replaced ? zlib.deflateRawSync(rec.replacement) : rec.data;
|
|
335
|
+
const method = replaced ? 8 : rec.method;
|
|
336
|
+
const crc = replaced ? crc32(rec.replacement) : rec.crc;
|
|
337
|
+
const usize = replaced ? rec.replacement.length : rec.usize;
|
|
338
|
+
const csize = body.length;
|
|
339
|
+
// Bit 3 moves the real sizes into a trailing data descriptor. We know them here, so we clear
|
|
340
|
+
// the bit and write them inline: strictly more readable, and it drops the descriptor entirely.
|
|
341
|
+
const flags = rec.flags & ~GPB.DATA_DESCRIPTOR;
|
|
342
|
+
const extra = stripZip64Extra(rec.extra || Buffer.alloc(0));
|
|
343
|
+
|
|
344
|
+
const local = Buffer.alloc(30);
|
|
345
|
+
local.writeUInt32LE(SIG.LOC, 0);
|
|
346
|
+
local.writeUInt16LE(rec.versionNeeded ?? 20, 4);
|
|
347
|
+
local.writeUInt16LE(flags, 6);
|
|
348
|
+
local.writeUInt16LE(method, 8);
|
|
349
|
+
local.writeUInt16LE(rec.modTime ?? 0, 10);
|
|
350
|
+
local.writeUInt16LE(rec.modDate ?? 0, 12);
|
|
351
|
+
local.writeUInt32LE(crc, 14);
|
|
352
|
+
local.writeUInt32LE(csize, 18);
|
|
353
|
+
local.writeUInt32LE(usize, 22);
|
|
354
|
+
local.writeUInt16LE(nameBuf.length, 26);
|
|
355
|
+
local.writeUInt16LE(0, 28);
|
|
356
|
+
chunks.push(local, nameBuf, body);
|
|
357
|
+
|
|
358
|
+
const cen = Buffer.alloc(46);
|
|
359
|
+
cen.writeUInt32LE(SIG.CEN, 0);
|
|
360
|
+
cen.writeUInt16LE(rec.versionMadeBy ?? 20, 4);
|
|
361
|
+
cen.writeUInt16LE(rec.versionNeeded ?? 20, 6);
|
|
362
|
+
cen.writeUInt16LE(flags, 8);
|
|
363
|
+
cen.writeUInt16LE(method, 10);
|
|
364
|
+
cen.writeUInt16LE(rec.modTime ?? 0, 12);
|
|
365
|
+
cen.writeUInt16LE(rec.modDate ?? 0, 14);
|
|
366
|
+
cen.writeUInt32LE(crc, 16);
|
|
367
|
+
cen.writeUInt32LE(csize, 20);
|
|
368
|
+
cen.writeUInt32LE(usize, 24);
|
|
369
|
+
cen.writeUInt16LE(nameBuf.length, 28);
|
|
370
|
+
cen.writeUInt16LE(extra.length, 30);
|
|
371
|
+
cen.writeUInt16LE((rec.comment || Buffer.alloc(0)).length, 32);
|
|
372
|
+
cen.writeUInt16LE(0, 34);
|
|
373
|
+
cen.writeUInt16LE(rec.internalAttrs ?? 0, 36);
|
|
374
|
+
cen.writeUInt32LE(rec.externalAttrs ?? 0, 38);
|
|
375
|
+
check(offset <= 0xffffffff, 'zip: output would need zip64 offsets; refusing to write', 'ZIP_TOO_LARGE');
|
|
376
|
+
cen.writeUInt32LE(offset, 42);
|
|
377
|
+
central.push(cen, nameBuf, extra, rec.comment || Buffer.alloc(0));
|
|
378
|
+
|
|
379
|
+
offset += 30 + nameBuf.length + body.length;
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
const cdBuf = Buffer.concat(central);
|
|
383
|
+
const eocd = Buffer.alloc(22);
|
|
384
|
+
eocd.writeUInt32LE(SIG.EOCD, 0);
|
|
385
|
+
eocd.writeUInt16LE(records.length, 8);
|
|
386
|
+
eocd.writeUInt16LE(records.length, 10);
|
|
387
|
+
eocd.writeUInt32LE(cdBuf.length, 12);
|
|
388
|
+
eocd.writeUInt32LE(offset, 16);
|
|
389
|
+
return Buffer.concat([...chunks, cdBuf, eocd]);
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
/**
|
|
393
|
+
* U15, run against our own output every time we write one. Four checks, and the fourth is the
|
|
394
|
+
* point: our reader and the python oracle are both central-directory based, so a local header
|
|
395
|
+
* that disagrees with the central directory is invisible to both. Streaming unzippers read the
|
|
396
|
+
* local header and nothing else, and that is exactly where they would break.
|
|
397
|
+
*/
|
|
398
|
+
export function verifyZipIntegrity(buf, opts = {}) {
|
|
399
|
+
const problems = [];
|
|
400
|
+
const entries = readZipDirectory(buf);
|
|
401
|
+
|
|
402
|
+
if (!entries.length) problems.push('아카이브에 엔트리가 없다');
|
|
403
|
+
else if (entries[0].name !== '[Content_Types].xml') {
|
|
404
|
+
problems.push(`첫 엔트리가 [Content_Types].xml 이 아니라 ${entries[0].name} 다 (OPC 위반)`);
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
for (const e of entries) {
|
|
408
|
+
if (e.isDir) continue;
|
|
409
|
+
|
|
410
|
+
// Local header cross-check FIRST, and not gated behind a successful inflate: a bad local
|
|
411
|
+
// header makes decompression fail too, and "could not decompress" would bury the actual
|
|
412
|
+
// diagnosis under a symptom.
|
|
413
|
+
const lo = e.localOffset;
|
|
414
|
+
if (!(lo >= 0 && lo + 30 <= buf.length) || buf.readUInt32LE(lo) !== SIG.LOC) {
|
|
415
|
+
problems.push(`${e.name}: 로컬 헤더 오프셋 ${lo} 에 시그니처 0x04034b50 이 없다`);
|
|
416
|
+
continue;
|
|
417
|
+
}
|
|
418
|
+
const nameLen = buf.readUInt16LE(lo + 26);
|
|
419
|
+
const localName = buf.toString('utf8', lo + 30, lo + 30 + nameLen);
|
|
420
|
+
if (localName !== e.name) {
|
|
421
|
+
problems.push(`${e.name}: 로컬 헤더 파일명이 "${localName}" 로 중앙 디렉터리와 다르다`);
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
let data;
|
|
425
|
+
try {
|
|
426
|
+
data = readEntryData(buf, e);
|
|
427
|
+
} catch (err) {
|
|
428
|
+
problems.push(`${e.name}: 압축 해제 실패 (${err.message})`);
|
|
429
|
+
continue;
|
|
430
|
+
}
|
|
431
|
+
const actual = crc32(data);
|
|
432
|
+
if (actual !== e.crc) {
|
|
433
|
+
problems.push(`${e.name}: CRC32 재계산 ${hex32(actual)} != 중앙 디렉터리 ${hex32(e.crc)}`);
|
|
434
|
+
}
|
|
435
|
+
if (data.length !== e.usize) {
|
|
436
|
+
problems.push(`${e.name}: 크기 ${data.length} != 중앙 디렉터리 ${e.usize}`);
|
|
437
|
+
}
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
// Byte identity for everything we said we did not touch.
|
|
441
|
+
if (opts.source && opts.untouched) {
|
|
442
|
+
const src = openZip(opts.source);
|
|
443
|
+
for (const name of opts.untouched) {
|
|
444
|
+
const before = src.read(name);
|
|
445
|
+
const after = entries.find((e) => e.name === name);
|
|
446
|
+
if (!before || !after) {
|
|
447
|
+
problems.push(`${name}: 원본/산출물 어느 한쪽에 없다`);
|
|
448
|
+
continue;
|
|
449
|
+
}
|
|
450
|
+
if (!before.equals(readEntryData(buf, after))) problems.push(`${name}: 미치환 엔트리인데 내용이 바뀌었다`);
|
|
451
|
+
}
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
return { ok: problems.length === 0, problems, entryCount: entries.length, firstEntry: entries[0]?.name ?? null };
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
const hex32 = (n) => `0x${(n >>> 0).toString(16).padStart(8, '0')}`;
|
|
458
|
+
|
|
459
|
+
/** Random-access view over a zip buffer, with a small decode cache. */
|
|
460
|
+
export function openZip(buf) {
|
|
461
|
+
const entries = readZipDirectory(buf);
|
|
462
|
+
const byName = new Map();
|
|
463
|
+
for (const e of entries) if (!e.isDir) byName.set(e.name, e);
|
|
464
|
+
const cache = new Map();
|
|
465
|
+
const read = (name) => {
|
|
466
|
+
if (cache.has(name)) return cache.get(name);
|
|
467
|
+
const e = byName.get(name);
|
|
468
|
+
if (!e) return null;
|
|
469
|
+
const data = readEntryData(buf, e);
|
|
470
|
+
cache.set(name, data);
|
|
471
|
+
return data;
|
|
472
|
+
};
|
|
473
|
+
return {
|
|
474
|
+
entries,
|
|
475
|
+
names: () => [...byName.keys()],
|
|
476
|
+
has: (name) => byName.has(name),
|
|
477
|
+
read,
|
|
478
|
+
/** Still-compressed bytes, for pass-through re-zipping. */
|
|
479
|
+
raw: (entry) => rawEntryBytes(buf, entry),
|
|
480
|
+
readText: (name) => {
|
|
481
|
+
const d = read(name);
|
|
482
|
+
return d === null ? null : stripBom(d.toString('utf8'));
|
|
483
|
+
},
|
|
484
|
+
};
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
const stripBom = (s) => (s.charCodeAt(0) === 0xfeff ? s.slice(1) : s);
|
|
488
|
+
|
|
489
|
+
// ---------------------------------------------------------------------------
|
|
490
|
+
// XML EXTRACTION (element-scoped, never a whole-document parse-and-reserialize)
|
|
491
|
+
// ---------------------------------------------------------------------------
|
|
492
|
+
//
|
|
493
|
+
// Reserializing would risk rewriting parts of a deck this tool only means to inspect, and would
|
|
494
|
+
// need a full namespace-aware parser for no gain. Everything below is scoped to one element at a
|
|
495
|
+
// time: find the open tag, then pull attributes from within that matched span, so attribute order
|
|
496
|
+
// never matters and a "cx" somewhere else in the document can never be mistaken for sldSz's.
|
|
497
|
+
|
|
498
|
+
/** End of an open tag, respecting quotes, because `>` is legal inside an XML attribute value. */
|
|
499
|
+
function endOfTag(xml, start) {
|
|
500
|
+
let quote = null;
|
|
501
|
+
for (let i = start; i < xml.length; i++) {
|
|
502
|
+
const c = xml[i];
|
|
503
|
+
if (quote) {
|
|
504
|
+
if (c === quote) quote = null;
|
|
505
|
+
continue;
|
|
506
|
+
}
|
|
507
|
+
if (c === '"' || c === "'") quote = c;
|
|
508
|
+
else if (c === '>') return i;
|
|
509
|
+
}
|
|
510
|
+
return -1;
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
export function xmlDecode(s) {
|
|
514
|
+
return String(s).replace(/&(#x?[0-9A-Fa-f]+|amp|lt|gt|quot|apos);/g, (whole, body) => {
|
|
515
|
+
if (body[0] === '#') {
|
|
516
|
+
const cp = body[1] === 'x' || body[1] === 'X' ? parseInt(body.slice(2), 16) : parseInt(body.slice(1), 10);
|
|
517
|
+
return Number.isFinite(cp) && cp >= 0 && cp <= 0x10ffff ? String.fromCodePoint(cp) : whole;
|
|
518
|
+
}
|
|
519
|
+
return { amp: '&', lt: '<', gt: '>', quot: '"', apos: "'" }[body];
|
|
520
|
+
});
|
|
521
|
+
}
|
|
522
|
+
|
|
523
|
+
/** Attributes of a single open tag, keyed by their literal names (prefix included). */
|
|
524
|
+
export function attrsOf(openTag) {
|
|
525
|
+
const out = {};
|
|
526
|
+
const re = /([A-Za-z_][\w.\-]*(?::[A-Za-z_][\w.\-]*)?)\s*=\s*(?:"([^"]*)"|'([^']*)')/g;
|
|
527
|
+
let m;
|
|
528
|
+
while ((m = re.exec(openTag)) !== null) out[m[1]] = xmlDecode(m[2] !== undefined ? m[2] : m[3]);
|
|
529
|
+
return out;
|
|
530
|
+
}
|
|
531
|
+
|
|
532
|
+
/** Attribute lookup that tolerates any namespace prefix on the attribute name. */
|
|
533
|
+
export function attr(attrs, localName) {
|
|
534
|
+
if (attrs[localName] !== undefined) return attrs[localName];
|
|
535
|
+
const suffix = `:${localName}`;
|
|
536
|
+
for (const k of Object.keys(attrs)) if (k.endsWith(suffix)) return attrs[k];
|
|
537
|
+
return undefined;
|
|
538
|
+
}
|
|
539
|
+
|
|
540
|
+
const localName = (tag) => (tag.includes(':') ? tag.slice(tag.indexOf(':') + 1) : tag);
|
|
541
|
+
|
|
542
|
+
/** Locate the closing tag for `tag` starting at `from`, counting same-name nesting (p:grpSp inside p:grpSp). */
|
|
543
|
+
function findClose(xml, tag, from) {
|
|
544
|
+
const re = new RegExp(`<(/?)${escapeRe(tag)}\\b`, 'g');
|
|
545
|
+
re.lastIndex = from;
|
|
546
|
+
let depth = 1;
|
|
547
|
+
let m;
|
|
548
|
+
while ((m = re.exec(xml)) !== null) {
|
|
549
|
+
const gt = endOfTag(xml, m.index);
|
|
550
|
+
if (gt < 0) return -1;
|
|
551
|
+
if (m[1] === '/') {
|
|
552
|
+
if (--depth === 0) return { contentEnd: m.index, end: gt + 1 };
|
|
553
|
+
} else if (xml[gt - 1] !== '/') {
|
|
554
|
+
depth++;
|
|
555
|
+
}
|
|
556
|
+
re.lastIndex = gt + 1;
|
|
557
|
+
}
|
|
558
|
+
return -1;
|
|
559
|
+
}
|
|
560
|
+
|
|
561
|
+
const escapeRe = (s) => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
562
|
+
|
|
563
|
+
/**
|
|
564
|
+
* First element with local name `tag` at or after `from`.
|
|
565
|
+
* Returns { tag, raw, attrs, inner, start, end } or null.
|
|
566
|
+
*/
|
|
567
|
+
export function readElement(xml, tag, from = 0) {
|
|
568
|
+
const re = new RegExp(`<${NS}${escapeRe(tag)}(?=[\\s/>])`, 'g');
|
|
569
|
+
re.lastIndex = from;
|
|
570
|
+
const m = re.exec(xml);
|
|
571
|
+
if (!m) return null;
|
|
572
|
+
const start = m.index;
|
|
573
|
+
const gt = endOfTag(xml, start);
|
|
574
|
+
if (gt < 0) return null;
|
|
575
|
+
const raw = xml.slice(start, gt + 1);
|
|
576
|
+
const full = m[0].slice(1);
|
|
577
|
+
if (raw[raw.length - 2] === '/') return { tag: full, raw, attrs: attrsOf(raw), inner: '', start, end: gt + 1 };
|
|
578
|
+
const close = findClose(xml, full, gt + 1);
|
|
579
|
+
if (close === -1) return { tag: full, raw, attrs: attrsOf(raw), inner: '', start, end: gt + 1 };
|
|
580
|
+
return { tag: full, raw, attrs: attrsOf(raw), inner: xml.slice(gt + 1, close.contentEnd), start, end: close.end };
|
|
581
|
+
}
|
|
582
|
+
|
|
583
|
+
/** Every element with local name `tag`, skipping the interior of one match before looking for the next. */
|
|
584
|
+
export function readElements(xml, tag) {
|
|
585
|
+
const out = [];
|
|
586
|
+
let at = 0;
|
|
587
|
+
for (;;) {
|
|
588
|
+
const el = readElement(xml, tag, at);
|
|
589
|
+
if (!el) return out;
|
|
590
|
+
out.push(el);
|
|
591
|
+
at = el.end;
|
|
592
|
+
}
|
|
593
|
+
}
|
|
594
|
+
|
|
595
|
+
/** Direct children of an element's content, so a group's own xfrm is never mistaken for a shape's. */
|
|
596
|
+
export function childElements(inner) {
|
|
597
|
+
const out = [];
|
|
598
|
+
let i = 0;
|
|
599
|
+
while (i < inner.length) {
|
|
600
|
+
const lt = inner.indexOf('<', i);
|
|
601
|
+
if (lt < 0) break;
|
|
602
|
+
if (inner.startsWith('<!', lt) || inner.startsWith('<?', lt)) {
|
|
603
|
+
const gt = inner.indexOf('>', lt);
|
|
604
|
+
if (gt < 0) break;
|
|
605
|
+
i = gt + 1;
|
|
606
|
+
continue;
|
|
607
|
+
}
|
|
608
|
+
const gt = endOfTag(inner, lt);
|
|
609
|
+
if (gt < 0) break;
|
|
610
|
+
const nameMatch = /^<([A-Za-z_][\w.\-]*(?::[A-Za-z_][\w.\-]*)?)/.exec(inner.slice(lt, gt + 1));
|
|
611
|
+
if (!nameMatch) {
|
|
612
|
+
i = gt + 1;
|
|
613
|
+
continue;
|
|
614
|
+
}
|
|
615
|
+
const tag = nameMatch[1];
|
|
616
|
+
const raw = inner.slice(lt, gt + 1);
|
|
617
|
+
if (inner[gt - 1] === '/') {
|
|
618
|
+
out.push({ tag, raw, attrs: attrsOf(raw), inner: '' });
|
|
619
|
+
i = gt + 1;
|
|
620
|
+
continue;
|
|
621
|
+
}
|
|
622
|
+
const close = findClose(inner, tag, gt + 1);
|
|
623
|
+
if (close === -1) break;
|
|
624
|
+
out.push({ tag, raw, attrs: attrsOf(raw), inner: inner.slice(gt + 1, close.contentEnd) });
|
|
625
|
+
i = close.end;
|
|
626
|
+
}
|
|
627
|
+
return out;
|
|
628
|
+
}
|
|
629
|
+
|
|
630
|
+
// ---------------------------------------------------------------------------
|
|
631
|
+
// SPEC VERDICTS
|
|
632
|
+
// ---------------------------------------------------------------------------
|
|
633
|
+
|
|
634
|
+
const finding = (code, detail, opts = {}) => ({
|
|
635
|
+
code,
|
|
636
|
+
slide: opts.slide ?? null,
|
|
637
|
+
shape: opts.shape ?? null,
|
|
638
|
+
severity: opts.severity || 'error',
|
|
639
|
+
status: opts.status || 'confirmed',
|
|
640
|
+
detail,
|
|
641
|
+
});
|
|
642
|
+
|
|
643
|
+
/** cy > cx portrait, cx > cy landscape, equal is a failure (a square slide has no orientation). */
|
|
644
|
+
export function orientationOf(cx, cy) {
|
|
645
|
+
if (cy > cx) return 'portrait';
|
|
646
|
+
if (cx > cy) return 'landscape';
|
|
647
|
+
return 'square';
|
|
648
|
+
}
|
|
649
|
+
|
|
650
|
+
/** p:sldSz, read out of ppt/presentation.xml. type is absent in a conforming custom-size deck. */
|
|
651
|
+
export function readSlideSize(presentationXml) {
|
|
652
|
+
const el = readElement(presentationXml, 'sldSz');
|
|
653
|
+
if (!el) return null;
|
|
654
|
+
const cx = Number(attr(el.attrs, 'cx'));
|
|
655
|
+
const cy = Number(attr(el.attrs, 'cy'));
|
|
656
|
+
const type = attr(el.attrs, 'type');
|
|
657
|
+
return {
|
|
658
|
+
cx: Number.isFinite(cx) ? cx : null,
|
|
659
|
+
cy: Number.isFinite(cy) ? cy : null,
|
|
660
|
+
type: type === undefined ? null : type,
|
|
661
|
+
raw: el.raw,
|
|
662
|
+
};
|
|
663
|
+
}
|
|
664
|
+
|
|
665
|
+
/**
|
|
666
|
+
* The three slide-size verdicts. No tolerance anywhere: the frozen spec's own numbers differ from
|
|
667
|
+
* a rounded 8.27 x 11.69in by ~0.06mm, and a tolerance wide enough to hide that is a tolerance wide
|
|
668
|
+
* enough to ship a deck a print shop rejects.
|
|
669
|
+
*/
|
|
670
|
+
export function checkSlideSize(size) {
|
|
671
|
+
const out = [];
|
|
672
|
+
if (!size || size.cx === null || size.cy === null) {
|
|
673
|
+
out.push(finding('NO_SLIDE_SIZE', 'ppt/presentation.xml 에 p:sldSz 가 없거나 cx/cy 가 수치가 아니다.'));
|
|
674
|
+
return out;
|
|
675
|
+
}
|
|
676
|
+
const { cx, cy, type } = size;
|
|
677
|
+
|
|
678
|
+
if (cx === PRESET_TRAP.cx && cy === PRESET_TRAP.cy) {
|
|
679
|
+
out.push(finding(
|
|
680
|
+
'PRESET_TRAP',
|
|
681
|
+
`PowerPoint 의 'A4 용지' 프리셋 치수다(${cx}x${cy} EMU = 10.833x7.5in 가로). 이름만 A4 이고 A4 가 아니다. `
|
|
682
|
+
+ `필요한 값은 ${A4P.cx}x${A4P.cy} 다.`,
|
|
683
|
+
));
|
|
684
|
+
}
|
|
685
|
+
|
|
686
|
+
// Absent type is a PASS: OOXML defaults p:sldSz@type to "custom", and pptxgenjs emits no type at all.
|
|
687
|
+
if (type !== null && type !== 'custom') {
|
|
688
|
+
out.push(finding(
|
|
689
|
+
'PRESET_TYPE_ATTR',
|
|
690
|
+
`p:sldSz@type="${type}" 다. 치수가 맞아도 선언이 프리셋이면 뷰어와 인쇄 경로가 프리셋을 따라갈 수 있다. `
|
|
691
|
+
+ `type 속성을 제거하거나 "custom" 으로 둔다.`,
|
|
692
|
+
));
|
|
693
|
+
}
|
|
694
|
+
|
|
695
|
+
if (cx !== A4P.cx || cy !== A4P.cy) {
|
|
696
|
+
out.push(finding(
|
|
697
|
+
'NOT_A4_PORTRAIT',
|
|
698
|
+
`sldSz 가 ${cx}x${cy} EMU (${round2(mmOf(cx))}x${round2(mmOf(cy))}mm) 다. `
|
|
699
|
+
+ `A4 세로는 정확히 ${A4P.cx}x${A4P.cy} EMU (210x297mm) 이고 허용오차는 0 이다.`,
|
|
700
|
+
));
|
|
701
|
+
}
|
|
702
|
+
|
|
703
|
+
if (orientationOf(cx, cy) === 'square') {
|
|
704
|
+
out.push(finding('MIXED_ORIENTATION', `cx 와 cy 가 같다(${cx}). 정사각형 슬라이드라 세로/가로 판정이 불가능하다.`));
|
|
705
|
+
}
|
|
706
|
+
|
|
707
|
+
return out;
|
|
708
|
+
}
|
|
709
|
+
|
|
710
|
+
/**
|
|
711
|
+
* Bounds and margin, per edge. Outside the slide and outside the margin are the same defect at two
|
|
712
|
+
* severities, so an edge that already left the slide is reported once as SHAPE_OUT_OF_BOUNDS and
|
|
713
|
+
* not a second time as MARGIN_VIOLATION. Both are pure coordinate arithmetic, hence confirmed.
|
|
714
|
+
*/
|
|
715
|
+
export function checkShapeBounds(shape, ctx) {
|
|
716
|
+
const out = [];
|
|
717
|
+
const { slideCx, slideCy, marginEmu } = ctx;
|
|
718
|
+
const { x, y, cx, cy } = shape;
|
|
719
|
+
if ([x, y, cx, cy].some((v) => !Number.isFinite(v))) return out;
|
|
720
|
+
|
|
721
|
+
const where = { slide: ctx.slide, shape: shape.label };
|
|
722
|
+
const oob = [];
|
|
723
|
+
const margin = [];
|
|
724
|
+
const note = (list, edge, over) => list.push(`${edge} ${over} EMU (${mmStr(over)}mm)`);
|
|
725
|
+
|
|
726
|
+
if (x < 0) note(oob, '왼쪽', -x);
|
|
727
|
+
else if (x < marginEmu) note(margin, '왼쪽', marginEmu - x);
|
|
728
|
+
if (y < 0) note(oob, '위', -y);
|
|
729
|
+
else if (y < marginEmu) note(margin, '위', marginEmu - y);
|
|
730
|
+
if (x + cx > slideCx) note(oob, '오른쪽', x + cx - slideCx);
|
|
731
|
+
else if (x + cx > slideCx - marginEmu) note(margin, '오른쪽', x + cx - (slideCx - marginEmu));
|
|
732
|
+
if (y + cy > slideCy) note(oob, '아래', y + cy - slideCy);
|
|
733
|
+
else if (y + cy > slideCy - marginEmu) note(margin, '아래', y + cy - (slideCy - marginEmu));
|
|
734
|
+
|
|
735
|
+
if (oob.length) {
|
|
736
|
+
out.push(finding(
|
|
737
|
+
'SHAPE_OUT_OF_BOUNDS',
|
|
738
|
+
`도형이 슬라이드를 벗어난다: ${oob.join(', ')}. `
|
|
739
|
+
+ `상자 x=${x} y=${y} cx=${cx} cy=${cy}, 슬라이드 ${slideCx}x${slideCy}.`,
|
|
740
|
+
where,
|
|
741
|
+
));
|
|
742
|
+
}
|
|
743
|
+
if (margin.length) {
|
|
744
|
+
out.push(finding(
|
|
745
|
+
'MARGIN_VIOLATION',
|
|
746
|
+
`도형이 여백(${round2(mmOf(marginEmu))}mm)을 침범한다: ${margin.join(', ')}. `
|
|
747
|
+
+ `상자 x=${x} y=${y} cx=${cx} cy=${cy}.`,
|
|
748
|
+
where,
|
|
749
|
+
));
|
|
750
|
+
}
|
|
751
|
+
return out;
|
|
752
|
+
}
|
|
753
|
+
|
|
754
|
+
/** The three Korean font slots. Absent slots may still inherit from the theme, so this reports rather than fails. */
|
|
755
|
+
function checkFontSlots(run, where) {
|
|
756
|
+
const out = [];
|
|
757
|
+
const slots = { 'a:latin': run.latin, 'a:ea': run.ea, 'a:cs': run.cs };
|
|
758
|
+
const missing = Object.entries(slots).filter(([, v]) => !v).map(([k]) => k);
|
|
759
|
+
const present = [...new Set(Object.values(slots).filter(Boolean))];
|
|
760
|
+
|
|
761
|
+
if (missing.length && missing.length < 3) {
|
|
762
|
+
out.push(finding(
|
|
763
|
+
'FONT_SLOT_MISSING',
|
|
764
|
+
`run 의 폰트 슬롯 ${missing.join('/')} 가 없다(선언된 것: ${present.join(', ') || '없음'}). `
|
|
765
|
+
+ `테마가 채워 주면 정상이지만, 한글은 a:ea 가 비면 뷰어 폴백으로 밀려 tofu 가 난다.`,
|
|
766
|
+
{ ...where, severity: 'warn', status: 'unconfirmed' },
|
|
767
|
+
));
|
|
768
|
+
}
|
|
769
|
+
if (present.length > 1) {
|
|
770
|
+
out.push(finding(
|
|
771
|
+
'FONT_SLOT_MISMATCH',
|
|
772
|
+
`run 의 세 슬롯이 서로 다른 typeface 를 가리킨다: ${present.map((p) => `"${p}"`).join(' / ')}. `
|
|
773
|
+
+ `한글 3슬롯은 같은 이름이어야 라틴/한글 혼용 줄에서 글꼴이 갈리지 않는다.`,
|
|
774
|
+
{ ...where, severity: 'warn' },
|
|
775
|
+
));
|
|
776
|
+
}
|
|
777
|
+
return out;
|
|
778
|
+
}
|
|
779
|
+
|
|
780
|
+
// ---------------------------------------------------------------------------
|
|
781
|
+
// SLIDE PARSING
|
|
782
|
+
// ---------------------------------------------------------------------------
|
|
783
|
+
|
|
784
|
+
const SHAPE_TAGS = new Set(['sp', 'pic', 'graphicFrame', 'cxnSp', 'grpSp']);
|
|
785
|
+
|
|
786
|
+
/**
|
|
787
|
+
* Slide parts in presentation order: p:sldIdLst gives the order, the rels part gives the paths.
|
|
788
|
+
* Falls back to numeric filename order when either is missing, which is what a hand-built or
|
|
789
|
+
* partially damaged deck looks like.
|
|
790
|
+
*/
|
|
791
|
+
export function slideParts(zip) {
|
|
792
|
+
const pres = zip.readText('ppt/presentation.xml');
|
|
793
|
+
const relsXml = zip.readText('ppt/_rels/presentation.xml.rels');
|
|
794
|
+
const ordered = [];
|
|
795
|
+
if (pres && relsXml) {
|
|
796
|
+
const rels = new Map();
|
|
797
|
+
for (const el of readElements(relsXml, 'Relationship')) {
|
|
798
|
+
const id = attr(el.attrs, 'Id');
|
|
799
|
+
const target = attr(el.attrs, 'Target');
|
|
800
|
+
if (id && target) rels.set(id, target);
|
|
801
|
+
}
|
|
802
|
+
const lst = readElement(pres, 'sldIdLst');
|
|
803
|
+
if (lst) {
|
|
804
|
+
for (const el of readElements(lst.inner, 'sldId')) {
|
|
805
|
+
// r:id, never the sibling plain `id` attribute, which is the slide's own numeric id.
|
|
806
|
+
const rid = Object.entries(el.attrs).find(([k]) => k.endsWith(':id'))?.[1];
|
|
807
|
+
const target = rid && rels.get(rid);
|
|
808
|
+
if (!target) continue;
|
|
809
|
+
const part = path.posix.normalize(path.posix.join('ppt', target)).replace(/^\/+/, '');
|
|
810
|
+
if (zip.has(part)) ordered.push(part);
|
|
811
|
+
}
|
|
812
|
+
}
|
|
813
|
+
}
|
|
814
|
+
if (ordered.length) return ordered;
|
|
815
|
+
return zip.names()
|
|
816
|
+
.filter((n) => /^ppt\/slides\/slide\d+\.xml$/.test(n))
|
|
817
|
+
.sort((a, b) => Number(a.match(/(\d+)\.xml$/)[1]) - Number(b.match(/(\d+)\.xml$/)[1]));
|
|
818
|
+
}
|
|
819
|
+
|
|
820
|
+
/** a:bodyPr: insets, wrap and autofit. Autofit means the viewer resizes, so overflow stops being assertable. */
|
|
821
|
+
function parseBodyPr(txBodyInner) {
|
|
822
|
+
const el = readElement(txBodyInner, 'bodyPr');
|
|
823
|
+
const a = el ? el.attrs : {};
|
|
824
|
+
const num = (name, dflt) => {
|
|
825
|
+
const v = Number(attr(a, name));
|
|
826
|
+
return Number.isFinite(v) ? v : dflt;
|
|
827
|
+
};
|
|
828
|
+
const inner = el ? el.inner : '';
|
|
829
|
+
return {
|
|
830
|
+
wrap: attr(a, 'wrap') || 'square',
|
|
831
|
+
lIns: num('lIns', INSET_DEFAULT.l),
|
|
832
|
+
tIns: num('tIns', INSET_DEFAULT.t),
|
|
833
|
+
rIns: num('rIns', INSET_DEFAULT.r),
|
|
834
|
+
bIns: num('bIns', INSET_DEFAULT.b),
|
|
835
|
+
autofit: /<[^>]*\b(?:normAutofit|spAutoFit)\b/.test(inner) ? (/spAutoFit/.test(inner) ? 'spAutoFit' : 'normAutofit') : 'none',
|
|
836
|
+
present: Boolean(el),
|
|
837
|
+
};
|
|
838
|
+
}
|
|
839
|
+
|
|
840
|
+
/** Runs of one paragraph, split into forced lines at a:br. */
|
|
841
|
+
function parseParagraph(pInner) {
|
|
842
|
+
const brSplit = new RegExp(`<${NS}br\\b(?:\\s*/>|[^>]*>[\\s\\S]*?</${NS}br>)`, 'g');
|
|
843
|
+
return pInner.split(brSplit).map((segment) => {
|
|
844
|
+
const runs = [];
|
|
845
|
+
for (const r of readElements(segment, 'r')) {
|
|
846
|
+
const rPr = readElement(r.inner, 'rPr');
|
|
847
|
+
const sz = rPr ? Number(attr(rPr.attrs, 'sz')) : NaN;
|
|
848
|
+
const face = (tag) => {
|
|
849
|
+
const el = rPr ? readElement(rPr.inner, tag) : null;
|
|
850
|
+
const tf = el ? attr(el.attrs, 'typeface') : undefined;
|
|
851
|
+
return tf || null;
|
|
852
|
+
};
|
|
853
|
+
const t = readElement(r.inner, 't');
|
|
854
|
+
runs.push({
|
|
855
|
+
text: t ? xmlDecode(t.inner) : '',
|
|
856
|
+
szPt: Number.isFinite(sz) ? sz / 100 : null,
|
|
857
|
+
latin: face('latin'),
|
|
858
|
+
ea: face('ea'),
|
|
859
|
+
cs: face('cs'),
|
|
860
|
+
});
|
|
861
|
+
}
|
|
862
|
+
return runs;
|
|
863
|
+
}).filter((runs) => runs.length > 0);
|
|
864
|
+
}
|
|
865
|
+
|
|
866
|
+
/** One shape's box plus its text. Grouped children stay inside their group's absolute box. */
|
|
867
|
+
function parseShape(child) {
|
|
868
|
+
const nv = readElement(child.inner, 'cNvPr');
|
|
869
|
+
const id = nv ? attr(nv.attrs, 'id') : null;
|
|
870
|
+
const name = nv ? attr(nv.attrs, 'name') : null;
|
|
871
|
+
const label = (name && name.trim()) || (id ? `#${id}` : `<${child.tag}>`);
|
|
872
|
+
|
|
873
|
+
// grpSp keeps its own a:xfrm in p:grpSpPr; sp/pic in p:spPr; graphicFrame in p:xfrm. Reading the
|
|
874
|
+
// first xfrm inside the shape covers all three, and never reaches a child shape's because those
|
|
875
|
+
// sit deeper than the shape's own properties element.
|
|
876
|
+
const xfrm = readElement(child.inner, 'xfrm');
|
|
877
|
+
const off = xfrm ? readElement(xfrm.inner, 'off') : null;
|
|
878
|
+
const ext = xfrm ? readElement(xfrm.inner, 'ext') : null;
|
|
879
|
+
const num = (el, k) => {
|
|
880
|
+
if (!el) return NaN;
|
|
881
|
+
const v = Number(attr(el.attrs, k));
|
|
882
|
+
return Number.isFinite(v) ? v : NaN;
|
|
883
|
+
};
|
|
884
|
+
|
|
885
|
+
const txBody = readElement(child.inner, 'txBody');
|
|
886
|
+
const paragraphs = [];
|
|
887
|
+
if (txBody) for (const p of readElements(txBody.inner, 'p')) paragraphs.push(parseParagraph(p.inner));
|
|
888
|
+
|
|
889
|
+
// a:lnSpc scales the font's own line height. Findings ignore it on purpose (the font metric
|
|
890
|
+
// alone is the sound floor); only --suggest-deck applies it, where under-provisioning is the
|
|
891
|
+
// costly direction. build_a4p.js emits 120%, so ignoring it would propose too few rows.
|
|
892
|
+
let lineSpacing = 1;
|
|
893
|
+
if (txBody) {
|
|
894
|
+
const ln = readElement(txBody.inner, 'lnSpc');
|
|
895
|
+
const pct = ln ? readElement(ln.inner, 'spcPct') : null;
|
|
896
|
+
const val = pct ? Number(attr(pct.attrs, 'val')) : NaN;
|
|
897
|
+
if (Number.isFinite(val) && val > 0) lineSpacing = val / 100000;
|
|
898
|
+
}
|
|
899
|
+
|
|
900
|
+
return {
|
|
901
|
+
tag: child.tag,
|
|
902
|
+
label,
|
|
903
|
+
x: num(off, 'x'),
|
|
904
|
+
y: num(off, 'y'),
|
|
905
|
+
cx: num(ext, 'cx'),
|
|
906
|
+
cy: num(ext, 'cy'),
|
|
907
|
+
hasXfrm: Boolean(xfrm && off && ext),
|
|
908
|
+
body: txBody ? parseBodyPr(txBody.inner) : null,
|
|
909
|
+
lineSpacing,
|
|
910
|
+
paragraphs,
|
|
911
|
+
};
|
|
912
|
+
}
|
|
913
|
+
|
|
914
|
+
/** Top-level shapes of one slide: direct children of p:spTree, minus the tree's own group properties. */
|
|
915
|
+
export function parseSlide(slideXml) {
|
|
916
|
+
const tree = readElement(slideXml, 'spTree');
|
|
917
|
+
if (!tree) return [];
|
|
918
|
+
return childElements(tree.inner)
|
|
919
|
+
.filter((c) => SHAPE_TAGS.has(localName(c.tag)))
|
|
920
|
+
.map(parseShape);
|
|
921
|
+
}
|
|
922
|
+
|
|
923
|
+
// ---------------------------------------------------------------------------
|
|
924
|
+
// FONT READER (TTF / TTC / OTF)
|
|
925
|
+
// ---------------------------------------------------------------------------
|
|
926
|
+
//
|
|
927
|
+
// Every offset below is bounds-checked before it is dereferenced: --font-file is arbitrary user
|
|
928
|
+
// input, and a truncated or hostile file must degrade to "estimate", never crash the verifier.
|
|
929
|
+
//
|
|
930
|
+
// The two traps that make a naive reader silently wrong rather than loudly broken:
|
|
931
|
+
// - hmtx is indexed by GLYPH ID, not by character code, so a cmap is mandatory. And CJK fonts
|
|
932
|
+
// routinely ship format 12 as their real subtable, so a format-4-only reader hands back
|
|
933
|
+
// .notdef widths for exactly the text this skill exists to lay out.
|
|
934
|
+
// - past hhea.numberOfHMetrics, hmtx stops carrying advances and only leftSideBearings follow.
|
|
935
|
+
// Noto Sans CJK KR has numGlyphs 65535 with numberOfHMetrics 65532, so the boundary is live
|
|
936
|
+
// even in the default font.
|
|
937
|
+
|
|
938
|
+
const NAME_ID = Object.freeze({ FAMILY: 1, FULL: 4, POSTSCRIPT: 6, TYPO_FAMILY: 16 });
|
|
939
|
+
|
|
940
|
+
const fchk = (cond, message) => {
|
|
941
|
+
if (!cond) throw new FontError(message);
|
|
942
|
+
};
|
|
943
|
+
|
|
944
|
+
function readTableDirectory(buf, base) {
|
|
945
|
+
fchk(base + 12 <= buf.length, 'font: table directory out of range');
|
|
946
|
+
const numTables = buf.readUInt16BE(base + 4);
|
|
947
|
+
fchk(numTables > 0 && numTables < 512, `font: implausible numTables ${numTables}`);
|
|
948
|
+
fchk(base + 12 + numTables * 16 <= buf.length, 'font: table records out of range');
|
|
949
|
+
const tables = new Map();
|
|
950
|
+
for (let i = 0; i < numTables; i++) {
|
|
951
|
+
const rec = base + 12 + i * 16;
|
|
952
|
+
const tag = buf.toString('latin1', rec, rec + 4);
|
|
953
|
+
const offset = buf.readUInt32BE(rec + 8);
|
|
954
|
+
const length = buf.readUInt32BE(rec + 12);
|
|
955
|
+
if (offset >= 0 && offset + length <= buf.length) tables.set(tag, { offset, length });
|
|
956
|
+
}
|
|
957
|
+
return tables;
|
|
958
|
+
}
|
|
959
|
+
|
|
960
|
+
/** Face offsets: a bare font has one, a ttcf collection has numFonts. Never index a face by number. */
|
|
961
|
+
function faceOffsets(buf) {
|
|
962
|
+
fchk(buf.length >= 12, 'font: file too small');
|
|
963
|
+
if (buf.toString('latin1', 0, 4) === 'ttcf') {
|
|
964
|
+
const numFonts = buf.readUInt32BE(8);
|
|
965
|
+
fchk(numFonts > 0 && numFonts < 1024, `font: implausible ttcf numFonts ${numFonts}`);
|
|
966
|
+
fchk(12 + numFonts * 4 <= buf.length, 'font: ttcf offset table out of range');
|
|
967
|
+
const out = [];
|
|
968
|
+
for (let i = 0; i < numFonts; i++) out.push(buf.readUInt32BE(12 + i * 4));
|
|
969
|
+
return out;
|
|
970
|
+
}
|
|
971
|
+
return [0];
|
|
972
|
+
}
|
|
973
|
+
|
|
974
|
+
function decodeNameString(buf, start, length, platformID) {
|
|
975
|
+
fchk(start >= 0 && start + length <= buf.length, 'font: name string out of range');
|
|
976
|
+
const slice = buf.subarray(start, start + length);
|
|
977
|
+
if (platformID === 3 || platformID === 0) {
|
|
978
|
+
// UTF-16BE. swap16 needs an even length; an odd one is malformed, so drop the stray byte.
|
|
979
|
+
const even = slice.length % 2 === 0 ? slice : slice.subarray(0, slice.length - 1);
|
|
980
|
+
return Buffer.from(even).swap16().toString('utf16le');
|
|
981
|
+
}
|
|
982
|
+
return slice.toString('latin1');
|
|
983
|
+
}
|
|
984
|
+
|
|
985
|
+
/** nameID -> string, preferring the Windows/Unicode records that PowerPoint itself matches on. */
|
|
986
|
+
function readNames(buf, tables) {
|
|
987
|
+
const t = tables.get('name');
|
|
988
|
+
fchk(t, 'font: no name table');
|
|
989
|
+
const base = t.offset;
|
|
990
|
+
fchk(base + 6 <= buf.length, 'font: name header out of range');
|
|
991
|
+
const count = buf.readUInt16BE(base + 2);
|
|
992
|
+
const stringOffset = buf.readUInt16BE(base + 4);
|
|
993
|
+
fchk(base + 6 + count * 12 <= buf.length, 'font: name records out of range');
|
|
994
|
+
const best = new Map();
|
|
995
|
+
for (let i = 0; i < count; i++) {
|
|
996
|
+
const rec = base + 6 + i * 12;
|
|
997
|
+
const platformID = buf.readUInt16BE(rec);
|
|
998
|
+
const nameID = buf.readUInt16BE(rec + 6);
|
|
999
|
+
const length = buf.readUInt16BE(rec + 8);
|
|
1000
|
+
const offset = buf.readUInt16BE(rec + 10);
|
|
1001
|
+
if (!Object.values(NAME_ID).includes(nameID)) continue;
|
|
1002
|
+
const rank = platformID === 3 ? 3 : platformID === 0 ? 2 : 1;
|
|
1003
|
+
const prev = best.get(nameID);
|
|
1004
|
+
if (prev && prev.rank >= rank) continue;
|
|
1005
|
+
let value;
|
|
1006
|
+
try {
|
|
1007
|
+
value = decodeNameString(buf, base + stringOffset + offset, length, platformID);
|
|
1008
|
+
} catch {
|
|
1009
|
+
continue;
|
|
1010
|
+
}
|
|
1011
|
+
if (value) best.set(nameID, { rank, value });
|
|
1012
|
+
}
|
|
1013
|
+
const out = {};
|
|
1014
|
+
for (const [id, v] of best) out[id] = v.value;
|
|
1015
|
+
return out;
|
|
1016
|
+
}
|
|
1017
|
+
|
|
1018
|
+
/** Normalized comparison key: font names differ by case and spacing far more often than by content. */
|
|
1019
|
+
export function normFontName(name) {
|
|
1020
|
+
return String(name || '').toLowerCase().replace(/[\s_-]+/g, '');
|
|
1021
|
+
}
|
|
1022
|
+
|
|
1023
|
+
/** Does any of a face's names identify it as `wanted`? Exact first, then a guarded prefix match. */
|
|
1024
|
+
export function faceMatches(names, wanted) {
|
|
1025
|
+
const want = normFontName(wanted);
|
|
1026
|
+
if (!want) return false;
|
|
1027
|
+
const candidates = Object.values(names).map(normFontName).filter(Boolean);
|
|
1028
|
+
if (candidates.includes(want)) return true;
|
|
1029
|
+
// "Noto Sans CJK KR" must not match "Noto Sans Mono CJK KR", so only accept a prefix when what
|
|
1030
|
+
// follows is a style suffix (Regular/Bold/...), never more family words.
|
|
1031
|
+
return candidates.some((c) => c.startsWith(want) && /^(regular|book|roman|bold|italic|oblique|medium|light|thin|black|semibold|demibold|extrabold|ultralight|demilight|)$/.test(c.slice(want.length)));
|
|
1032
|
+
}
|
|
1033
|
+
|
|
1034
|
+
function parseCmapSubtable(buf, base, format) {
|
|
1035
|
+
if (format === 0) {
|
|
1036
|
+
fchk(base + 262 <= buf.length, 'font: cmap0 out of range');
|
|
1037
|
+
const table = buf.subarray(base + 6, base + 262);
|
|
1038
|
+
return (cp) => (cp < 256 ? table[cp] : 0);
|
|
1039
|
+
}
|
|
1040
|
+
if (format === 4) {
|
|
1041
|
+
fchk(base + 14 <= buf.length, 'font: cmap4 header out of range');
|
|
1042
|
+
const segX2 = buf.readUInt16BE(base + 6);
|
|
1043
|
+
const seg = segX2 >> 1;
|
|
1044
|
+
const endBase = base + 14;
|
|
1045
|
+
const startBase = endBase + segX2 + 2;
|
|
1046
|
+
const deltaBase = startBase + segX2;
|
|
1047
|
+
const rangeBase = deltaBase + segX2;
|
|
1048
|
+
fchk(rangeBase + segX2 <= buf.length, 'font: cmap4 arrays out of range');
|
|
1049
|
+
return (cp) => {
|
|
1050
|
+
if (cp > 0xffff) return 0;
|
|
1051
|
+
let lo = 0;
|
|
1052
|
+
let hi = seg - 1;
|
|
1053
|
+
while (lo <= hi) {
|
|
1054
|
+
const mid = (lo + hi) >> 1;
|
|
1055
|
+
if (buf.readUInt16BE(endBase + mid * 2) < cp) lo = mid + 1;
|
|
1056
|
+
else hi = mid - 1;
|
|
1057
|
+
}
|
|
1058
|
+
if (lo >= seg) return 0;
|
|
1059
|
+
const start = buf.readUInt16BE(startBase + lo * 2);
|
|
1060
|
+
if (cp < start) return 0;
|
|
1061
|
+
const delta = buf.readInt16BE(deltaBase + lo * 2);
|
|
1062
|
+
const rangeOffset = buf.readUInt16BE(rangeBase + lo * 2);
|
|
1063
|
+
if (rangeOffset === 0) return (cp + delta) & 0xffff;
|
|
1064
|
+
const gAddr = rangeBase + lo * 2 + rangeOffset + (cp - start) * 2;
|
|
1065
|
+
if (gAddr + 2 > buf.length) return 0;
|
|
1066
|
+
const g = buf.readUInt16BE(gAddr);
|
|
1067
|
+
return g === 0 ? 0 : (g + delta) & 0xffff;
|
|
1068
|
+
};
|
|
1069
|
+
}
|
|
1070
|
+
if (format === 6) {
|
|
1071
|
+
fchk(base + 10 <= buf.length, 'font: cmap6 header out of range');
|
|
1072
|
+
const first = buf.readUInt16BE(base + 6);
|
|
1073
|
+
const count = buf.readUInt16BE(base + 8);
|
|
1074
|
+
fchk(base + 10 + count * 2 <= buf.length, 'font: cmap6 array out of range');
|
|
1075
|
+
return (cp) => (cp >= first && cp < first + count ? buf.readUInt16BE(base + 10 + (cp - first) * 2) : 0);
|
|
1076
|
+
}
|
|
1077
|
+
if (format === 12) {
|
|
1078
|
+
fchk(base + 16 <= buf.length, 'font: cmap12 header out of range');
|
|
1079
|
+
const nGroups = buf.readUInt32BE(base + 12);
|
|
1080
|
+
fchk(nGroups < 1_000_000 && base + 16 + nGroups * 12 <= buf.length, 'font: cmap12 groups out of range');
|
|
1081
|
+
const groupsBase = base + 16;
|
|
1082
|
+
return (cp) => {
|
|
1083
|
+
let lo = 0;
|
|
1084
|
+
let hi = nGroups - 1;
|
|
1085
|
+
while (lo <= hi) {
|
|
1086
|
+
const mid = (lo + hi) >> 1;
|
|
1087
|
+
const g = groupsBase + mid * 12;
|
|
1088
|
+
const startChar = buf.readUInt32BE(g);
|
|
1089
|
+
const endChar = buf.readUInt32BE(g + 4);
|
|
1090
|
+
if (cp < startChar) hi = mid - 1;
|
|
1091
|
+
else if (cp > endChar) lo = mid + 1;
|
|
1092
|
+
else return buf.readUInt32BE(g + 8) + (cp - startChar);
|
|
1093
|
+
}
|
|
1094
|
+
return 0;
|
|
1095
|
+
};
|
|
1096
|
+
}
|
|
1097
|
+
throw new FontError(`font: unsupported cmap format ${format}`);
|
|
1098
|
+
}
|
|
1099
|
+
|
|
1100
|
+
/**
|
|
1101
|
+
* Best Unicode cmap. Preference (3,10) > (3,1) > (0,x), and format 14 (variation selectors) is
|
|
1102
|
+
* skipped outright: it is not a character-to-glyph map and reading it as one yields nonsense.
|
|
1103
|
+
*/
|
|
1104
|
+
function parseCmap(buf, tables) {
|
|
1105
|
+
const t = tables.get('cmap');
|
|
1106
|
+
fchk(t, 'font: no cmap table');
|
|
1107
|
+
const base = t.offset;
|
|
1108
|
+
fchk(base + 4 <= buf.length, 'font: cmap header out of range');
|
|
1109
|
+
const numTables = buf.readUInt16BE(base + 2);
|
|
1110
|
+
fchk(base + 4 + numTables * 8 <= buf.length, 'font: cmap records out of range');
|
|
1111
|
+
let best = null;
|
|
1112
|
+
for (let i = 0; i < numTables; i++) {
|
|
1113
|
+
const rec = base + 4 + i * 8;
|
|
1114
|
+
const platformID = buf.readUInt16BE(rec);
|
|
1115
|
+
const encodingID = buf.readUInt16BE(rec + 2);
|
|
1116
|
+
const subOffset = base + buf.readUInt32BE(rec + 4);
|
|
1117
|
+
if (subOffset + 2 > buf.length) continue;
|
|
1118
|
+
const format = buf.readUInt16BE(subOffset);
|
|
1119
|
+
if (![0, 4, 6, 12].includes(format)) continue;
|
|
1120
|
+
let score = 0;
|
|
1121
|
+
if (platformID === 3 && encodingID === 10) score = 100;
|
|
1122
|
+
else if (platformID === 0 && encodingID >= 4) score = 95;
|
|
1123
|
+
else if (platformID === 3 && encodingID === 1) score = 80;
|
|
1124
|
+
else if (platformID === 0) score = 70;
|
|
1125
|
+
else if (platformID === 1) score = 10;
|
|
1126
|
+
if (format === 12) score += 5;
|
|
1127
|
+
if (!best || score > best.score) best = { score, subOffset, format };
|
|
1128
|
+
}
|
|
1129
|
+
fchk(best, 'font: no usable cmap subtable');
|
|
1130
|
+
return parseCmapSubtable(buf, best.subOffset, best.format);
|
|
1131
|
+
}
|
|
1132
|
+
|
|
1133
|
+
/**
|
|
1134
|
+
* Open one face and expose the three things width prediction needs: names, unitsPerEm, and an
|
|
1135
|
+
* advance lookup by code point. Throws FontError on anything malformed; callers degrade.
|
|
1136
|
+
*/
|
|
1137
|
+
export function loadFace(buf, faceOffset) {
|
|
1138
|
+
const tables = readTableDirectory(buf, faceOffset);
|
|
1139
|
+
const head = tables.get('head');
|
|
1140
|
+
fchk(head && head.offset + 54 <= buf.length, 'font: no head table');
|
|
1141
|
+
const unitsPerEm = buf.readUInt16BE(head.offset + 18);
|
|
1142
|
+
fchk(unitsPerEm > 0, 'font: unitsPerEm is 0');
|
|
1143
|
+
|
|
1144
|
+
const hhea = tables.get('hhea');
|
|
1145
|
+
fchk(hhea && hhea.offset + 36 <= buf.length, 'font: no hhea table');
|
|
1146
|
+
const ascender = buf.readInt16BE(hhea.offset + 4);
|
|
1147
|
+
const descender = buf.readInt16BE(hhea.offset + 6);
|
|
1148
|
+
const lineGap = buf.readInt16BE(hhea.offset + 8);
|
|
1149
|
+
const numberOfHMetrics = buf.readUInt16BE(hhea.offset + 34);
|
|
1150
|
+
fchk(numberOfHMetrics > 0, 'font: numberOfHMetrics is 0');
|
|
1151
|
+
|
|
1152
|
+
const maxp = tables.get('maxp');
|
|
1153
|
+
fchk(maxp && maxp.offset + 6 <= buf.length, 'font: no maxp table');
|
|
1154
|
+
const numGlyphs = buf.readUInt16BE(maxp.offset + 4);
|
|
1155
|
+
|
|
1156
|
+
// Weight and slant, needed to tell one face of a family from another. Names cannot do it: the
|
|
1157
|
+
// Black cut of Noto Sans CJK carries nameID 16 "Noto Sans CJK KR", byte for byte the same
|
|
1158
|
+
// typographic family as the Regular cut. usWeightClass is the only field that separates them.
|
|
1159
|
+
const os2 = tables.get('OS/2');
|
|
1160
|
+
const weightClass = os2 && os2.offset + 6 <= buf.length ? buf.readUInt16BE(os2.offset + 4) : 400;
|
|
1161
|
+
const macStyle = buf.readUInt16BE(head.offset + 44);
|
|
1162
|
+
|
|
1163
|
+
const hmtx = tables.get('hmtx');
|
|
1164
|
+
fchk(hmtx, 'font: no hmtx table');
|
|
1165
|
+
const lookup = parseCmap(buf, tables);
|
|
1166
|
+
const names = readNames(buf, tables);
|
|
1167
|
+
|
|
1168
|
+
const lastMetric = Math.min(numberOfHMetrics, Math.floor(hmtx.length / 4)) - 1;
|
|
1169
|
+
fchk(lastMetric >= 0, 'font: hmtx too small for a single metric');
|
|
1170
|
+
|
|
1171
|
+
const advanceOfGlyph = (gid) => {
|
|
1172
|
+
// Past numberOfHMetrics the table stops carrying advances; the last one applies to every glyph
|
|
1173
|
+
// after it. Monospaced CJK fonts lean on this hard, so getting it wrong is not a corner case.
|
|
1174
|
+
const idx = gid < numberOfHMetrics ? Math.min(gid, lastMetric) : lastMetric;
|
|
1175
|
+
const at = hmtx.offset + idx * 4;
|
|
1176
|
+
if (at + 2 > buf.length) throw new FontError('font: hmtx read out of range');
|
|
1177
|
+
return buf.readUInt16BE(at);
|
|
1178
|
+
};
|
|
1179
|
+
|
|
1180
|
+
return {
|
|
1181
|
+
names,
|
|
1182
|
+
family: names[NAME_ID.TYPO_FAMILY] || names[NAME_ID.FAMILY] || names[NAME_ID.FULL] || names[NAME_ID.POSTSCRIPT] || '',
|
|
1183
|
+
unitsPerEm,
|
|
1184
|
+
numGlyphs,
|
|
1185
|
+
numberOfHMetrics,
|
|
1186
|
+
weightClass,
|
|
1187
|
+
bold: Boolean(macStyle & 0x01),
|
|
1188
|
+
italic: Boolean(macStyle & 0x02),
|
|
1189
|
+
// hhea-derived line height; PowerPoint's single-spaced line tracks these metrics closely.
|
|
1190
|
+
lineHeightEm: (ascender - descender + lineGap) / unitsPerEm,
|
|
1191
|
+
variable: tables.has('fvar'),
|
|
1192
|
+
hasHVAR: tables.has('HVAR'),
|
|
1193
|
+
glyphOf: lookup,
|
|
1194
|
+
advanceOfGlyph,
|
|
1195
|
+
/** Advance in em for one code point. gid 0 (.notdef) is reported so callers can drop confidence. */
|
|
1196
|
+
advanceEm(cp) {
|
|
1197
|
+
const gid = lookup(cp);
|
|
1198
|
+
if (!gid || gid >= Math.max(numGlyphs, 1)) return { em: advanceOfGlyph(0) / unitsPerEm, missing: true };
|
|
1199
|
+
return { em: advanceOfGlyph(gid) / unitsPerEm, missing: false };
|
|
1200
|
+
},
|
|
1201
|
+
};
|
|
1202
|
+
}
|
|
1203
|
+
|
|
1204
|
+
/**
|
|
1205
|
+
* Open a font file and return the face whose name table says it is `family`.
|
|
1206
|
+
* Returns null when the file cannot be read, cannot be parsed, or holds no matching face. That
|
|
1207
|
+
* "no matching face" case is the whole point: it is what makes --font-file verified, not trusted.
|
|
1208
|
+
*/
|
|
1209
|
+
export function openFontFile(filePath, family) {
|
|
1210
|
+
let buf;
|
|
1211
|
+
try {
|
|
1212
|
+
const st = fs.statSync(filePath);
|
|
1213
|
+
if (!st.isFile() || st.size < 12) return null;
|
|
1214
|
+
buf = fs.readFileSync(filePath);
|
|
1215
|
+
} catch {
|
|
1216
|
+
return null;
|
|
1217
|
+
}
|
|
1218
|
+
let offsets;
|
|
1219
|
+
try {
|
|
1220
|
+
offsets = faceOffsets(buf);
|
|
1221
|
+
} catch {
|
|
1222
|
+
return null;
|
|
1223
|
+
}
|
|
1224
|
+
const faces = [];
|
|
1225
|
+
for (let i = 0; i < offsets.length; i++) {
|
|
1226
|
+
try {
|
|
1227
|
+
const face = loadFace(buf, offsets[i]);
|
|
1228
|
+
faces.push({ index: i, face });
|
|
1229
|
+
} catch {
|
|
1230
|
+
// A collection with one bad face is still usable through its good ones.
|
|
1231
|
+
}
|
|
1232
|
+
}
|
|
1233
|
+
if (!faces.length) return null;
|
|
1234
|
+
const matching = family ? faces.filter(({ face }) => faceMatches(face.names, family)) : [];
|
|
1235
|
+
if (!matching.length) return null;
|
|
1236
|
+
// Several faces of one collection can answer to the same family name, so pick the upright
|
|
1237
|
+
// regular cut rather than whichever happened to be stored first.
|
|
1238
|
+
const hit = matching.reduce((best, cur) => (faceDistance(cur.face) < faceDistance(best.face) ? cur : best));
|
|
1239
|
+
return { path: filePath, faceIndex: hit.index, faceCount: offsets.length, ...hit.face };
|
|
1240
|
+
}
|
|
1241
|
+
|
|
1242
|
+
/**
|
|
1243
|
+
* How far a face is from "the plain upright text cut", which is what a bare typeface name means.
|
|
1244
|
+
* Zero is ideal. Weight matters because Latin advances move with it (Noto Sans CJK 'A' runs 574
|
|
1245
|
+
* units at Thin and 660 at Black); CJK advances do not move at all, being duospaced at 0.92em.
|
|
1246
|
+
*/
|
|
1247
|
+
export function faceDistance(face) {
|
|
1248
|
+
return Math.abs((face.weightClass || 400) - 400) + (face.italic ? 10000 : 0);
|
|
1249
|
+
}
|
|
1250
|
+
|
|
1251
|
+
// ---------------------------------------------------------------------------
|
|
1252
|
+
// FONT PATH RESOLUTION (names in, paths out)
|
|
1253
|
+
// ---------------------------------------------------------------------------
|
|
1254
|
+
//
|
|
1255
|
+
// A pptx names a typeface; hmtx needs a file. Resolution failure is a normal path here, not an
|
|
1256
|
+
// exception: on a machine without the deck's font the honest answer is "estimate".
|
|
1257
|
+
|
|
1258
|
+
/** fontconfig pattern escaping. An unescaped ':' or ',' turns a family name into a property list. */
|
|
1259
|
+
const fcEscape = (name) => String(name).replace(/([\\:,\-])/g, '\\$1');
|
|
1260
|
+
|
|
1261
|
+
function fcMatch(name) {
|
|
1262
|
+
let r;
|
|
1263
|
+
try {
|
|
1264
|
+
r = spawnSync('fc-match', [`--format=%{file}`, fcEscape(name)], { encoding: 'utf8', timeout: 4000 });
|
|
1265
|
+
} catch {
|
|
1266
|
+
return null;
|
|
1267
|
+
}
|
|
1268
|
+
if (!r || r.error || r.status !== 0) return null;
|
|
1269
|
+
const file = (r.stdout || '').trim();
|
|
1270
|
+
return file && fs.existsSync(file) ? file : null;
|
|
1271
|
+
}
|
|
1272
|
+
|
|
1273
|
+
export function fontDirs() {
|
|
1274
|
+
const home = os.homedir();
|
|
1275
|
+
if (process.platform === 'darwin') {
|
|
1276
|
+
return ['/System/Library/Fonts', '/Library/Fonts', path.join(home, 'Library', 'Fonts')];
|
|
1277
|
+
}
|
|
1278
|
+
if (process.platform === 'win32') {
|
|
1279
|
+
const local = process.env.LOCALAPPDATA;
|
|
1280
|
+
const win = process.env.SystemRoot || 'C:\\Windows';
|
|
1281
|
+
return [path.join(win, 'Fonts'), local ? path.join(local, 'Microsoft', 'Windows', 'Fonts') : null].filter(Boolean);
|
|
1282
|
+
}
|
|
1283
|
+
return ['/usr/share/fonts', '/usr/local/share/fonts', path.join(home, '.local', 'share', 'fonts'), path.join(home, '.fonts')];
|
|
1284
|
+
}
|
|
1285
|
+
|
|
1286
|
+
function walkFonts(dirs) {
|
|
1287
|
+
const out = [];
|
|
1288
|
+
const seen = new Set();
|
|
1289
|
+
const visit = (dir, depth) => {
|
|
1290
|
+
if (depth > FONT_SCAN.MAX_DEPTH || out.length >= FONT_SCAN.MAX_FILES) return;
|
|
1291
|
+
let items;
|
|
1292
|
+
try {
|
|
1293
|
+
items = fs.readdirSync(dir, { withFileTypes: true });
|
|
1294
|
+
} catch {
|
|
1295
|
+
return;
|
|
1296
|
+
}
|
|
1297
|
+
for (const it of items) {
|
|
1298
|
+
if (out.length >= FONT_SCAN.MAX_FILES) return;
|
|
1299
|
+
const full = path.join(dir, it.name);
|
|
1300
|
+
if (it.isDirectory()) {
|
|
1301
|
+
if (seen.has(full)) continue;
|
|
1302
|
+
seen.add(full);
|
|
1303
|
+
visit(full, depth + 1);
|
|
1304
|
+
} else if (FONT_EXT.test(it.name)) {
|
|
1305
|
+
out.push(full);
|
|
1306
|
+
}
|
|
1307
|
+
}
|
|
1308
|
+
};
|
|
1309
|
+
for (const d of dirs) visit(d, 0);
|
|
1310
|
+
return out;
|
|
1311
|
+
}
|
|
1312
|
+
|
|
1313
|
+
/**
|
|
1314
|
+
* Name -> verified path. fc-match first, then an OS-specific glob ranked by filename similarity.
|
|
1315
|
+
* Every candidate, fc-match's included, is opened and checked against the name table before it is
|
|
1316
|
+
* accepted: fc-match always answers, so an unverified answer to "Malgun Gothic" on Linux would be
|
|
1317
|
+
* DejaVu Sans wearing the wrong label.
|
|
1318
|
+
*/
|
|
1319
|
+
export function resolveFont(family, extraPaths = []) {
|
|
1320
|
+
const tried = [];
|
|
1321
|
+
for (const p of extraPaths) {
|
|
1322
|
+
if (!p) continue;
|
|
1323
|
+
tried.push(p);
|
|
1324
|
+
const font = openFontFile(p, family);
|
|
1325
|
+
if (font) return { font, via: 'explicit' };
|
|
1326
|
+
}
|
|
1327
|
+
|
|
1328
|
+
const fc = fcMatch(family);
|
|
1329
|
+
if (fc && !tried.includes(fc)) {
|
|
1330
|
+
tried.push(fc);
|
|
1331
|
+
const font = openFontFile(fc, family);
|
|
1332
|
+
if (font) return { font, via: 'fc-match' };
|
|
1333
|
+
}
|
|
1334
|
+
|
|
1335
|
+
const want = normFontName(family);
|
|
1336
|
+
const files = walkFonts(fontDirs())
|
|
1337
|
+
.filter((f) => !tried.includes(f))
|
|
1338
|
+
.map((f) => ({ f, score: filenameScore(normFontName(path.basename(f).replace(FONT_EXT, '')), want) }))
|
|
1339
|
+
.filter((c) => c.score > 0)
|
|
1340
|
+
.sort((a, b) => b.score - a.score)
|
|
1341
|
+
.slice(0, FONT_SCAN.MAX_OPENS);
|
|
1342
|
+
// Best, not first. One family is routinely spread across a file per weight, and taking the first
|
|
1343
|
+
// hit picks whichever the directory walk reached first: NotoSansCJK-Black.ttc sorts before
|
|
1344
|
+
// -Regular.ttc alphabetically and answers to the same family name.
|
|
1345
|
+
let best = null;
|
|
1346
|
+
for (const { f } of files) {
|
|
1347
|
+
const font = openFontFile(f, family);
|
|
1348
|
+
if (!font) continue;
|
|
1349
|
+
if (!best || faceDistance(font) < faceDistance(best)) best = font;
|
|
1350
|
+
if (faceDistance(font) === 0) break;
|
|
1351
|
+
}
|
|
1352
|
+
return best ? { font: best, via: 'glob' } : { font: null, via: 'none' };
|
|
1353
|
+
}
|
|
1354
|
+
|
|
1355
|
+
const commonPrefixLen = (a, b) => {
|
|
1356
|
+
let i = 0;
|
|
1357
|
+
while (i < a.length && i < b.length && a[i] === b[i]) i++;
|
|
1358
|
+
return i;
|
|
1359
|
+
};
|
|
1360
|
+
|
|
1361
|
+
/**
|
|
1362
|
+
* Rank a font FILENAME against a wanted family name. Only ever a ranking: the name table still has
|
|
1363
|
+
* the final say in openFontFile, so a generous score costs one file open and never a wrong answer.
|
|
1364
|
+
*
|
|
1365
|
+
* Generosity is required, not sloppiness. A filename is not a face name, and for the fonts this
|
|
1366
|
+
* skill cares about most it actively disagrees with one:
|
|
1367
|
+
* NotoSansCJK-Regular.ttc holds "Noto Sans CJK KR" (a collection; no filename can name 10 faces)
|
|
1368
|
+
* malgun.ttf holds "Malgun Gothic" (the shipped Windows Korean font)
|
|
1369
|
+
* A strict substring test rejects both, which on any machine without fontconfig would silently
|
|
1370
|
+
* demote every Korean deck to "estimate".
|
|
1371
|
+
*/
|
|
1372
|
+
function filenameScore(base, want) {
|
|
1373
|
+
if (!base || !want) return 0;
|
|
1374
|
+
if (base === want) return 1000;
|
|
1375
|
+
if (base.startsWith(want)) return 800;
|
|
1376
|
+
if (want.startsWith(base) && base.length >= 4) return 700;
|
|
1377
|
+
if (base.includes(want)) return 600;
|
|
1378
|
+
if (want.includes(base) && base.length >= 4) return 500;
|
|
1379
|
+
const shared = commonPrefixLen(base, want);
|
|
1380
|
+
return shared >= 5 ? shared : 0;
|
|
1381
|
+
}
|
|
1382
|
+
|
|
1383
|
+
// ---------------------------------------------------------------------------
|
|
1384
|
+
// WIDTH PREDICTION
|
|
1385
|
+
// ---------------------------------------------------------------------------
|
|
1386
|
+
//
|
|
1387
|
+
// Always a LOWER BOUND. Kerning pairs shrink or grow a line and are not applied; line-breaking
|
|
1388
|
+
// rules (Korean allows a break almost anywhere, Latin does not) are not reproduced. So a run's
|
|
1389
|
+
// real advance is at least what is computed here, which is what makes a positive overflow finding
|
|
1390
|
+
// sound and a negative one merely silent.
|
|
1391
|
+
|
|
1392
|
+
const WIDE_RANGES = [
|
|
1393
|
+
[0x1100, 0x115f], [0x2e80, 0x303e], [0x3041, 0x33ff], [0x3400, 0x4dbf], [0x4e00, 0x9fff],
|
|
1394
|
+
[0xa000, 0xa4cf], [0xa960, 0xa97f], [0xac00, 0xd7a3], [0xf900, 0xfaff], [0xfe10, 0xfe19],
|
|
1395
|
+
[0xfe30, 0xfe6f], [0xff00, 0xff60], [0xffe0, 0xffe6], [0x20000, 0x3fffd],
|
|
1396
|
+
];
|
|
1397
|
+
|
|
1398
|
+
const isWide = (cp) => WIDE_RANGES.some(([lo, hi]) => cp >= lo && cp <= hi);
|
|
1399
|
+
|
|
1400
|
+
/**
|
|
1401
|
+
* The crude fallback used only when nothing was measured. Its error runs from +2.5% to +96% on
|
|
1402
|
+
* real corpora (plan F5), which is exactly why anything computed from it is labelled unconfirmed
|
|
1403
|
+
* and never phrased as an assertion.
|
|
1404
|
+
*/
|
|
1405
|
+
const estimateEm = (cp) => (cp === 0x20 ? 0.25 : isWide(cp) ? 1.0 : 0.5);
|
|
1406
|
+
|
|
1407
|
+
/** Width of one run in EMU, plus whether the font could actually render every character. */
|
|
1408
|
+
export function runWidthEmu(text, szPt, font) {
|
|
1409
|
+
let em = 0;
|
|
1410
|
+
let missing = 0;
|
|
1411
|
+
for (const ch of String(text)) {
|
|
1412
|
+
const cp = ch.codePointAt(0);
|
|
1413
|
+
if (cp === 0x0a || cp === 0x0d) continue;
|
|
1414
|
+
if (font) {
|
|
1415
|
+
const a = font.advanceEm(cp);
|
|
1416
|
+
em += a.em;
|
|
1417
|
+
if (a.missing) missing++;
|
|
1418
|
+
} else {
|
|
1419
|
+
em += estimateEm(cp);
|
|
1420
|
+
}
|
|
1421
|
+
}
|
|
1422
|
+
return { emu: em * szPt * EMU_PER_PT, em, missing };
|
|
1423
|
+
}
|
|
1424
|
+
|
|
1425
|
+
/**
|
|
1426
|
+
* Text fit for one shape. Two distinct defects, because they have different proofs:
|
|
1427
|
+
* wrap="none" -> a single line wider than the box runs out of the shape horizontally.
|
|
1428
|
+
* wrapping -> ceil(lowerBoundWidth / usableWidth) is a LOWER BOUND on line count (greedy
|
|
1429
|
+
* breaking can only ever need more lines), so a stack of lines taller than the
|
|
1430
|
+
* box proves vertical overflow.
|
|
1431
|
+
* Either way the finding is only "confirmed" when every ingredient was measured rather than guessed.
|
|
1432
|
+
*/
|
|
1433
|
+
function checkTextFit(shape, ctx) {
|
|
1434
|
+
const out = [];
|
|
1435
|
+
if (!shape.body || !shape.hasXfrm || !shape.paragraphs.length) return out;
|
|
1436
|
+
const usableW = shape.cx - shape.body.lIns - shape.body.rIns;
|
|
1437
|
+
const usableH = shape.cy - shape.body.tIns - shape.body.bIns;
|
|
1438
|
+
if (!(usableW > 0) || !(usableH > 0)) return out;
|
|
1439
|
+
|
|
1440
|
+
const where = { slide: ctx.slide, shape: shape.label };
|
|
1441
|
+
let totalH = 0;
|
|
1442
|
+
let anyText = false;
|
|
1443
|
+
let allMeasured = true;
|
|
1444
|
+
let anyMissingGlyph = false;
|
|
1445
|
+
let anyInheritedSz = false;
|
|
1446
|
+
let widest = 0;
|
|
1447
|
+
|
|
1448
|
+
for (const paragraph of shape.paragraphs) {
|
|
1449
|
+
for (const line of paragraph) {
|
|
1450
|
+
let lineW = 0;
|
|
1451
|
+
let lineH = 0;
|
|
1452
|
+
// Confidence is tracked per line as well as per shape: a horizontal finding on line 1 must
|
|
1453
|
+
// not borrow confidence from a font that only line 5 turned out to be missing.
|
|
1454
|
+
let lineMeasured = true;
|
|
1455
|
+
let lineMissingGlyph = false;
|
|
1456
|
+
let lineInheritedSz = false;
|
|
1457
|
+
for (const run of line) {
|
|
1458
|
+
if (!run.text) continue;
|
|
1459
|
+
anyText = true;
|
|
1460
|
+
const szPt = run.szPt ?? FALLBACK_SZ_PT;
|
|
1461
|
+
if (run.szPt === null) lineInheritedSz = true;
|
|
1462
|
+
const resolved = ctx.fontFor(run.latin);
|
|
1463
|
+
if (!resolved.font) lineMeasured = false;
|
|
1464
|
+
const w = runWidthEmu(run.text, szPt, resolved.font);
|
|
1465
|
+
if (w.missing) lineMissingGlyph = true;
|
|
1466
|
+
lineW += w.emu;
|
|
1467
|
+
const lh = (resolved.font ? resolved.font.lineHeightEm : FALLBACK_LINE_EM) * szPt * EMU_PER_PT;
|
|
1468
|
+
if (lh > lineH) lineH = lh;
|
|
1469
|
+
}
|
|
1470
|
+
allMeasured = allMeasured && lineMeasured;
|
|
1471
|
+
anyMissingGlyph = anyMissingGlyph || lineMissingGlyph;
|
|
1472
|
+
anyInheritedSz = anyInheritedSz || lineInheritedSz;
|
|
1473
|
+
if (!lineH) continue;
|
|
1474
|
+
widest = Math.max(widest, lineW);
|
|
1475
|
+
if (shape.body.wrap === 'none') {
|
|
1476
|
+
totalH += lineH;
|
|
1477
|
+
if (lineW > usableW) {
|
|
1478
|
+
out.push(fitFinding('TEXT_OVERFLOW_NOWRAP', where, lineMeasured, lineMissingGlyph, lineInheritedSz, shape.body.autofit,
|
|
1479
|
+
`wrap="none" 인데 한 줄 폭 ${Math.round(lineW)} EMU (${round2(mmOf(lineW))}mm) 가 상자 안쪽 폭 `
|
|
1480
|
+
+ `${usableW} EMU (${round2(mmOf(usableW))}mm) 보다 크다`));
|
|
1481
|
+
}
|
|
1482
|
+
} else {
|
|
1483
|
+
totalH += Math.max(1, Math.ceil(lineW / usableW)) * lineH;
|
|
1484
|
+
}
|
|
1485
|
+
}
|
|
1486
|
+
}
|
|
1487
|
+
|
|
1488
|
+
if (anyText && shape.body.wrap !== 'none' && totalH > usableH) {
|
|
1489
|
+
out.push(fitFinding('TEXT_OVERFLOW', where, allMeasured, anyMissingGlyph, anyInheritedSz, shape.body.autofit,
|
|
1490
|
+
`줄 높이 합계 하한 ${Math.round(totalH)} EMU (${round2(mmOf(totalH))}mm) 가 상자 안쪽 높이 `
|
|
1491
|
+
+ `${usableH} EMU (${round2(mmOf(usableH))}mm) 를 넘는다 (가장 긴 줄 ${round2(mmOf(widest))}mm)`));
|
|
1492
|
+
}
|
|
1493
|
+
return out;
|
|
1494
|
+
}
|
|
1495
|
+
|
|
1496
|
+
/**
|
|
1497
|
+
* Phrase an overflow finding at the confidence its inputs actually support. In estimate mode this
|
|
1498
|
+
* never says the text overflows; it says the tool cannot confirm it, and the finding is a warning.
|
|
1499
|
+
*/
|
|
1500
|
+
function fitFinding(code, where, measured, missingGlyph, inheritedSz, autofit, body) {
|
|
1501
|
+
const blockers = [];
|
|
1502
|
+
if (!measured) blockers.push('폰트 미측정(estimate)');
|
|
1503
|
+
if (missingGlyph) blockers.push('폰트에 없는 글자 포함');
|
|
1504
|
+
if (inheritedSz) blockers.push(`a:rPr@sz 부재(${FALLBACK_SZ_PT}pt 로 가정)`);
|
|
1505
|
+
if (autofit !== 'none') blockers.push(`a:bodyPr 에 ${autofit} (뷰어가 크기를 조정한다)`);
|
|
1506
|
+
if (!blockers.length) {
|
|
1507
|
+
return finding(code, `텍스트가 상자를 넘는다: ${body}. 폭은 커널링/줄바꿈 미반영 하한이므로 실제는 이보다 넓다.`, { ...where, severity: 'error' });
|
|
1508
|
+
}
|
|
1509
|
+
return finding(code, `확인 불가(estimate): ${body}. 단정할 수 없는 이유: ${blockers.join(', ')}.`, { ...where, severity: 'warn', status: 'unconfirmed' });
|
|
1510
|
+
}
|
|
1511
|
+
|
|
1512
|
+
// ---------------------------------------------------------------------------
|
|
1513
|
+
// REPORT
|
|
1514
|
+
// ---------------------------------------------------------------------------
|
|
1515
|
+
|
|
1516
|
+
/** meta.marginMm contract check. An out-of-contract deck is a usage error, not a deck defect. */
|
|
1517
|
+
export function deckMarginMm(deck) {
|
|
1518
|
+
const m = deck?.meta?.marginMm;
|
|
1519
|
+
if (m === undefined || m === null) return MARGIN_MM.DEFAULT;
|
|
1520
|
+
if (typeof m !== 'number' || !Number.isInteger(m) || m < MARGIN_MM.MIN || m > MARGIN_MM.MAX) {
|
|
1521
|
+
throw new VerifyError(
|
|
1522
|
+
`deck meta.marginMm 은 ${MARGIN_MM.MIN}..${MARGIN_MM.MAX} 범위의 정수여야 한다 (받은 값: ${JSON.stringify(m)})`,
|
|
1523
|
+
'BAD_MARGIN',
|
|
1524
|
+
);
|
|
1525
|
+
}
|
|
1526
|
+
return m;
|
|
1527
|
+
}
|
|
1528
|
+
|
|
1529
|
+
/**
|
|
1530
|
+
* Verify one deck buffer. Options:
|
|
1531
|
+
* fontFile candidate font path, applied only to runs whose a:latin family it actually matches
|
|
1532
|
+
* deck parsed deck JSON, whose marginMm replaces the spec floor for the margin check
|
|
1533
|
+
*/
|
|
1534
|
+
export function verifyBuffer(buf, opts = {}) {
|
|
1535
|
+
const report = {
|
|
1536
|
+
skill: 'vertical-pptx',
|
|
1537
|
+
file: opts.file || null,
|
|
1538
|
+
ok: false,
|
|
1539
|
+
slideSize: null,
|
|
1540
|
+
marginMm: MARGIN_MM.DEFAULT,
|
|
1541
|
+
marginSource: 'spec-floor',
|
|
1542
|
+
fontSource: 'estimate',
|
|
1543
|
+
widthBasis: 'lower-bound (커널링/줄바꿈 미반영)',
|
|
1544
|
+
fonts: [],
|
|
1545
|
+
counts: { slides: 0, shapes: 0, runs: 0, errors: 0, warnings: 0 },
|
|
1546
|
+
findings: [],
|
|
1547
|
+
};
|
|
1548
|
+
|
|
1549
|
+
if (opts.deck !== undefined && opts.deck !== null) {
|
|
1550
|
+
report.marginMm = deckMarginMm(opts.deck);
|
|
1551
|
+
report.marginSource = 'deck';
|
|
1552
|
+
}
|
|
1553
|
+
|
|
1554
|
+
const zip = openZip(buf);
|
|
1555
|
+
const presentation = zip.readText('ppt/presentation.xml');
|
|
1556
|
+
if (presentation === null) {
|
|
1557
|
+
report.findings.push(finding('MISSING_PRESENTATION_XML', 'ppt/presentation.xml 이 없다. pptx(OPC) 패키지가 아니거나 손상됐다.'));
|
|
1558
|
+
return finish(report);
|
|
1559
|
+
}
|
|
1560
|
+
|
|
1561
|
+
const size = readSlideSize(presentation);
|
|
1562
|
+
report.slideSize = size && {
|
|
1563
|
+
cx: size.cx,
|
|
1564
|
+
cy: size.cy,
|
|
1565
|
+
type: size.type,
|
|
1566
|
+
orientation: size.cx !== null && size.cy !== null ? orientationOf(size.cx, size.cy) : null,
|
|
1567
|
+
widthMm: size.cx === null ? null : round2(mmOf(size.cx)),
|
|
1568
|
+
heightMm: size.cy === null ? null : round2(mmOf(size.cy)),
|
|
1569
|
+
};
|
|
1570
|
+
report.findings.push(...checkSlideSize(size));
|
|
1571
|
+
|
|
1572
|
+
const slideCx = size?.cx ?? A4P.cx;
|
|
1573
|
+
const slideCy = size?.cy ?? A4P.cy;
|
|
1574
|
+
const marginEmu = report.marginMm * EMU_PER_MM;
|
|
1575
|
+
|
|
1576
|
+
// One resolution attempt per distinct typeface, cached. --font-file is offered to every family
|
|
1577
|
+
// but accepted only by the one whose name table agrees, so a mixed-typeface deck (which is what
|
|
1578
|
+
// an externally produced deck is) never gets one path smeared across all of its runs.
|
|
1579
|
+
const fontFilePath = opts.fontFile ? path.resolve(opts.fontFile) : null;
|
|
1580
|
+
const fontCache = new Map();
|
|
1581
|
+
const fontFor = (typeface) => {
|
|
1582
|
+
// "+mn-lt"/"+mj-lt" are theme references, resolvable only through the theme part's font scheme.
|
|
1583
|
+
const name = typeface && !typeface.startsWith('+') ? typeface : null;
|
|
1584
|
+
if (!name) return { font: null, via: 'none' };
|
|
1585
|
+
if (fontCache.has(name)) return fontCache.get(name);
|
|
1586
|
+
const resolved = resolveFont(name, fontFilePath ? [fontFilePath] : []);
|
|
1587
|
+
fontCache.set(name, resolved);
|
|
1588
|
+
return resolved;
|
|
1589
|
+
};
|
|
1590
|
+
|
|
1591
|
+
const parts = slideParts(zip);
|
|
1592
|
+
report.counts.slides = parts.length;
|
|
1593
|
+
for (let i = 0; i < parts.length; i++) {
|
|
1594
|
+
const slideNo = i + 1;
|
|
1595
|
+
const xml = zip.readText(parts[i]);
|
|
1596
|
+
if (xml === null) continue;
|
|
1597
|
+
const shapes = parseSlide(xml);
|
|
1598
|
+
report.counts.shapes += shapes.length;
|
|
1599
|
+
for (const shape of shapes) {
|
|
1600
|
+
if (shape.hasXfrm) {
|
|
1601
|
+
report.findings.push(...checkShapeBounds(shape, { slideCx, slideCy, marginEmu, slide: slideNo }));
|
|
1602
|
+
}
|
|
1603
|
+
for (const paragraph of shape.paragraphs) {
|
|
1604
|
+
for (const line of paragraph) {
|
|
1605
|
+
for (const run of line) {
|
|
1606
|
+
if (!run.text) continue;
|
|
1607
|
+
report.counts.runs++;
|
|
1608
|
+
fontFor(run.latin);
|
|
1609
|
+
report.findings.push(...checkFontSlots(run, { slide: slideNo, shape: shape.label }));
|
|
1610
|
+
}
|
|
1611
|
+
}
|
|
1612
|
+
}
|
|
1613
|
+
report.findings.push(...checkTextFit(shape, { slide: slideNo, fontFor }));
|
|
1614
|
+
}
|
|
1615
|
+
}
|
|
1616
|
+
|
|
1617
|
+
// A typeface that never resolved keeps the whole report at "estimate": the label is fail-safe by
|
|
1618
|
+
// construction, so a partially measured deck does not get to advertise itself as measured.
|
|
1619
|
+
const used = [...fontCache.entries()].filter(([name]) => Boolean(name));
|
|
1620
|
+
report.fonts = used.map(([name, r]) => ({
|
|
1621
|
+
typeface: name,
|
|
1622
|
+
source: r.font ? 'measured' : 'estimate',
|
|
1623
|
+
via: r.via,
|
|
1624
|
+
path: r.font ? r.font.path : null,
|
|
1625
|
+
face: r.font ? `#${r.font.faceIndex} of ${r.font.faceCount}` : null,
|
|
1626
|
+
family: r.font ? r.font.family : null,
|
|
1627
|
+
unitsPerEm: r.font ? r.font.unitsPerEm : null,
|
|
1628
|
+
variable: r.font ? r.font.variable : null,
|
|
1629
|
+
label: r.font ? (r.font.variable ? '가변 폰트 기본 인스턴스 폭' : '정적 폭') : null,
|
|
1630
|
+
}));
|
|
1631
|
+
report.fontSource = used.length && used.every(([, r]) => r.font) ? 'measured' : 'estimate';
|
|
1632
|
+
|
|
1633
|
+
if (fontFilePath) {
|
|
1634
|
+
const accepted = report.fonts.some((f) => f.path === fontFilePath);
|
|
1635
|
+
report.fontFile = { path: fontFilePath, accepted };
|
|
1636
|
+
// Nothing to reject when the deck names no typeface at all, so stay quiet in that case.
|
|
1637
|
+
if (!accepted && used.length) {
|
|
1638
|
+
report.findings.push(finding(
|
|
1639
|
+
'FONT_FILE_MISMATCH',
|
|
1640
|
+
`--font-file 로 받은 "${opts.fontFile}" 의 name 테이블 패밀리가 이 덱의 a:latin typeface 와 일치하지 않아 버렸다. `
|
|
1641
|
+
+ `이름 재해석으로 내려갔고, 그것도 실패하면 estimate 다.`,
|
|
1642
|
+
{ severity: 'warn' },
|
|
1643
|
+
));
|
|
1644
|
+
}
|
|
1645
|
+
}
|
|
1646
|
+
|
|
1647
|
+
return attachFontResolver(finish(report), fontFor);
|
|
1648
|
+
}
|
|
1649
|
+
|
|
1650
|
+
/** Hand the resolved-font lookup to callers (--suggest-deck) without putting a function in the JSON. */
|
|
1651
|
+
function attachFontResolver(report, fontFor) {
|
|
1652
|
+
Object.defineProperty(report, 'fontFor', { value: fontFor, enumerable: false });
|
|
1653
|
+
return report;
|
|
1654
|
+
}
|
|
1655
|
+
|
|
1656
|
+
function finish(report) {
|
|
1657
|
+
report.counts.errors = report.findings.filter((f) => f.severity === 'error').length;
|
|
1658
|
+
report.counts.warnings = report.findings.filter((f) => f.severity !== 'error').length;
|
|
1659
|
+
report.ok = report.counts.errors === 0;
|
|
1660
|
+
return report;
|
|
1661
|
+
}
|
|
1662
|
+
|
|
1663
|
+
export function verifyFile(file, opts = {}) {
|
|
1664
|
+
let buf;
|
|
1665
|
+
try {
|
|
1666
|
+
buf = fs.readFileSync(file);
|
|
1667
|
+
} catch (err) {
|
|
1668
|
+
throw new VerifyError(`입력 파일을 읽을 수 없다: ${file} (${err.code || err.message})`, 'BAD_INPUT');
|
|
1669
|
+
}
|
|
1670
|
+
return verifyBuffer(buf, { ...opts, file: path.resolve(file) });
|
|
1671
|
+
}
|
|
1672
|
+
|
|
1673
|
+
// ---------------------------------------------------------------------------
|
|
1674
|
+
// WRITE PATH (--repair / --convert-a4p / --suggest-deck)
|
|
1675
|
+
// ---------------------------------------------------------------------------
|
|
1676
|
+
//
|
|
1677
|
+
// NOTHING here moves a shape. Ever. The whole point of this skill is that a print-spec document
|
|
1678
|
+
// does not get silently reflowed; a deck whose content sticks out of A4 gets a report saying which
|
|
1679
|
+
// shape sticks out and by how many mm, and a human decides. Automatic repositioning would trade
|
|
1680
|
+
// the one defect this tool exists to catch for a defect nobody can see.
|
|
1681
|
+
|
|
1682
|
+
/**
|
|
1683
|
+
* Rewrite p:sldSz to the frozen spec in place, preserving whatever namespace prefix the document
|
|
1684
|
+
* uses and dropping the type attribute (absent means "custom", which is what an A4 deck is).
|
|
1685
|
+
* String surgery on the matched element only: no reserialization of parts we are not fixing.
|
|
1686
|
+
*/
|
|
1687
|
+
export function replaceSlideSize(presentationXml) {
|
|
1688
|
+
const el = readElement(presentationXml, 'sldSz');
|
|
1689
|
+
if (!el) return { xml: presentationXml, changed: false, before: null, after: null };
|
|
1690
|
+
const keep = Object.entries(el.attrs).filter(([k]) => !['cx', 'cy', 'type'].includes(k));
|
|
1691
|
+
const attrs = [`cx="${A4P.cx}"`, `cy="${A4P.cy}"`, ...keep.map(([k, v]) => `${k}="${escapeXmlAttr(v)}"`)];
|
|
1692
|
+
const rebuilt = `<${el.tag} ${attrs.join(' ')}/>`;
|
|
1693
|
+
if (rebuilt === el.raw) return { xml: presentationXml, changed: false, before: el.raw, after: rebuilt };
|
|
1694
|
+
return {
|
|
1695
|
+
xml: presentationXml.slice(0, el.start) + rebuilt + presentationXml.slice(el.end),
|
|
1696
|
+
changed: true,
|
|
1697
|
+
before: el.raw,
|
|
1698
|
+
after: rebuilt,
|
|
1699
|
+
};
|
|
1700
|
+
}
|
|
1701
|
+
|
|
1702
|
+
const escapeXmlAttr = (s) => String(s).replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
|
|
1703
|
+
|
|
1704
|
+
/**
|
|
1705
|
+
* Produce a repaired archive. Only ppt/presentation.xml is rewritten; every other part is copied
|
|
1706
|
+
* through with its original compressed bytes. [Content_Types].xml is forced to the front because
|
|
1707
|
+
* OPC requires it there and leaving it to iteration order makes the output depend on the input's
|
|
1708
|
+
* entry ordering.
|
|
1709
|
+
*/
|
|
1710
|
+
export function repairBuffer(buf) {
|
|
1711
|
+
const zip = openZip(buf);
|
|
1712
|
+
const PRES = 'ppt/presentation.xml';
|
|
1713
|
+
const presXml = zip.readText(PRES);
|
|
1714
|
+
check(presXml !== null, `${PRES} 이 없다. pptx(OPC) 패키지가 아니다.`, 'MISSING_PRESENTATION_XML');
|
|
1715
|
+
const swap = replaceSlideSize(presXml);
|
|
1716
|
+
|
|
1717
|
+
const CT = '[Content_Types].xml';
|
|
1718
|
+
const ordered = [...zip.entries].sort((a, b) => (a.name === CT ? -1 : b.name === CT ? 1 : 0));
|
|
1719
|
+
const untouched = [];
|
|
1720
|
+
const records = ordered.map((e) => {
|
|
1721
|
+
if (e.name === PRES && swap.changed) return { ...e, replacement: Buffer.from(swap.xml, 'utf8') };
|
|
1722
|
+
if (!e.isDir) untouched.push(e.name);
|
|
1723
|
+
return { ...e, data: zip.raw(e) };
|
|
1724
|
+
});
|
|
1725
|
+
|
|
1726
|
+
return { out: writeZip(records), changed: swap.changed, before: swap.before, after: swap.after, untouched };
|
|
1727
|
+
}
|
|
1728
|
+
|
|
1729
|
+
/**
|
|
1730
|
+
* Per-slide breakage: which shapes fall outside the A4 portrait box, and by how much. Reported in
|
|
1731
|
+
* the coordinate system the shapes already have, because conversion does not move them.
|
|
1732
|
+
*/
|
|
1733
|
+
export function breakageReport(buf, marginEmu) {
|
|
1734
|
+
const zip = openZip(buf);
|
|
1735
|
+
const out = [];
|
|
1736
|
+
const parts = slideParts(zip);
|
|
1737
|
+
for (let i = 0; i < parts.length; i++) {
|
|
1738
|
+
const xml = zip.readText(parts[i]);
|
|
1739
|
+
if (xml === null) continue;
|
|
1740
|
+
const shapes = parseSlide(xml).filter((s) => s.hasXfrm);
|
|
1741
|
+
const broken = [];
|
|
1742
|
+
for (const s of shapes) {
|
|
1743
|
+
const over = {
|
|
1744
|
+
right: s.x + s.cx - A4P.cx,
|
|
1745
|
+
bottom: s.y + s.cy - A4P.cy,
|
|
1746
|
+
left: -s.x,
|
|
1747
|
+
top: -s.y,
|
|
1748
|
+
};
|
|
1749
|
+
const outside = Object.entries(over).filter(([, v]) => v > 0);
|
|
1750
|
+
const marginOnly = !outside.length && (s.x < marginEmu || s.y < marginEmu
|
|
1751
|
+
|| s.x + s.cx > A4P.cx - marginEmu || s.y + s.cy > A4P.cy - marginEmu);
|
|
1752
|
+
if (outside.length || marginOnly) {
|
|
1753
|
+
broken.push({
|
|
1754
|
+
shape: s.label,
|
|
1755
|
+
box: { x: s.x, y: s.y, cx: s.cx, cy: s.cy },
|
|
1756
|
+
kind: outside.length ? 'outside-slide' : 'inside-margin',
|
|
1757
|
+
overflowMm: Object.fromEntries(outside.map(([k, v]) => [k, round2(mmOf(v))])),
|
|
1758
|
+
});
|
|
1759
|
+
}
|
|
1760
|
+
}
|
|
1761
|
+
out.push({ slide: i + 1, part: parts[i], shapes: shapes.length, broken });
|
|
1762
|
+
}
|
|
1763
|
+
return out;
|
|
1764
|
+
}
|
|
1765
|
+
|
|
1766
|
+
// ---------------------------------------------------------------------------
|
|
1767
|
+
// --suggest-deck
|
|
1768
|
+
// ---------------------------------------------------------------------------
|
|
1769
|
+
//
|
|
1770
|
+
// The pptx gives (slide, shape); the answer has to be (page, block) `rows`. That reverse mapping
|
|
1771
|
+
// IS INV-G run backwards, which is why the original deck JSON is a required input and why the
|
|
1772
|
+
// pagination comes from build_a4p.js rather than from a second implementation here. A third copy
|
|
1773
|
+
// of the grid (node builder, python builder, verifier) would be the only one no test compares:
|
|
1774
|
+
// E1/E1b only ever check the two builders against each other.
|
|
1775
|
+
|
|
1776
|
+
function loadBuilder() {
|
|
1777
|
+
const here = path.dirname(fileURLToPath(import.meta.url));
|
|
1778
|
+
const target = path.join(here, 'build_a4p.js');
|
|
1779
|
+
if (!fs.existsSync(target)) {
|
|
1780
|
+
throw new VerifyError(`--suggest-deck 은 ${target} 를 필요로 한다(페이지네이션을 재구현하지 않는다).`, 'NO_BUILDER');
|
|
1781
|
+
}
|
|
1782
|
+
try {
|
|
1783
|
+
// build_a4p.js is CommonJS (the repo has no "type": "module"), so it loads through createRequire.
|
|
1784
|
+
return createRequire(import.meta.url)(target);
|
|
1785
|
+
} catch (err) {
|
|
1786
|
+
throw new VerifyError(`build_a4p.js 를 불러올 수 없다: ${err.message}`, 'NO_BUILDER');
|
|
1787
|
+
}
|
|
1788
|
+
}
|
|
1789
|
+
|
|
1790
|
+
/**
|
|
1791
|
+
* Height one shape's text really needs, in EMU. Deliberately the CONSERVATIVE direction, opposite
|
|
1792
|
+
* to the one findings use: a proposal that under-provisions rows produces a rebuild that still
|
|
1793
|
+
* overflows, which is worse than a proposal that is one row generous.
|
|
1794
|
+
*/
|
|
1795
|
+
function requiredHeight(shape, ctx) {
|
|
1796
|
+
if (!shape.body || !shape.paragraphs.length) return 0;
|
|
1797
|
+
const usableW = shape.cx - shape.body.lIns - shape.body.rIns;
|
|
1798
|
+
if (!(usableW > 0)) return 0;
|
|
1799
|
+
let total = 0;
|
|
1800
|
+
for (const paragraph of shape.paragraphs) {
|
|
1801
|
+
for (const line of paragraph) {
|
|
1802
|
+
let w = 0;
|
|
1803
|
+
let h = 0;
|
|
1804
|
+
for (const run of line) {
|
|
1805
|
+
if (!run.text) continue;
|
|
1806
|
+
const szPt = run.szPt ?? FALLBACK_SZ_PT;
|
|
1807
|
+
const font = ctx.fontFor(run.latin).font;
|
|
1808
|
+
w += runWidthEmu(run.text, szPt, font).emu;
|
|
1809
|
+
const em = (font ? font.lineHeightEm : FALLBACK_LINE_EM) * Math.max(1, shape.lineSpacing || 1);
|
|
1810
|
+
h = Math.max(h, em * szPt * EMU_PER_PT);
|
|
1811
|
+
}
|
|
1812
|
+
if (h) total += Math.max(1, Math.ceil(w / usableW)) * h;
|
|
1813
|
+
}
|
|
1814
|
+
}
|
|
1815
|
+
return total;
|
|
1816
|
+
}
|
|
1817
|
+
|
|
1818
|
+
const insetH = (shape) => (shape.body ? shape.body.tIns + shape.body.bIns : 0);
|
|
1819
|
+
|
|
1820
|
+
/**
|
|
1821
|
+
* Propose `rows` values that would stop the overflow. Returns { deck, suggestions, tooTall }.
|
|
1822
|
+
* Candidate rows are tried by re-running the builder's own layout(), so the box heights come from
|
|
1823
|
+
* the builder and this file never computes a row pitch.
|
|
1824
|
+
*/
|
|
1825
|
+
export function suggestDeck(deck, pptxBuf, ctx) {
|
|
1826
|
+
const builder = loadBuilder();
|
|
1827
|
+
const laid = builder.layout(deck);
|
|
1828
|
+
const zip = openZip(pptxBuf);
|
|
1829
|
+
const parts = slideParts(zip);
|
|
1830
|
+
|
|
1831
|
+
// Refuse to guess: if this deck did not produce this pptx, the (slide, shape) -> (page, block)
|
|
1832
|
+
// mapping below is fiction. Compare shape counts and every coordinate.
|
|
1833
|
+
check(laid.slides.length === parts.length,
|
|
1834
|
+
`--deck 이 이 pptx 를 만들지 않았다: deck 은 슬라이드 ${laid.slides.length}개, pptx 는 ${parts.length}개다.`, 'DECK_MISMATCH');
|
|
1835
|
+
const pptxShapes = parts.map((p) => parseSlide(zip.readText(p)).filter((s) => s.hasXfrm));
|
|
1836
|
+
for (let i = 0; i < laid.slides.length; i++) {
|
|
1837
|
+
const want = laid.slides[i].shapes;
|
|
1838
|
+
const got = pptxShapes[i];
|
|
1839
|
+
check(want.length === got.length,
|
|
1840
|
+
`--deck 이 이 pptx 를 만들지 않았다: 슬라이드 ${i + 1} 의 도형 수가 deck ${want.length} vs pptx ${got.length} 다.`, 'DECK_MISMATCH');
|
|
1841
|
+
for (let j = 0; j < want.length; j++) {
|
|
1842
|
+
check(want[j].x === got[j].x && want[j].y === got[j].y && want[j].cx === got[j].cx && want[j].cy === got[j].cy,
|
|
1843
|
+
`--deck 이 이 pptx 를 만들지 않았다: 슬라이드 ${i + 1} 도형 ${j + 1} 좌표 불일치.`, 'DECK_MISMATCH');
|
|
1844
|
+
}
|
|
1845
|
+
}
|
|
1846
|
+
|
|
1847
|
+
// Measured need per (page, block, item), taken from the pptx's own runs.
|
|
1848
|
+
const need = new Map();
|
|
1849
|
+
for (let i = 0; i < laid.slides.length; i++) {
|
|
1850
|
+
laid.slides[i].shapes.forEach((planned, j) => {
|
|
1851
|
+
if (planned.blockIndex === null) return;
|
|
1852
|
+
const shape = pptxShapes[i][j];
|
|
1853
|
+
const key = `${planned.pageIndex}:${planned.blockIndex}`;
|
|
1854
|
+
const list = need.get(key) || [];
|
|
1855
|
+
list[planned.itemIndex ?? 0] = requiredHeight(shape, ctx) + insetH(shape);
|
|
1856
|
+
need.set(key, list);
|
|
1857
|
+
});
|
|
1858
|
+
}
|
|
1859
|
+
|
|
1860
|
+
const fixed = JSON.parse(JSON.stringify(deck));
|
|
1861
|
+
const suggestions = [];
|
|
1862
|
+
const tooTall = [];
|
|
1863
|
+
|
|
1864
|
+
deck.pages.forEach((page, pageIndex) => {
|
|
1865
|
+
(page.blocks || []).forEach((block, blockIndex) => {
|
|
1866
|
+
const key = `${pageIndex}:${blockIndex}`;
|
|
1867
|
+
const needs = need.get(key);
|
|
1868
|
+
if (!needs) return;
|
|
1869
|
+
const current = builder.rowsOf(block);
|
|
1870
|
+
const fits = (n) => {
|
|
1871
|
+
const probe = JSON.parse(JSON.stringify(deck));
|
|
1872
|
+
probe.pages[pageIndex].blocks[blockIndex].rows = n;
|
|
1873
|
+
const shapes = builder.layout(probe).slides
|
|
1874
|
+
.flatMap((s) => s.shapes)
|
|
1875
|
+
.filter((s) => s.pageIndex === pageIndex && s.blockIndex === blockIndex);
|
|
1876
|
+
if (shapes.length !== needs.length) return false;
|
|
1877
|
+
return shapes.every((s, i) => (needs[i] ?? 0) <= s.cy);
|
|
1878
|
+
};
|
|
1879
|
+
if (fits(current)) return;
|
|
1880
|
+
let chosen = null;
|
|
1881
|
+
for (let n = current + 1; n <= builder.MAX_BLOCK_ROWS; n++) {
|
|
1882
|
+
if (fits(n)) { chosen = n; break; }
|
|
1883
|
+
}
|
|
1884
|
+
if (chosen === null) {
|
|
1885
|
+
// 18 is the cap that keeps geometry inside the margins. Proposing rows: 19 would produce a
|
|
1886
|
+
// deck the builder clamps and the verifier then rejects, so we say "split it" instead.
|
|
1887
|
+
tooTall.push({ page: pageIndex, block: blockIndex, type: block.type, current });
|
|
1888
|
+
return;
|
|
1889
|
+
}
|
|
1890
|
+
fixed.pages[pageIndex].blocks[blockIndex].rows = chosen;
|
|
1891
|
+
suggestions.push({ page: pageIndex, block: blockIndex, type: block.type, from: current, to: chosen });
|
|
1892
|
+
});
|
|
1893
|
+
});
|
|
1894
|
+
|
|
1895
|
+
return { deck: fixed, suggestions, tooTall };
|
|
1896
|
+
}
|
|
1897
|
+
|
|
1898
|
+
/** Every path where the two JSON trees differ, so "differs in rows only" is checkable, not asserted. */
|
|
1899
|
+
export function jsonDiffPaths(a, b, base = '') {
|
|
1900
|
+
const paths = [];
|
|
1901
|
+
const keys = new Set([...Object.keys(a || {}), ...Object.keys(b || {})]);
|
|
1902
|
+
for (const k of keys) {
|
|
1903
|
+
const av = a ? a[k] : undefined;
|
|
1904
|
+
const bv = b ? b[k] : undefined;
|
|
1905
|
+
const here = base ? `${base}.${k}` : k;
|
|
1906
|
+
if (av && bv && typeof av === 'object' && typeof bv === 'object') paths.push(...jsonDiffPaths(av, bv, here));
|
|
1907
|
+
else if (JSON.stringify(av) !== JSON.stringify(bv)) paths.push(here);
|
|
1908
|
+
}
|
|
1909
|
+
return paths;
|
|
1910
|
+
}
|
|
1911
|
+
|
|
1912
|
+
// ---------------------------------------------------------------------------
|
|
1913
|
+
// OUTPUT
|
|
1914
|
+
// ---------------------------------------------------------------------------
|
|
1915
|
+
|
|
1916
|
+
function renderTable(report, strict) {
|
|
1917
|
+
const lines = [];
|
|
1918
|
+
const s = report.slideSize;
|
|
1919
|
+
lines.push(`vertical-pptx verify: ${report.file || '(buffer)'}`);
|
|
1920
|
+
if (s) {
|
|
1921
|
+
lines.push(`slide size : cx=${s.cx} cy=${s.cy} (${s.widthMm}x${s.heightMm}mm) type=${s.type ?? '(없음)'} orientation=${s.orientation}`);
|
|
1922
|
+
} else {
|
|
1923
|
+
lines.push('slide size : (p:sldSz 없음)');
|
|
1924
|
+
}
|
|
1925
|
+
lines.push(`margin : ${report.marginMm}mm (${report.marginSource === 'deck' ? 'deck meta.marginMm' : '동결 스펙 하한'})`);
|
|
1926
|
+
lines.push(`counts : slides=${report.counts.slides} shapes=${report.counts.shapes} runs=${report.counts.runs}`);
|
|
1927
|
+
lines.push(`fontSource : ${report.fontSource} (width = ${report.widthBasis})`);
|
|
1928
|
+
for (const f of report.fonts) {
|
|
1929
|
+
lines.push(` font : "${f.typeface}" -> ${f.source}${f.path ? ` ${f.path} face=${f.face} upm=${f.unitsPerEm} via=${f.via} (${f.label})` : ' (경로 해석 실패)'}`);
|
|
1930
|
+
}
|
|
1931
|
+
|
|
1932
|
+
if (report.findings.length) {
|
|
1933
|
+
lines.push('');
|
|
1934
|
+
lines.push(`FINDINGS (${report.findings.length})`);
|
|
1935
|
+
const cell = (v, n) => String(v ?? '-').padEnd(n);
|
|
1936
|
+
const row = (a, b, c, d, e) => ` ${cell(a, 22)} ${cell(b, 5)} ${cell(c, 18)} ${cell(d, 17)} ${e}`;
|
|
1937
|
+
lines.push(row('CODE', 'SLIDE', 'SHAPE', 'STATUS', 'DETAIL'));
|
|
1938
|
+
for (const f of report.findings) {
|
|
1939
|
+
lines.push(row(f.code, f.slide, f.shape, `${f.severity}/${f.status}`, f.detail));
|
|
1940
|
+
}
|
|
1941
|
+
}
|
|
1942
|
+
|
|
1943
|
+
if (report.write) {
|
|
1944
|
+
const w = report.write;
|
|
1945
|
+
lines.push('');
|
|
1946
|
+
lines.push(`WRITE (${w.mode})${w.inPlace ? ' --in-place' : ''}`);
|
|
1947
|
+
lines.push(` out : ${w.out}`);
|
|
1948
|
+
lines.push(` sldSz : ${w.sldSzChanged ? `${w.before} -> ${w.after}` : '변경 없음(이미 규격)'}`);
|
|
1949
|
+
lines.push(` re-zip : 엔트리 ${w.entries}개 · 첫 엔트리 ${w.firstEntry} · 미치환 ${w.untouchedVerified}개 바이트 동일 · CRC/로컬헤더 재검사 ${w.integrity}`);
|
|
1950
|
+
lines.push(' 도형은 하나도 움직이지 않았다 (자동 재배치 없음).');
|
|
1951
|
+
// Writing successfully and producing a conforming deck are different claims. A landscape deck
|
|
1952
|
+
// repaired to A4 keeps every shape where it was, so it can still fail on shapes that stick out.
|
|
1953
|
+
lines.push(' 쓰기는 성공했다. 아래 VERDICT 는 원본이 아니라 산출물을 다시 검증한 결과다.');
|
|
1954
|
+
if (w.breakage) {
|
|
1955
|
+
for (const s of w.breakage) {
|
|
1956
|
+
lines.push(` 슬라이드 ${s.slide}: 도형 ${s.shapes}개 중 파손 ${s.broken.length}개`);
|
|
1957
|
+
for (const b of s.broken) {
|
|
1958
|
+
const over = Object.entries(b.overflowMm).map(([k, v]) => `${k} ${v}mm`).join(', ');
|
|
1959
|
+
lines.push(` - ${b.shape} [${b.kind}] ${over || '여백 침범'} (x=${b.box.x} y=${b.box.y} cx=${b.box.cx} cy=${b.box.cy})`);
|
|
1960
|
+
}
|
|
1961
|
+
}
|
|
1962
|
+
}
|
|
1963
|
+
}
|
|
1964
|
+
|
|
1965
|
+
if (report.suggest) {
|
|
1966
|
+
const s = report.suggest;
|
|
1967
|
+
lines.push('');
|
|
1968
|
+
lines.push(`SUGGEST (기준 fontSource=${s.basis})`);
|
|
1969
|
+
lines.push(` out : ${s.out}`);
|
|
1970
|
+
for (const c of s.changed) lines.push(` page ${c.page} block ${c.block} (${c.type}): rows ${c.from} -> ${c.to}`);
|
|
1971
|
+
for (const t of s.blockTooTall) {
|
|
1972
|
+
lines.push(` page ${t.page} block ${t.block} (${t.type}): BLOCK_TOO_TALL. rows 18 로도 안 들어간다 - 블록을 나눠라.`);
|
|
1973
|
+
}
|
|
1974
|
+
if (!s.changed.length && !s.blockTooTall.length) lines.push(' 제안 없음(오버플로 0).');
|
|
1975
|
+
lines.push(` 원본 대비 차이: ${s.diffPaths.length ? s.diffPaths.join(', ') : '없음'}`);
|
|
1976
|
+
}
|
|
1977
|
+
|
|
1978
|
+
const failed = report.counts.errors > 0 || (strict && report.counts.warnings > 0);
|
|
1979
|
+
lines.push('');
|
|
1980
|
+
lines.push(`VERDICT: ${failed ? 'FAIL' : 'PASS'} (errors ${report.counts.errors}, warnings ${report.counts.warnings}${strict ? ', --strict' : ''})`);
|
|
1981
|
+
return lines.join('\n');
|
|
1982
|
+
}
|
|
1983
|
+
|
|
1984
|
+
const USAGE = [
|
|
1985
|
+
'usage: verify_a4p.mjs <file.pptx> [options]',
|
|
1986
|
+
' --json 기계 판독용 JSON',
|
|
1987
|
+
' --strict 경고도 실패로 취급',
|
|
1988
|
+
' --font-file <path> 폰트 후보 경로 (패밀리 대조 후에만 채택)',
|
|
1989
|
+
' --deck <deck.json> 원본 deck JSON (여백 기준 + --suggest-deck 입력)',
|
|
1990
|
+
' --repair sldSz 만 A4 세로로 교체해 다시 압축',
|
|
1991
|
+
' --convert-a4p --repair + 슬라이드별 파손 리포트 (도형은 움직이지 않는다)',
|
|
1992
|
+
' --out <path> 산출 파일 (--repair/--convert-a4p 기본값)',
|
|
1993
|
+
' --in-place 입력 파일을 덮어쓴다 (명시적 옵트인)',
|
|
1994
|
+
' --suggest-deck <path> 오버플로를 없애는 rows 를 제안해 새 deck JSON 으로 저장',
|
|
1995
|
+
].join('\n');
|
|
1996
|
+
|
|
1997
|
+
export function parseArgs(argv) {
|
|
1998
|
+
const opts = {
|
|
1999
|
+
json: false, strict: false, fontFile: null, deckPath: null, file: null,
|
|
2000
|
+
repair: false, convert: false, out: null, inPlace: false, suggestDeck: null,
|
|
2001
|
+
};
|
|
2002
|
+
for (let i = 0; i < argv.length; i++) {
|
|
2003
|
+
const a = argv[i];
|
|
2004
|
+
const valued = (name) => {
|
|
2005
|
+
if (a === `--${name}`) {
|
|
2006
|
+
const v = argv[++i];
|
|
2007
|
+
if (v === undefined) throw new VerifyError(`--${name} 에 값이 없다`, 'USAGE');
|
|
2008
|
+
return v;
|
|
2009
|
+
}
|
|
2010
|
+
if (a.startsWith(`--${name}=`)) return a.slice(name.length + 3);
|
|
2011
|
+
return null;
|
|
2012
|
+
};
|
|
2013
|
+
if (a === '--json') opts.json = true;
|
|
2014
|
+
else if (a === '--strict') opts.strict = true;
|
|
2015
|
+
else if (a === '--help' || a === '-h') opts.help = true;
|
|
2016
|
+
else if (a === '--repair') opts.repair = true;
|
|
2017
|
+
else if (a === '--convert-a4p') opts.convert = true;
|
|
2018
|
+
else if (a === '--in-place') opts.inPlace = true;
|
|
2019
|
+
else if (a === '--font-file' || a.startsWith('--font-file=')) opts.fontFile = valued('font-file');
|
|
2020
|
+
else if (a === '--deck' || a.startsWith('--deck=')) opts.deckPath = valued('deck');
|
|
2021
|
+
else if (a === '--out' || a.startsWith('--out=')) opts.out = valued('out');
|
|
2022
|
+
else if (a === '--suggest-deck' || a.startsWith('--suggest-deck=')) opts.suggestDeck = valued('suggest-deck');
|
|
2023
|
+
else if (a.startsWith('-')) throw new VerifyError(`알 수 없는 옵션: ${a}`, 'USAGE');
|
|
2024
|
+
else if (opts.file === null) opts.file = a;
|
|
2025
|
+
else throw new VerifyError(`입력 파일이 둘 이상이다: ${opts.file}, ${a}`, 'USAGE');
|
|
2026
|
+
}
|
|
2027
|
+
return opts;
|
|
2028
|
+
}
|
|
2029
|
+
|
|
2030
|
+
export function main(argv) {
|
|
2031
|
+
let opts;
|
|
2032
|
+
try {
|
|
2033
|
+
opts = parseArgs(argv);
|
|
2034
|
+
} catch (err) {
|
|
2035
|
+
console.error(`verify_a4p: ${err.message}`);
|
|
2036
|
+
console.error(USAGE);
|
|
2037
|
+
return EXIT.USAGE;
|
|
2038
|
+
}
|
|
2039
|
+
if (opts.help || !opts.file) {
|
|
2040
|
+
console.error(USAGE);
|
|
2041
|
+
return opts.help ? EXIT.PASS : EXIT.USAGE;
|
|
2042
|
+
}
|
|
2043
|
+
if (!fs.existsSync(opts.file)) {
|
|
2044
|
+
console.error(`verify_a4p: 입력 파일이 없다: ${opts.file}`);
|
|
2045
|
+
return EXIT.USAGE;
|
|
2046
|
+
}
|
|
2047
|
+
|
|
2048
|
+
let deck = null;
|
|
2049
|
+
if (opts.deckPath) {
|
|
2050
|
+
try {
|
|
2051
|
+
deck = JSON.parse(fs.readFileSync(opts.deckPath, 'utf8'));
|
|
2052
|
+
} catch (err) {
|
|
2053
|
+
console.error(`verify_a4p: --deck 을 읽을 수 없다: ${opts.deckPath} (${err.message})`);
|
|
2054
|
+
return EXIT.USAGE;
|
|
2055
|
+
}
|
|
2056
|
+
}
|
|
2057
|
+
|
|
2058
|
+
let report;
|
|
2059
|
+
try {
|
|
2060
|
+
report = verifyFile(opts.file, { fontFile: opts.fontFile, deck });
|
|
2061
|
+
} catch (err) {
|
|
2062
|
+
if (err instanceof VerifyError && (err.code === 'BAD_MARGIN' || err.code === 'BAD_INPUT')) {
|
|
2063
|
+
console.error(`verify_a4p: ${err.message}`);
|
|
2064
|
+
return EXIT.USAGE;
|
|
2065
|
+
}
|
|
2066
|
+
// A file that cannot be opened as a zip is a verdict about the artifact, not a crash.
|
|
2067
|
+
const code = err instanceof VerifyError ? err.code : 'READ_ERROR';
|
|
2068
|
+
const failed = finish({
|
|
2069
|
+
skill: 'vertical-pptx',
|
|
2070
|
+
file: path.resolve(opts.file),
|
|
2071
|
+
ok: false,
|
|
2072
|
+
slideSize: null,
|
|
2073
|
+
marginMm: MARGIN_MM.DEFAULT,
|
|
2074
|
+
marginSource: 'spec-floor',
|
|
2075
|
+
fontSource: 'estimate',
|
|
2076
|
+
widthBasis: 'lower-bound (커널링/줄바꿈 미반영)',
|
|
2077
|
+
fonts: [],
|
|
2078
|
+
counts: { slides: 0, shapes: 0, runs: 0, errors: 0, warnings: 0 },
|
|
2079
|
+
findings: [finding(code, err.message)],
|
|
2080
|
+
});
|
|
2081
|
+
console.log(opts.json ? JSON.stringify(failed, null, 2) : renderTable(failed, opts.strict));
|
|
2082
|
+
return EXIT.FAIL;
|
|
2083
|
+
}
|
|
2084
|
+
|
|
2085
|
+
// ---- write paths ----
|
|
2086
|
+
const wantsWrite = opts.repair || opts.convert;
|
|
2087
|
+
let outReport = report;
|
|
2088
|
+
try {
|
|
2089
|
+
if (wantsWrite) {
|
|
2090
|
+
if (!opts.out && !opts.inPlace) {
|
|
2091
|
+
console.error('verify_a4p: --repair/--convert-a4p 는 --out <경로> 또는 --in-place 가 필요하다.');
|
|
2092
|
+
console.error(' (기본은 새 파일이다. 원본 덮어쓰기는 --in-place 로 명시해야 한다.)');
|
|
2093
|
+
return EXIT.USAGE;
|
|
2094
|
+
}
|
|
2095
|
+
const src = fs.readFileSync(opts.file);
|
|
2096
|
+
const rep = repairBuffer(src);
|
|
2097
|
+
const target = opts.inPlace ? opts.file : opts.out;
|
|
2098
|
+
|
|
2099
|
+
const integrity = verifyZipIntegrity(rep.out, { source: src, untouched: rep.untouched });
|
|
2100
|
+
if (!integrity.ok) {
|
|
2101
|
+
console.error(`verify_a4p: 재압축 산출물이 자기 무결성 검사를 통과하지 못해 쓰지 않았다:\n ${integrity.problems.join('\n ')}`);
|
|
2102
|
+
return EXIT.FAIL;
|
|
2103
|
+
}
|
|
2104
|
+
// Stage then rename, so an interrupted --in-place cannot leave a half-written deck.
|
|
2105
|
+
const tmp = `${target}.a4p-tmp`;
|
|
2106
|
+
fs.writeFileSync(tmp, rep.out);
|
|
2107
|
+
fs.renameSync(tmp, target);
|
|
2108
|
+
|
|
2109
|
+
report.write = {
|
|
2110
|
+
mode: opts.convert ? 'convert-a4p' : 'repair',
|
|
2111
|
+
out: path.resolve(target),
|
|
2112
|
+
inPlace: opts.inPlace,
|
|
2113
|
+
sldSzChanged: rep.changed,
|
|
2114
|
+
before: rep.before,
|
|
2115
|
+
after: rep.after,
|
|
2116
|
+
entries: integrity.entryCount,
|
|
2117
|
+
firstEntry: integrity.firstEntry,
|
|
2118
|
+
untouchedVerified: rep.untouched.length,
|
|
2119
|
+
integrity: 'ok',
|
|
2120
|
+
};
|
|
2121
|
+
if (opts.convert) report.write.breakage = breakageReport(rep.out, report.marginMm * EMU_PER_MM);
|
|
2122
|
+
// The verdict that matters after a write is the OUTPUT's, not the input's.
|
|
2123
|
+
outReport = verifyFile(target, { fontFile: opts.fontFile, deck });
|
|
2124
|
+
outReport.write = report.write;
|
|
2125
|
+
}
|
|
2126
|
+
|
|
2127
|
+
if (opts.suggestDeck) {
|
|
2128
|
+
if (!deck) {
|
|
2129
|
+
console.error('verify_a4p: --suggest-deck 은 --deck <deck.json> 를 함께 요구한다.');
|
|
2130
|
+
console.error(' (pptx 는 (슬라이드, 도형)만 알려 준다. 내놓아야 하는 것은 (페이지, 블록)의 rows 다.)');
|
|
2131
|
+
return EXIT.USAGE;
|
|
2132
|
+
}
|
|
2133
|
+
const src = fs.readFileSync(wantsWrite && opts.inPlace ? opts.file : (report.write ? report.write.out : opts.file));
|
|
2134
|
+
const s = suggestDeck(deck, src, { fontFor: report.fontFor });
|
|
2135
|
+
fs.writeFileSync(opts.suggestDeck, `${JSON.stringify(s.deck, null, 2)}\n`);
|
|
2136
|
+
outReport.suggest = {
|
|
2137
|
+
out: path.resolve(opts.suggestDeck),
|
|
2138
|
+
changed: s.suggestions,
|
|
2139
|
+
blockTooTall: s.tooTall,
|
|
2140
|
+
diffPaths: jsonDiffPaths(deck, s.deck),
|
|
2141
|
+
basis: report.fontSource,
|
|
2142
|
+
};
|
|
2143
|
+
}
|
|
2144
|
+
} catch (err) {
|
|
2145
|
+
if (err instanceof VerifyError && ['NO_BUILDER', 'DECK_MISMATCH'].includes(err.code)) {
|
|
2146
|
+
console.error(`verify_a4p: ${err.message}`);
|
|
2147
|
+
return err.code === 'NO_BUILDER' ? EXIT.ENV : EXIT.USAGE;
|
|
2148
|
+
}
|
|
2149
|
+
console.error(`verify_a4p: ${err.message}`);
|
|
2150
|
+
return EXIT.FAIL;
|
|
2151
|
+
}
|
|
2152
|
+
|
|
2153
|
+
const failed = outReport.counts.errors > 0 || (opts.strict && outReport.counts.warnings > 0);
|
|
2154
|
+
outReport.exit = failed ? EXIT.FAIL : EXIT.PASS;
|
|
2155
|
+
console.log(opts.json ? JSON.stringify(outReport, null, 2) : renderTable(outReport, opts.strict));
|
|
2156
|
+
return outReport.exit;
|
|
2157
|
+
}
|
|
2158
|
+
|
|
2159
|
+
// fileURLToPath, not URL.pathname: the latter yields "/C:/..." on Windows and never matches argv[1].
|
|
2160
|
+
const invoked = process.argv[1] && path.resolve(process.argv[1]) === path.resolve(fileURLToPath(import.meta.url));
|
|
2161
|
+
if (invoked) {
|
|
2162
|
+
try {
|
|
2163
|
+
process.exit(main(process.argv.slice(2)));
|
|
2164
|
+
} catch (err) {
|
|
2165
|
+
console.error(`verify_a4p: ${err && err.stack ? err.stack : err}`);
|
|
2166
|
+
process.exit(EXIT.ENV);
|
|
2167
|
+
}
|
|
2168
|
+
}
|