@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/README.md +35 -25
- package/package.json +3 -3
- package/server.json +1 -1
- package/src/gu-font-cmap-fix.ts +34 -0
- package/src/index.ts +934 -21
- package/src/server.ts +6 -2
- package/src/gu-pdf-extract.ts +0 -652
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.
|
|
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
|
|
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 {
|
package/src/gu-pdf-extract.ts
DELETED
|
@@ -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
|
-
}
|