@hraness/wordcell 0.26.3 → 0.27.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/README.md +36 -21
- package/dist/capture.js +15 -7
- package/dist/cli-bin.js +2 -2
- package/dist/cli.js +21 -19
- package/dist/clip/acquire.js +4 -3
- package/dist/clip/bundle-reader.js +3 -2
- package/dist/clip/cli.js +8 -7
- package/dist/clip/defuddle-worker.js +4 -1
- package/dist/clip/doctor.js +5 -4
- package/dist/clip/jobs.js +2 -1
- package/dist/clip/persist.js +2 -1
- package/dist/clip/refresh.js +4 -3
- package/dist/evaluation-builder-bin.js +2 -2
- package/dist/evaluation-builder.js +3 -3
- package/dist/evaluation-kb.js +3 -3
- package/dist/{index-mp5y3ty3.js → index-30v9w4cy.js} +36 -20
- package/dist/{index-9paxge84.js → index-3hts6yy9.js} +1 -1
- package/dist/{index-h9vyd03z.js → index-58azscg5.js} +7 -6
- package/dist/{index-9zkba8hr.js → index-7bqwzjba.js} +7 -6
- package/dist/{index-j4zgmzjr.js → index-8djdwjjd.js} +1 -1
- package/dist/{index-5h5awwh4.js → index-b40e2dkx.js} +2 -2
- package/dist/index-e2wstc7t.js +323 -0
- package/dist/{index-hacpnaew.js → index-fd8j527z.js} +1 -1
- package/dist/{index-mxxxytys.js → index-g63d3937.js} +4 -0
- package/dist/{index-k86wepd8.js → index-h2w7syy2.js} +5 -1
- package/dist/{index-9fywek7g.js → index-ht14xne3.js} +1 -1
- package/dist/{index-82r6g4py.js → index-k9yzc4vn.js} +1 -1
- package/dist/{index-m9p2a4mj.js → index-my50xsa3.js} +221 -36
- package/dist/{index-ncwzmsge.js → index-nwdzhj1h.js} +2 -2
- package/dist/{index-9rf81m0p.js → index-pbz3qw1s.js} +35 -20
- package/dist/index-pn14a2m5.js +59 -0
- package/dist/{index-nd8v7r0z.js → index-sp43012q.js} +1 -1
- package/dist/{index-8pabwzqg.js → index-t4hjm1kk.js} +2 -1
- package/dist/{index-npg9z1a4.js → index-xn5q4kne.js} +5 -1
- package/dist/{index-j2jt49gr.js → index-yatn62qq.js} +1 -1
- package/dist/index.js +14 -4
- package/dist/pdf.js +438 -4
- package/dist/portfolio.js +3 -3
- package/dist/rerank-clef.js +16 -0
- package/dist/rerank-typesafe.js +3 -1
- package/dist/sdk.js +2 -2
- package/dist/{support-9qdr4hb8.js → support-s059eytv.js} +20 -18
- package/dist/url-intelligence.js +4 -3
- package/dist/workflows/decision-context.js +3 -3
- package/dist/workflows/index.js +3 -3
- package/package.json +23 -9
- package/skills/wordcell/SKILL.md +1 -1
- package/skills/wordcell/references/query.md +11 -6
- package/src/capture.ts +5 -0
- package/src/cli-help.ts +2 -1
- package/src/cli-program.ts +23 -8
- package/src/clip/bundle-reader.ts +3 -0
- package/src/clip/capture.ts +1 -0
- package/src/clip/extract.ts +2 -0
- package/src/clip/lib.ts +3 -0
- package/src/clip/metadata-search-tool/Cargo.lock +6 -6
- package/src/clip/metadata.ts +232 -0
- package/src/clip/persist.ts +3 -0
- package/src/index.ts +2 -1
- package/src/pdf/bundle-reader.ts +457 -0
- package/src/pdf/capture.ts +6 -2
- package/src/pdf/cli.ts +2 -1
- package/src/pdf/errors.ts +11 -0
- package/src/pdf/extract.ts +16 -13
- package/src/pdf/layout.ts +136 -3
- package/src/pdf/markdown.ts +7 -1
- package/src/pdf/model.ts +9 -0
- package/src/pdf/persist.ts +8 -2
- package/src/pdf/source.ts +5 -4
- package/src/pdf/tools.ts +8 -6
- package/src/pdf.ts +10 -0
- package/src/rerank-clef.ts +52 -0
- package/src/rerank-credentials.ts +11 -2
- package/src/rerank-typesafe.ts +44 -20
- package/src/rerank.ts +1 -1
- package/src/sdk.ts +4 -2
- /package/dist/{index-cd75vky9.js → index-jbtyp6q5.js} +0 -0
|
@@ -0,0 +1,457 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import { constants as fsConstants, type BigIntStats } from "node:fs";
|
|
3
|
+
import { lstat, open, opendir, realpath, type FileHandle } from "node:fs/promises";
|
|
4
|
+
import { dirname, isAbsolute, relative, resolve, sep } from "node:path";
|
|
5
|
+
import { PdfCaptureError } from "./errors.js";
|
|
6
|
+
import { parsePdfImageInterpretations } from "./layout.js";
|
|
7
|
+
|
|
8
|
+
// The same no-follow/inode fencing used by the article reader, specialized for PDF.
|
|
9
|
+
function boundedInteger(value: number | undefined, fallback: number, maximum: number, label: string): number {
|
|
10
|
+
const selected = value ?? fallback;
|
|
11
|
+
if (!Number.isSafeInteger(selected) || selected < 1 || selected > maximum) {
|
|
12
|
+
throw new PdfCaptureError("invalid-input", `${label} must be an integer from 1 through ${maximum}`);
|
|
13
|
+
}
|
|
14
|
+
return selected;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function confinedRelativePath(value: unknown, label: string): string {
|
|
18
|
+
if (typeof value !== "string" || value.length === 0 || value.length > 4_096 || value.includes("\0")) {
|
|
19
|
+
throw new PdfCaptureError("integrity", `${label} must be a bounded non-empty path`);
|
|
20
|
+
}
|
|
21
|
+
if (isAbsolute(value) || value.includes("\\")) throw new PdfCaptureError("integrity", `${label} must be a relative POSIX path`);
|
|
22
|
+
const segments = value.split("/");
|
|
23
|
+
if (segments.some((segment) => segment === "" || segment === "." || segment === "..")) {
|
|
24
|
+
throw new PdfCaptureError("integrity", `${label} contains an unsafe segment`);
|
|
25
|
+
}
|
|
26
|
+
return value;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
type SafeBundleRoot = {
|
|
30
|
+
readonly path: string;
|
|
31
|
+
readonly handle: FileHandle;
|
|
32
|
+
readonly device: bigint;
|
|
33
|
+
readonly inode: bigint;
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
class MissingCaptureAssetError extends PdfCaptureError {
|
|
37
|
+
constructor(message: string) { super("integrity", message); }
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function isMissingPathError(error: unknown): boolean {
|
|
41
|
+
return error instanceof Error && "code" in error && error.code === "ENOENT";
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function assertAssetVerificationDeadline(deadline: number | undefined): void {
|
|
45
|
+
if (deadline !== undefined && performance.now() >= deadline) {
|
|
46
|
+
throw new PdfCaptureError("timeout", "capture asset verification exceeded its time budget");
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function confined(root: SafeBundleRoot, relativePath: string, label: string): string {
|
|
51
|
+
const path = resolve(root.path, relativePath);
|
|
52
|
+
const lexical = relative(root.path, path);
|
|
53
|
+
if (lexical === "" || lexical === ".." || lexical.startsWith(`..${sep}`) || isAbsolute(lexical)) {
|
|
54
|
+
throw new PdfCaptureError("integrity", `${label} escapes the capture bundle`);
|
|
55
|
+
}
|
|
56
|
+
return path;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
async function safeDirectory(pathInput: string): Promise<SafeBundleRoot> {
|
|
60
|
+
if (pathInput.trim() === "") throw new PdfCaptureError("integrity", "capture bundle path is required");
|
|
61
|
+
const path = resolve(pathInput);
|
|
62
|
+
const canonical = await realpath(path);
|
|
63
|
+
if (canonical !== path) throw new PdfCaptureError("integrity", "capture bundle path must be canonical and cannot traverse a symbolic link");
|
|
64
|
+
const stats = await lstat(path, { bigint: true });
|
|
65
|
+
if (!stats.isDirectory() || stats.isSymbolicLink()) throw new PdfCaptureError("integrity", "capture bundle path must be a real directory");
|
|
66
|
+
if (dirname(path) === path) throw new PdfCaptureError("integrity", "refusing a filesystem root as a capture bundle");
|
|
67
|
+
const handle = await open(path, fsConstants.O_RDONLY | fsConstants.O_NOFOLLOW);
|
|
68
|
+
try {
|
|
69
|
+
const opened = await handle.stat({ bigint: true });
|
|
70
|
+
if (!opened.isDirectory() || opened.dev !== stats.dev || opened.ino !== stats.ino) {
|
|
71
|
+
throw new PdfCaptureError("integrity", "capture bundle root changed while it was opened");
|
|
72
|
+
}
|
|
73
|
+
return { path, handle, device: opened.dev, inode: opened.ino };
|
|
74
|
+
} catch (error) {
|
|
75
|
+
await handle.close();
|
|
76
|
+
throw error;
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
async function assertBundleRoot(root: SafeBundleRoot, deadline?: number): Promise<void> {
|
|
81
|
+
assertAssetVerificationDeadline(deadline);
|
|
82
|
+
const [opened, named, canonical] = await Promise.all([
|
|
83
|
+
root.handle.stat({ bigint: true }),
|
|
84
|
+
lstat(root.path, { bigint: true }),
|
|
85
|
+
realpath(root.path),
|
|
86
|
+
]);
|
|
87
|
+
assertAssetVerificationDeadline(deadline);
|
|
88
|
+
if (
|
|
89
|
+
canonical !== root.path
|
|
90
|
+
|| !opened.isDirectory()
|
|
91
|
+
|| !named.isDirectory()
|
|
92
|
+
|| named.isSymbolicLink()
|
|
93
|
+
|| opened.dev !== root.device
|
|
94
|
+
|| opened.ino !== root.inode
|
|
95
|
+
|| named.dev !== root.device
|
|
96
|
+
|| named.ino !== root.inode
|
|
97
|
+
) throw new PdfCaptureError("integrity", "capture bundle root changed during inspection");
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
async function assertAncestorChain(
|
|
101
|
+
root: SafeBundleRoot,
|
|
102
|
+
relativePath: string,
|
|
103
|
+
label: string,
|
|
104
|
+
deadline?: number,
|
|
105
|
+
): Promise<void> {
|
|
106
|
+
await assertBundleRoot(root, deadline);
|
|
107
|
+
const segments = confinedRelativePath(relativePath, label).split("/");
|
|
108
|
+
let current = root.path;
|
|
109
|
+
for (const segment of segments.slice(0, -1)) {
|
|
110
|
+
current = resolve(current, segment);
|
|
111
|
+
assertAssetVerificationDeadline(deadline);
|
|
112
|
+
const metadata = await lstat(current, { bigint: true });
|
|
113
|
+
assertAssetVerificationDeadline(deadline);
|
|
114
|
+
if (!metadata.isDirectory() || metadata.isSymbolicLink()) {
|
|
115
|
+
throw new PdfCaptureError("integrity", `${label} must not traverse a linked or non-directory ancestor`);
|
|
116
|
+
}
|
|
117
|
+
const canonical = await realpath(current);
|
|
118
|
+
assertAssetVerificationDeadline(deadline);
|
|
119
|
+
if (canonical !== current) {
|
|
120
|
+
throw new PdfCaptureError("integrity", `${label} must not traverse a filesystem alias`);
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
await assertBundleRoot(root, deadline);
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
async function withRegularFile<T>(
|
|
127
|
+
root: SafeBundleRoot,
|
|
128
|
+
relativePath: string,
|
|
129
|
+
maximumBytes: number,
|
|
130
|
+
label: string,
|
|
131
|
+
read: (handle: FileHandle, size: number) => Promise<T>,
|
|
132
|
+
deadline?: number,
|
|
133
|
+
missingError?: Error,
|
|
134
|
+
): Promise<T> {
|
|
135
|
+
await assertAncestorChain(root, relativePath, label, deadline);
|
|
136
|
+
const path = confined(root, relativePath, label);
|
|
137
|
+
assertAssetVerificationDeadline(deadline);
|
|
138
|
+
let before: BigIntStats;
|
|
139
|
+
try {
|
|
140
|
+
before = await lstat(path, { bigint: true });
|
|
141
|
+
} catch (error) {
|
|
142
|
+
assertAssetVerificationDeadline(deadline);
|
|
143
|
+
if (missingError !== undefined && isMissingPathError(error)) throw missingError;
|
|
144
|
+
throw error;
|
|
145
|
+
}
|
|
146
|
+
assertAssetVerificationDeadline(deadline);
|
|
147
|
+
if (!before.isFile() || before.isSymbolicLink() || before.nlink !== 1n) {
|
|
148
|
+
throw new PdfCaptureError("integrity", `${label} must be a regular single-link file`);
|
|
149
|
+
}
|
|
150
|
+
if (before.size > BigInt(maximumBytes)) throw new PdfCaptureError("integrity", `${label} exceeds ${maximumBytes} bytes`);
|
|
151
|
+
const canonical = await realpath(path);
|
|
152
|
+
assertAssetVerificationDeadline(deadline);
|
|
153
|
+
if (canonical !== path) throw new PdfCaptureError("integrity", `${label} must not traverse a filesystem alias`);
|
|
154
|
+
const handle = await open(path, fsConstants.O_RDONLY | fsConstants.O_NOFOLLOW);
|
|
155
|
+
try {
|
|
156
|
+
assertAssetVerificationDeadline(deadline);
|
|
157
|
+
const opened = await handle.stat({ bigint: true });
|
|
158
|
+
assertAssetVerificationDeadline(deadline);
|
|
159
|
+
if (
|
|
160
|
+
!opened.isFile()
|
|
161
|
+
|| opened.nlink !== 1n
|
|
162
|
+
|| opened.dev !== before.dev
|
|
163
|
+
|| opened.ino !== before.ino
|
|
164
|
+
|| opened.size !== before.size
|
|
165
|
+
) throw new PdfCaptureError("integrity", `${label} changed while it was opened`);
|
|
166
|
+
await assertAncestorChain(root, relativePath, label, deadline);
|
|
167
|
+
const result = await read(handle, Number(opened.size));
|
|
168
|
+
assertAssetVerificationDeadline(deadline);
|
|
169
|
+
const after = await handle.stat({ bigint: true });
|
|
170
|
+
assertAssetVerificationDeadline(deadline);
|
|
171
|
+
const named = await lstat(path, { bigint: true });
|
|
172
|
+
assertAssetVerificationDeadline(deadline);
|
|
173
|
+
await assertAncestorChain(root, relativePath, label, deadline);
|
|
174
|
+
const finalCanonical = await realpath(path);
|
|
175
|
+
assertAssetVerificationDeadline(deadline);
|
|
176
|
+
if (
|
|
177
|
+
after.dev !== before.dev
|
|
178
|
+
|| after.ino !== before.ino
|
|
179
|
+
|| after.size !== before.size
|
|
180
|
+
|| after.mtimeNs !== before.mtimeNs
|
|
181
|
+
|| after.nlink !== 1n
|
|
182
|
+
|| named.dev !== before.dev
|
|
183
|
+
|| named.ino !== before.ino
|
|
184
|
+
|| named.size !== before.size
|
|
185
|
+
|| named.mtimeNs !== before.mtimeNs
|
|
186
|
+
|| named.nlink !== 1n
|
|
187
|
+
|| named.isSymbolicLink()
|
|
188
|
+
|| finalCanonical !== path
|
|
189
|
+
) throw new PdfCaptureError("integrity", `${label} changed while it was read`);
|
|
190
|
+
return result;
|
|
191
|
+
} finally {
|
|
192
|
+
await handle.close();
|
|
193
|
+
assertAssetVerificationDeadline(deadline);
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
async function readRegularFile(
|
|
198
|
+
root: SafeBundleRoot,
|
|
199
|
+
relativePath: string,
|
|
200
|
+
maximumBytes: number,
|
|
201
|
+
label: string,
|
|
202
|
+
deadline?: number,
|
|
203
|
+
): Promise<Buffer> {
|
|
204
|
+
return withRegularFile(root, relativePath, maximumBytes, label, async (handle, size) => {
|
|
205
|
+
const bytes = Buffer.alloc(size);
|
|
206
|
+
let offset = 0;
|
|
207
|
+
while (offset < bytes.byteLength) {
|
|
208
|
+
assertAssetVerificationDeadline(deadline);
|
|
209
|
+
const result = await handle.read(bytes, offset, bytes.byteLength - offset, offset);
|
|
210
|
+
if (result.bytesRead === 0) throw new PdfCaptureError("integrity", `${label} changed while it was read`);
|
|
211
|
+
offset += result.bytesRead;
|
|
212
|
+
}
|
|
213
|
+
return bytes;
|
|
214
|
+
}, deadline);
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
async function hashRegularFile(
|
|
218
|
+
root: SafeBundleRoot,
|
|
219
|
+
relativePath: string,
|
|
220
|
+
maximumBytes: number,
|
|
221
|
+
deadline: number,
|
|
222
|
+
label: string,
|
|
223
|
+
): Promise<{ readonly bytes: number; readonly sha256: string }> {
|
|
224
|
+
assertAssetVerificationDeadline(deadline);
|
|
225
|
+
await assertBundleRoot(root, deadline);
|
|
226
|
+
assertAssetVerificationDeadline(deadline);
|
|
227
|
+
await assertAncestorChain(root, relativePath, label, deadline);
|
|
228
|
+
const path = confined(root, relativePath, label);
|
|
229
|
+
assertAssetVerificationDeadline(deadline);
|
|
230
|
+
try {
|
|
231
|
+
await lstat(path, { bigint: true });
|
|
232
|
+
} catch (error) {
|
|
233
|
+
assertAssetVerificationDeadline(deadline);
|
|
234
|
+
if (isMissingPathError(error)) {
|
|
235
|
+
await assertBundleRoot(root, deadline);
|
|
236
|
+
throw new MissingCaptureAssetError(`capture asset ${relativePath} is missing`);
|
|
237
|
+
}
|
|
238
|
+
throw error;
|
|
239
|
+
}
|
|
240
|
+
assertAssetVerificationDeadline(deadline);
|
|
241
|
+
return withRegularFile(root, relativePath, maximumBytes, label, async (handle, size) => {
|
|
242
|
+
assertAssetVerificationDeadline(deadline);
|
|
243
|
+
const hash = createHash("sha256");
|
|
244
|
+
const chunk = Buffer.allocUnsafe(Math.min(1 * 1_024 * 1_024, Math.max(1, size)));
|
|
245
|
+
let offset = 0;
|
|
246
|
+
while (offset < size) {
|
|
247
|
+
assertAssetVerificationDeadline(deadline);
|
|
248
|
+
const result = await handle.read(chunk, 0, Math.min(chunk.byteLength, size - offset), offset);
|
|
249
|
+
assertAssetVerificationDeadline(deadline);
|
|
250
|
+
if (result.bytesRead === 0) throw new PdfCaptureError("integrity", `${label} changed while it was read`);
|
|
251
|
+
hash.update(chunk.subarray(0, result.bytesRead));
|
|
252
|
+
offset += result.bytesRead;
|
|
253
|
+
}
|
|
254
|
+
assertAssetVerificationDeadline(deadline);
|
|
255
|
+
return { bytes: size, sha256: hash.digest("hex") };
|
|
256
|
+
}, deadline, new MissingCaptureAssetError(`capture asset ${relativePath} is missing`));
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
function digest(bytes: Uint8Array): string {
|
|
260
|
+
return createHash("sha256").update(bytes).digest("hex");
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
|
|
264
|
+
type RecordValue = Readonly<Record<string, unknown>>;
|
|
265
|
+
type Descriptor = { readonly path: string; readonly bytes: number; readonly sha256: string };
|
|
266
|
+
export type PdfBundleIntegrity = "verified" | "mismatch" | "unavailable";
|
|
267
|
+
export type PdfBundleFile = Descriptor & { readonly integrity: PdfBundleIntegrity };
|
|
268
|
+
export type PdfBundleInspection = {
|
|
269
|
+
readonly root: string;
|
|
270
|
+
readonly schemaVersion: 1;
|
|
271
|
+
readonly status: "complete" | "partial";
|
|
272
|
+
readonly pageCount: number;
|
|
273
|
+
readonly processedPages: number;
|
|
274
|
+
readonly markdown: PdfBundleFile & { readonly markdown: string };
|
|
275
|
+
readonly source: PdfBundleFile;
|
|
276
|
+
readonly assets: readonly PdfBundleFile[];
|
|
277
|
+
readonly annotations: PdfBundleFile | null;
|
|
278
|
+
};
|
|
279
|
+
export type ReadPdfCaptureBundleOptions = {
|
|
280
|
+
readonly maxDocumentBytes?: number;
|
|
281
|
+
readonly maxSourceBytes?: number;
|
|
282
|
+
readonly maxAssetBytes?: number;
|
|
283
|
+
readonly maxTotalAssetBytes?: number;
|
|
284
|
+
readonly maxVerifiedAssets?: number;
|
|
285
|
+
readonly maxVerificationMs?: number;
|
|
286
|
+
};
|
|
287
|
+
export type PdfBundleVerification = {
|
|
288
|
+
readonly ok: boolean;
|
|
289
|
+
readonly inspection: PdfBundleInspection;
|
|
290
|
+
readonly issues: readonly { readonly kind: "document-integrity" | "source-integrity" | "asset-integrity" | "annotation-integrity"; readonly path: string; readonly integrity: PdfBundleIntegrity }[];
|
|
291
|
+
};
|
|
292
|
+
function invalid(message: string): never { throw new PdfCaptureError("invalid-input", message); }
|
|
293
|
+
function object(value: unknown): RecordValue {
|
|
294
|
+
if (value === null || typeof value !== "object" || Array.isArray(value)) invalid("PDF bundle requires an object");
|
|
295
|
+
return value as RecordValue;
|
|
296
|
+
}
|
|
297
|
+
function integer(value: unknown, maximum: number, minimum = 0): number {
|
|
298
|
+
if (typeof value !== "number" || !Number.isSafeInteger(value) || value < minimum || value > maximum) invalid("PDF bundle has an invalid count or page reference");
|
|
299
|
+
return value;
|
|
300
|
+
}
|
|
301
|
+
function descriptor(value: unknown): Descriptor {
|
|
302
|
+
const entry = object(value);
|
|
303
|
+
if (typeof entry.sha256 !== "string" || !/^[a-f0-9]{64}$/u.test(entry.sha256)) invalid("PDF bundle has an invalid digest");
|
|
304
|
+
return { path: confinedRelativePath(entry.path, "PDF artifact"), bytes: integer(entry.bytes, 8 * 1024 ** 3), sha256: entry.sha256 };
|
|
305
|
+
}
|
|
306
|
+
function decode(bytes: Buffer): string {
|
|
307
|
+
try { return new TextDecoder("utf-8", { fatal: true }).decode(bytes); }
|
|
308
|
+
catch { return invalid("PDF bundle text is not UTF-8"); }
|
|
309
|
+
}
|
|
310
|
+
function parse(bytes: Buffer): unknown {
|
|
311
|
+
try { return JSON.parse(decode(bytes)) as unknown; }
|
|
312
|
+
catch (error) { if (error instanceof PdfCaptureError) throw error; return invalid("PDF bundle JSON is invalid"); }
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
/** Inspect retained source and assets without following aliases or trusting manifest paths. */
|
|
316
|
+
export async function inspectPdfCaptureBundle(path: string, options: ReadPdfCaptureBundleOptions = {}): Promise<PdfBundleInspection> {
|
|
317
|
+
const maxDocument = boundedInteger(options.maxDocumentBytes, 64 * 1024 ** 2, 256 * 1024 ** 2, "maxDocumentBytes");
|
|
318
|
+
const maxSource = boundedInteger(options.maxSourceBytes, 512 * 1024 ** 2, 8 * 1024 ** 3, "maxSourceBytes");
|
|
319
|
+
const maxAsset = boundedInteger(options.maxAssetBytes, 100 * 1024 ** 2, 8 * 1024 ** 3, "maxAssetBytes");
|
|
320
|
+
const maxTotal = boundedInteger(options.maxTotalAssetBytes, 500 * 1024 ** 2, 8 * 1024 ** 3, "maxTotalAssetBytes");
|
|
321
|
+
const maxAssets = boundedInteger(options.maxVerifiedAssets, 1000, 10_000, "maxVerifiedAssets");
|
|
322
|
+
const deadline = performance.now() + boundedInteger(options.maxVerificationMs, 30_000, 300_000, "maxVerificationMs");
|
|
323
|
+
const root = await safeDirectory(path);
|
|
324
|
+
try {
|
|
325
|
+
const manifest = object(parse(await readRegularFile(root, "capture.json", 4 * 1024 ** 2, "PDF manifest", deadline)));
|
|
326
|
+
if (manifest.schemaVersion !== 1) throw new PdfCaptureError("unsupported-schema", "Unsupported PDF manifest schema");
|
|
327
|
+
if (manifest.kind !== "pdf" || (manifest.status !== "complete" && manifest.status !== "partial")) invalid("Invalid PDF manifest kind or status");
|
|
328
|
+
const document = object(manifest.document);
|
|
329
|
+
const pageCount = integer(document.pageCount, 100_000, 1);
|
|
330
|
+
const processedPages = integer(document.processedPages, pageCount, 1);
|
|
331
|
+
if (processedPages < pageCount && manifest.status !== "partial") invalid("Truncated PDF cannot claim complete");
|
|
332
|
+
if (manifest.quality !== undefined) {
|
|
333
|
+
const quality = object(manifest.quality);
|
|
334
|
+
if (quality.schemaVersion !== 1) throw new PdfCaptureError("unsupported-schema", "Unsupported PDF quality schema");
|
|
335
|
+
if (!Array.isArray(quality.issues) || quality.issues.length > 100_000) invalid("Invalid PDF quality issues");
|
|
336
|
+
for (const raw of quality.issues as unknown[]) {
|
|
337
|
+
const issue = object(raw);
|
|
338
|
+
if (!["reading-order-uncertain", "table-structure-uncertain", "math-structure-uncertain", "ocr-needed"].includes(String(issue.code))) invalid("Unknown PDF quality issue");
|
|
339
|
+
integer(issue.page, processedPages, 1);
|
|
340
|
+
}
|
|
341
|
+
if (quality.issues.length > 0 && manifest.status !== "partial") invalid("Uncertain PDF cannot claim complete");
|
|
342
|
+
}
|
|
343
|
+
const sourceDescriptor = descriptor(manifest.source);
|
|
344
|
+
if (sourceDescriptor.path !== "source.pdf" || object(manifest.source).mimeType !== "application/pdf") invalid("Invalid retained PDF source");
|
|
345
|
+
const check = async (expected: Descriptor, maximum: number): Promise<PdfBundleFile> => {
|
|
346
|
+
if (expected.bytes > maximum) throw new PdfCaptureError("limit-exceeded", "PDF artifact exceeds verification byte limit");
|
|
347
|
+
const actual = await hashRegularFile(root, expected.path, maximum, deadline, "PDF artifact");
|
|
348
|
+
return { ...actual, path: expected.path, integrity: actual.bytes === expected.bytes && actual.sha256 === expected.sha256 ? "verified" : "mismatch" };
|
|
349
|
+
};
|
|
350
|
+
const source = await check(sourceDescriptor, maxSource);
|
|
351
|
+
let expectedMarkdown: Descriptor | null = null;
|
|
352
|
+
let markdownPath: string;
|
|
353
|
+
if (manifest.markdown !== undefined) {
|
|
354
|
+
expectedMarkdown = descriptor(manifest.markdown);
|
|
355
|
+
markdownPath = expectedMarkdown.path;
|
|
356
|
+
if (markdownPath.includes("/") || !markdownPath.endsWith(".md")) invalid("Invalid PDF Markdown path");
|
|
357
|
+
} else {
|
|
358
|
+
// Legacy bundles retain their readable Markdown, but its digest is not attested.
|
|
359
|
+
const directory = await opendir(root.path);
|
|
360
|
+
const candidates: string[] = [];
|
|
361
|
+
let entries = 0;
|
|
362
|
+
try {
|
|
363
|
+
for await (const entry of directory) {
|
|
364
|
+
assertAssetVerificationDeadline(deadline);
|
|
365
|
+
if (++entries > 1000) throw new PdfCaptureError("limit-exceeded", "Legacy PDF directory exceeds entry limit");
|
|
366
|
+
if (entry.name.endsWith(".md")) candidates.push(entry.name);
|
|
367
|
+
}
|
|
368
|
+
} finally { try { await directory.close(); } catch { /* Iteration closes the directory. */ } }
|
|
369
|
+
if (candidates.length !== 1) invalid("Legacy PDF bundle must contain exactly one Markdown document");
|
|
370
|
+
markdownPath = candidates[0]!;
|
|
371
|
+
}
|
|
372
|
+
const markdownBytes = await readRegularFile(root, markdownPath, maxDocument, "PDF Markdown", deadline);
|
|
373
|
+
const markdownText = decode(markdownBytes);
|
|
374
|
+
const markdownDigest = digest(markdownBytes);
|
|
375
|
+
const markdown: PdfBundleInspection["markdown"] = {
|
|
376
|
+
path: markdownPath, bytes: markdownBytes.length, sha256: markdownDigest, markdown: markdownText,
|
|
377
|
+
integrity: expectedMarkdown === null ? "unavailable" : expectedMarkdown.bytes === markdownBytes.length && expectedMarkdown.sha256 === markdownDigest ? "verified" : "mismatch",
|
|
378
|
+
};
|
|
379
|
+
const assetReferences = new Map<string, Set<number>>();
|
|
380
|
+
let currentPage = 0;
|
|
381
|
+
for (const match of markdownText.matchAll(/<!-- pdf-page: (\d+) -->|!\[[^\]\n]*\]\((assets\/[^)\r\n]+)\)/gu)) {
|
|
382
|
+
if (match[1] !== undefined) currentPage = integer(Number(match[1]), processedPages, 1);
|
|
383
|
+
else if (match[2] !== undefined) {
|
|
384
|
+
const pages = assetReferences.get(match[2]) ?? new Set<number>(); pages.add(currentPage); assetReferences.set(match[2], pages);
|
|
385
|
+
}
|
|
386
|
+
}
|
|
387
|
+
const anchors = new Set<number>();
|
|
388
|
+
let anchorCount = 0;
|
|
389
|
+
for (const match of markdownText.matchAll(/<!-- pdf-page: (\d+) -->/gu)) {
|
|
390
|
+
if (++anchorCount > 100_000) invalid("Too many PDF page anchors");
|
|
391
|
+
anchors.add(integer(Number(match[1]), processedPages, 1));
|
|
392
|
+
}
|
|
393
|
+
if (!Array.isArray(manifest.images) || manifest.images.length > maxAssets) invalid("Invalid PDF image count");
|
|
394
|
+
const imageIds = new Map<string, string>();
|
|
395
|
+
const assetsByPath = new Map<string, PdfBundleFile>();
|
|
396
|
+
const declaredPages = new Map<string, Set<number>>();
|
|
397
|
+
let totalBytes = 0;
|
|
398
|
+
for (const raw of manifest.images as unknown[]) {
|
|
399
|
+
const image = object(raw);
|
|
400
|
+
if (typeof image.id !== "string" || !/^[a-z0-9][a-z0-9._:-]{0,255}$/u.test(image.id) || imageIds.has(image.id)) invalid("Invalid or duplicate PDF image ID");
|
|
401
|
+
const page = integer(image.page, processedPages, 1);
|
|
402
|
+
if (!anchors.has(page)) invalid("PDF image has no corresponding Markdown page anchor");
|
|
403
|
+
const asset = descriptor(image.asset);
|
|
404
|
+
if (!/^assets\/[^/]+$/u.test(asset.path)) invalid("PDF image must be a direct assets child");
|
|
405
|
+
if (!assetReferences.get(asset.path)?.has(page)) invalid("PDF image has no corresponding Markdown asset reference");
|
|
406
|
+
imageIds.set(image.id, asset.sha256);
|
|
407
|
+
const pages = declaredPages.get(asset.path) ?? new Set<number>();
|
|
408
|
+
pages.add(page); declaredPages.set(asset.path, pages);
|
|
409
|
+
const existing = assetsByPath.get(asset.path);
|
|
410
|
+
if (existing !== undefined) {
|
|
411
|
+
if (existing.bytes !== asset.bytes || existing.sha256 !== asset.sha256) invalid("Conflicting PDF asset descriptors");
|
|
412
|
+
} else {
|
|
413
|
+
totalBytes += asset.bytes;
|
|
414
|
+
if (totalBytes > maxTotal) throw new PdfCaptureError("limit-exceeded", "PDF assets exceed total byte limit");
|
|
415
|
+
const checked = await check(asset, Math.min(maxAsset, maxTotal - (totalBytes - asset.bytes)));
|
|
416
|
+
totalBytes += checked.bytes - asset.bytes;
|
|
417
|
+
assetsByPath.set(asset.path, checked);
|
|
418
|
+
}
|
|
419
|
+
}
|
|
420
|
+
for (const [path, pages] of assetReferences) {
|
|
421
|
+
if (!assetsByPath.has(path)) invalid("Markdown references an undeclared PDF asset");
|
|
422
|
+
if ([...pages].some(page => !declaredPages.get(path)?.has(page))) invalid("Markdown asset page disagrees with PDF manifest");
|
|
423
|
+
}
|
|
424
|
+
const extraction = object(manifest.extraction);
|
|
425
|
+
if (integer(extraction.imageCount, maxAssets) !== imageIds.size) invalid("PDF image count disagrees with manifest");
|
|
426
|
+
let annotations: PdfBundleFile | null = null;
|
|
427
|
+
if (manifest.annotations !== null) {
|
|
428
|
+
const annotationDescriptor = descriptor(manifest.annotations);
|
|
429
|
+
if (annotationDescriptor.path !== "annotations.json") invalid("Invalid PDF annotations path");
|
|
430
|
+
const annotationBytes = await readRegularFile(root, "annotations.json", 16 * 1024 ** 2, "PDF annotations", deadline);
|
|
431
|
+
const annotationHash = digest(annotationBytes);
|
|
432
|
+
annotations = { path: annotationDescriptor.path, bytes: annotationBytes.length, sha256: annotationHash, integrity: annotationDescriptor.bytes === annotationBytes.length && annotationDescriptor.sha256 === annotationHash ? "verified" : "mismatch" };
|
|
433
|
+
let retained: ReturnType<typeof parsePdfImageInterpretations>;
|
|
434
|
+
try { retained = parsePdfImageInterpretations(parse(annotationBytes)); }
|
|
435
|
+
catch (error) { if (error instanceof PdfCaptureError) throw error; throw new PdfCaptureError("invalid-input", "Invalid PDF image annotations", { cause: error }); }
|
|
436
|
+
if (retained.length !== integer(object(manifest.annotations).count, 10_000)) invalid("PDF annotation count disagrees with manifest");
|
|
437
|
+
for (const annotation of retained) {
|
|
438
|
+
if (imageIds.get(annotation.id) !== annotation.sha256) invalid("PDF annotation references an unknown image or digest");
|
|
439
|
+
}
|
|
440
|
+
}
|
|
441
|
+
await assertBundleRoot(root, deadline);
|
|
442
|
+
return { root: root.path, schemaVersion: 1, status: manifest.status, pageCount, processedPages, markdown, source, assets: [...assetsByPath.values()], annotations };
|
|
443
|
+
} finally { await root.handle.close(); }
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
/** Missing legacy Markdown provenance is explicit and never counted as verified. */
|
|
447
|
+
export async function verifyPdfCaptureBundle(path: string, options: ReadPdfCaptureBundleOptions = {}): Promise<PdfBundleVerification> {
|
|
448
|
+
const inspection = await inspectPdfCaptureBundle(path, options);
|
|
449
|
+
const issues: Array<PdfBundleVerification["issues"][number]> = [];
|
|
450
|
+
const add = (file: PdfBundleFile, kind: PdfBundleVerification["issues"][number]["kind"]): void => {
|
|
451
|
+
if (file.integrity !== "verified") issues.push({ kind, path: file.path, integrity: file.integrity });
|
|
452
|
+
};
|
|
453
|
+
add(inspection.markdown, "document-integrity"); add(inspection.source, "source-integrity");
|
|
454
|
+
for (const asset of inspection.assets) add(asset, "asset-integrity");
|
|
455
|
+
if (inspection.annotations !== null) add(inspection.annotations, "annotation-integrity");
|
|
456
|
+
return { ok: issues.length === 0, inspection, issues };
|
|
457
|
+
}
|
package/src/pdf/capture.ts
CHANGED
|
@@ -8,7 +8,7 @@ import { slugify } from "../clip/lib.js";
|
|
|
8
8
|
import { sanitizeTerminalText } from "../clip/terminal.js";
|
|
9
9
|
import { inspectPdf } from "./extract.js";
|
|
10
10
|
import {
|
|
11
|
-
|
|
11
|
+
analyzePdfLayout,
|
|
12
12
|
parsePdfImageInterpretations,
|
|
13
13
|
} from "./layout.js";
|
|
14
14
|
import {
|
|
@@ -183,6 +183,8 @@ export async function runPdfCapture(
|
|
|
183
183
|
});
|
|
184
184
|
}
|
|
185
185
|
const platforms = embeddedPlatforms(resolvedImages);
|
|
186
|
+
const layout = analyzePdfLayout(inspection.pages);
|
|
187
|
+
for (const issue of layout.issues) warnings.push(`Page ${issue.page}: ${issue.code}; consult the retained source PDF.`);
|
|
186
188
|
const status = warnings.length > 0 || unclassifiedImages > 0 || ocrWarningCount > 0
|
|
187
189
|
? "partial"
|
|
188
190
|
: "complete";
|
|
@@ -190,7 +192,7 @@ export async function runPdfCapture(
|
|
|
190
192
|
const requestedSlug = options.slug ?? inspection.metadata.title ?? filenameTitle;
|
|
191
193
|
const slug = slugify(sanitizeTerminalText(requestedSlug));
|
|
192
194
|
if (slug === "") throw new Error("could not derive a safe PDF capture slug; pass an explicit slug");
|
|
193
|
-
const blocks =
|
|
195
|
+
const blocks = layout.blocks;
|
|
194
196
|
const byId = new Map(resolvedImages.map((image) => [image.image.id, image]));
|
|
195
197
|
const capturedAt = (dependencies.now ?? (() => new Date()))().toISOString();
|
|
196
198
|
const built = buildPdfMarkdown({
|
|
@@ -233,6 +235,8 @@ export async function runPdfCapture(
|
|
|
233
235
|
const manifest: PdfCaptureManifest = {
|
|
234
236
|
schemaVersion: PDF_CAPTURE_MANIFEST_SCHEMA_VERSION,
|
|
235
237
|
kind: "pdf",
|
|
238
|
+
quality: { schemaVersion: 1, issues: layout.issues },
|
|
239
|
+
markdown: { path: pdfMarkdownFilename(slug), bytes: Buffer.byteLength(built.markdown), sha256: createHash("sha256").update(built.markdown).digest("hex") },
|
|
236
240
|
capturedAt,
|
|
237
241
|
status,
|
|
238
242
|
source: {
|
package/src/pdf/cli.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { PdfCaptureError } from "./errors.js";
|
|
1
2
|
import { lstatSync, readFileSync } from "node:fs";
|
|
2
3
|
import { resolve } from "node:path";
|
|
3
4
|
|
|
@@ -149,7 +150,7 @@ export async function main(
|
|
|
149
150
|
return 0;
|
|
150
151
|
} catch (error) {
|
|
151
152
|
const message = safe(error instanceof Error ? error.message : String(error));
|
|
152
|
-
if (arguments_.json) output.stdout(terminalSafeJson({ ok: false, error: message }));
|
|
153
|
+
if (arguments_.json) output.stdout(terminalSafeJson({ ok: false, error: message, code: error instanceof PdfCaptureError ? error.code : "unknown" }));
|
|
153
154
|
else output.stderr(renderFailure(message, "wordcell doctor", stderrStyle(environment, output)));
|
|
154
155
|
return 1;
|
|
155
156
|
} finally {
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
export type PdfCaptureErrorCode = "invalid-input" | "encrypted" | "limit-exceeded" | "tool-unavailable" | "extraction-failed" | "timeout" | "cancelled" | "integrity" | "unsupported-schema" | "unknown";
|
|
2
|
+
|
|
3
|
+
/** Codes describe observed failures; unknown errors are never classified from their prose. */
|
|
4
|
+
export class PdfCaptureError extends Error {
|
|
5
|
+
readonly code: PdfCaptureErrorCode;
|
|
6
|
+
constructor(code: PdfCaptureErrorCode, message: string, options?: ErrorOptions) {
|
|
7
|
+
super(message, options);
|
|
8
|
+
this.code = code;
|
|
9
|
+
this.name = "PdfCaptureError";
|
|
10
|
+
}
|
|
11
|
+
}
|
package/src/pdf/extract.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { PdfCaptureError } from "./errors.js";
|
|
1
2
|
import { createHash } from "node:crypto";
|
|
2
3
|
import {
|
|
3
4
|
chmodSync,
|
|
@@ -43,7 +44,7 @@ export const pdfCaptureDefaults = {
|
|
|
43
44
|
function positiveBound(value: number | undefined, fallback: number, maximum: number, label: string): number {
|
|
44
45
|
const selected = value ?? fallback;
|
|
45
46
|
if (!Number.isSafeInteger(selected) || selected < 1 || selected > maximum) {
|
|
46
|
-
throw new
|
|
47
|
+
throw new PdfCaptureError("invalid-input", `${label} must be an integer from 1 through ${maximum}`);
|
|
47
48
|
}
|
|
48
49
|
return selected;
|
|
49
50
|
}
|
|
@@ -61,10 +62,10 @@ function prepareWorkspace(path: string): string {
|
|
|
61
62
|
if (existsSync(absolute)) {
|
|
62
63
|
const stats = lstatSync(absolute);
|
|
63
64
|
if (!stats.isDirectory() || stats.isSymbolicLink()) {
|
|
64
|
-
throw new
|
|
65
|
+
throw new PdfCaptureError("invalid-input", "PDF inspection workspace must be a real directory");
|
|
65
66
|
}
|
|
66
67
|
if (readdirSync(absolute).length !== 0) {
|
|
67
|
-
throw new
|
|
68
|
+
throw new PdfCaptureError("invalid-input", "PDF inspection workspace must be empty");
|
|
68
69
|
}
|
|
69
70
|
} else {
|
|
70
71
|
mkdirSync(absolute, { recursive: false, mode: 0o700 });
|
|
@@ -81,15 +82,15 @@ function sourceIdentity(path: string, maxPdfBytes: number): {
|
|
|
81
82
|
const originalFilename = basename(path);
|
|
82
83
|
const canonical = realpathSync(resolve(path));
|
|
83
84
|
const stats = statSync(canonical);
|
|
84
|
-
if (!stats.isFile()) throw new
|
|
85
|
-
if (stats.size < 5) throw new
|
|
86
|
-
if (stats.size > maxPdfBytes) throw new
|
|
85
|
+
if (!stats.isFile()) throw new PdfCaptureError("invalid-input", "PDF input must be a regular file");
|
|
86
|
+
if (stats.size < 5) throw new PdfCaptureError("invalid-input", "PDF input is too small to contain a PDF header");
|
|
87
|
+
if (stats.size > maxPdfBytes) throw new PdfCaptureError("limit-exceeded", `PDF input exceeds the ${maxPdfBytes}-byte limit`);
|
|
87
88
|
const descriptor = openSync(canonical, "r");
|
|
88
89
|
try {
|
|
89
90
|
const signature = Buffer.alloc(5);
|
|
90
91
|
const count = readSync(descriptor, signature, 0, signature.length, 0);
|
|
91
92
|
if (count !== signature.length || signature.toString("ascii") !== "%PDF-") {
|
|
92
|
-
throw new
|
|
93
|
+
throw new PdfCaptureError("invalid-input", "PDF input does not have a valid PDF signature");
|
|
93
94
|
}
|
|
94
95
|
} finally {
|
|
95
96
|
closeSync(descriptor);
|
|
@@ -155,18 +156,18 @@ function safeGeneratedImage(
|
|
|
155
156
|
function generatedTreeSize(workspace: string, maximumFiles: number, maximumBytes: number): void {
|
|
156
157
|
const entries = readdirSync(workspace, { withFileTypes: true });
|
|
157
158
|
if (entries.length > maximumFiles) {
|
|
158
|
-
throw new
|
|
159
|
+
throw new PdfCaptureError("limit-exceeded", `PDF extraction created more than ${maximumFiles} files`);
|
|
159
160
|
}
|
|
160
161
|
let totalBytes = 0;
|
|
161
162
|
for (const entry of entries) {
|
|
162
163
|
const path = join(workspace, entry.name);
|
|
163
164
|
const stats = lstatSync(path);
|
|
164
165
|
if (!entry.isFile() || stats.isSymbolicLink()) {
|
|
165
|
-
throw new
|
|
166
|
+
throw new PdfCaptureError("extraction-failed", "PDF extraction created an unexpected non-file output");
|
|
166
167
|
}
|
|
167
168
|
totalBytes += stats.size;
|
|
168
169
|
if (totalBytes > maximumBytes) {
|
|
169
|
-
throw new
|
|
170
|
+
throw new PdfCaptureError("limit-exceeded", `PDF extraction exceeded the ${maximumBytes}-byte workspace limit`);
|
|
170
171
|
}
|
|
171
172
|
}
|
|
172
173
|
}
|
|
@@ -208,7 +209,9 @@ export async function inspectPdf(
|
|
|
208
209
|
cwd: workspaceDirectory,
|
|
209
210
|
});
|
|
210
211
|
if (info.exitCode !== 0) {
|
|
211
|
-
|
|
212
|
+
// Poppler runs in the C locale; only its exact password diagnostic establishes encryption.
|
|
213
|
+
const encrypted = info.stderr.split(/\r?\n/u).some(line => line.trim() === "Command Line Error: Incorrect password");
|
|
214
|
+
throw new PdfCaptureError(encrypted ? "encrypted" : "extraction-failed", "pdfinfo could not inspect the input PDF");
|
|
212
215
|
}
|
|
213
216
|
const metadata = parsePdfInfo(info.stdout);
|
|
214
217
|
const processedPageLimit = Math.min(metadata.pageCount, maxPages);
|
|
@@ -233,7 +236,7 @@ export async function inspectPdf(
|
|
|
233
236
|
cwd: workspaceDirectory,
|
|
234
237
|
});
|
|
235
238
|
if (extracted.exitCode !== 0) {
|
|
236
|
-
throw new
|
|
239
|
+
throw new PdfCaptureError("extraction-failed", "pdftohtml could not extract the input PDF");
|
|
237
240
|
}
|
|
238
241
|
generatedTreeSize(
|
|
239
242
|
workspaceDirectory,
|
|
@@ -245,7 +248,7 @@ export async function inspectPdf(
|
|
|
245
248
|
);
|
|
246
249
|
const layoutStats = statSync(layoutPath);
|
|
247
250
|
if (!layoutStats.isFile() || layoutStats.size > pdfCaptureDefaults.maxLayoutBytes) {
|
|
248
|
-
throw new
|
|
251
|
+
throw new PdfCaptureError("limit-exceeded", `Poppler layout XML exceeds the ${pdfCaptureDefaults.maxLayoutBytes}-byte limit`);
|
|
249
252
|
}
|
|
250
253
|
const parsed = parsePopplerXml(readFileSync(layoutPath, "utf8"), {
|
|
251
254
|
maxPages: processedPageLimit,
|