@pipeworx/mcp-guam-code 0.1.0 → 0.1.1

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,843 @@ 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
+ interface RawObject {
939
+ dict: string;
940
+ streamBytes: Uint8Array | null;
941
+ }
942
+
943
+ /** Extract readable text from a simple, non-encrypted, non-scanned PDF. */
944
+ async function extractPdfText(buf: ArrayBuffer, options?: PdfExtractOptions): Promise<PdfExtractResult> {
945
+ const bytes = new Uint8Array(buf);
946
+ // windows-1252 decode is total (every byte maps to exactly one UTF-16 code
947
+ // unit) and length-preserving, so a match index found in `scan` is also a
948
+ // valid BYTE offset into `bytes` — used below to slice stream data without
949
+ // ever routing binary bytes through a lossy string round-trip.
950
+ const scan = new TextDecoder('windows-1252').decode(bytes);
951
+ const warnings: string[] = [];
952
+ const objCache = new Map<number, RawObject | null>();
953
+
954
+ // Byte offset of every DIRECT "N 0 obj" marker, sorted, for two uses: (1)
955
+ // the object-lookup fallback stays regex-per-object the same as before;
956
+ // (2) `objNumBefore` below answers "which object's text is this index
957
+ // inside", which is how Catalog/Pages/ObjStm discovery works regardless of
958
+ // what else that object's dictionary contains — see the file header.
959
+ const objStarts: { index: number; num: number }[] = [];
960
+ // Object number -> index right after its "N 0 obj" marker (i.e. where the
961
+ // dictionary text begins), for every object found by the SAME forward
962
+ // scan that builds objStarts — first occurrence wins, matching what a
963
+ // one-shot `.exec` would find. This turns getDirectObject's lookup from
964
+ // one full-document regex scan PER DISTINCT OBJECT NUMBER into one O(1)
965
+ // map read: with a large page-tree walk (New Mexico's chapter PDFs run
966
+ // 657-1,138+ pages, fleet #2884) the old per-call scan made even a
967
+ // `pageRange`-restricted extraction cost nearly as much as extracting the
968
+ // whole document, because walking the Pages tree alone touches every
969
+ // page's object once regardless of how many are actually rendered —
970
+ // measured 1,611ms for a 56-of-1,138-page extraction before this fix vs.
971
+ // 1,699ms for the full document. The fallback regex scan below is kept
972
+ // for any object this forward pass didn't catch (none observed, but
973
+ // cheaper to keep than to prove impossible).
974
+ const objEndByNum = new Map<number, number>();
975
+ {
976
+ const re = /(\d+)\s+0\s+obj\b/g;
977
+ let m: RegExpExecArray | null;
978
+ while ((m = re.exec(scan))) {
979
+ objStarts.push({ index: m.index, num: parseInt(m[1], 10) });
980
+ const num = parseInt(m[1], 10);
981
+ if (!objEndByNum.has(num)) objEndByNum.set(num, m.index + m[0].length);
982
+ }
983
+ }
984
+ function objNumBefore(markerIndex: number): number | null {
985
+ let lo = 0, hi = objStarts.length - 1, ans: number | null = null;
986
+ while (lo <= hi) {
987
+ const mid = (lo + hi) >> 1;
988
+ if (objStarts[mid].index <= markerIndex) { ans = objStarts[mid].num; lo = mid + 1; }
989
+ else hi = mid - 1;
990
+ }
991
+ return ans;
992
+ }
993
+
994
+ function getDirectObject(n: number): RawObject | null {
995
+ if (objCache.has(n)) return objCache.get(n) ?? null;
996
+ const fastEnd = objEndByNum.get(n);
997
+ let objStart: number;
998
+ if (fastEnd !== undefined) {
999
+ objStart = fastEnd;
1000
+ } else {
1001
+ const re = new RegExp(`(?:^|\\D)${n}\\s+0\\s+obj`);
1002
+ const m = re.exec(scan);
1003
+ if (!m) { objCache.set(n, null); return null; }
1004
+ objStart = m.index + m[0].length;
1005
+ }
1006
+ const endobjIdx = scan.indexOf('endobj', objStart);
1007
+ const streamIdx = scan.indexOf('stream', objStart);
1008
+ let dict: string;
1009
+ let streamBytes: Uint8Array | null = null;
1010
+ if (streamIdx !== -1 && (endobjIdx === -1 || streamIdx < endobjIdx)) {
1011
+ dict = scan.slice(objStart, streamIdx);
1012
+ let dataStart = streamIdx + 'stream'.length;
1013
+ if (bytes[dataStart] === 0x0d) dataStart++;
1014
+ if (bytes[dataStart] === 0x0a) dataStart++;
1015
+ // Matches EITHER "/Length 428" (literal) or "/Length 12 0 R"
1016
+ // (indirect) — group 2 is present only for the indirect form. Written
1017
+ // this way ON PURPOSE instead of the more obvious
1018
+ // `/\/Length\s+(\d+)(?!\s+0\s+R)/` for a literal: that negative
1019
+ // lookahead is a backtracking trap for any MULTI-DIGIT indirect
1020
+ // reference. On "/Length 12 0 R", a greedy `(\d+)` first tries "12",
1021
+ // the lookahead correctly forbids it (followed by " 0 R") — but the
1022
+ // engine then backtracks `\d+` down to "1", at which point the
1023
+ // lookahead is checked against "2 0 R", which does NOT start with
1024
+ // whitespace, so the forbidden pattern no longer matches and the
1025
+ // lookahead (wrongly) PASSES. The match silently becomes "1" instead
1026
+ // of failing, so this object's /Length reads as the literal integer 1
1027
+ // instead of as a reference to resolve — exactly the shape of North
1028
+ // Dakota's Century Code chapter PDFs (fleet #2743), whose indirect
1029
+ // Length refs are almost all two or more digits. An explicit optional
1030
+ // group has nothing to backtrack: `(\d+)` greedily keeps "12" and the
1031
+ // optional `(\s+0\s+R)?` either matches right after it or doesn't.
1032
+ const lenDictMatch = /\/Length\s+(\d+)(\s+0\s+R)?/.exec(dict);
1033
+ let dataEnd = -1;
1034
+ if (lenDictMatch) {
1035
+ if (!lenDictMatch[2]) {
1036
+ dataEnd = dataStart + parseInt(lenDictMatch[1], 10);
1037
+ } else {
1038
+ // Indirect reference. The referenced object is a bare integer
1039
+ // ("12 0 obj\n4541\nendobj"), never itself a stream, so resolving
1040
+ // it through the same getObject() is safe (no recursion risk back
1041
+ // onto object n). Falling back to scanning for the "endstream"
1042
+ // keyword instead — which the pre-fix code did unconditionally
1043
+ // here — slices in the EOL bytes PDF producers place between the
1044
+ // compressed data and that keyword, which Node's strict zlib
1045
+ // rejects as "trailing junk" even though the compressed payload
1046
+ // itself is intact.
1047
+ const lenObj = getObject(parseInt(lenDictMatch[1], 10));
1048
+ const lenVal = lenObj ? parseInt(lenObj.dict.trim(), 10) : NaN;
1049
+ if (Number.isFinite(lenVal) && lenVal >= 0) dataEnd = dataStart + lenVal;
1050
+ }
1051
+ }
1052
+ if (dataEnd === -1) dataEnd = scan.indexOf('endstream', dataStart);
1053
+ streamBytes = bytes.slice(dataStart, dataEnd);
1054
+ } else {
1055
+ dict = scan.slice(objStart, endobjIdx === -1 ? scan.length : endobjIdx);
1056
+ }
1057
+ const result = { dict, streamBytes };
1058
+ objCache.set(n, result);
1059
+ return result;
1060
+ }
1061
+
1062
+ // Compressed objects (inside a /Type/ObjStm) — object number -> dict text.
1063
+ // Populated below, before any Catalog/Pages/Font lookup runs, because a
1064
+ // compressed Catalog or Pages root (Wyoming's shape) must resolve exactly
1065
+ // like a direct one from that point on. Never holds a stream: the PDF spec
1066
+ // forbids a compressed object from containing one.
1067
+ const compressedObjects = new Map<number, string>();
1068
+ {
1069
+ const objStmNums: number[] = [];
1070
+ const re = /\/Type\s*\/ObjStm\b/g;
1071
+ let m: RegExpExecArray | null;
1072
+ while ((m = re.exec(scan))) {
1073
+ const n = objNumBefore(m.index);
1074
+ if (n !== null) objStmNums.push(n);
1075
+ }
1076
+ for (const n of objStmNums) {
1077
+ const obj = getDirectObject(n);
1078
+ if (!obj || !obj.streamBytes) { warnings.push(`ObjStm ${n} has no stream data`); continue; }
1079
+ let data: Uint8Array;
1080
+ if (/\/Filter\s*\/FlateDecode/.test(obj.dict)) {
1081
+ try {
1082
+ data = await inflate(obj.streamBytes);
1083
+ } catch (e) {
1084
+ warnings.push(`inflate failed for ObjStm ${n}: ${e instanceof Error ? e.message : String(e)}`);
1085
+ continue;
1086
+ }
1087
+ } else {
1088
+ data = obj.streamBytes;
1089
+ }
1090
+ const nMatch = /\/N\s+(\d+)/.exec(obj.dict);
1091
+ const firstMatch = /\/First\s+(\d+)/.exec(obj.dict);
1092
+ if (!nMatch || !firstMatch) { warnings.push(`ObjStm ${n} missing /N or /First`); continue; }
1093
+ const count = parseInt(nMatch[1], 10);
1094
+ const first = parseInt(firstMatch[1], 10);
1095
+ const header = new TextDecoder('latin1').decode(data.slice(0, first)).trim().split(/\s+/).map((x) => parseInt(x, 10));
1096
+ const entries: { num: number; offset: number }[] = [];
1097
+ for (let i = 0; i + 1 < header.length && entries.length < count; i += 2) {
1098
+ entries.push({ num: header[i], offset: header[i + 1] });
1099
+ }
1100
+ const body = new TextDecoder('windows-1252').decode(data);
1101
+ for (let i = 0; i < entries.length; i++) {
1102
+ const start = first + entries[i].offset;
1103
+ const end = i + 1 < entries.length ? first + entries[i + 1].offset : data.length;
1104
+ if (start < 0 || end > data.length || start > end) {
1105
+ warnings.push(`ObjStm ${n} entry ${entries[i].num} has an out-of-range offset, skipped`);
1106
+ continue;
1107
+ }
1108
+ compressedObjects.set(entries[i].num, body.slice(start, end));
1109
+ }
1110
+ }
1111
+ }
1112
+
1113
+ // The lookup every caller below actually uses: direct object text, falling
1114
+ // back to a compressed one. Never both — a given object number is either
1115
+ // found literally in the file or inside exactly one ObjStm, not both.
1116
+ function getObject(n: number): RawObject | null {
1117
+ const direct = getDirectObject(n);
1118
+ if (direct) return direct;
1119
+ const compressed = compressedObjects.get(n);
1120
+ if (compressed !== undefined) return { dict: compressed, streamBytes: null };
1121
+ return null;
1122
+ }
1123
+
1124
+ async function decompressedStream(n: number): Promise<Uint8Array | null> {
1125
+ const obj = getObject(n);
1126
+ if (!obj || !obj.streamBytes) return null;
1127
+ if (/\/FlateDecode/.test(obj.dict)) {
1128
+ // Ported from mcps/guam-code's gu-pdf-extract.ts FORK FIX (fleet
1129
+ // #2888/#2904): several real PDFs store a /Length a small number of
1130
+ // bytes LONGER than the TRUE deflate stream length (confirmed on
1131
+ // Guam's 21gc033.pdf object 2: /Length says 96, the deflate stream is
1132
+ // complete at byte 95, and the 96th byte is one stray trailing byte).
1133
+ // Python's zlib tolerates trailing garbage by default; the
1134
+ // Workers/Node DecompressionStream used here does not and throws
1135
+ // "Trailing junk found after the end of the compressed stream".
1136
+ // Rather than fail the whole page, retry with the slice trimmed one
1137
+ // byte shorter a few times -- the true stream length is always <= the
1138
+ // declared one in every sample seen, never longer.
1139
+ for (let trim = 0; trim <= 4 && trim < obj.streamBytes.length; trim++) {
1140
+ try {
1141
+ return await inflate(trim === 0 ? obj.streamBytes : obj.streamBytes.subarray(0, obj.streamBytes.length - trim));
1142
+ } catch (e) {
1143
+ if (trim === 4) {
1144
+ warnings.push(`inflate failed for object ${n} even after trimming trailing bytes: ${e instanceof Error ? e.message : String(e)}`);
1145
+ return null;
1146
+ }
1147
+ }
1148
+ }
1149
+ return null;
1150
+ }
1151
+ return obj.streamBytes;
1152
+ }
1153
+
1154
+ // Catalog -> Pages -> Kids, walked in document order. Falls back to the
1155
+ // first /Type/Pages object if no /Type/Catalog is found. Both searches
1156
+ // look for the TYPE MARKER as plain text first (direct text, or inside any
1157
+ // decompressed ObjStm body) and then find which object that text belongs
1158
+ // to — see the file header for why this replaced a single greedy regex.
1159
+ let pagesRef: number | null = null;
1160
+ function findObjWithType(typeName: string): number | null {
1161
+ const directIdx = scan.search(new RegExp(`/Type\\s*/${typeName}\\b`));
1162
+ if (directIdx !== -1) {
1163
+ const n = objNumBefore(directIdx);
1164
+ if (n !== null) return n;
1165
+ }
1166
+ const typeRe = new RegExp(`/Type\\s*/${typeName}\\b`);
1167
+ for (const [num, text] of compressedObjects) {
1168
+ if (typeRe.test(text)) return num;
1169
+ }
1170
+ return null;
1171
+ }
1172
+ const catNum = findObjWithType('Catalog');
1173
+ if (catNum !== null) {
1174
+ const catObj = getObject(catNum);
1175
+ const pm = catObj ? /\/Pages\s+(\d+)\s+0\s+R/.exec(catObj.dict) : null;
1176
+ if (pm) pagesRef = parseInt(pm[1], 10);
1177
+ }
1178
+ if (pagesRef === null) {
1179
+ pagesRef = findObjWithType('Pages');
1180
+ }
1181
+
1182
+ const leafPages: number[] = [];
1183
+ function walkPages(n: number, depth: number): void {
1184
+ if (depth > 12) { warnings.push('Pages tree exceeded depth 12, stopped walking'); return; }
1185
+ const obj = getObject(n);
1186
+ if (!obj) return;
1187
+ if (/\/Type\s*\/Page\b(?!s)/.test(obj.dict)) { leafPages.push(n); return; }
1188
+ const kidsMatch = /\/Kids\s*\[([^\]]*)\]/.exec(obj.dict);
1189
+ if (!kidsMatch) return;
1190
+ for (const km of kidsMatch[1].matchAll(/(\d+)\s+0\s+R/g)) walkPages(parseInt(km[1], 10), depth + 1);
1191
+ }
1192
+ if (pagesRef !== null) walkPages(pagesRef, 0);
1193
+ else warnings.push('no /Type/Catalog or /Type/Pages object found');
1194
+
1195
+ const totalPages = leafPages.length;
1196
+ let pagesToExtract = leafPages;
1197
+ if (options?.pageRange) {
1198
+ const { fromPageObj, toPageObj } = options.pageRange;
1199
+ const fromIdx = leafPages.indexOf(fromPageObj);
1200
+ if (fromIdx === -1) {
1201
+ warnings.push(
1202
+ `pageRange.fromPageObj ${fromPageObj} is not a leaf page in this document — extracting the whole document instead of the requested range.`,
1203
+ );
1204
+ } else {
1205
+ let toIdx = leafPages.length - 1;
1206
+ if (toPageObj !== undefined) {
1207
+ const foundToIdx = leafPages.indexOf(toPageObj);
1208
+ // toPageObj not found, or found strictly BEFORE fromIdx (a bad or
1209
+ // out-of-order bookmark), falls back to end-of-document rather than
1210
+ // producing a negative-length slice. Equal to fromIdx is normal and
1211
+ // valid — a section whose first bookmarked child (e.g. its own
1212
+ // "ANNOTATIONS") starts on the SAME page is a single-page range,
1213
+ // not an error.
1214
+ if (foundToIdx >= fromIdx) toIdx = foundToIdx;
1215
+ else if (foundToIdx !== -1) {
1216
+ warnings.push(
1217
+ `pageRange.toPageObj ${toPageObj} resolves to a page before fromPageObj ${fromPageObj} — extracting through end of document instead.`,
1218
+ );
1219
+ } else {
1220
+ warnings.push(`pageRange.toPageObj ${toPageObj} is not a leaf page in this document — extracting through end of document instead.`);
1221
+ }
1222
+ }
1223
+ pagesToExtract = leafPages.slice(fromIdx, toIdx + 1);
1224
+ }
1225
+ }
1226
+
1227
+ /** Parse a /ToUnicode CMap stream's `beginbfchar`/`beginbfrange` blocks into
1228
+ * a code -> Unicode-codepoint map. Shared between Type0's 2-byte CIDs and
1229
+ * a simple font's 1-byte codes — the CMap text format is identical either
1230
+ * way; only how the caller SLICES its hex string differs (see decodeRun). */
1231
+ async function parseToUnicodeCMap(cmapObjNum: number): Promise<Map<number, number>> {
1232
+ const cmapBytes = await decompressedStream(cmapObjNum);
1233
+ const cmapText = cmapBytes ? new TextDecoder('latin1').decode(cmapBytes) : '';
1234
+ const map = new Map<number, number>();
1235
+ for (const block of cmapText.matchAll(/beginbfchar([\s\S]*?)endbfchar/g)) {
1236
+ for (const pair of block[1].matchAll(/<([0-9A-Fa-f]+)>\s*<([0-9A-Fa-f]+)>/g)) {
1237
+ map.set(parseInt(pair[1], 16), parseInt(pair[2].slice(0, 4), 16));
1238
+ }
1239
+ }
1240
+ for (const block of cmapText.matchAll(/beginbfrange([\s\S]*?)endbfrange/g)) {
1241
+ for (const triple of block[1].matchAll(/<([0-9A-Fa-f]+)>\s*<([0-9A-Fa-f]+)>\s*<([0-9A-Fa-f]+)>/g)) {
1242
+ const lo = parseInt(triple[1], 16), hi = parseInt(triple[2], 16), base = parseInt(triple[3].slice(0, 4), 16);
1243
+ for (let c = lo; c <= hi && c - lo < 65536; c++) map.set(c, base + (c - lo));
1244
+ }
1245
+ }
1246
+ return map;
1247
+ }
1248
+
1249
+ async function fontDecoderFor(fontObjNum: number): Promise<FontDecoder> {
1250
+ const obj = getObject(fontObjNum);
1251
+ if (!obj) return { kind: 'winansi' };
1252
+ if (/\/Subtype\s*\/Type0\b/.test(obj.dict)) {
1253
+ const tuMatch = /\/ToUnicode\s+(\d+)\s+0\s+R/.exec(obj.dict);
1254
+ if (!tuMatch) {
1255
+ warnings.push(`Type0 font object ${fontObjNum} has no /ToUnicode; its text will show as U+FFFD`);
1256
+ return { kind: 'cid', map: new Map() };
1257
+ }
1258
+ const map = await parseToUnicodeCMap(parseInt(tuMatch[1], 10));
1259
+ return { kind: 'cid', map: options?.fontCMapOverride?.(obj.dict, map) ?? map };
1260
+ }
1261
+ // Simple font. Every KRS sample used /WinAnsiEncoding, which decodes
1262
+ // directly as windows-1252 bytes below — unchanged. North Dakota's
1263
+ // Century Code PDFs (fleet #2743) use simple TrueType fonts that declare
1264
+ // NO /Encoding at all: their embedded subsets number glyphs 0, 1, 2... in
1265
+ // whatever order the subsetter emitted them, with no relationship to any
1266
+ // standard code page — reading code 0x01 as the WinAnsi byte 0x01 (a
1267
+ // control character) produces blank/garbled output, not a wrong letter.
1268
+ // Those fonts DO carry a /ToUnicode CMap (the same mechanism Type0 fonts
1269
+ // use above, just keyed by a 1-byte code instead of a 2-byte CID), which
1270
+ // is the only place the real character is recorded — use it whenever the
1271
+ // font isn't declared WinAnsi rather than guessing.
1272
+ if (!/\/Encoding\s*\/WinAnsiEncoding\b/.test(obj.dict)) {
1273
+ const tuMatch = /\/ToUnicode\s+(\d+)\s+0\s+R/.exec(obj.dict);
1274
+ if (tuMatch) {
1275
+ const parsedMap = await parseToUnicodeCMap(parseInt(tuMatch[1], 10));
1276
+ const map = options?.fontCMapOverride?.(obj.dict, parsedMap) ?? parsedMap;
1277
+ if (map.size) return { kind: 'mapped1', map };
1278
+ }
1279
+ warnings.push(`font object ${fontObjNum} has a non-WinAnsi simple encoding and no usable /ToUnicode; decoded as WinAnsi anyway`);
1280
+ }
1281
+ return { kind: 'winansi' };
1282
+ }
1283
+
1284
+ const TOKEN_RE =
1285
+ /\/(\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;
1286
+ const TJ_PIECE_RE = /\(((?:\\.|[^()])*)\)|<([0-9A-Fa-f\s]*)>/g;
1287
+
1288
+ function decodeRun(dec: FontDecoder, literal: string | undefined, hex: string | undefined): string {
1289
+ if (literal !== undefined) {
1290
+ const unescaped = unescapePdfLiteral(literal);
1291
+ // Ported from mcps/guam-code's gu-pdf-extract.ts FORK FIX 1/2 (fleet
1292
+ // #2888/#2904): a WinAnsi run's literal-string bytes ARE already the
1293
+ // correct windows-1252 text (unchanged below) -- but a non-WinAnsi
1294
+ // simple font ('mapped1') or a Type0 font ('cid') can ALSO show up as
1295
+ // a literal string rather than hex. Guam's PDF producer writes
1296
+ // several of its subset-embedded fonts' text this way; passing the
1297
+ // raw byte through unchanged (the old behavior here) skips that
1298
+ // font's /ToUnicode map entirely and produces line-noise control
1299
+ // characters instead of the real character -- route it through the
1300
+ // SAME decoder a hex run below already uses.
1301
+ if (dec.kind === 'winansi') return unescaped;
1302
+ let out = '';
1303
+ for (let i = 0; i < unescaped.length; i++) {
1304
+ const code = unescaped.charCodeAt(i) & 0xff;
1305
+ out += String.fromCodePoint(dec.map.get(code) ?? 0xfffd);
1306
+ }
1307
+ return out;
1308
+ }
1309
+ const cleaned = (hex ?? '').replace(/\s+/g, '');
1310
+ let out = '';
1311
+ if (dec.kind === 'mapped1') {
1312
+ // 1-byte code per glyph (a simple font's code space), unlike Type0's
1313
+ // 2-byte CIDs below — see fontDecoderFor.
1314
+ for (let i = 0; i + 2 <= cleaned.length; i += 2) {
1315
+ const code = parseInt(cleaned.slice(i, i + 2), 16);
1316
+ out += String.fromCodePoint(dec.map.get(code) ?? 0xfffd);
1317
+ }
1318
+ return out;
1319
+ }
1320
+ for (let i = 0; i + 4 <= cleaned.length; i += 4) {
1321
+ const cid = parseInt(cleaned.slice(i, i + 4), 16);
1322
+ if (dec.kind === 'cid') out += String.fromCodePoint(dec.map.get(cid) ?? 0xfffd);
1323
+ else out += String.fromCharCode(cid & 0xff);
1324
+ }
1325
+ return out;
1326
+ }
1327
+
1328
+ const pageTexts: string[] = [];
1329
+ for (const pn of pagesToExtract) {
1330
+ const page = getObject(pn);
1331
+ if (!page) continue;
1332
+
1333
+ let resourcesDict = page.dict;
1334
+ const resRef = /\/Resources\s+(\d+)\s+0\s+R/.exec(page.dict);
1335
+ if (resRef) {
1336
+ const ro = getObject(parseInt(resRef[1], 10));
1337
+ if (ro) resourcesDict = ro.dict;
1338
+ }
1339
+ // /Font is usually inline in Resources, but North Dakota's Century Code
1340
+ // PDFs (fleet #2743) write it as an indirect reference ("/Font 18 0 R")
1341
+ // to a separate dict object — resolve that one hop before giving up.
1342
+ let fontDictBody: string | null = null;
1343
+ const inlineFontMatch = /\/Font\s*<<([^>]*)>>/.exec(resourcesDict);
1344
+ if (inlineFontMatch) {
1345
+ fontDictBody = inlineFontMatch[1];
1346
+ } else {
1347
+ const fontRefMatch = /\/Font\s+(\d+)\s+0\s+R/.exec(resourcesDict);
1348
+ if (fontRefMatch) {
1349
+ const fontObj = getObject(parseInt(fontRefMatch[1], 10));
1350
+ if (fontObj) fontDictBody = fontObj.dict;
1351
+ }
1352
+ }
1353
+ const fontMap = new Map<string, FontDecoder>();
1354
+ if (fontDictBody) {
1355
+ for (const fm of fontDictBody.matchAll(/\/(\S+?)\s+(\d+)\s+0\s+R/g)) {
1356
+ fontMap.set(fm[1], await fontDecoderFor(parseInt(fm[2], 10)));
1357
+ }
1358
+ }
1359
+
1360
+ const contentsMatch = /\/Contents\s*(\[[^\]]*\]|\d+\s+0\s+R)/.exec(page.dict);
1361
+ const contentRefs = contentsMatch
1362
+ ? [...contentsMatch[1].matchAll(/(\d+)\s+0\s+R/g)].map((m) => parseInt(m[1], 10))
1363
+ : [];
1364
+ let raw = '';
1365
+ for (const cr of contentRefs) {
1366
+ const dec = await decompressedStream(cr);
1367
+ if (dec) raw += new TextDecoder('latin1').decode(dec) + '\n';
1368
+ }
1369
+
1370
+ let out = '';
1371
+ let currentFont: string | null = null;
1372
+ let lastY: number | null = null;
1373
+ for (const m of raw.matchAll(TOKEN_RE)) {
1374
+ if (m[1] !== undefined) { currentFont = m[1]; continue; }
1375
+ if (m[5] !== undefined) {
1376
+ const y = parseFloat(m[5]);
1377
+ if (lastY !== null && Math.abs(y - lastY) > 1) out += '\n';
1378
+ lastY = y;
1379
+ continue;
1380
+ }
1381
+ const dec = (currentFont && fontMap.get(currentFont)) || { kind: 'winansi' as const };
1382
+ if (m[2] !== undefined) {
1383
+ for (const piece of m[2].matchAll(TJ_PIECE_RE)) out += decodeRun(dec, piece[1], piece[2]);
1384
+ } else if (m[3] !== undefined) {
1385
+ out += decodeRun(dec, undefined, m[3]);
1386
+ } else if (m[4] !== undefined) {
1387
+ out += decodeRun(dec, m[4], undefined);
1388
+ }
1389
+ }
1390
+ pageTexts.push(out.trim());
1391
+ }
1392
+
1393
+ return { text: pageTexts.join('\n\n'), pages: totalPages, extractedPages: pagesToExtract.length, warnings };
1394
+ }
1395
+
1396
+ /**
1397
+ * Parse a PDF's outline (bookmark) tree into a flat, document-order list of
1398
+ * {title, pageObjNum, depth} entries — WITHOUT decompressing any content
1399
+ * stream, so this is orders of magnitude cheaper than `extractPdfText` and
1400
+ * safe to run just to decide WHERE to extract from. Built for
1401
+ * mcps/new-mexico-code (fleet #2884): NMOneSource publishes each NMSA 1978
1402
+ * CHAPTER as one PDF (657-1,138+ pages observed) but bookmarks every
1403
+ * ARTICLE heading (depth 0), every SECTION citation (depth 1, e.g.
1404
+ * "30-2-1. Murder."), and that section's own "ANNOTATIONS" sub-heading
1405
+ * (depth 2) — giving a caller everything needed to resolve a bare citation
1406
+ * straight to a page RANGE via `extractPdfText`'s `pageRange` option,
1407
+ * without ever extracting pages the caller didn't ask for.
1408
+ *
1409
+ * Assumes outline objects are DIRECT (never compressed into an /ObjStm) —
1410
+ * true of every source sampled while building this. A PDF whose outline IS
1411
+ * compressed, or that has no /Outlines at all, returns an empty array
1412
+ * rather than guessing; check for that before concluding a citation isn't
1413
+ * bookmarked.
1414
+ */
1415
+ function extractPdfOutline(buf: ArrayBuffer): PdfOutlineEntry[] {
1416
+ const bytes = new Uint8Array(buf);
1417
+ const scan = new TextDecoder('windows-1252').decode(bytes);
1418
+
1419
+ const objStarts: { index: number; num: number }[] = [];
1420
+ // See extractPdfText's identical map for why: one O(1) lookup per object
1421
+ // instead of one full-document regex scan — an outline tree with ~1,400
1422
+ // bookmarks (New Mexico's larger chapters) made this function itself the
1423
+ // bottleneck before this fix (measured 2.8s on Chapter 30's 5.8MB PDF).
1424
+ const objEndByNum = new Map<number, number>();
1425
+ {
1426
+ const re = /(\d+)\s+0\s+obj\b/g;
1427
+ let m: RegExpExecArray | null;
1428
+ while ((m = re.exec(scan))) {
1429
+ objStarts.push({ index: m.index, num: parseInt(m[1], 10) });
1430
+ const num = parseInt(m[1], 10);
1431
+ if (!objEndByNum.has(num)) objEndByNum.set(num, m.index + m[0].length);
1432
+ }
1433
+ }
1434
+ function objNumBefore(markerIndex: number): number | null {
1435
+ let lo = 0, hi = objStarts.length - 1, ans: number | null = null;
1436
+ while (lo <= hi) {
1437
+ const mid = (lo + hi) >> 1;
1438
+ if (objStarts[mid].index <= markerIndex) { ans = objStarts[mid].num; lo = mid + 1; }
1439
+ else hi = mid - 1;
1440
+ }
1441
+ return ans;
1442
+ }
1443
+
1444
+ const dictCache = new Map<number, string | null>();
1445
+ function dictOf(n: number): string | null {
1446
+ if (dictCache.has(n)) return dictCache.get(n) ?? null;
1447
+ let start: number;
1448
+ const fastEnd = objEndByNum.get(n);
1449
+ if (fastEnd !== undefined) {
1450
+ start = fastEnd;
1451
+ } else {
1452
+ const re = new RegExp(`(?:^|\\D)${n}\\s+0\\s+obj`);
1453
+ const m = re.exec(scan);
1454
+ if (!m) { dictCache.set(n, null); return null; }
1455
+ start = m.index + m[0].length;
1456
+ }
1457
+ const end = scan.indexOf('endobj', start);
1458
+ const dict = end === -1 ? scan.slice(start, Math.min(scan.length, start + 4000)) : scan.slice(start, end);
1459
+ dictCache.set(n, dict);
1460
+ return dict;
1461
+ }
1462
+
1463
+ // Prefer the Catalog's own /Outlines reference; fall back to the first
1464
+ // direct /Type/Outlines object found anywhere (same "search for the type
1465
+ // marker as plain text" approach extractPdfText uses for Catalog/Pages).
1466
+ let outlinesRoot: number | null = null;
1467
+ const catIdx = scan.search(/\/Type\s*\/Catalog\b/);
1468
+ if (catIdx !== -1) {
1469
+ const catNum = objNumBefore(catIdx);
1470
+ const catDict = catNum !== null ? dictOf(catNum) : null;
1471
+ const om = catDict ? /\/Outlines\s+(\d+)\s+0\s+R/.exec(catDict) : null;
1472
+ if (om) outlinesRoot = parseInt(om[1], 10);
1473
+ }
1474
+ if (outlinesRoot === null) {
1475
+ const outIdx = scan.search(/\/Type\s*\/Outlines\b/);
1476
+ if (outIdx !== -1) outlinesRoot = objNumBefore(outIdx);
1477
+ }
1478
+ if (outlinesRoot === null) return [];
1479
+
1480
+ function pageObjOf(dict: string): number | null {
1481
+ const destMatch = /\/Dest\s*\[\s*(\d+)\s+0\s+R/.exec(dict);
1482
+ if (destMatch) return parseInt(destMatch[1], 10);
1483
+ const actionMatch = /\/A\s*<<[^>]*\/D\s*\[\s*(\d+)\s+0\s+R/.exec(dict);
1484
+ if (actionMatch) return parseInt(actionMatch[1], 10);
1485
+ return null;
1486
+ }
1487
+
1488
+ const entries: PdfOutlineEntry[] = [];
1489
+ const seen = new Set<number>();
1490
+ function walk(n: number, depth: number): void {
1491
+ if (depth > 20 || seen.has(n)) return; // guards a malformed/cyclic tree
1492
+ seen.add(n);
1493
+ const dict = dictOf(n);
1494
+ if (!dict) return;
1495
+ const titleMatch = /\/Title\s*\(((?:\\.|[^()])*)\)/.exec(dict);
1496
+ if (titleMatch) {
1497
+ entries.push({ title: unescapePdfLiteral(titleMatch[1]).replace(/\s+/g, ' ').trim(), pageObjNum: pageObjOf(dict), depth });
1498
+ }
1499
+ const firstMatch = /\/First\s+(\d+)\s+0\s+R/.exec(dict);
1500
+ if (firstMatch) walk(parseInt(firstMatch[1], 10), titleMatch ? depth + 1 : depth);
1501
+ const nextMatch = /\/Next\s+(\d+)\s+0\s+R/.exec(dict);
1502
+ if (nextMatch) walk(parseInt(nextMatch[1], 10), depth);
1503
+ }
1504
+ const rootDict = dictOf(outlinesRoot);
1505
+ const rootFirst = rootDict ? /\/First\s+(\d+)\s+0\s+R/.exec(rootDict) : null;
1506
+ if (rootFirst) walk(parseInt(rootFirst[1], 10), 0);
1507
+
1508
+ return entries;
1509
+ }
673
1510
  /**
674
1511
  * Guam Code Annotated (GCA) — section lookup and search. Fleet #2888.
675
1512
  *
@@ -700,25 +1537,31 @@ function collapse(s: string): string {
700
1537
  * section number with NO separator — "21 GCA § 33101" is chapter 33,
701
1538
  * section 101. gu_statute accepts either form; see `parseGuCitation`.
702
1539
  *
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.
1540
+ * THE PDF EXTRACTOR WAS PACK-LOCALLY FORKED, THEN FOLDED BACK IN (fleet
1541
+ * #2904). `shared/src/pdf-text.ts` already read Kentucky's and North
1542
+ * Dakota's statute PDFs, but Guam's chapter PDFs defeated it in ways it did
1543
+ * not yet handle, and this pack was built the same week another pack
1544
+ * (#2884, New Mexico) was independently editing that shared file — so the
1545
+ * fixes initially lived in a pack-local fork (`./gu-pdf-extract.ts`), with a
1546
+ * loud note for whoever next touched the shared extractor to fold them in.
1547
+ * That fold-in happened in #2904: all three fixes were genuinely NOT
1548
+ * Guam-specific and now live in `shared/src/pdf-text.ts` itself — (1) a
1549
+ * literal (parenthesized) string run is routed through the current font's
1550
+ * ToUnicode map exactly like a hex run, instead of being passed through
1551
+ * unchanged (which produced line-noise control characters for Guam's
1552
+ * non-WinAnsi subset fonts); (2) a `/Length` a few bytes longer than the
1553
+ * true deflate stream is retried with the tail trimmed, rather than failing
1554
+ * outright on Node/Workers' strict trailing-junk check; (3) `inflate()`'s
1555
+ * two stream writes are no longer fire-and-forget, so a malformed deflate
1556
+ * stream can no longer reject an UNHANDLED promise (this one crashed this
1557
+ * pack's own bake script outright before the fix). The fork is deleted —
1558
+ * ONE delta stayed pack-local, in `./gu-font-cmap-fix.ts`, because it
1559
+ * genuinely is Guam-specific: one embedded symbol font per chapter PDF
1560
+ * (`...+WPTypographicSymbols`) carries a `/ToUnicode` CMap that is itself
1561
+ * WRONG in Guam's own published source file (its two curly-quote codes map
1562
+ * to 'A' and '@' instead of the real glyphs) — passed into `extractPdfText`
1563
+ * via its new `fontCMapOverride` option, a generic hook with nothing
1564
+ * Guam-specific about the mechanism itself.
722
1565
  *
723
1566
  * FOUR CAPABILITIES — two available, two are not:
724
1567
  * - citation lookup -> gu_statute
@@ -736,7 +1579,7 @@ function collapse(s: string): string {
736
1579
  *
737
1580
  * Keyless, no signup.
738
1581
  */
739
- import { extractGuamPdfText } from './gu-pdf-extract.js';
1582
+ import { guamFontCMapOverride } from './gu-font-cmap-fix.js';
740
1583
  import type { GuChapterRow, GuTitleRow } from './gu-index-data.js';
741
1584
  import type { GuSectionRow } from './gu-section-data.js';
742
1585
 
@@ -1011,7 +1854,7 @@ async function guStatute(args: Record<string, unknown>) {
1011
1854
 
1012
1855
  const pdfRes = await pwFetch(pdfUrl);
1013
1856
  if (!pdfRes.ok) throw await httpError(pdfRes, 'Guam Compiler of Laws chapter PDF');
1014
- const extracted = await extractGuamPdfText(await pdfRes.arrayBuffer());
1857
+ const extracted = await extractPdfText(await pdfRes.arrayBuffer(), { fontCMapOverride: guamFontCMapOverride });
1015
1858
  if (!extracted.text.trim()) {
1016
1859
  return {
1017
1860
  found: true,