@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/index.ts CHANGED
@@ -670,6 +670,913 @@ function pickMessage(node: unknown, depth: number): string | null {
670
670
  function collapse(s: string): string {
671
671
  return s.replace(/\s+/g, ' ').trim();
672
672
  }
673
+
674
+
675
+ /**
676
+ * Minimal text extractor for simple, text-based PDFs — the kind a
677
+ * mail-merge/print pipeline produces (Word or LibreOffice "export to PDF"),
678
+ * not a scanned image. Built for Kentucky's per-statute PDFs
679
+ * (apps.legislature.ky.gov/law/statutes/statute.aspx?id=...), which ARE
680
+ * exactly that shape: one object tree, FlateDecode content streams, standard
681
+ * TrueType fonts. This is the capability the state-law survey flagged as
682
+ * "the largest single win available" (docs/state-law-probe.md) — several
683
+ * state statute sites serve text ONLY as a per-section PDF, with no HTML
684
+ * fallback, and until now this repo had no way to read one.
685
+ *
686
+ * WHAT THIS DOES NOT DO: parse a general PDF. No encryption, no cross-
687
+ * reference recovery for a damaged file, no image/OCR, no custom
688
+ * `/Differences` glyph encodings, no LZW or RunLength filters, no multi-byte
689
+ * codespaces other than the 2-byte one every sampled font used. Most objects
690
+ * this needs — Catalog, Pages, a leaf Page, its Resources/Font dict, its
691
+ * Contents stream(s), a Type0 font's ToUnicode CMap — are found by scanning
692
+ * for `N 0 obj ... endobj` directly in the file bytes. If a producer moves
693
+ * one into a compressed object stream and this extractor still cannot find
694
+ * it (see the next paragraph for what IS handled), extraction degrades to
695
+ * `pages_found: 0` / empty text rather than silently returning garbage —
696
+ * check `warnings` before trusting a thin result.
697
+ *
698
+ * COMPRESSED OBJECT STREAMS (`/Type/ObjStm`) ARE SUPPORTED (fleet #2744,
699
+ * added for Wyoming's statute PDFs, which — unlike Kentucky's — are
700
+ * cross-reference-STREAM files whose Catalog and Pages root are themselves
701
+ * compressed into an ObjStm: `5205 0 obj` never appears literally in the
702
+ * file; it exists only as entry in a decompressed `/Type/ObjStm` stream).
703
+ * Every direct `/Type/ObjStm` object found in the file is inflated up front;
704
+ * its header (`/N` object pairs starting at byte `/First`) is parsed into a
705
+ * `(object number -> dict text)` map, and `getObject` falls back to that map
706
+ * whenever the direct byte-scan finds nothing. Per the PDF spec a compressed
707
+ * object can never itself contain a stream (Contents, ToUnicode CMaps, and
708
+ * ObjStm/XRef streams are therefore always direct objects, found the
709
+ * original way) — so this fallback only ever needs to resolve plain
710
+ * dictionaries/arrays (Catalog, Pages, Font, sometimes Page), which is
711
+ * exactly what Wyoming's shape needs. An ObjStm nested inside another
712
+ * ObjStm via `/Extends` is NOT walked — not observed in any file tested.
713
+ *
714
+ * ALSO FIXED HERE: the Catalog/Pages discovery no longer requires `/Type`
715
+ * to be the first thing found after `<<` in a Catalog's own dictionary text
716
+ * — the original `<<[^>]*\/Type\s*\/Catalog` regex broke the moment any
717
+ * NESTED dict (e.g. `/MarkInfo<</Marked true>>`) appeared earlier in the
718
+ * same object, because `[^>]*` cannot cross that inner `>>`. Wyoming's real
719
+ * Catalog objects are exactly this shape (`/Lang(...)/MarkInfo<<...>>
720
+ * /Metadata .../Pages .../Type/Catalog/ViewerPreferences<<...>>`, `/Type`
721
+ * nowhere near the start). Discovery now finds `/Type/Catalog` (or
722
+ * `/Type/Pages`) as plain text anywhere — direct or inside a decompressed
723
+ * ObjStm — and locates its OWN object number by nearest preceding
724
+ * `N 0 obj` marker, which works regardless of what else is in the dict.
725
+ *
726
+ * TWO FONT SHAPES, BOTH NEEDED (confirmed against real KRS statute PDFs,
727
+ * fleet #2734):
728
+ * - Simple TrueType/Type1 with `/Encoding/WinAnsiEncoding` — the common
729
+ * case. WinAnsiEncoding is BY DESIGN identical to Windows code page 1252
730
+ * (that is what "WinAnsi" means), so the raw string-literal bytes decode
731
+ * directly via `TextDecoder('windows-1252')` with no per-glyph table of
732
+ * our own to get wrong.
733
+ * - Type0/CIDFontType2 with `/Encoding/Identity-H` — seen even inside an
734
+ * otherwise-simple statute, apparently whenever the PDF producer's text
735
+ * shaping fell back to a subset-embedded font for one run (KRS 532.025's
736
+ * closing sentence, which includes a curly apostrophe, was entirely in
737
+ * this font while the rest of the document used WinAnsi). Each character
738
+ * is a 2-byte CID with NO inherent meaning; the `/ToUnicode` CMap
739
+ * attached to the font is the only place the real Unicode value lives
740
+ * (`beginbfchar`/`beginbfrange` blocks). Skipping this font shape would
741
+ * have truncated or corrupted exactly the statutes that happen to use a
742
+ * special character — not a rare edge case, a silent one.
743
+ *
744
+ * Verified end-to-end against two real KRS statutes during this pack's
745
+ * build: a single-page WinAnsi-only PDF (507.020, Murder) and a four-page PDF
746
+ * mixing both font shapes (532.025, including the CID-encoded sentence
747
+ * naming "Kimber's Law") — both reproduced the statute's true text exactly,
748
+ * eyeballed against the rendered PDF.
749
+ *
750
+ * INDIRECT /Length (fleet #2743): extended for North Dakota's Century Code
751
+ * chapter PDFs (ndlegis.gov/cencode/t*.pdf), whose content streams all write
752
+ * `/Length` as an indirect reference ("/Length 3 0 R") rather than inlining
753
+ * the literal integer the way every sampled KRS PDF did. The referenced
754
+ * object is resolved (it is always a bare integer object, never itself a
755
+ * stream) rather than falling back to scanning for the "endstream" keyword —
756
+ * that fallback slices in the EOL bytes between the compressed data and the
757
+ * keyword, which this repo's own Node zlib rejects as "trailing junk" even
758
+ * though the compressed payload is intact (Workers' DecompressionStream may
759
+ * be more lenient, but Node is what `prepush-tests.mjs` runs, so the bug was
760
+ * real either way). Verified against ND Century Code chapter 12.1-16
761
+ * (Homicide, t12-1c16.pdf) — every content stream in that 9-page PDF uses
762
+ * the indirect form.
763
+ *
764
+ * THREE FIXES PORTED FROM mcps/guam-code's PACK-LOCAL FORK (fleet #2904,
765
+ * folding in #2888's src/gu-pdf-extract.ts -- nothing about them was
766
+ * actually Guam-specific, which is why they live here now instead of only
767
+ * in that fork):
768
+ * 1. inflate()'s two stream writes were fire-and-forget
769
+ * (void writer.write(...) / void writer.close()). A malformed deflate
770
+ * stream (real, not hypothetical -- seen across several Guam chapter
771
+ * PDFs) rejects that detached promise UNHANDLED, invisible to any
772
+ * try/catch wrapped around this function's caller and fatal to the
773
+ * whole Node process; in a Worker it is an unhandled rejection on a
774
+ * live request. Both writes now carry a no-op .catch() -- the real
775
+ * failure still surfaces through reader.read() below, which every
776
+ * caller already handles.
777
+ * 2. A literal (parenthesized) string run was decoded by passing its raw
778
+ * windows-1252-decoded bytes through unchanged, which is correct for a
779
+ * WinAnsi font but wrong for a mapped1/cid font whose code space only
780
+ * means anything via its own /ToUnicode CMap -- the same lookup a hex
781
+ * string run already gets. Guam's PDF producer writes several of its
782
+ * non-WinAnsi subset-embedded fonts' text as literal strings rather
783
+ * than hex, which this extractor previously decoded as line-noise
784
+ * control characters instead of the real text.
785
+ * 3. decompressedStream now retries a /FlateDecode stream with its tail
786
+ * trimmed 1-4 bytes before giving up. Several real PDFs declare a
787
+ * /Length a few bytes LONGER than the true deflate stream (confirmed
788
+ * on Guam's 21gc033.pdf object 2: /Length says 96, the deflate stream
789
+ * completes at byte 95, byte 96 is one stray trailing byte). Python's
790
+ * zlib tolerates trailing garbage by default; the Workers/Node
791
+ * DecompressionStream used here does not and throws "Trailing junk
792
+ * found after the end of the compressed stream" -- the true stream
793
+ * length is always <= the declared one in every sample seen, never
794
+ * longer, so trimming is safe.
795
+ */
796
+
797
+ interface PdfExtractResult {
798
+ /** Extracted text, pages joined with a blank line. Empty string if nothing
799
+ * could be read — check `warnings`, not just truthiness, before deciding
800
+ * that means the document has no text. */
801
+ text: string;
802
+ /** Number of leaf /Type/Page objects found via the Pages tree (the WHOLE
803
+ * document's page count, even when `pageRange` restricted extraction to
804
+ * a subset — see `extractedPages` for how many were actually read). */
805
+ pages: number;
806
+ /** Number of pages actually extracted into `text`. Equal to `pages`
807
+ * unless a `pageRange` option restricted the run. */
808
+ extractedPages: number;
809
+ /** Non-fatal problems found while extracting (unknown font, missing
810
+ * ToUnicode, etc). A non-empty array does not mean the text is wrong, but
811
+ * it means something was guessed rather than read. */
812
+ warnings: string[];
813
+ }
814
+
815
+ interface PdfExtractOptions {
816
+ /** Restrict extraction to a slice of the document's leaf pages, bounded by
817
+ * PAGE OBJECT NUMBERS rather than page indexes — built for sources (e.g.
818
+ * New Mexico's NMOneSource chapter PDFs, fleet #2884) whose per-section
819
+ * bookmarks in `extractPdfOutline`'s output give a /Dest page object
820
+ * directly, with no indexing step of their own needed. `fromPageObj` is
821
+ * resolved to its position in document order and extraction starts
822
+ * there; `toPageObj` (also resolved to its position) is the LAST page
823
+ * included — pass the object a FOLLOWING bookmark points to and extract
824
+ * up to (not including) it by resolving that page's own PRECEDING page
825
+ * instead, since this is page-granularity, not byte-granularity. If
826
+ * `fromPageObj` is not found among the document's pages, this option is
827
+ * ignored and the whole document is extracted (a safe fallback, not a
828
+ * silent wrong answer — a warning is added). */
829
+ pageRange?: { fromPageObj: number; toPageObj?: number };
830
+
831
+ /** Called right after a font's own /ToUnicode CMap is parsed (for either
832
+ * a Type0 font's CID map or a simple font's 1-byte map), before it is
833
+ * used to decode any text. Return a REPLACEMENT map to override specific
834
+ * codes, or return nothing (undefined) to use the parsed map unchanged.
835
+ * Built for mcps/guam-code (fleet #2888/#2904): one embedded symbol font
836
+ * per chapter PDF carries a /ToUnicode CMap that is itself wrong in the
837
+ * SOURCE file (confirmed by eye against the rendered PDF — not a general
838
+ * parsing defect this shared extractor could detect), so the fix is
839
+ * keyed by matching `fontDict` against something only that caller's PDFs
840
+ * produce (there, a font name) rather than built into this file. */
841
+ fontCMapOverride?: (fontDict: string, map: Map<number, number>) => Map<number, number> | void;
842
+ }
843
+
844
+ interface PdfOutlineEntry {
845
+ /** The bookmark's own text, verbatim (e.g. "30-2-1. Murder." or
846
+ * "ARTICLE 1 General Provisions" or "ANNOTATIONS"). */
847
+ title: string;
848
+ /** The PDF page OBJECT NUMBER this bookmark's /Dest (or /A GoTo action)
849
+ * points to, or null if neither was found on this node. Pass this
850
+ * straight into `extractPdfText`'s `pageRange` option — no index lookup
851
+ * needed on the caller's side. */
852
+ pageObjNum: number | null;
853
+ /** Nesting depth in the outline tree, root's children = 0. A section's
854
+ * own "ANNOTATIONS" child (depth = section's depth + 1) is how a caller
855
+ * distinguishes statutory text from case annotations that start on the
856
+ * same page — see mcps/new-mexico-code for the worked example. */
857
+ depth: number;
858
+ }
859
+
860
+ type FontDecoder =
861
+ | { kind: 'winansi' }
862
+ | { kind: 'cid'; map: Map<number, number> }
863
+ // Simple (1-byte-code) font decoded via its own /ToUnicode CMap rather than
864
+ // WinAnsi — see fontDecoderFor's comment on North Dakota's subsetted fonts.
865
+ | { kind: 'mapped1'; map: Map<number, number> };
866
+
867
+ /** Inflate one `/FlateDecode` stream using the platform's own zlib — no
868
+ * dependency, works identically in the Workers runtime and in Node (used by
869
+ * this repo's prepush tests), because both implement the standard
870
+ * CompressionStream/DecompressionStream API over the zlib wire format PDF's
871
+ * FlateDecode filter already uses. */
872
+ async function inflate(bytes: Uint8Array): Promise<Uint8Array> {
873
+ const ds = new DecompressionStream('deflate');
874
+ const writer = ds.writable.getWriter();
875
+ // Fire-and-forget on purpose: the actual decompression result/error comes
876
+ // through reader.read() below, which every caller already wraps in a
877
+ // try/catch. Without a .catch() here, a malformed deflate stream rejects
878
+ // this DETACHED write/close promise UNHANDLED -- invisible to that
879
+ // try/catch and fatal to the whole process (ported from
880
+ // mcps/guam-code's gu-pdf-extract.ts FORK FIX 3/3, fleet #2888/#2904;
881
+ // confirmed crashing scripts/bake-sections.mjs outright on real Guam
882
+ // chapter PDFs). The no-op .catch() here does not hide a real failure --
883
+ // it still surfaces through reader.read() for the caller to see.
884
+ const writeDone = writer.write(bytes).catch(() => {});
885
+ const closeDone = writer.close().catch(() => {});
886
+ void writeDone;
887
+ void closeDone;
888
+ const chunks: Uint8Array[] = [];
889
+ const reader = ds.readable.getReader();
890
+ for (;;) {
891
+ const { done, value } = await reader.read();
892
+ if (done) break;
893
+ chunks.push(value);
894
+ }
895
+ const total = chunks.reduce((n, c) => n + c.length, 0);
896
+ const out = new Uint8Array(total);
897
+ let offset = 0;
898
+ for (const c of chunks) {
899
+ out.set(c, offset);
900
+ offset += c.length;
901
+ }
902
+ return out;
903
+ }
904
+
905
+ /**
906
+ * Undo PDF string-literal escaping — `\(`, `\)`, `\\`, `\n`/`\r`/`\t`,
907
+ * octal `\ddd`, and a trailing backslash-newline line continuation — on a
908
+ * windows-1252-decoded slice. Safe to run on the decoded string rather than
909
+ * raw bytes: every character this function inspects or produces (backslash,
910
+ * parens, digits, the letters n/r/t/b/f) is plain ASCII, which windows-1252
911
+ * never remaps, so the 1-byte-per-character alignment this relies on holds
912
+ * throughout.
913
+ */
914
+ function unescapePdfLiteral(s: string): string {
915
+ let out = '';
916
+ for (let i = 0; i < s.length; i++) {
917
+ const c = s[i];
918
+ if (c !== '\\') { out += c; continue; }
919
+ const next = s[i + 1];
920
+ if (next === undefined) continue;
921
+ if (next === 'n') { out += '\n'; i++; }
922
+ else if (next === 'r') { out += '\r'; i++; }
923
+ else if (next === 't') { out += '\t'; i++; }
924
+ else if (next === 'b' || next === 'f') { i++; }
925
+ else if (next === '(' || next === ')' || next === '\\') { out += next; i++; }
926
+ else if (next >= '0' && next <= '7') {
927
+ let oct = next, j = i + 1, k = 0;
928
+ while (k < 2 && s[j + 1] >= '0' && s[j + 1] <= '7') { j++; oct += s[j]; k++; }
929
+ out += String.fromCharCode(parseInt(oct, 8) & 0xff);
930
+ i = j;
931
+ } else if (next === '\n') { i++; }
932
+ else if (next === '\r') { i++; if (s[i + 1] === '\n') i++; }
933
+ else { out += next; i++; }
934
+ }
935
+ return out;
936
+ }
937
+
938
+ /**
939
+ * Recover the ORIGINAL raw byte (0-255) that `new TextDecoder('latin1')`
940
+ * produced a given codepoint for. This exists because the WHATWG Encoding
941
+ * Standard makes "latin1" an ALIAS for "windows-1252", not true ISO-8859-1 —
942
+ * every browser and Node's TextDecoder follow that spec. Bytes 0x00-0x7F and
943
+ * 0xA0-0xFF decode as identity either way, but bytes 0x80-0x9F come back as
944
+ * windows-1252's typographic symbols (0x99 -> U+2122 "™", 0x87 -> U+2021
945
+ * "‡", …) instead of themselves — so `charCodeAt(i) & 0xff`, used everywhere
946
+ * else in this file to recover a raw byte from a decoded string, silently
947
+ * returns the WRONG byte for any of those 32 codepoints (U+2122 & 0xff is
948
+ * 0x22, not the real 0x99).
949
+ *
950
+ * This only matters where the decoded character is being used as DATA (a
951
+ * CID half-byte, a `mapped1` font's 1-byte code) rather than as the actual
952
+ * text it renders (a true WinAnsi-encoded character, where the windows-1252
953
+ * reading IS the correct glyph — see `fontDecoderFor`'s `'winansi'` kind,
954
+ * which returns the decoded string unchanged on purpose and must NOT be
955
+ * routed through this function). Found on mainland China's NPC/MOJ law PDFs
956
+ * (cn-law, fleet #3136): Founder PDFGenerator (方正) writes CJK body text as
957
+ * LITERAL strings under a Type0/Identity-H CID font, and roughly an eighth
958
+ * of all 2-byte CIDs have at least one half-byte landing in 0x80-0x9F —
959
+ * `合` (U+5408, CID 0x0F87) was coming back as `骨` (U+9AA8) because its low
960
+ * byte 0x87 decoded to U+2021, and `U+2021 & 0xff` (0x21) is a different,
961
+ * also-valid CID that happens to mean `骨` in the same font.
962
+ */
963
+ function rawByteFromLatin1Char(code: number): number {
964
+ const b = WIN1252_HIGH_TO_BYTE.get(code);
965
+ return b !== undefined ? b : code & 0xff;
966
+ }
967
+ // windows-1252's 0x80-0x9F table (the WHATWG "windows-1252" index), inverted
968
+ // codepoint -> original byte. 0x81/0x8D/0x8F/0x90/0x9D are undefined in that
969
+ // table and decode as themselves (identity), so they need no entry here.
970
+ const WIN1252_HIGH_TO_BYTE = new Map<number, number>([
971
+ [0x20ac, 0x80], [0x201a, 0x82], [0x0192, 0x83], [0x201e, 0x84],
972
+ [0x2026, 0x85], [0x2020, 0x86], [0x2021, 0x87], [0x02c6, 0x88],
973
+ [0x2030, 0x89], [0x0160, 0x8a], [0x2039, 0x8b], [0x0152, 0x8c],
974
+ [0x017d, 0x8e], [0x2018, 0x91], [0x2019, 0x92], [0x201c, 0x93],
975
+ [0x201d, 0x94], [0x2022, 0x95], [0x2013, 0x96], [0x2014, 0x97],
976
+ [0x02dc, 0x98], [0x2122, 0x99], [0x0161, 0x9a], [0x203a, 0x9b],
977
+ [0x0153, 0x9c], [0x017e, 0x9e], [0x0178, 0x9f],
978
+ ]);
979
+
980
+ interface RawObject {
981
+ dict: string;
982
+ streamBytes: Uint8Array | null;
983
+ }
984
+
985
+ /** Extract readable text from a simple, non-encrypted, non-scanned PDF. */
986
+ async function extractPdfText(buf: ArrayBuffer, options?: PdfExtractOptions): Promise<PdfExtractResult> {
987
+ const bytes = new Uint8Array(buf);
988
+ // windows-1252 decode is total (every byte maps to exactly one UTF-16 code
989
+ // unit) and length-preserving, so a match index found in `scan` is also a
990
+ // valid BYTE offset into `bytes` — used below to slice stream data without
991
+ // ever routing binary bytes through a lossy string round-trip.
992
+ const scan = new TextDecoder('windows-1252').decode(bytes);
993
+ const warnings: string[] = [];
994
+ const objCache = new Map<number, RawObject | null>();
995
+
996
+ // Byte offset of every DIRECT "N 0 obj" marker, sorted, for two uses: (1)
997
+ // the object-lookup fallback stays regex-per-object the same as before;
998
+ // (2) `objNumBefore` below answers "which object's text is this index
999
+ // inside", which is how Catalog/Pages/ObjStm discovery works regardless of
1000
+ // what else that object's dictionary contains — see the file header.
1001
+ const objStarts: { index: number; num: number }[] = [];
1002
+ // Object number -> index right after its "N 0 obj" marker (i.e. where the
1003
+ // dictionary text begins), for every object found by the SAME forward
1004
+ // scan that builds objStarts — first occurrence wins, matching what a
1005
+ // one-shot `.exec` would find. This turns getDirectObject's lookup from
1006
+ // one full-document regex scan PER DISTINCT OBJECT NUMBER into one O(1)
1007
+ // map read: with a large page-tree walk (New Mexico's chapter PDFs run
1008
+ // 657-1,138+ pages, fleet #2884) the old per-call scan made even a
1009
+ // `pageRange`-restricted extraction cost nearly as much as extracting the
1010
+ // whole document, because walking the Pages tree alone touches every
1011
+ // page's object once regardless of how many are actually rendered —
1012
+ // measured 1,611ms for a 56-of-1,138-page extraction before this fix vs.
1013
+ // 1,699ms for the full document. The fallback regex scan below is kept
1014
+ // for any object this forward pass didn't catch (none observed, but
1015
+ // cheaper to keep than to prove impossible).
1016
+ const objEndByNum = new Map<number, number>();
1017
+ {
1018
+ const re = /(\d+)\s+0\s+obj\b/g;
1019
+ let m: RegExpExecArray | null;
1020
+ while ((m = re.exec(scan))) {
1021
+ objStarts.push({ index: m.index, num: parseInt(m[1], 10) });
1022
+ const num = parseInt(m[1], 10);
1023
+ if (!objEndByNum.has(num)) objEndByNum.set(num, m.index + m[0].length);
1024
+ }
1025
+ }
1026
+ function objNumBefore(markerIndex: number): number | null {
1027
+ let lo = 0, hi = objStarts.length - 1, ans: number | null = null;
1028
+ while (lo <= hi) {
1029
+ const mid = (lo + hi) >> 1;
1030
+ if (objStarts[mid].index <= markerIndex) { ans = objStarts[mid].num; lo = mid + 1; }
1031
+ else hi = mid - 1;
1032
+ }
1033
+ return ans;
1034
+ }
1035
+
1036
+ function getDirectObject(n: number): RawObject | null {
1037
+ if (objCache.has(n)) return objCache.get(n) ?? null;
1038
+ const fastEnd = objEndByNum.get(n);
1039
+ let objStart: number;
1040
+ if (fastEnd !== undefined) {
1041
+ objStart = fastEnd;
1042
+ } else {
1043
+ const re = new RegExp(`(?:^|\\D)${n}\\s+0\\s+obj`);
1044
+ const m = re.exec(scan);
1045
+ if (!m) { objCache.set(n, null); return null; }
1046
+ objStart = m.index + m[0].length;
1047
+ }
1048
+ const endobjIdx = scan.indexOf('endobj', objStart);
1049
+ const streamIdx = scan.indexOf('stream', objStart);
1050
+ let dict: string;
1051
+ let streamBytes: Uint8Array | null = null;
1052
+ if (streamIdx !== -1 && (endobjIdx === -1 || streamIdx < endobjIdx)) {
1053
+ dict = scan.slice(objStart, streamIdx);
1054
+ let dataStart = streamIdx + 'stream'.length;
1055
+ if (bytes[dataStart] === 0x0d) dataStart++;
1056
+ if (bytes[dataStart] === 0x0a) dataStart++;
1057
+ // Matches EITHER "/Length 428" (literal) or "/Length 12 0 R"
1058
+ // (indirect) — group 2 is present only for the indirect form. Written
1059
+ // this way ON PURPOSE instead of the more obvious
1060
+ // `/\/Length\s+(\d+)(?!\s+0\s+R)/` for a literal: that negative
1061
+ // lookahead is a backtracking trap for any MULTI-DIGIT indirect
1062
+ // reference. On "/Length 12 0 R", a greedy `(\d+)` first tries "12",
1063
+ // the lookahead correctly forbids it (followed by " 0 R") — but the
1064
+ // engine then backtracks `\d+` down to "1", at which point the
1065
+ // lookahead is checked against "2 0 R", which does NOT start with
1066
+ // whitespace, so the forbidden pattern no longer matches and the
1067
+ // lookahead (wrongly) PASSES. The match silently becomes "1" instead
1068
+ // of failing, so this object's /Length reads as the literal integer 1
1069
+ // instead of as a reference to resolve — exactly the shape of North
1070
+ // Dakota's Century Code chapter PDFs (fleet #2743), whose indirect
1071
+ // Length refs are almost all two or more digits. An explicit optional
1072
+ // group has nothing to backtrack: `(\d+)` greedily keeps "12" and the
1073
+ // optional `(\s+0\s+R)?` either matches right after it or doesn't.
1074
+ const lenDictMatch = /\/Length\s+(\d+)(\s+0\s+R)?/.exec(dict);
1075
+ let dataEnd = -1;
1076
+ if (lenDictMatch) {
1077
+ if (!lenDictMatch[2]) {
1078
+ dataEnd = dataStart + parseInt(lenDictMatch[1], 10);
1079
+ } else {
1080
+ // Indirect reference. The referenced object is a bare integer
1081
+ // ("12 0 obj\n4541\nendobj"), never itself a stream, so resolving
1082
+ // it through the same getObject() is safe (no recursion risk back
1083
+ // onto object n). Falling back to scanning for the "endstream"
1084
+ // keyword instead — which the pre-fix code did unconditionally
1085
+ // here — slices in the EOL bytes PDF producers place between the
1086
+ // compressed data and that keyword, which Node's strict zlib
1087
+ // rejects as "trailing junk" even though the compressed payload
1088
+ // itself is intact.
1089
+ const lenObj = getObject(parseInt(lenDictMatch[1], 10));
1090
+ const lenVal = lenObj ? parseInt(lenObj.dict.trim(), 10) : NaN;
1091
+ if (Number.isFinite(lenVal) && lenVal >= 0) dataEnd = dataStart + lenVal;
1092
+ }
1093
+ }
1094
+ if (dataEnd === -1) dataEnd = scan.indexOf('endstream', dataStart);
1095
+ streamBytes = bytes.slice(dataStart, dataEnd);
1096
+ } else {
1097
+ dict = scan.slice(objStart, endobjIdx === -1 ? scan.length : endobjIdx);
1098
+ }
1099
+ const result = { dict, streamBytes };
1100
+ objCache.set(n, result);
1101
+ return result;
1102
+ }
1103
+
1104
+ // Compressed objects (inside a /Type/ObjStm) — object number -> dict text.
1105
+ // Populated below, before any Catalog/Pages/Font lookup runs, because a
1106
+ // compressed Catalog or Pages root (Wyoming's shape) must resolve exactly
1107
+ // like a direct one from that point on. Never holds a stream: the PDF spec
1108
+ // forbids a compressed object from containing one.
1109
+ const compressedObjects = new Map<number, string>();
1110
+ {
1111
+ const objStmNums: number[] = [];
1112
+ const re = /\/Type\s*\/ObjStm\b/g;
1113
+ let m: RegExpExecArray | null;
1114
+ while ((m = re.exec(scan))) {
1115
+ const n = objNumBefore(m.index);
1116
+ if (n !== null) objStmNums.push(n);
1117
+ }
1118
+ for (const n of objStmNums) {
1119
+ const obj = getDirectObject(n);
1120
+ if (!obj || !obj.streamBytes) { warnings.push(`ObjStm ${n} has no stream data`); continue; }
1121
+ let data: Uint8Array;
1122
+ if (/\/Filter\s*\/FlateDecode/.test(obj.dict)) {
1123
+ try {
1124
+ data = await inflate(obj.streamBytes);
1125
+ } catch (e) {
1126
+ warnings.push(`inflate failed for ObjStm ${n}: ${e instanceof Error ? e.message : String(e)}`);
1127
+ continue;
1128
+ }
1129
+ } else {
1130
+ data = obj.streamBytes;
1131
+ }
1132
+ const nMatch = /\/N\s+(\d+)/.exec(obj.dict);
1133
+ const firstMatch = /\/First\s+(\d+)/.exec(obj.dict);
1134
+ if (!nMatch || !firstMatch) { warnings.push(`ObjStm ${n} missing /N or /First`); continue; }
1135
+ const count = parseInt(nMatch[1], 10);
1136
+ const first = parseInt(firstMatch[1], 10);
1137
+ const header = new TextDecoder('latin1').decode(data.slice(0, first)).trim().split(/\s+/).map((x) => parseInt(x, 10));
1138
+ const entries: { num: number; offset: number }[] = [];
1139
+ for (let i = 0; i + 1 < header.length && entries.length < count; i += 2) {
1140
+ entries.push({ num: header[i], offset: header[i + 1] });
1141
+ }
1142
+ const body = new TextDecoder('windows-1252').decode(data);
1143
+ for (let i = 0; i < entries.length; i++) {
1144
+ const start = first + entries[i].offset;
1145
+ const end = i + 1 < entries.length ? first + entries[i + 1].offset : data.length;
1146
+ if (start < 0 || end > data.length || start > end) {
1147
+ warnings.push(`ObjStm ${n} entry ${entries[i].num} has an out-of-range offset, skipped`);
1148
+ continue;
1149
+ }
1150
+ compressedObjects.set(entries[i].num, body.slice(start, end));
1151
+ }
1152
+ }
1153
+ }
1154
+
1155
+ // The lookup every caller below actually uses: direct object text, falling
1156
+ // back to a compressed one. Never both — a given object number is either
1157
+ // found literally in the file or inside exactly one ObjStm, not both.
1158
+ function getObject(n: number): RawObject | null {
1159
+ const direct = getDirectObject(n);
1160
+ if (direct) return direct;
1161
+ const compressed = compressedObjects.get(n);
1162
+ if (compressed !== undefined) return { dict: compressed, streamBytes: null };
1163
+ return null;
1164
+ }
1165
+
1166
+ async function decompressedStream(n: number): Promise<Uint8Array | null> {
1167
+ const obj = getObject(n);
1168
+ if (!obj || !obj.streamBytes) return null;
1169
+ if (/\/FlateDecode/.test(obj.dict)) {
1170
+ // Ported from mcps/guam-code's gu-pdf-extract.ts FORK FIX (fleet
1171
+ // #2888/#2904): several real PDFs store a /Length a small number of
1172
+ // bytes LONGER than the TRUE deflate stream length (confirmed on
1173
+ // Guam's 21gc033.pdf object 2: /Length says 96, the deflate stream is
1174
+ // complete at byte 95, and the 96th byte is one stray trailing byte).
1175
+ // Python's zlib tolerates trailing garbage by default; the
1176
+ // Workers/Node DecompressionStream used here does not and throws
1177
+ // "Trailing junk found after the end of the compressed stream".
1178
+ // Rather than fail the whole page, retry with the slice trimmed one
1179
+ // byte shorter a few times -- the true stream length is always <= the
1180
+ // declared one in every sample seen, never longer.
1181
+ for (let trim = 0; trim <= 4 && trim < obj.streamBytes.length; trim++) {
1182
+ try {
1183
+ return await inflate(trim === 0 ? obj.streamBytes : obj.streamBytes.subarray(0, obj.streamBytes.length - trim));
1184
+ } catch (e) {
1185
+ if (trim === 4) {
1186
+ warnings.push(`inflate failed for object ${n} even after trimming trailing bytes: ${e instanceof Error ? e.message : String(e)}`);
1187
+ return null;
1188
+ }
1189
+ }
1190
+ }
1191
+ return null;
1192
+ }
1193
+ return obj.streamBytes;
1194
+ }
1195
+
1196
+ // Catalog -> Pages -> Kids, walked in document order. Falls back to the
1197
+ // first /Type/Pages object if no /Type/Catalog is found. Both searches
1198
+ // look for the TYPE MARKER as plain text first (direct text, or inside any
1199
+ // decompressed ObjStm body) and then find which object that text belongs
1200
+ // to — see the file header for why this replaced a single greedy regex.
1201
+ let pagesRef: number | null = null;
1202
+ function findObjWithType(typeName: string): number | null {
1203
+ const directIdx = scan.search(new RegExp(`/Type\\s*/${typeName}\\b`));
1204
+ if (directIdx !== -1) {
1205
+ const n = objNumBefore(directIdx);
1206
+ if (n !== null) return n;
1207
+ }
1208
+ const typeRe = new RegExp(`/Type\\s*/${typeName}\\b`);
1209
+ for (const [num, text] of compressedObjects) {
1210
+ if (typeRe.test(text)) return num;
1211
+ }
1212
+ return null;
1213
+ }
1214
+ const catNum = findObjWithType('Catalog');
1215
+ if (catNum !== null) {
1216
+ const catObj = getObject(catNum);
1217
+ const pm = catObj ? /\/Pages\s+(\d+)\s+0\s+R/.exec(catObj.dict) : null;
1218
+ if (pm) pagesRef = parseInt(pm[1], 10);
1219
+ }
1220
+ if (pagesRef === null) {
1221
+ pagesRef = findObjWithType('Pages');
1222
+ }
1223
+
1224
+ const leafPages: number[] = [];
1225
+ function walkPages(n: number, depth: number): void {
1226
+ if (depth > 12) { warnings.push('Pages tree exceeded depth 12, stopped walking'); return; }
1227
+ const obj = getObject(n);
1228
+ if (!obj) return;
1229
+ if (/\/Type\s*\/Page\b(?!s)/.test(obj.dict)) { leafPages.push(n); return; }
1230
+ const kidsMatch = /\/Kids\s*\[([^\]]*)\]/.exec(obj.dict);
1231
+ if (!kidsMatch) return;
1232
+ for (const km of kidsMatch[1].matchAll(/(\d+)\s+0\s+R/g)) walkPages(parseInt(km[1], 10), depth + 1);
1233
+ }
1234
+ if (pagesRef !== null) walkPages(pagesRef, 0);
1235
+ else warnings.push('no /Type/Catalog or /Type/Pages object found');
1236
+
1237
+ const totalPages = leafPages.length;
1238
+ let pagesToExtract = leafPages;
1239
+ if (options?.pageRange) {
1240
+ const { fromPageObj, toPageObj } = options.pageRange;
1241
+ const fromIdx = leafPages.indexOf(fromPageObj);
1242
+ if (fromIdx === -1) {
1243
+ warnings.push(
1244
+ `pageRange.fromPageObj ${fromPageObj} is not a leaf page in this document — extracting the whole document instead of the requested range.`,
1245
+ );
1246
+ } else {
1247
+ let toIdx = leafPages.length - 1;
1248
+ if (toPageObj !== undefined) {
1249
+ const foundToIdx = leafPages.indexOf(toPageObj);
1250
+ // toPageObj not found, or found strictly BEFORE fromIdx (a bad or
1251
+ // out-of-order bookmark), falls back to end-of-document rather than
1252
+ // producing a negative-length slice. Equal to fromIdx is normal and
1253
+ // valid — a section whose first bookmarked child (e.g. its own
1254
+ // "ANNOTATIONS") starts on the SAME page is a single-page range,
1255
+ // not an error.
1256
+ if (foundToIdx >= fromIdx) toIdx = foundToIdx;
1257
+ else if (foundToIdx !== -1) {
1258
+ warnings.push(
1259
+ `pageRange.toPageObj ${toPageObj} resolves to a page before fromPageObj ${fromPageObj} — extracting through end of document instead.`,
1260
+ );
1261
+ } else {
1262
+ warnings.push(`pageRange.toPageObj ${toPageObj} is not a leaf page in this document — extracting through end of document instead.`);
1263
+ }
1264
+ }
1265
+ pagesToExtract = leafPages.slice(fromIdx, toIdx + 1);
1266
+ }
1267
+ }
1268
+
1269
+ /** Parse a /ToUnicode CMap stream's `beginbfchar`/`beginbfrange` blocks into
1270
+ * a code -> Unicode-codepoint map. Shared between Type0's 2-byte CIDs and
1271
+ * a simple font's 1-byte codes — the CMap text format is identical either
1272
+ * way; only how the caller SLICES its hex string differs (see decodeRun). */
1273
+ async function parseToUnicodeCMap(cmapObjNum: number): Promise<Map<number, number>> {
1274
+ const cmapBytes = await decompressedStream(cmapObjNum);
1275
+ const cmapText = cmapBytes ? new TextDecoder('latin1').decode(cmapBytes) : '';
1276
+ const map = new Map<number, number>();
1277
+ for (const block of cmapText.matchAll(/beginbfchar([\s\S]*?)endbfchar/g)) {
1278
+ for (const pair of block[1].matchAll(/<([0-9A-Fa-f]+)>\s*<([0-9A-Fa-f]+)>/g)) {
1279
+ map.set(parseInt(pair[1], 16), parseInt(pair[2].slice(0, 4), 16));
1280
+ }
1281
+ }
1282
+ for (const block of cmapText.matchAll(/beginbfrange([\s\S]*?)endbfrange/g)) {
1283
+ for (const triple of block[1].matchAll(/<([0-9A-Fa-f]+)>\s*<([0-9A-Fa-f]+)>\s*<([0-9A-Fa-f]+)>/g)) {
1284
+ const lo = parseInt(triple[1], 16), hi = parseInt(triple[2], 16), base = parseInt(triple[3].slice(0, 4), 16);
1285
+ for (let c = lo; c <= hi && c - lo < 65536; c++) map.set(c, base + (c - lo));
1286
+ }
1287
+ }
1288
+ return map;
1289
+ }
1290
+
1291
+ async function fontDecoderFor(fontObjNum: number): Promise<FontDecoder> {
1292
+ const obj = getObject(fontObjNum);
1293
+ if (!obj) return { kind: 'winansi' };
1294
+ if (/\/Subtype\s*\/Type0\b/.test(obj.dict)) {
1295
+ const tuMatch = /\/ToUnicode\s+(\d+)\s+0\s+R/.exec(obj.dict);
1296
+ if (!tuMatch) {
1297
+ warnings.push(`Type0 font object ${fontObjNum} has no /ToUnicode; its text will show as U+FFFD`);
1298
+ return { kind: 'cid', map: new Map() };
1299
+ }
1300
+ const map = await parseToUnicodeCMap(parseInt(tuMatch[1], 10));
1301
+ return { kind: 'cid', map: options?.fontCMapOverride?.(obj.dict, map) ?? map };
1302
+ }
1303
+ // Simple font. Every KRS sample used /WinAnsiEncoding, which decodes
1304
+ // directly as windows-1252 bytes below — unchanged. North Dakota's
1305
+ // Century Code PDFs (fleet #2743) use simple TrueType fonts that declare
1306
+ // NO /Encoding at all: their embedded subsets number glyphs 0, 1, 2... in
1307
+ // whatever order the subsetter emitted them, with no relationship to any
1308
+ // standard code page — reading code 0x01 as the WinAnsi byte 0x01 (a
1309
+ // control character) produces blank/garbled output, not a wrong letter.
1310
+ // Those fonts DO carry a /ToUnicode CMap (the same mechanism Type0 fonts
1311
+ // use above, just keyed by a 1-byte code instead of a 2-byte CID), which
1312
+ // is the only place the real character is recorded — use it whenever the
1313
+ // font isn't declared WinAnsi rather than guessing.
1314
+ if (!/\/Encoding\s*\/WinAnsiEncoding\b/.test(obj.dict)) {
1315
+ const tuMatch = /\/ToUnicode\s+(\d+)\s+0\s+R/.exec(obj.dict);
1316
+ if (tuMatch) {
1317
+ const parsedMap = await parseToUnicodeCMap(parseInt(tuMatch[1], 10));
1318
+ const map = options?.fontCMapOverride?.(obj.dict, parsedMap) ?? parsedMap;
1319
+ if (map.size) return { kind: 'mapped1', map };
1320
+ }
1321
+ warnings.push(`font object ${fontObjNum} has a non-WinAnsi simple encoding and no usable /ToUnicode; decoded as WinAnsi anyway`);
1322
+ }
1323
+ return { kind: 'winansi' };
1324
+ }
1325
+
1326
+ const TOKEN_RE =
1327
+ /\/(\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;
1328
+ const TJ_PIECE_RE = /\(((?:\\.|[^()])*)\)|<([0-9A-Fa-f\s]*)>/g;
1329
+
1330
+ function decodeRun(dec: FontDecoder, literal: string | undefined, hex: string | undefined): string {
1331
+ if (literal !== undefined) {
1332
+ const unescaped = unescapePdfLiteral(literal);
1333
+ // Ported from mcps/guam-code's gu-pdf-extract.ts FORK FIX 1/2 (fleet
1334
+ // #2888/#2904): a WinAnsi run's literal-string bytes ARE already the
1335
+ // correct windows-1252 text (unchanged below) -- but a non-WinAnsi
1336
+ // simple font ('mapped1') or a Type0 font ('cid') can ALSO show up as
1337
+ // a literal string rather than hex. Guam's PDF producer writes
1338
+ // several of its subset-embedded fonts' text this way; passing the
1339
+ // raw byte through unchanged (the old behavior here) skips that
1340
+ // font's /ToUnicode map entirely and produces line-noise control
1341
+ // characters instead of the real character -- route it through the
1342
+ // SAME decoder a hex run below already uses.
1343
+ //
1344
+ // 'cid' (Type0/Identity-H) is a 2-BYTE code space, same as the hex-
1345
+ // string path below -- a literal string's BYTES must be paired up
1346
+ // big-endian into one CID per pair, not looked up one byte at a time.
1347
+ // Found on mainland China's NPC/MOJ law PDFs (cn-law, fleet #3136):
1348
+ // Founder PDFGenerator (方正), the producer behind flk.npc.gov.cn's
1349
+ // and xzfg.moj.gov.cn's statute PDFs, writes most CJK body text as
1350
+ // literal strings like `(\000I)Tj` under a Type0 CID font rather than
1351
+ // as hex strings -- the shape this branch was missing. Decoding each
1352
+ // byte separately (the pre-fix behavior) looks up CID 0x00 and CID
1353
+ // 0x49 independently instead of the real CID 0x0049, so virtually
1354
+ // every CJK glyph misses its ToUnicode entry and comes back as
1355
+ // U+FFFD -- silently: no warning, a non-empty result, just wrong.
1356
+ // Measured against 中华人民共和国个人信息保护法 (PIPL)'s official
1357
+ // gazette PDF: 18,929 of 19,858 extracted characters were U+FFFD
1358
+ // before this fix (95%), 0 after, and `第十三条` (Article 13) and its
1359
+ // neighbors read correctly. If the literal string has an odd byte
1360
+ // count (shouldn't happen for a well-formed 2-byte-code run), the
1361
+ // trailing lone byte is silently dropped rather than guessed at.
1362
+ if (dec.kind === 'winansi') return unescaped;
1363
+ if (dec.kind === 'cid') {
1364
+ let out = '';
1365
+ for (let i = 0; i + 2 <= unescaped.length; i += 2) {
1366
+ const code =
1367
+ (rawByteFromLatin1Char(unescaped.charCodeAt(i)) << 8) | rawByteFromLatin1Char(unescaped.charCodeAt(i + 1));
1368
+ out += String.fromCodePoint(dec.map.get(code) ?? 0xfffd);
1369
+ }
1370
+ return out;
1371
+ }
1372
+ let out = '';
1373
+ for (let i = 0; i < unescaped.length; i++) {
1374
+ const code = rawByteFromLatin1Char(unescaped.charCodeAt(i));
1375
+ out += String.fromCodePoint(dec.map.get(code) ?? 0xfffd);
1376
+ }
1377
+ return out;
1378
+ }
1379
+ const cleaned = (hex ?? '').replace(/\s+/g, '');
1380
+ let out = '';
1381
+ if (dec.kind === 'mapped1') {
1382
+ // 1-byte code per glyph (a simple font's code space), unlike Type0's
1383
+ // 2-byte CIDs below — see fontDecoderFor.
1384
+ for (let i = 0; i + 2 <= cleaned.length; i += 2) {
1385
+ const code = parseInt(cleaned.slice(i, i + 2), 16);
1386
+ out += String.fromCodePoint(dec.map.get(code) ?? 0xfffd);
1387
+ }
1388
+ return out;
1389
+ }
1390
+ for (let i = 0; i + 4 <= cleaned.length; i += 4) {
1391
+ const cid = parseInt(cleaned.slice(i, i + 4), 16);
1392
+ if (dec.kind === 'cid') out += String.fromCodePoint(dec.map.get(cid) ?? 0xfffd);
1393
+ else out += String.fromCharCode(cid & 0xff);
1394
+ }
1395
+ return out;
1396
+ }
1397
+
1398
+ const pageTexts: string[] = [];
1399
+ for (const pn of pagesToExtract) {
1400
+ const page = getObject(pn);
1401
+ if (!page) continue;
1402
+
1403
+ let resourcesDict = page.dict;
1404
+ const resRef = /\/Resources\s+(\d+)\s+0\s+R/.exec(page.dict);
1405
+ if (resRef) {
1406
+ const ro = getObject(parseInt(resRef[1], 10));
1407
+ if (ro) resourcesDict = ro.dict;
1408
+ }
1409
+ // /Font is usually inline in Resources, but North Dakota's Century Code
1410
+ // PDFs (fleet #2743) write it as an indirect reference ("/Font 18 0 R")
1411
+ // to a separate dict object — resolve that one hop before giving up.
1412
+ let fontDictBody: string | null = null;
1413
+ const inlineFontMatch = /\/Font\s*<<([^>]*)>>/.exec(resourcesDict);
1414
+ if (inlineFontMatch) {
1415
+ fontDictBody = inlineFontMatch[1];
1416
+ } else {
1417
+ const fontRefMatch = /\/Font\s+(\d+)\s+0\s+R/.exec(resourcesDict);
1418
+ if (fontRefMatch) {
1419
+ const fontObj = getObject(parseInt(fontRefMatch[1], 10));
1420
+ if (fontObj) fontDictBody = fontObj.dict;
1421
+ }
1422
+ }
1423
+ const fontMap = new Map<string, FontDecoder>();
1424
+ if (fontDictBody) {
1425
+ for (const fm of fontDictBody.matchAll(/\/(\S+?)\s+(\d+)\s+0\s+R/g)) {
1426
+ fontMap.set(fm[1], await fontDecoderFor(parseInt(fm[2], 10)));
1427
+ }
1428
+ }
1429
+
1430
+ const contentsMatch = /\/Contents\s*(\[[^\]]*\]|\d+\s+0\s+R)/.exec(page.dict);
1431
+ const contentRefs = contentsMatch
1432
+ ? [...contentsMatch[1].matchAll(/(\d+)\s+0\s+R/g)].map((m) => parseInt(m[1], 10))
1433
+ : [];
1434
+ let raw = '';
1435
+ for (const cr of contentRefs) {
1436
+ const dec = await decompressedStream(cr);
1437
+ if (dec) raw += new TextDecoder('latin1').decode(dec) + '\n';
1438
+ }
1439
+
1440
+ let out = '';
1441
+ let currentFont: string | null = null;
1442
+ let lastY: number | null = null;
1443
+ for (const m of raw.matchAll(TOKEN_RE)) {
1444
+ if (m[1] !== undefined) { currentFont = m[1]; continue; }
1445
+ if (m[5] !== undefined) {
1446
+ const y = parseFloat(m[5]);
1447
+ if (lastY !== null && Math.abs(y - lastY) > 1) out += '\n';
1448
+ lastY = y;
1449
+ continue;
1450
+ }
1451
+ const dec = (currentFont && fontMap.get(currentFont)) || { kind: 'winansi' as const };
1452
+ if (m[2] !== undefined) {
1453
+ for (const piece of m[2].matchAll(TJ_PIECE_RE)) out += decodeRun(dec, piece[1], piece[2]);
1454
+ } else if (m[3] !== undefined) {
1455
+ out += decodeRun(dec, undefined, m[3]);
1456
+ } else if (m[4] !== undefined) {
1457
+ out += decodeRun(dec, m[4], undefined);
1458
+ }
1459
+ }
1460
+ pageTexts.push(out.trim());
1461
+ }
1462
+
1463
+ return { text: pageTexts.join('\n\n'), pages: totalPages, extractedPages: pagesToExtract.length, warnings };
1464
+ }
1465
+
1466
+ /**
1467
+ * Parse a PDF's outline (bookmark) tree into a flat, document-order list of
1468
+ * {title, pageObjNum, depth} entries — WITHOUT decompressing any content
1469
+ * stream, so this is orders of magnitude cheaper than `extractPdfText` and
1470
+ * safe to run just to decide WHERE to extract from. Built for
1471
+ * mcps/new-mexico-code (fleet #2884): NMOneSource publishes each NMSA 1978
1472
+ * CHAPTER as one PDF (657-1,138+ pages observed) but bookmarks every
1473
+ * ARTICLE heading (depth 0), every SECTION citation (depth 1, e.g.
1474
+ * "30-2-1. Murder."), and that section's own "ANNOTATIONS" sub-heading
1475
+ * (depth 2) — giving a caller everything needed to resolve a bare citation
1476
+ * straight to a page RANGE via `extractPdfText`'s `pageRange` option,
1477
+ * without ever extracting pages the caller didn't ask for.
1478
+ *
1479
+ * Assumes outline objects are DIRECT (never compressed into an /ObjStm) —
1480
+ * true of every source sampled while building this. A PDF whose outline IS
1481
+ * compressed, or that has no /Outlines at all, returns an empty array
1482
+ * rather than guessing; check for that before concluding a citation isn't
1483
+ * bookmarked.
1484
+ */
1485
+ function extractPdfOutline(buf: ArrayBuffer): PdfOutlineEntry[] {
1486
+ const bytes = new Uint8Array(buf);
1487
+ const scan = new TextDecoder('windows-1252').decode(bytes);
1488
+
1489
+ const objStarts: { index: number; num: number }[] = [];
1490
+ // See extractPdfText's identical map for why: one O(1) lookup per object
1491
+ // instead of one full-document regex scan — an outline tree with ~1,400
1492
+ // bookmarks (New Mexico's larger chapters) made this function itself the
1493
+ // bottleneck before this fix (measured 2.8s on Chapter 30's 5.8MB PDF).
1494
+ const objEndByNum = new Map<number, number>();
1495
+ {
1496
+ const re = /(\d+)\s+0\s+obj\b/g;
1497
+ let m: RegExpExecArray | null;
1498
+ while ((m = re.exec(scan))) {
1499
+ objStarts.push({ index: m.index, num: parseInt(m[1], 10) });
1500
+ const num = parseInt(m[1], 10);
1501
+ if (!objEndByNum.has(num)) objEndByNum.set(num, m.index + m[0].length);
1502
+ }
1503
+ }
1504
+ function objNumBefore(markerIndex: number): number | null {
1505
+ let lo = 0, hi = objStarts.length - 1, ans: number | null = null;
1506
+ while (lo <= hi) {
1507
+ const mid = (lo + hi) >> 1;
1508
+ if (objStarts[mid].index <= markerIndex) { ans = objStarts[mid].num; lo = mid + 1; }
1509
+ else hi = mid - 1;
1510
+ }
1511
+ return ans;
1512
+ }
1513
+
1514
+ const dictCache = new Map<number, string | null>();
1515
+ function dictOf(n: number): string | null {
1516
+ if (dictCache.has(n)) return dictCache.get(n) ?? null;
1517
+ let start: number;
1518
+ const fastEnd = objEndByNum.get(n);
1519
+ if (fastEnd !== undefined) {
1520
+ start = fastEnd;
1521
+ } else {
1522
+ const re = new RegExp(`(?:^|\\D)${n}\\s+0\\s+obj`);
1523
+ const m = re.exec(scan);
1524
+ if (!m) { dictCache.set(n, null); return null; }
1525
+ start = m.index + m[0].length;
1526
+ }
1527
+ const end = scan.indexOf('endobj', start);
1528
+ const dict = end === -1 ? scan.slice(start, Math.min(scan.length, start + 4000)) : scan.slice(start, end);
1529
+ dictCache.set(n, dict);
1530
+ return dict;
1531
+ }
1532
+
1533
+ // Prefer the Catalog's own /Outlines reference; fall back to the first
1534
+ // direct /Type/Outlines object found anywhere (same "search for the type
1535
+ // marker as plain text" approach extractPdfText uses for Catalog/Pages).
1536
+ let outlinesRoot: number | null = null;
1537
+ const catIdx = scan.search(/\/Type\s*\/Catalog\b/);
1538
+ if (catIdx !== -1) {
1539
+ const catNum = objNumBefore(catIdx);
1540
+ const catDict = catNum !== null ? dictOf(catNum) : null;
1541
+ const om = catDict ? /\/Outlines\s+(\d+)\s+0\s+R/.exec(catDict) : null;
1542
+ if (om) outlinesRoot = parseInt(om[1], 10);
1543
+ }
1544
+ if (outlinesRoot === null) {
1545
+ const outIdx = scan.search(/\/Type\s*\/Outlines\b/);
1546
+ if (outIdx !== -1) outlinesRoot = objNumBefore(outIdx);
1547
+ }
1548
+ if (outlinesRoot === null) return [];
1549
+
1550
+ function pageObjOf(dict: string): number | null {
1551
+ const destMatch = /\/Dest\s*\[\s*(\d+)\s+0\s+R/.exec(dict);
1552
+ if (destMatch) return parseInt(destMatch[1], 10);
1553
+ const actionMatch = /\/A\s*<<[^>]*\/D\s*\[\s*(\d+)\s+0\s+R/.exec(dict);
1554
+ if (actionMatch) return parseInt(actionMatch[1], 10);
1555
+ return null;
1556
+ }
1557
+
1558
+ const entries: PdfOutlineEntry[] = [];
1559
+ const seen = new Set<number>();
1560
+ function walk(n: number, depth: number): void {
1561
+ if (depth > 20 || seen.has(n)) return; // guards a malformed/cyclic tree
1562
+ seen.add(n);
1563
+ const dict = dictOf(n);
1564
+ if (!dict) return;
1565
+ const titleMatch = /\/Title\s*\(((?:\\.|[^()])*)\)/.exec(dict);
1566
+ if (titleMatch) {
1567
+ entries.push({ title: unescapePdfLiteral(titleMatch[1]).replace(/\s+/g, ' ').trim(), pageObjNum: pageObjOf(dict), depth });
1568
+ }
1569
+ const firstMatch = /\/First\s+(\d+)\s+0\s+R/.exec(dict);
1570
+ if (firstMatch) walk(parseInt(firstMatch[1], 10), titleMatch ? depth + 1 : depth);
1571
+ const nextMatch = /\/Next\s+(\d+)\s+0\s+R/.exec(dict);
1572
+ if (nextMatch) walk(parseInt(nextMatch[1], 10), depth);
1573
+ }
1574
+ const rootDict = dictOf(outlinesRoot);
1575
+ const rootFirst = rootDict ? /\/First\s+(\d+)\s+0\s+R/.exec(rootDict) : null;
1576
+ if (rootFirst) walk(parseInt(rootFirst[1], 10), 0);
1577
+
1578
+ return entries;
1579
+ }
673
1580
  /**
674
1581
  * Guam Code Annotated (GCA) — section lookup and search. Fleet #2888.
675
1582
  *
@@ -700,25 +1607,31 @@ function collapse(s: string): string {
700
1607
  * section number with NO separator — "21 GCA § 33101" is chapter 33,
701
1608
  * section 101. gu_statute accepts either form; see `parseGuCitation`.
702
1609
  *
703
- * THE PDF EXTRACTOR NEEDED A PACK-LOCAL FORK, NOT A SHARED-FILE EDIT.
704
- * `shared/src/pdf-text.ts` already reads Kentucky's and North Dakota's
705
- * statute PDFs, but Guam's chapter PDFs defeated it in two new ways, and
706
- * this pack was built the same week another pack (#2884, New Mexico) was
707
- * independently editing that shared file — so the fixes live in
708
- * `./gu-pdf-extract.ts`, a pack-local fork, with a loud note for whoever
709
- * next touches the shared extractor to fold them in. Full detail in that
710
- * file's header; short version: (1) Guam's producer writes some non-WinAnsi
711
- * subset fonts' text as parenthesized LITERAL strings rather than hex, which
712
- * the shared extractor's `decodeRun` passes through unchanged instead of
713
- * resolving through that font's ToUnicode map; (2) several older chapter
714
- * PDFs declare a `/Length` a few bytes longer than their actual deflate
715
- * stream, which Node/Workers' strict `DecompressionStream` rejects as
716
- * trailing junk (Python's zlib tolerates it silently) — retried here with
717
- * the tail trimmed a few bytes at a time; (3) a crash-adjacent fix: the
718
- * shared `inflate()`'s fire-and-forget `void writer.write(...)` turns any
719
- * bad deflate stream into an UNHANDLED promise rejection that no caller's
720
- * try/catch can see, which crashed this pack's own bake script outright —
721
- * worth checking whether the shared file has the same exposure.
1610
+ * THE PDF EXTRACTOR WAS PACK-LOCALLY FORKED, THEN FOLDED BACK IN (fleet
1611
+ * #2904). `shared/src/pdf-text.ts` already read Kentucky's and North
1612
+ * Dakota's statute PDFs, but Guam's chapter PDFs defeated it in ways it did
1613
+ * not yet handle, and this pack was built the same week another pack
1614
+ * (#2884, New Mexico) was independently editing that shared file — so the
1615
+ * fixes initially lived in a pack-local fork (`./gu-pdf-extract.ts`), with a
1616
+ * loud note for whoever next touched the shared extractor to fold them in.
1617
+ * That fold-in happened in #2904: all three fixes were genuinely NOT
1618
+ * Guam-specific and now live in `shared/src/pdf-text.ts` itself — (1) a
1619
+ * literal (parenthesized) string run is routed through the current font's
1620
+ * ToUnicode map exactly like a hex run, instead of being passed through
1621
+ * unchanged (which produced line-noise control characters for Guam's
1622
+ * non-WinAnsi subset fonts); (2) a `/Length` a few bytes longer than the
1623
+ * true deflate stream is retried with the tail trimmed, rather than failing
1624
+ * outright on Node/Workers' strict trailing-junk check; (3) `inflate()`'s
1625
+ * two stream writes are no longer fire-and-forget, so a malformed deflate
1626
+ * stream can no longer reject an UNHANDLED promise (this one crashed this
1627
+ * pack's own bake script outright before the fix). The fork is deleted —
1628
+ * ONE delta stayed pack-local, in `./gu-font-cmap-fix.ts`, because it
1629
+ * genuinely is Guam-specific: one embedded symbol font per chapter PDF
1630
+ * (`...+WPTypographicSymbols`) carries a `/ToUnicode` CMap that is itself
1631
+ * WRONG in Guam's own published source file (its two curly-quote codes map
1632
+ * to 'A' and '@' instead of the real glyphs) — passed into `extractPdfText`
1633
+ * via its new `fontCMapOverride` option, a generic hook with nothing
1634
+ * Guam-specific about the mechanism itself.
722
1635
  *
723
1636
  * FOUR CAPABILITIES — two available, two are not:
724
1637
  * - citation lookup -> gu_statute
@@ -736,7 +1649,7 @@ function collapse(s: string): string {
736
1649
  *
737
1650
  * Keyless, no signup.
738
1651
  */
739
- import { extractGuamPdfText } from './gu-pdf-extract.js';
1652
+ import { guamFontCMapOverride } from './gu-font-cmap-fix.js';
740
1653
  import type { GuChapterRow, GuTitleRow } from './gu-index-data.js';
741
1654
  import type { GuSectionRow } from './gu-section-data.js';
742
1655
 
@@ -1011,7 +1924,7 @@ async function guStatute(args: Record<string, unknown>) {
1011
1924
 
1012
1925
  const pdfRes = await pwFetch(pdfUrl);
1013
1926
  if (!pdfRes.ok) throw await httpError(pdfRes, 'Guam Compiler of Laws chapter PDF');
1014
- const extracted = await extractGuamPdfText(await pdfRes.arrayBuffer());
1927
+ const extracted = await extractPdfText(await pdfRes.arrayBuffer(), { fontCMapOverride: guamFontCMapOverride });
1015
1928
  if (!extracted.text.trim()) {
1016
1929
  return {
1017
1930
  found: true,