@pipeworx/mcp-guam-code 0.1.0 → 0.1.2

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/src/server.ts CHANGED
@@ -7,9 +7,11 @@ import { Server } from '@modelcontextprotocol/sdk/server/index.js';
7
7
  import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
8
8
  import { CallToolRequestSchema, ListToolsRequestSchema } from '@modelcontextprotocol/sdk/types.js';
9
9
  import pack from './index.js';
10
+ import * as bakedIndexModule from './gu-baked-data.js';
11
+ const bakedIndex = Object.fromEntries(Object.entries(bakedIndexModule));
10
12
 
11
13
  const server = new Server(
12
- { name: '@pipeworx/mcp-guam-code', version: '0.1.0' },
14
+ { name: '@pipeworx/mcp-guam-code', version: '0.1.2' },
13
15
  { capabilities: { tools: {} } },
14
16
  );
15
17
 
@@ -24,7 +26,9 @@ server.setRequestHandler(ListToolsRequestSchema, async () => ({
24
26
  server.setRequestHandler(CallToolRequestSchema, async (request) => {
25
27
  const { name, arguments: args } = request.params;
26
28
  try {
27
- const result = await pack.callTool(name, (args ?? {}) as Record<string, unknown>);
29
+ const callArgs = { ...((args ?? {}) as Record<string, unknown>) };
30
+ callArgs._bakedIndex = bakedIndex;
31
+ const result = await pack.callTool(name, callArgs);
28
32
  return { content: [{ type: 'text', text: JSON.stringify(result, null, 2) }] };
29
33
  } catch (err) {
30
34
  return {
@@ -1,652 +0,0 @@
1
- /**
2
- * PACK-LOCAL FORK of shared/src/pdf-text.ts (fleet #2888), NOT a re-export.
3
- *
4
- * Guam's chapter PDFs (col.guamcourts.gov) defeat the shared extractor in a
5
- * way the shared file does not yet handle, and this pack was built the same
6
- * week another pack (#2884, New Mexico) was independently editing that
7
- * shared file — so rather than risk a collision on a file two packs touch at
8
- * once, the fix lives here, pack-local, with a loud note for whoever next
9
- * works on the shared extractor to fold it in.
10
- *
11
- * THE BUG, AND THE FIX (search "FORK FIX" below for the two call sites):
12
- * Guam's PDF producer (Neevia Document Converter, driven from Word) writes
13
- * most runs of text as parenthesized LITERAL strings, e.g. `(abc)Tj` — even
14
- * for its several embedded TrueType font subsets that declare NO
15
- * `/Encoding` and use a tiny code space (one font had only 2 defined glyphs)
16
- * whose codes have no relationship to any standard code page and are only
17
- * resolvable through that font's own `/ToUnicode` CMap. The shared
18
- * extractor already has that CMap-lookup machinery (`fontDecoderFor`'s
19
- * `mapped1` branch, built for North Dakota's similarly-shaped subset fonts)
20
- * — but it is wired ONLY to the HEX-string (`<...>`) decode path. A literal
21
- * (parenthesized) string is decoded by `decodeRun` as though its raw bytes
22
- * were ALREADY the correct windows-1252 text, with no font lookup at all.
23
- * North Dakota and Kentucky never tripped this because their own non-WinAnsi
24
- * runs happen to be hex-encoded in their content streams; Guam's producer
25
- * encodes the SAME font shape as literal strings instead. Confirmed live
26
- * (9gc016.pdf, Criminal Homicide): unfixed, `§ 16.10` extracts as line-noise
27
- * control characters; with literal strings routed through the current
28
- * font's decoder exactly like a hex run, it reads correctly ("As used in
29
- * this Chapter: (a) Human Being means a person who has been born...").
30
- *
31
- * SECOND, SMALLER FIX: one embedded symbol font per chapter PDF
32
- * (`...+WPTypographicSymbols`, used only for the chapter's curly quote
33
- * marks) carries a `/ToUnicode` CMap that is ITSELF wrong in the source
34
- * file — code 1 (meant to be a left double curly quote) maps to U+0041
35
- * ('A') and code 2 (right curly quote) maps to U+0040 ('@'), confirmed by
36
- * eye against surrounding text: "ADirector of Public Safety@" is exactly
37
- * what you get if you substitute back U+201C/U+201D for those two codes —
38
- * "“Director of Public Safety”" — which is unmistakably the intended
39
- * reading. This is a defect in Guam's own published PDF (any PDF reader's
40
- * copy-paste reproduces it too), not something a general-purpose extractor
41
- * can detect from first principles, so it is special-cased by font name
42
- * rather than generalized.
43
- *
44
- * Everything below this point is otherwise IDENTICAL to
45
- * shared/src/pdf-text.ts as of this pack's build — same ObjStm/indirect-
46
- * Length/Type0/mapped1 support, same caveats (no encryption, no OCR, no
47
- * custom `/Differences` encodings). If you are touching the shared file for
48
- * another pack, consider porting these two fixes upstream and deleting this
49
- * fork; nothing about them is Guam-specific except the WPTypographicSymbols
50
- * font-name match.
51
- *
52
- * ORIGINAL HEADER, UNCHANGED, FOR CONTEXT ON EVERYTHING ELSE THIS DOES:
53
- *
54
- * Minimal text extractor for simple, text-based PDFs — the kind a
55
- * mail-merge/print pipeline produces (Word or LibreOffice "export to PDF"),
56
- * not a scanned image. Built for Kentucky's per-statute PDFs
57
- * (apps.legislature.ky.gov/law/statutes/statute.aspx?id=...), which ARE
58
- * exactly that shape: one object tree, FlateDecode content streams, standard
59
- * TrueType fonts. This is the capability the state-law survey flagged as
60
- * "the largest single win available" (docs/state-law-probe.md) — several
61
- * state statute sites serve text ONLY as a per-section PDF, with no HTML
62
- * fallback, and until now this repo had no way to read one.
63
- *
64
- * WHAT THIS DOES NOT DO: parse a general PDF. No encryption, no cross-
65
- * reference recovery for a damaged file, no image/OCR, no custom
66
- * `/Differences` glyph encodings, no LZW or RunLength filters, no multi-byte
67
- * codespaces other than the 2-byte one every sampled font used. Most objects
68
- * this needs — Catalog, Pages, a leaf Page, its Resources/Font dict, its
69
- * Contents stream(s), a Type0 font's ToUnicode CMap — are found by scanning
70
- * for `N 0 obj ... endobj` directly in the file bytes. If a producer moves
71
- * one into a compressed object stream and this extractor still cannot find
72
- * it (see the next paragraph for what IS handled), extraction degrades to
73
- * `pages_found: 0` / empty text rather than silently returning garbage —
74
- * check `warnings` before trusting a thin result.
75
- *
76
- * COMPRESSED OBJECT STREAMS (`/Type/ObjStm`) ARE SUPPORTED (fleet #2744,
77
- * added for Wyoming's statute PDFs, which — unlike Kentucky's — are
78
- * cross-reference-STREAM files whose Catalog and Pages root are themselves
79
- * compressed into an ObjStm: `5205 0 obj` never appears literally in the
80
- * file; it exists only as entry in a decompressed `/Type/ObjStm` stream).
81
- * Every direct `/Type/ObjStm` object found in the file is inflated up front;
82
- * its header (`/N` object pairs starting at byte `/First`) is parsed into a
83
- * `(object number -> dict text)` map, and `getObject` falls back to that map
84
- * whenever the direct byte-scan finds nothing. Per the PDF spec a compressed
85
- * object can never itself contain a stream (Contents, ToUnicode CMaps, and
86
- * ObjStm/XRef streams are therefore always direct objects, found the
87
- * original way) — so this fallback only ever needs to resolve plain
88
- * dictionaries/arrays (Catalog, Pages, Font, sometimes Page), which is
89
- * exactly what Wyoming's shape needs. An ObjStm nested inside another
90
- * ObjStm via `/Extends` is NOT walked — not observed in any file tested.
91
- *
92
- * ALSO FIXED HERE: the Catalog/Pages discovery no longer requires `/Type`
93
- * to be the first thing found after `<<` in a Catalog's own dictionary text
94
- * — the original `<<[^>]*\/Type\s*\/Catalog` regex broke the moment any
95
- * NESTED dict (e.g. `/MarkInfo<</Marked true>>`) appeared earlier in the
96
- * same object, because `[^>]*` cannot cross that inner `>>`. Wyoming's real
97
- * Catalog objects are exactly this shape (`/Lang(...)/MarkInfo<<...>>
98
- * /Metadata .../Pages .../Type/Catalog/ViewerPreferences<<...>>`, `/Type`
99
- * nowhere near the start). Discovery now finds `/Type/Catalog` (or
100
- * `/Type/Pages`) as plain text anywhere — direct or inside a decompressed
101
- * ObjStm — and locates its OWN object number by nearest preceding
102
- * `N 0 obj` marker, which works regardless of what else is in the dict.
103
- *
104
- * TWO FONT SHAPES, BOTH NEEDED (confirmed against real KRS statute PDFs,
105
- * fleet #2734):
106
- * - Simple TrueType/Type1 with `/Encoding/WinAnsiEncoding` — the common
107
- * case. WinAnsiEncoding is BY DESIGN identical to Windows code page 1252
108
- * (that is what "WinAnsi" means), so the raw string-literal bytes decode
109
- * directly via `TextDecoder('windows-1252')` with no per-glyph table of
110
- * our own to get wrong.
111
- * - Type0/CIDFontType2 with `/Encoding/Identity-H` — seen even inside an
112
- * otherwise-simple statute, apparently whenever the PDF producer's text
113
- * shaping fell back to a subset-embedded font for one run (KRS 532.025's
114
- * closing sentence, which includes a curly apostrophe, was entirely in
115
- * this font while the rest of the document used WinAnsi). Each character
116
- * is a 2-byte CID with NO inherent meaning; the `/ToUnicode` CMap
117
- * attached to the font is the only place the real Unicode value lives
118
- * (`beginbfchar`/`beginbfrange` blocks). Skipping this font shape would
119
- * have truncated or corrupted exactly the statutes that happen to use a
120
- * special character — not a rare edge case, a silent one.
121
- *
122
- * Verified end-to-end against two real KRS statutes during this pack's
123
- * build: a single-page WinAnsi-only PDF (507.020, Murder) and a four-page PDF
124
- * mixing both font shapes (532.025, including the CID-encoded sentence
125
- * naming "Kimber's Law") — both reproduced the statute's true text exactly,
126
- * eyeballed against the rendered PDF.
127
- *
128
- * INDIRECT /Length (fleet #2743): extended for North Dakota's Century Code
129
- * chapter PDFs (ndlegis.gov/cencode/t*.pdf), whose content streams all write
130
- * `/Length` as an indirect reference ("/Length 3 0 R") rather than inlining
131
- * the literal integer the way every sampled KRS PDF did. The referenced
132
- * object is resolved (it is always a bare integer object, never itself a
133
- * stream) rather than falling back to scanning for the "endstream" keyword —
134
- * that fallback slices in the EOL bytes between the compressed data and the
135
- * keyword, which this repo's own Node zlib rejects as "trailing junk" even
136
- * though the compressed payload is intact (Workers' DecompressionStream may
137
- * be more lenient, but Node is what `prepush-tests.mjs` runs, so the bug was
138
- * real either way). Verified against ND Century Code chapter 12.1-16
139
- * (Homicide, t12-1c16.pdf) — every content stream in that 9-page PDF uses
140
- * the indirect form.
141
- */
142
-
143
- export interface GuPdfExtractResult {
144
- /** Extracted text, pages joined with a blank line. Empty string if nothing
145
- * could be read — check `warnings`, not just truthiness, before deciding
146
- * that means the document has no text. */
147
- text: string;
148
- /** Number of leaf /Type/Page objects found via the Pages tree. */
149
- pages: number;
150
- /** Non-fatal problems found while extracting (unknown font, missing
151
- * ToUnicode, etc). A non-empty array does not mean the text is wrong, but
152
- * it means something was guessed rather than read. */
153
- warnings: string[];
154
- }
155
-
156
- type FontDecoder =
157
- | { kind: 'winansi' }
158
- | { kind: 'cid'; map: Map<number, number> }
159
- // Simple (1-byte-code) font decoded via its own /ToUnicode CMap rather than
160
- // WinAnsi — see fontDecoderFor's comment on North Dakota's subsetted fonts.
161
- | { kind: 'mapped1'; map: Map<number, number> };
162
-
163
- /** Inflate one `/FlateDecode` stream using the platform's own zlib — no
164
- * dependency, works identically in the Workers runtime and in Node (used by
165
- * this repo's prepush tests), because both implement the standard
166
- * CompressionStream/DecompressionStream API over the zlib wire format PDF's
167
- * FlateDecode filter already uses. */
168
- async function inflate(bytes: Uint8Array): Promise<Uint8Array> {
169
- const ds = new DecompressionStream('deflate');
170
- const writer = ds.writable.getWriter();
171
- // FORK FIX 3/3 (not described in the file header's list of two — this one
172
- // is a crash, not a decode-quality bug): malformed FlateDecode data (seen
173
- // across several real Guam chapter PDFs, not a single outlier) rejects
174
- // the writer side of the stream. The upstream code left those two writes
175
- // fire-and-forget (`void writer.write(...)`), so that rejection became an
176
- // UNHANDLED promise rejection — invisible to any try/catch wrapped around
177
- // this function's caller, and fatal to the whole Node process (confirmed:
178
- // crashed scripts/bake-sections.mjs outright, mid-run, with no chapter
179
- // name in the trace). Attaching a no-op .catch() here does not hide a
180
- // real failure — the SAME error still surfaces through `reader.read()`
181
- // below, which the caller already catches; this only stops the detached
182
- // write promise from also rejecting on its own.
183
- const writeDone = writer.write(bytes).catch(() => {});
184
- const closeDone = writer.close().catch(() => {});
185
- void writeDone;
186
- void closeDone;
187
- const chunks: Uint8Array[] = [];
188
- const reader = ds.readable.getReader();
189
- for (;;) {
190
- const { done, value } = await reader.read();
191
- if (done) break;
192
- chunks.push(value);
193
- }
194
- const total = chunks.reduce((n, c) => n + c.length, 0);
195
- const out = new Uint8Array(total);
196
- let offset = 0;
197
- for (const c of chunks) {
198
- out.set(c, offset);
199
- offset += c.length;
200
- }
201
- return out;
202
- }
203
-
204
- /**
205
- * Undo PDF string-literal escaping — `\(`, `\)`, `\\`, `\n`/`\r`/`\t`,
206
- * octal `\ddd`, and a trailing backslash-newline line continuation — on a
207
- * windows-1252-decoded slice. Safe to run on the decoded string rather than
208
- * raw bytes: every character this function inspects or produces (backslash,
209
- * parens, digits, the letters n/r/t/b/f) is plain ASCII, which windows-1252
210
- * never remaps, so the 1-byte-per-character alignment this relies on holds
211
- * throughout.
212
- */
213
- function unescapePdfLiteral(s: string): string {
214
- let out = '';
215
- for (let i = 0; i < s.length; i++) {
216
- const c = s[i];
217
- if (c !== '\\') { out += c; continue; }
218
- const next = s[i + 1];
219
- if (next === undefined) continue;
220
- if (next === 'n') { out += '\n'; i++; }
221
- else if (next === 'r') { out += '\r'; i++; }
222
- else if (next === 't') { out += '\t'; i++; }
223
- else if (next === 'b' || next === 'f') { i++; }
224
- else if (next === '(' || next === ')' || next === '\\') { out += next; i++; }
225
- else if (next >= '0' && next <= '7') {
226
- let oct = next, j = i + 1, k = 0;
227
- while (k < 2 && s[j + 1] >= '0' && s[j + 1] <= '7') { j++; oct += s[j]; k++; }
228
- out += String.fromCharCode(parseInt(oct, 8) & 0xff);
229
- i = j;
230
- } else if (next === '\n') { i++; }
231
- else if (next === '\r') { i++; if (s[i + 1] === '\n') i++; }
232
- else { out += next; i++; }
233
- }
234
- return out;
235
- }
236
-
237
- interface RawObject {
238
- dict: string;
239
- streamBytes: Uint8Array | null;
240
- }
241
-
242
- /** Extract readable text from a simple, non-encrypted, non-scanned PDF. */
243
- export async function extractGuamPdfText(buf: ArrayBuffer): Promise<GuPdfExtractResult> {
244
- const bytes = new Uint8Array(buf);
245
- // windows-1252 decode is total (every byte maps to exactly one UTF-16 code
246
- // unit) and length-preserving, so a match index found in `scan` is also a
247
- // valid BYTE offset into `bytes` — used below to slice stream data without
248
- // ever routing binary bytes through a lossy string round-trip.
249
- const scan = new TextDecoder('windows-1252').decode(bytes);
250
- const warnings: string[] = [];
251
- const objCache = new Map<number, RawObject | null>();
252
-
253
- // Byte offset of every DIRECT "N 0 obj" marker, sorted, for two uses: (1)
254
- // the object-lookup fallback stays regex-per-object the same as before;
255
- // (2) `objNumBefore` below answers "which object's text is this index
256
- // inside", which is how Catalog/Pages/ObjStm discovery works regardless of
257
- // what else that object's dictionary contains — see the file header.
258
- const objStarts: { index: number; num: number }[] = [];
259
- {
260
- const re = /(\d+)\s+0\s+obj\b/g;
261
- let m: RegExpExecArray | null;
262
- while ((m = re.exec(scan))) objStarts.push({ index: m.index, num: parseInt(m[1], 10) });
263
- }
264
- function objNumBefore(markerIndex: number): number | null {
265
- let lo = 0, hi = objStarts.length - 1, ans: number | null = null;
266
- while (lo <= hi) {
267
- const mid = (lo + hi) >> 1;
268
- if (objStarts[mid].index <= markerIndex) { ans = objStarts[mid].num; lo = mid + 1; }
269
- else hi = mid - 1;
270
- }
271
- return ans;
272
- }
273
-
274
- function getDirectObject(n: number): RawObject | null {
275
- if (objCache.has(n)) return objCache.get(n) ?? null;
276
- const re = new RegExp(`(?:^|\\D)${n}\\s+0\\s+obj`);
277
- const m = re.exec(scan);
278
- if (!m) { objCache.set(n, null); return null; }
279
- const objStart = m.index + m[0].length;
280
- const endobjIdx = scan.indexOf('endobj', objStart);
281
- const streamIdx = scan.indexOf('stream', objStart);
282
- let dict: string;
283
- let streamBytes: Uint8Array | null = null;
284
- if (streamIdx !== -1 && (endobjIdx === -1 || streamIdx < endobjIdx)) {
285
- dict = scan.slice(objStart, streamIdx);
286
- let dataStart = streamIdx + 'stream'.length;
287
- if (bytes[dataStart] === 0x0d) dataStart++;
288
- if (bytes[dataStart] === 0x0a) dataStart++;
289
- // Matches EITHER "/Length 428" (literal) or "/Length 12 0 R"
290
- // (indirect) — group 2 is present only for the indirect form. Written
291
- // this way ON PURPOSE instead of the more obvious
292
- // `/\/Length\s+(\d+)(?!\s+0\s+R)/` for a literal: that negative
293
- // lookahead is a backtracking trap for any MULTI-DIGIT indirect
294
- // reference. On "/Length 12 0 R", a greedy `(\d+)` first tries "12",
295
- // the lookahead correctly forbids it (followed by " 0 R") — but the
296
- // engine then backtracks `\d+` down to "1", at which point the
297
- // lookahead is checked against "2 0 R", which does NOT start with
298
- // whitespace, so the forbidden pattern no longer matches and the
299
- // lookahead (wrongly) PASSES. The match silently becomes "1" instead
300
- // of failing, so this object's /Length reads as the literal integer 1
301
- // instead of as a reference to resolve — exactly the shape of North
302
- // Dakota's Century Code chapter PDFs (fleet #2743), whose indirect
303
- // Length refs are almost all two or more digits. An explicit optional
304
- // group has nothing to backtrack: `(\d+)` greedily keeps "12" and the
305
- // optional `(\s+0\s+R)?` either matches right after it or doesn't.
306
- const lenDictMatch = /\/Length\s+(\d+)(\s+0\s+R)?/.exec(dict);
307
- let dataEnd = -1;
308
- if (lenDictMatch) {
309
- if (!lenDictMatch[2]) {
310
- dataEnd = dataStart + parseInt(lenDictMatch[1], 10);
311
- } else {
312
- // Indirect reference. The referenced object is a bare integer
313
- // ("12 0 obj\n4541\nendobj"), never itself a stream, so resolving
314
- // it through the same getObject() is safe (no recursion risk back
315
- // onto object n). Falling back to scanning for the "endstream"
316
- // keyword instead — which the pre-fix code did unconditionally
317
- // here — slices in the EOL bytes PDF producers place between the
318
- // compressed data and that keyword, which Node's strict zlib
319
- // rejects as "trailing junk" even though the compressed payload
320
- // itself is intact.
321
- const lenObj = getObject(parseInt(lenDictMatch[1], 10));
322
- const lenVal = lenObj ? parseInt(lenObj.dict.trim(), 10) : NaN;
323
- if (Number.isFinite(lenVal) && lenVal >= 0) dataEnd = dataStart + lenVal;
324
- }
325
- }
326
- if (dataEnd === -1) dataEnd = scan.indexOf('endstream', dataStart);
327
- streamBytes = bytes.slice(dataStart, dataEnd);
328
- } else {
329
- dict = scan.slice(objStart, endobjIdx === -1 ? scan.length : endobjIdx);
330
- }
331
- const result = { dict, streamBytes };
332
- objCache.set(n, result);
333
- return result;
334
- }
335
-
336
- // Compressed objects (inside a /Type/ObjStm) — object number -> dict text.
337
- // Populated below, before any Catalog/Pages/Font lookup runs, because a
338
- // compressed Catalog or Pages root (Wyoming's shape) must resolve exactly
339
- // like a direct one from that point on. Never holds a stream: the PDF spec
340
- // forbids a compressed object from containing one.
341
- const compressedObjects = new Map<number, string>();
342
- {
343
- const objStmNums: number[] = [];
344
- const re = /\/Type\s*\/ObjStm\b/g;
345
- let m: RegExpExecArray | null;
346
- while ((m = re.exec(scan))) {
347
- const n = objNumBefore(m.index);
348
- if (n !== null) objStmNums.push(n);
349
- }
350
- for (const n of objStmNums) {
351
- const obj = getDirectObject(n);
352
- if (!obj || !obj.streamBytes) { warnings.push(`ObjStm ${n} has no stream data`); continue; }
353
- let data: Uint8Array;
354
- if (/\/Filter\s*\/FlateDecode/.test(obj.dict)) {
355
- try {
356
- data = await inflate(obj.streamBytes);
357
- } catch (e) {
358
- warnings.push(`inflate failed for ObjStm ${n}: ${e instanceof Error ? e.message : String(e)}`);
359
- continue;
360
- }
361
- } else {
362
- data = obj.streamBytes;
363
- }
364
- const nMatch = /\/N\s+(\d+)/.exec(obj.dict);
365
- const firstMatch = /\/First\s+(\d+)/.exec(obj.dict);
366
- if (!nMatch || !firstMatch) { warnings.push(`ObjStm ${n} missing /N or /First`); continue; }
367
- const count = parseInt(nMatch[1], 10);
368
- const first = parseInt(firstMatch[1], 10);
369
- const header = new TextDecoder('latin1').decode(data.slice(0, first)).trim().split(/\s+/).map((x) => parseInt(x, 10));
370
- const entries: { num: number; offset: number }[] = [];
371
- for (let i = 0; i + 1 < header.length && entries.length < count; i += 2) {
372
- entries.push({ num: header[i], offset: header[i + 1] });
373
- }
374
- const body = new TextDecoder('windows-1252').decode(data);
375
- for (let i = 0; i < entries.length; i++) {
376
- const start = first + entries[i].offset;
377
- const end = i + 1 < entries.length ? first + entries[i + 1].offset : data.length;
378
- if (start < 0 || end > data.length || start > end) {
379
- warnings.push(`ObjStm ${n} entry ${entries[i].num} has an out-of-range offset, skipped`);
380
- continue;
381
- }
382
- compressedObjects.set(entries[i].num, body.slice(start, end));
383
- }
384
- }
385
- }
386
-
387
- // The lookup every caller below actually uses: direct object text, falling
388
- // back to a compressed one. Never both — a given object number is either
389
- // found literally in the file or inside exactly one ObjStm, not both.
390
- function getObject(n: number): RawObject | null {
391
- const direct = getDirectObject(n);
392
- if (direct) return direct;
393
- const compressed = compressedObjects.get(n);
394
- if (compressed !== undefined) return { dict: compressed, streamBytes: null };
395
- return null;
396
- }
397
-
398
- async function decompressedStream(n: number): Promise<Uint8Array | null> {
399
- const obj = getObject(n);
400
- if (!obj || !obj.streamBytes) return null;
401
- if (/\/FlateDecode/.test(obj.dict)) {
402
- // FORK FIX (crash-adjacent, found while baking the section index — see
403
- // bake-sections.mjs's header for how this surfaced): several real
404
- // Guam chapter PDFs ("MS Word for Windows Document" producer, an
405
- // older shape than the Neevia-converted ones this file's main header
406
- // documents) store a /Length that is off by a small number of bytes
407
- // from the TRUE deflate stream length — confirmed on 21gc033.pdf
408
- // object 2: /Length says 96, the deflate stream is complete at byte
409
- // 95, and the 96th byte is one stray trailing byte. Python's zlib
410
- // tolerates trailing garbage by default; the Workers/Node
411
- // DecompressionStream used here does not and throws
412
- // ERR_TRAILING_JUNK_AFTER_STREAM_END. Rather than fail the whole page
413
- // (or, before an earlier fix in this same fork, crash the process via
414
- // an unhandled rejection), retry with the slice trimmed one byte
415
- // shorter a few times — the true stream length is always <= the
416
- // declared one in every sample seen, never longer.
417
- for (let trim = 0; trim <= 4 && trim < obj.streamBytes.length; trim++) {
418
- try {
419
- return await inflate(trim === 0 ? obj.streamBytes : obj.streamBytes.subarray(0, obj.streamBytes.length - trim));
420
- } catch (e) {
421
- if (trim === 4) {
422
- warnings.push(`inflate failed for object ${n} even after trimming trailing bytes: ${e instanceof Error ? e.message : String(e)}`);
423
- return null;
424
- }
425
- }
426
- }
427
- return null;
428
- }
429
- return obj.streamBytes;
430
- }
431
-
432
- // Catalog -> Pages -> Kids, walked in document order. Falls back to the
433
- // first /Type/Pages object if no /Type/Catalog is found. Both searches
434
- // look for the TYPE MARKER as plain text first (direct text, or inside any
435
- // decompressed ObjStm body) and then find which object that text belongs
436
- // to — see the file header for why this replaced a single greedy regex.
437
- let pagesRef: number | null = null;
438
- function findObjWithType(typeName: string): number | null {
439
- const directIdx = scan.search(new RegExp(`/Type\\s*/${typeName}\\b`));
440
- if (directIdx !== -1) {
441
- const n = objNumBefore(directIdx);
442
- if (n !== null) return n;
443
- }
444
- const typeRe = new RegExp(`/Type\\s*/${typeName}\\b`);
445
- for (const [num, text] of compressedObjects) {
446
- if (typeRe.test(text)) return num;
447
- }
448
- return null;
449
- }
450
- const catNum = findObjWithType('Catalog');
451
- if (catNum !== null) {
452
- const catObj = getObject(catNum);
453
- const pm = catObj ? /\/Pages\s+(\d+)\s+0\s+R/.exec(catObj.dict) : null;
454
- if (pm) pagesRef = parseInt(pm[1], 10);
455
- }
456
- if (pagesRef === null) {
457
- pagesRef = findObjWithType('Pages');
458
- }
459
-
460
- const leafPages: number[] = [];
461
- function walkPages(n: number, depth: number): void {
462
- if (depth > 12) { warnings.push('Pages tree exceeded depth 12, stopped walking'); return; }
463
- const obj = getObject(n);
464
- if (!obj) return;
465
- if (/\/Type\s*\/Page\b(?!s)/.test(obj.dict)) { leafPages.push(n); return; }
466
- const kidsMatch = /\/Kids\s*\[([^\]]*)\]/.exec(obj.dict);
467
- if (!kidsMatch) return;
468
- for (const km of kidsMatch[1].matchAll(/(\d+)\s+0\s+R/g)) walkPages(parseInt(km[1], 10), depth + 1);
469
- }
470
- if (pagesRef !== null) walkPages(pagesRef, 0);
471
- else warnings.push('no /Type/Catalog or /Type/Pages object found');
472
-
473
- /** Parse a /ToUnicode CMap stream's `beginbfchar`/`beginbfrange` blocks into
474
- * a code -> Unicode-codepoint map. Shared between Type0's 2-byte CIDs and
475
- * a simple font's 1-byte codes — the CMap text format is identical either
476
- * way; only how the caller SLICES its hex string differs (see decodeRun). */
477
- async function parseToUnicodeCMap(cmapObjNum: number): Promise<Map<number, number>> {
478
- const cmapBytes = await decompressedStream(cmapObjNum);
479
- const cmapText = cmapBytes ? new TextDecoder('latin1').decode(cmapBytes) : '';
480
- const map = new Map<number, number>();
481
- for (const block of cmapText.matchAll(/beginbfchar([\s\S]*?)endbfchar/g)) {
482
- for (const pair of block[1].matchAll(/<([0-9A-Fa-f]+)>\s*<([0-9A-Fa-f]+)>/g)) {
483
- map.set(parseInt(pair[1], 16), parseInt(pair[2].slice(0, 4), 16));
484
- }
485
- }
486
- for (const block of cmapText.matchAll(/beginbfrange([\s\S]*?)endbfrange/g)) {
487
- for (const triple of block[1].matchAll(/<([0-9A-Fa-f]+)>\s*<([0-9A-Fa-f]+)>\s*<([0-9A-Fa-f]+)>/g)) {
488
- const lo = parseInt(triple[1], 16), hi = parseInt(triple[2], 16), base = parseInt(triple[3].slice(0, 4), 16);
489
- for (let c = lo; c <= hi && c - lo < 65536; c++) map.set(c, base + (c - lo));
490
- }
491
- }
492
- return map;
493
- }
494
-
495
- async function fontDecoderFor(fontObjNum: number): Promise<FontDecoder> {
496
- const obj = getObject(fontObjNum);
497
- if (!obj) return { kind: 'winansi' };
498
- if (/\/Subtype\s*\/Type0\b/.test(obj.dict)) {
499
- const tuMatch = /\/ToUnicode\s+(\d+)\s+0\s+R/.exec(obj.dict);
500
- if (!tuMatch) {
501
- warnings.push(`Type0 font object ${fontObjNum} has no /ToUnicode; its text will show as U+FFFD`);
502
- return { kind: 'cid', map: new Map() };
503
- }
504
- const map = await parseToUnicodeCMap(parseInt(tuMatch[1], 10));
505
- return { kind: 'cid', map };
506
- }
507
- // Simple font. Every KRS sample used /WinAnsiEncoding, which decodes
508
- // directly as windows-1252 bytes below — unchanged. North Dakota's
509
- // Century Code PDFs (fleet #2743) use simple TrueType fonts that declare
510
- // NO /Encoding at all: their embedded subsets number glyphs 0, 1, 2... in
511
- // whatever order the subsetter emitted them, with no relationship to any
512
- // standard code page — reading code 0x01 as the WinAnsi byte 0x01 (a
513
- // control character) produces blank/garbled output, not a wrong letter.
514
- // Those fonts DO carry a /ToUnicode CMap (the same mechanism Type0 fonts
515
- // use above, just keyed by a 1-byte code instead of a 2-byte CID), which
516
- // is the only place the real character is recorded — use it whenever the
517
- // font isn't declared WinAnsi rather than guessing.
518
- if (!/\/Encoding\s*\/WinAnsiEncoding\b/.test(obj.dict)) {
519
- const tuMatch = /\/ToUnicode\s+(\d+)\s+0\s+R/.exec(obj.dict);
520
- if (tuMatch) {
521
- const map = await parseToUnicodeCMap(parseInt(tuMatch[1], 10));
522
- // FORK FIX 2/2: Guam's embedded "...+WPTypographicSymbols" font (used
523
- // only for curly quote marks) carries a /ToUnicode CMap that is
524
- // itself wrong in the source PDF — code 1 (left curly quote) maps to
525
- // U+0041 ('A') and code 2 (right curly quote) maps to U+0040 ('@').
526
- // Confirmed by eye: "ADirector of Public Safety@" in the raw
527
- // extraction is exactly "“Director of Public Safety”" with
528
- // those two codes substituted back — the intended reading, not a
529
- // coincidence. See this file's header. Any PDF viewer's copy-paste
530
- // reproduces the same wrong codes, so this is a defect in Guam's own
531
- // published file, overridden here by font name rather than trusted.
532
- if (/WPTypographicSymbols/.test(obj.dict)) {
533
- const fixed = new Map(map);
534
- fixed.set(1, 0x201c);
535
- fixed.set(2, 0x201d);
536
- return { kind: 'mapped1', map: fixed };
537
- }
538
- if (map.size) return { kind: 'mapped1', map };
539
- }
540
- warnings.push(`font object ${fontObjNum} has a non-WinAnsi simple encoding and no usable /ToUnicode; decoded as WinAnsi anyway`);
541
- }
542
- return { kind: 'winansi' };
543
- }
544
-
545
- const TOKEN_RE =
546
- /\/(\S+)\s+[\d.]+\s+Tf|\[((?:\\.|[^\]])*)\]\s*TJ|<([0-9A-Fa-f\s]*)>\s*Tj|\(((?:\\.|[^()])*)\)\s*Tj|1\s+0\s+0\s+1\s+[\d.-]+\s+([\d.-]+)\s+Tm/g;
547
- const TJ_PIECE_RE = /\(((?:\\.|[^()])*)\)|<([0-9A-Fa-f\s]*)>/g;
548
-
549
- function decodeRun(dec: FontDecoder, literal: string | undefined, hex: string | undefined): string {
550
- if (literal !== undefined) {
551
- const unescaped = unescapePdfLiteral(literal);
552
- // FORK FIX 1/2: a WinAnsi run's literal-string bytes ARE already the
553
- // correct windows-1252 text (unchanged from upstream) — but Guam's
554
- // producer also writes its non-WinAnsi subset fonts' text as literal
555
- // strings rather than hex, so a 'mapped1'/'cid' run must be resolved
556
- // through the SAME font decoder a hex run below uses, not passed
557
- // through as if the raw byte were already the character. See this
558
- // file's header for how this was confirmed live.
559
- if (dec.kind === 'winansi') return unescaped;
560
- let out = '';
561
- for (let i = 0; i < unescaped.length; i++) {
562
- const code = unescaped.charCodeAt(i) & 0xff;
563
- out += String.fromCodePoint(dec.map.get(code) ?? 0xfffd);
564
- }
565
- return out;
566
- }
567
- const cleaned = (hex ?? '').replace(/\s+/g, '');
568
- let out = '';
569
- if (dec.kind === 'mapped1') {
570
- // 1-byte code per glyph (a simple font's code space), unlike Type0's
571
- // 2-byte CIDs below — see fontDecoderFor.
572
- for (let i = 0; i + 2 <= cleaned.length; i += 2) {
573
- const code = parseInt(cleaned.slice(i, i + 2), 16);
574
- out += String.fromCodePoint(dec.map.get(code) ?? 0xfffd);
575
- }
576
- return out;
577
- }
578
- for (let i = 0; i + 4 <= cleaned.length; i += 4) {
579
- const cid = parseInt(cleaned.slice(i, i + 4), 16);
580
- if (dec.kind === 'cid') out += String.fromCodePoint(dec.map.get(cid) ?? 0xfffd);
581
- else out += String.fromCharCode(cid & 0xff);
582
- }
583
- return out;
584
- }
585
-
586
- const pageTexts: string[] = [];
587
- for (const pn of leafPages) {
588
- const page = getObject(pn);
589
- if (!page) continue;
590
-
591
- let resourcesDict = page.dict;
592
- const resRef = /\/Resources\s+(\d+)\s+0\s+R/.exec(page.dict);
593
- if (resRef) {
594
- const ro = getObject(parseInt(resRef[1], 10));
595
- if (ro) resourcesDict = ro.dict;
596
- }
597
- // /Font is usually inline in Resources, but North Dakota's Century Code
598
- // PDFs (fleet #2743) write it as an indirect reference ("/Font 18 0 R")
599
- // to a separate dict object — resolve that one hop before giving up.
600
- let fontDictBody: string | null = null;
601
- const inlineFontMatch = /\/Font\s*<<([^>]*)>>/.exec(resourcesDict);
602
- if (inlineFontMatch) {
603
- fontDictBody = inlineFontMatch[1];
604
- } else {
605
- const fontRefMatch = /\/Font\s+(\d+)\s+0\s+R/.exec(resourcesDict);
606
- if (fontRefMatch) {
607
- const fontObj = getObject(parseInt(fontRefMatch[1], 10));
608
- if (fontObj) fontDictBody = fontObj.dict;
609
- }
610
- }
611
- const fontMap = new Map<string, FontDecoder>();
612
- if (fontDictBody) {
613
- for (const fm of fontDictBody.matchAll(/\/(\S+?)\s+(\d+)\s+0\s+R/g)) {
614
- fontMap.set(fm[1], await fontDecoderFor(parseInt(fm[2], 10)));
615
- }
616
- }
617
-
618
- const contentsMatch = /\/Contents\s*(\[[^\]]*\]|\d+\s+0\s+R)/.exec(page.dict);
619
- const contentRefs = contentsMatch
620
- ? [...contentsMatch[1].matchAll(/(\d+)\s+0\s+R/g)].map((m) => parseInt(m[1], 10))
621
- : [];
622
- let raw = '';
623
- for (const cr of contentRefs) {
624
- const dec = await decompressedStream(cr);
625
- if (dec) raw += new TextDecoder('latin1').decode(dec) + '\n';
626
- }
627
-
628
- let out = '';
629
- let currentFont: string | null = null;
630
- let lastY: number | null = null;
631
- for (const m of raw.matchAll(TOKEN_RE)) {
632
- if (m[1] !== undefined) { currentFont = m[1]; continue; }
633
- if (m[5] !== undefined) {
634
- const y = parseFloat(m[5]);
635
- if (lastY !== null && Math.abs(y - lastY) > 1) out += '\n';
636
- lastY = y;
637
- continue;
638
- }
639
- const dec = (currentFont && fontMap.get(currentFont)) || { kind: 'winansi' as const };
640
- if (m[2] !== undefined) {
641
- for (const piece of m[2].matchAll(TJ_PIECE_RE)) out += decodeRun(dec, piece[1], piece[2]);
642
- } else if (m[3] !== undefined) {
643
- out += decodeRun(dec, undefined, m[3]);
644
- } else if (m[4] !== undefined) {
645
- out += decodeRun(dec, m[4], undefined);
646
- }
647
- }
648
- pageTexts.push(out.trim());
649
- }
650
-
651
- return { text: pageTexts.join('\n\n'), pages: leafPages.length, warnings };
652
- }