@pipeworx/mcp-guam-code 0.1.0 → 0.1.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +35 -25
- package/package.json +3 -3
- package/server.json +1 -1
- package/src/gu-font-cmap-fix.ts +34 -0
- package/src/index.ts +934 -21
- package/src/server.ts +6 -2
- package/src/gu-pdf-extract.ts +0 -652
package/src/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
|
|
704
|
-
* `shared/src/pdf-text.ts` already
|
|
705
|
-
* statute PDFs, but Guam's chapter PDFs defeated it in
|
|
706
|
-
* this pack was built the same week another pack
|
|
707
|
-
* independently editing that shared file — so the
|
|
708
|
-
* `./gu-pdf-extract.ts
|
|
709
|
-
* next
|
|
710
|
-
*
|
|
711
|
-
*
|
|
712
|
-
*
|
|
713
|
-
*
|
|
714
|
-
*
|
|
715
|
-
*
|
|
716
|
-
*
|
|
717
|
-
*
|
|
718
|
-
*
|
|
719
|
-
*
|
|
720
|
-
*
|
|
721
|
-
*
|
|
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 {
|
|
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
|
|
1927
|
+
const extracted = await extractPdfText(await pdfRes.arrayBuffer(), { fontCMapOverride: guamFontCMapOverride });
|
|
1015
1928
|
if (!extracted.text.trim()) {
|
|
1016
1929
|
return {
|
|
1017
1930
|
found: true,
|