pi-unsloth-webtools 0.1.0

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/pdf.ts ADDED
@@ -0,0 +1,717 @@
1
+ import { createRequire } from "node:module";
2
+ import { inflateSync } from "node:zlib";
3
+
4
+ export const MAX_WEB_PDF_PAGES = 50;
5
+
6
+ export class PdfParseError extends Error {
7
+ constructor() {
8
+ super("pdf parse failed");
9
+ }
10
+ }
11
+
12
+ const require = createRequire(import.meta.url);
13
+
14
+ type MupdfModule = {
15
+ Document: {
16
+ openDocument(data: Buffer, type: string): MupdfDocument;
17
+ };
18
+ };
19
+
20
+ interface MupdfDocument {
21
+ needsPassword(): boolean;
22
+ countPages(): number;
23
+ loadPage(index: number): MupdfPage;
24
+ destroy(): void;
25
+ }
26
+
27
+ interface MupdfPage {
28
+ toStructuredText(options?: string): MupdfStructuredText;
29
+ getLinks(): { getBounds(): [number, number, number, number]; getURI(): string }[];
30
+ destroy(): void;
31
+ }
32
+
33
+ interface MupdfStructuredText {
34
+ asText(): string;
35
+ asJSON(): string;
36
+ destroy(): void;
37
+ }
38
+
39
+ let mupdfModule: MupdfModule | null | undefined;
40
+
41
+ async function loadMupdf(): Promise<MupdfModule | null> {
42
+ if (mupdfModule !== undefined) return mupdfModule;
43
+ try {
44
+ mupdfModule = (await import(require.resolve("mupdf"))) as unknown as MupdfModule;
45
+ } catch {
46
+ try {
47
+ mupdfModule = (await import("mupdf")) as unknown as MupdfModule;
48
+ } catch {
49
+ mupdfModule = null;
50
+ }
51
+ }
52
+ return mupdfModule;
53
+ }
54
+
55
+ interface JsonLine {
56
+ bbox?: { x?: number; y?: number; w?: number; h?: number };
57
+ font?: { family?: string; weight?: string; style?: string; size?: number };
58
+ text?: string;
59
+ }
60
+
61
+ interface JsonBlock {
62
+ type?: string;
63
+ lines?: JsonLine[];
64
+ }
65
+
66
+ interface SpanData {
67
+ x0: number;
68
+ y0: number;
69
+ x1: number;
70
+ y1: number;
71
+ size: number;
72
+ text: string;
73
+ bold: boolean;
74
+ italic: boolean;
75
+ mono: boolean;
76
+ block: number;
77
+ }
78
+
79
+ interface MergedLine {
80
+ lrect: { x0: number; y0: number; x1: number; y1: number };
81
+ spans: SpanData[];
82
+ }
83
+
84
+ interface LinkInfo {
85
+ x0: number;
86
+ y0: number;
87
+ x1: number;
88
+ y1: number;
89
+ uri: string;
90
+ }
91
+
92
+ interface TableBand {
93
+ markdown: string;
94
+ firstLineIndex: number;
95
+ lineIndexes: Set<number>;
96
+ }
97
+
98
+ interface HeaderInfo {
99
+ bodyLimit: number;
100
+ headerId: Map<number, string>;
101
+ }
102
+
103
+ const BULLETS = new Set([
104
+ 0x2a, 0x2d, 0x3e, 0x6f, 0xb6, 0xb7, 0x2010, 0x2011, 0x2012, 0x2013, 0x2014,
105
+ 0x2015, 0x2020, 0x2021, 0x2022, 0x2212, 0x2219, 0xf0a7, 0xf0b7, 0xfffd,
106
+ ...Array.from({ length: 0x2600 - 0x25a0 }, (_, i) => i + 0x25a0),
107
+ ]);
108
+
109
+ const WHITE_CHARS = new Set([
110
+ ...Array.from({ length: 33 }, (_, i) => i),
111
+ 0xa0, 0x2000, 0x2001, 0x2002, 0x2003, 0x2004, 0x2005, 0x2006, 0x2007, 0x2008,
112
+ 0x2009, 0x200a, 0x202f, 0x205f, 0x3000,
113
+ ]);
114
+
115
+ function isWhite(text: string): boolean {
116
+ return [...text].every((c) => WHITE_CHARS.has(c.codePointAt(0)!));
117
+ }
118
+
119
+ function startswithBullet(text: string): boolean {
120
+ if (!text) return false;
121
+ const code = text.codePointAt(0)!;
122
+ if (!BULLETS.has(code)) return false;
123
+ if (text.length === 1) return true;
124
+ return text[1] === " ";
125
+ }
126
+
127
+ const SHAPED_PRESENTATION_FORMS = /[\uFB1D-\uFDFF\uFE70-\uFEFC]/g;
128
+ const PDF_FALLBACK_MIN_BAD_GLYPHS = 5;
129
+ const PDF_FALLBACK_BAD_GLYPH_RATIO = 0.0005;
130
+ const PDF_INCOMPLETE_RATIO = 0.75;
131
+ const PDF_INCOMPLETE_MIN_LETTERS = 200;
132
+
133
+ function markdownCorrupted(text: string): boolean {
134
+ if (!text) return false;
135
+ const threshold = Math.max(PDF_FALLBACK_MIN_BAD_GLYPHS, PDF_FALLBACK_BAD_GLYPH_RATIO * text.length);
136
+ const shaped = (text.match(SHAPED_PRESENTATION_FORMS) ?? []).length;
137
+ return shaped > threshold || (text.match(/\ufffd/g) ?? []).length > threshold;
138
+ }
139
+
140
+ function markdownIncomplete(markdown: string, plain: string): boolean {
141
+ const plainLetters = [...plain].filter((c) => /[\p{L}\p{N}]/u.test(c)).length;
142
+ if (plainLetters < PDF_INCOMPLETE_MIN_LETTERS) return false;
143
+ const markdownLetters = [...markdown].filter((c) => /[\p{L}\p{N}]/u.test(c)).length;
144
+ return markdownLetters < PDF_INCOMPLETE_RATIO * plainLetters;
145
+ }
146
+
147
+ function identifyHeaders(pageLines: MergedLine[][]): HeaderInfo {
148
+ const fontSizes = new Map<number, number>();
149
+ for (const lines of pageLines) {
150
+ for (const line of lines) {
151
+ for (const span of line.spans) {
152
+ if (isWhite(span.text)) continue;
153
+ const size = Math.round(span.size);
154
+ fontSizes.set(size, (fontSizes.get(size) ?? 0) + span.text.trim().length);
155
+ }
156
+ }
157
+ }
158
+ const sorted = [...fontSizes.entries()].sort((a, b) => a[1] - b[1] || a[0] - b[0]);
159
+ const bodyLimit = sorted.length ? Math.max(12, sorted[sorted.length - 1][0]) : 12;
160
+ const sizes = sorted
161
+ .map(([size]) => size)
162
+ .filter((size) => size > bodyLimit)
163
+ .sort((a, b) => b - a)
164
+ .slice(0, 6);
165
+ const headerId = new Map<number, string>();
166
+ sizes.forEach((size, i) => headerId.set(size, "#".repeat(i + 1) + " "));
167
+ const finalBody = headerId.size ? Math.min(...headerId.keys()) - 1 : bodyLimit;
168
+ return { bodyLimit: finalBody, headerId };
169
+ }
170
+
171
+ function getHeaderId(span: SpanData, info: HeaderInfo): string {
172
+ const size = Math.round(span.size);
173
+ if (size <= info.bodyLimit) return "";
174
+ return info.headerId.get(size) ?? "";
175
+ }
176
+
177
+ function maxHeaderId(spans: SpanData[], info: HeaderInfo): string {
178
+ const levels = [
179
+ ...new Set(spans.map((s) => getHeaderId(s, info).length).filter((len) => len > 0)),
180
+ ].sort((a, b) => a - b);
181
+ if (!levels.length) return "";
182
+ return "#".repeat(levels[0] - 1) + " ";
183
+ }
184
+
185
+ function sanitizeLine(spans: SpanData[]): SpanData[] {
186
+ const line = [...spans].sort((a, b) => a.x0 - b.x0);
187
+ for (let i = line.length - 1; i > 0; i--) {
188
+ const s0 = line[i - 1];
189
+ const s1 = line[i];
190
+ const delta = s1.size * 0.1;
191
+ const sameStyle = s0.bold === s1.bold && s0.italic === s1.italic && s0.mono === s1.mono;
192
+ if (s0.x1 + delta < s1.x0 || !sameStyle) continue;
193
+ if (s0.text !== s1.text) s0.text += s1.text;
194
+ s0.x0 = Math.min(s0.x0, s1.x0);
195
+ s0.y0 = Math.min(s0.y0, s1.y0);
196
+ s0.x1 = Math.max(s0.x1, s1.x1);
197
+ s0.y1 = Math.max(s0.y1, s1.y1);
198
+ line.splice(i, 1);
199
+ }
200
+ return line;
201
+ }
202
+
203
+ function getRawLines(json: JsonBlock[]): MergedLine[] {
204
+ const spans: SpanData[] = [];
205
+ for (let bno = 0; bno < json.length; bno++) {
206
+ const block = json[bno];
207
+ if (block.type !== "text") continue;
208
+ for (const line of block.lines ?? []) {
209
+ const bbox = line.bbox ?? {};
210
+ const font = line.font ?? {};
211
+ const text = line.text ?? "";
212
+ if (isWhite(text)) continue;
213
+ const span: SpanData = {
214
+ x0: bbox.x ?? 0,
215
+ y0: bbox.y ?? 0,
216
+ x1: (bbox.x ?? 0) + (bbox.w ?? 0),
217
+ y1: (bbox.y ?? 0) + (bbox.h ?? 0),
218
+ size: font.size ?? 0,
219
+ text,
220
+ bold: font.weight === "bold",
221
+ italic: font.style === "italic",
222
+ mono: font.family === "monospace",
223
+ block: bno,
224
+ };
225
+ if (span.x1 <= span.x0 || span.y1 <= span.y0) continue;
226
+ spans.push(span);
227
+ }
228
+ }
229
+ if (!spans.length) return [];
230
+ spans.sort((a, b) => a.y1 - b.y1);
231
+ const nlines: MergedLine[] = [];
232
+ let line: SpanData[] = [spans[0]];
233
+ let lrect = {
234
+ x0: spans[0].x0,
235
+ y0: spans[0].y0,
236
+ x1: spans[0].x1,
237
+ y1: spans[0].y1,
238
+ };
239
+ for (const s of spans.slice(1)) {
240
+ const last = line[line.length - 1];
241
+ if (Math.abs(s.y1 - last.y1) <= 3 || Math.abs(s.y0 - last.y0) <= 3) {
242
+ line.push(s);
243
+ lrect = {
244
+ x0: Math.min(lrect.x0, s.x0),
245
+ y0: Math.min(lrect.y0, s.y0),
246
+ x1: Math.max(lrect.x1, s.x1),
247
+ y1: Math.max(lrect.y1, s.y1),
248
+ };
249
+ continue;
250
+ }
251
+ nlines.push({ lrect, spans: sanitizeLine(line) });
252
+ line = [s];
253
+ lrect = { x0: s.x0, y0: s.y0, x1: s.x1, y1: s.y1 };
254
+ }
255
+ nlines.push({ lrect, spans: sanitizeLine(line) });
256
+ return nlines;
257
+ }
258
+
259
+ function findLink(links: LinkInfo[], span: SpanData): string | null {
260
+ const midX = (span.x0 + span.x1) / 2;
261
+ const midY = (span.y0 + span.y1) / 2;
262
+ for (const link of links) {
263
+ if (midX < link.x0 || midX > link.x1 || midY < link.y0 || midY > link.y1) continue;
264
+ let uri = link.uri;
265
+ for (const c of "()\n") {
266
+ uri = uri.replaceAll(c, "%0x" + c.charCodeAt(0).toString(16));
267
+ }
268
+ return `[${span.text.trim()}](${uri})`;
269
+ }
270
+ return null;
271
+ }
272
+
273
+ function cellText(spans: SpanData[]): string {
274
+ return spans
275
+ .map((s) => s.text.trim())
276
+ .filter((t) => t.length > 0)
277
+ .join(" ");
278
+ }
279
+
280
+ function detectTableBands(lines: MergedLine[]): TableBand[] {
281
+ const bands: TableBand[] = [];
282
+ let band: MergedLine[] = [];
283
+ const flush = () => {
284
+ if (band.length >= 2) {
285
+ const x0s = band.flatMap((l) => l.spans.map((s) => s.x0)).sort((a, b) => a - b);
286
+ const columns: number[] = [];
287
+ for (const x of x0s) {
288
+ const existing = columns.find((c) => Math.abs(c - x) <= 5);
289
+ if (existing === undefined) columns.push(x);
290
+ }
291
+ if (columns.length >= 2) {
292
+ columns.sort((a, b) => a - b);
293
+ const rows: string[][] = [];
294
+ for (const line of band) {
295
+ const cells = columns.map(() => [] as SpanData[]);
296
+ for (const span of line.spans) {
297
+ let best = 0;
298
+ let bestDist = Infinity;
299
+ columns.forEach((col, i) => {
300
+ const dist = Math.abs(span.x0 - col);
301
+ if (dist < bestDist) {
302
+ bestDist = dist;
303
+ best = i;
304
+ }
305
+ });
306
+ cells[best].push(span);
307
+ }
308
+ rows.push(cells.map(cellText));
309
+ }
310
+ const occupied = rows.map((r) => r.filter((c) => c.length > 0).length);
311
+ if (!occupied.some((n) => n < 2)) {
312
+ const header = rows[0].map((name, i) =>
313
+ name ? name.replaceAll("\n", "<br>") : `Col${i + 1}`,
314
+ );
315
+ let output = "|" + header.join("|") + "|\n";
316
+ output += "|" + columns.map(() => "---").join("|") + "|\n";
317
+ for (const row of rows.slice(1)) {
318
+ output += "|" + row.join("|") + "|\n";
319
+ }
320
+ const indexes = new Set(band.map((l) => lines.indexOf(l)));
321
+ bands.push({ markdown: output + "\n", firstLineIndex: Math.min(...indexes), lineIndexes: indexes });
322
+ }
323
+ }
324
+ }
325
+ band = [];
326
+ };
327
+ for (const line of lines) {
328
+ const starts = new Set(line.spans.map((s) => Math.round(s.x0 / 5) * 5));
329
+ if (starts.size >= 2 && line.spans.length >= 2) {
330
+ band.push(line);
331
+ } else {
332
+ flush();
333
+ }
334
+ }
335
+ flush();
336
+ return bands;
337
+ }
338
+
339
+ function writeText(
340
+ lines: MergedLine[],
341
+ info: HeaderInfo,
342
+ links: LinkInfo[],
343
+ tableBands: TableBand[],
344
+ ): string {
345
+ let out = "";
346
+ let prevLrect: { x0: number; y0: number; x1: number; y1: number } | null = null;
347
+ let prevBno = -1;
348
+ let code = false;
349
+ let prevHdr: string | null = null;
350
+ let emittedBands = 0;
351
+ for (let li = 0; li < lines.length; li++) {
352
+ while (emittedBands < tableBands.length && tableBands[emittedBands].firstLineIndex <= li) {
353
+ out += "\n" + tableBands[emittedBands].markdown;
354
+ emittedBands++;
355
+ }
356
+ const { lrect, spans } = lines[li];
357
+ const height = lrect.y1 - lrect.y0;
358
+ if (prevLrect && lrect.y1 - prevLrect.y1 > height * 1.5) out += "\n";
359
+ let text = spans.map((s) => s.text).join(" ").trim();
360
+ const allItalic = spans.every((s) => s.italic);
361
+ const allBold = spans.every((s) => s.bold);
362
+ const allMono = spans.every((s) => s.mono);
363
+ const hdrString = maxHeaderId(spans, info);
364
+ if (hdrString) {
365
+ if (allMono) text = "`" + text + "`";
366
+ if (allItalic) text = "_" + text + "_";
367
+ if (allBold) text = "**" + text + "**";
368
+ if (hdrString !== prevHdr) {
369
+ out += hdrString + text + "\n";
370
+ } else {
371
+ while (out.endsWith("\n")) out = out.slice(0, -1);
372
+ out += " " + text + "\n";
373
+ }
374
+ prevHdr = hdrString;
375
+ prevLrect = lrect;
376
+ continue;
377
+ }
378
+ prevHdr = hdrString;
379
+ if (allMono) {
380
+ if (!code) {
381
+ out += "```\n";
382
+ code = true;
383
+ }
384
+ const delta = Math.floor(lrect.x0 / (spans[0].size * 0.5));
385
+ out += " ".repeat(Math.max(0, delta)) + text + "\n";
386
+ prevLrect = lrect;
387
+ continue;
388
+ }
389
+ if (code && !allMono) {
390
+ out += "```\n";
391
+ code = false;
392
+ }
393
+ const bno = spans[0].block;
394
+ if (bno !== prevBno) {
395
+ out += "\n";
396
+ prevBno = bno;
397
+ }
398
+ if (
399
+ prevLrect &&
400
+ (lrect.y1 - prevLrect.y1 > height * 1.5 ||
401
+ spans[0].text.startsWith("[") ||
402
+ startswithBullet(spans[0].text))
403
+ ) {
404
+ out += "\n";
405
+ }
406
+ prevLrect = lrect;
407
+ if (code) {
408
+ out += "```\n";
409
+ code = false;
410
+ }
411
+ for (const span of spans) {
412
+ let prefix = "";
413
+ let suffix = "";
414
+ if (span.bold) {
415
+ prefix += "**";
416
+ suffix += "**";
417
+ }
418
+ if (span.italic) {
419
+ prefix += "_";
420
+ suffix += "_";
421
+ }
422
+ if (span.mono) {
423
+ prefix += "`";
424
+ suffix += "`";
425
+ }
426
+ let part = findLink(links, span) ?? span.text.trim();
427
+ part = prefix + part + suffix + " ";
428
+ if (startswithBullet(part)) {
429
+ part = "- " + part.slice(1);
430
+ part = part.replace(/ {2}/g, " ");
431
+ const cwidth =
432
+ span.x1 - span.x0 > 0 ? (span.x1 - span.x0) / span.text.length : span.size * 0.5;
433
+ part = " ".repeat(Math.round(span.x0 / cwidth)) + part;
434
+ }
435
+ out += part;
436
+ }
437
+ out += "\n";
438
+ }
439
+ while (emittedBands < tableBands.length) {
440
+ out += "\n" + tableBands[emittedBands].markdown;
441
+ emittedBands++;
442
+ }
443
+ out += "\n";
444
+ if (code) out += "```\n";
445
+ out += "\n\n";
446
+ return out;
447
+ }
448
+
449
+ function renderPageMarkdown(lines: MergedLine[], info: HeaderInfo, links: LinkInfo[]): string {
450
+ const bands = detectTableBands(lines);
451
+ return writeText(lines, info, links, bands);
452
+ }
453
+
454
+ interface PageData {
455
+ lines: MergedLine[];
456
+ plain: string;
457
+ links: LinkInfo[];
458
+ }
459
+
460
+ export async function extractPdfPages(
461
+ data: Buffer,
462
+ ): Promise<{ pages: { text: string; pageNumber: number }[]; totalPages: number }> {
463
+ const mupdf = await loadMupdf();
464
+ if (!mupdf) throw new PdfParseError();
465
+ let doc: MupdfDocument | null = null;
466
+ try {
467
+ doc = mupdf.Document.openDocument(data, "application/pdf");
468
+ } catch {
469
+ throw new PdfParseError();
470
+ }
471
+ try {
472
+ if (doc.needsPassword()) throw new PdfParseError();
473
+ const total = doc.countPages();
474
+ const count = Math.min(total, MAX_WEB_PDF_PAGES);
475
+ const pages: PageData[] = [];
476
+ for (let i = 0; i < count; i++) {
477
+ const page = doc.loadPage(i);
478
+ let st: MupdfStructuredText | null = null;
479
+ try {
480
+ st = page.toStructuredText("");
481
+ const plain = st.asText() ?? "";
482
+ let json: { blocks?: JsonBlock[] } = { blocks: [] };
483
+ try {
484
+ json = JSON.parse(st.asJSON());
485
+ } catch {
486
+ json = { blocks: [] };
487
+ }
488
+ const lines = getRawLines(json.blocks ?? []);
489
+ const links = page.getLinks().map((link) => {
490
+ const [x0, y0, x1, y1] = link.getBounds();
491
+ return { x0, y0, x1, y1, uri: link.getURI() };
492
+ });
493
+ pages.push({ lines, plain, links });
494
+ } finally {
495
+ if (st) st.destroy();
496
+ page.destroy();
497
+ }
498
+ }
499
+ const info = identifyHeaders(pages.map((p) => p.lines));
500
+ const extracted = pages.map((p, i) => {
501
+ const markdown = renderPageMarkdown(p.lines, info, p.links);
502
+ const text = markdownCorrupted(markdown) || markdownIncomplete(markdown, p.plain) ? p.plain : markdown;
503
+ return { text, pageNumber: i + 1 };
504
+ });
505
+ return { pages: extracted, totalPages: total };
506
+ } finally {
507
+ doc.destroy();
508
+ }
509
+ }
510
+
511
+ function assemblePages(
512
+ pages: { text: string; pageNumber: number }[],
513
+ pageLimitOverride?: boolean,
514
+ ): string {
515
+ const parts: string[] = [];
516
+ const pageLimitReached = pageLimitOverride ?? pages.length >= MAX_WEB_PDF_PAGES;
517
+ for (const page of pages) {
518
+ const pageText = page.text.trim();
519
+ if (!pageText) continue;
520
+ parts.push((parts.length ? "\n\n" : "") + `## Page ${page.pageNumber}\n\n${pageText}`);
521
+ }
522
+ let text = parts.join("").trimEnd();
523
+ if (!text) {
524
+ if (pageLimitReached) {
525
+ return `(PDF contains no extractable text in the first ${MAX_WEB_PDF_PAGES} pages)`;
526
+ }
527
+ return "";
528
+ }
529
+ if (pageLimitReached) {
530
+ text += `\n\n... (PDF extraction page processing capped at ${MAX_WEB_PDF_PAGES} pages)`;
531
+ }
532
+ return text;
533
+ }
534
+
535
+ export async function extractPdfText(data: Buffer): Promise<string> {
536
+ const mupdf = await loadMupdf();
537
+ if (!mupdf) return extractPdfTextFallback(data);
538
+ const { pages, totalPages } = await extractPdfPages(data);
539
+ return assemblePages(pages, totalPages > MAX_WEB_PDF_PAGES);
540
+ }
541
+
542
+ function extractTextOps(bytes: Buffer): string {
543
+ const text = bytes.toString("latin1");
544
+ const out: string[] = [];
545
+ let inText = false;
546
+ let i = 0;
547
+ const n = text.length;
548
+ while (i < n) {
549
+ const c = text[i];
550
+ if (c === "%") {
551
+ while (i < n && text[i] !== "\n") i++;
552
+ continue;
553
+ }
554
+ if (c === "(") {
555
+ let depth = 1;
556
+ let j = i + 1;
557
+ let raw = "";
558
+ while (j < n && depth) {
559
+ const ch = text[j];
560
+ if (ch === "\\") {
561
+ raw += ch + (text[j + 1] ?? "");
562
+ j += 2;
563
+ continue;
564
+ }
565
+ if (ch === "(") depth++;
566
+ if (ch === ")") depth--;
567
+ if (depth) raw += ch;
568
+ j++;
569
+ }
570
+ if (inText) out.push(decodePdfString(raw));
571
+ i = j;
572
+ continue;
573
+ }
574
+ if (c === "[") {
575
+ const parts: string[] = [];
576
+ let depth = 1;
577
+ let j = i + 1;
578
+ while (j < n && depth) {
579
+ const ch = text[j];
580
+ if (ch === "(") {
581
+ let k = j + 1;
582
+ let inner = "";
583
+ let innerDepth = 1;
584
+ while (k < n && innerDepth) {
585
+ const ic = text[k];
586
+ if (ic === "\\") {
587
+ inner += ic + (text[k + 1] ?? "");
588
+ k += 2;
589
+ continue;
590
+ }
591
+ if (ic === "(") innerDepth++;
592
+ if (ic === ")") innerDepth--;
593
+ if (innerDepth) inner += ic;
594
+ k++;
595
+ }
596
+ parts.push(decodePdfString(inner));
597
+ j = k;
598
+ continue;
599
+ }
600
+ if (ch === "]") depth--;
601
+ if (ch === "[") depth++;
602
+ j++;
603
+ }
604
+ if (inText) out.push(parts.join(""));
605
+ i = j;
606
+ continue;
607
+ }
608
+ if (c === "<") {
609
+ const close = text.indexOf(">", i + 1);
610
+ if (close !== -1) {
611
+ const hex = text.slice(i + 1, close).replace(/\s+/g, "");
612
+ if (/^[0-9a-fA-F]*$/.test(hex) && hex.length % 2 === 0) {
613
+ if (inText) out.push(Buffer.from(hex, "hex").toString("latin1"));
614
+ i = close + 1;
615
+ continue;
616
+ }
617
+ }
618
+ }
619
+ const token = text.slice(i, i + 2);
620
+ if (token === "BT") {
621
+ inText = true;
622
+ i += 2;
623
+ continue;
624
+ }
625
+ if (token === "ET") {
626
+ inText = false;
627
+ i += 2;
628
+ continue;
629
+ }
630
+ if (token === "Td" || token === "TD" || token === "Tm" || token === "T*") {
631
+ if (inText) out.push("\n");
632
+ i += 2;
633
+ continue;
634
+ }
635
+ i++;
636
+ }
637
+ return out.join("").replace(/[ \t]+\n/g, "\n").replace(/\n{3,}/g, "\n\n").trim();
638
+ }
639
+
640
+ function decodePdfString(raw: string): string {
641
+ let out = "";
642
+ for (let i = 0; i < raw.length; i++) {
643
+ const c = raw[i];
644
+ if (c === "\\") {
645
+ const n = raw[i + 1];
646
+ if (n === "n") {
647
+ out += "\n";
648
+ i++;
649
+ } else if (n === "r") {
650
+ out += "\r";
651
+ i++;
652
+ } else if (n === "t") {
653
+ out += "\t";
654
+ i++;
655
+ } else if (n === "b") {
656
+ out += "\b";
657
+ i++;
658
+ } else if (n === "f") {
659
+ out += "\f";
660
+ i++;
661
+ } else if (n === "(" || n === ")" || n === "\\") {
662
+ out += n;
663
+ i++;
664
+ } else if (n >= "0" && n <= "7") {
665
+ let octal = n;
666
+ i++;
667
+ let count = 1;
668
+ while (count < 3 && i + 1 < raw.length && raw[i + 1] >= "0" && raw[i + 1] <= "7") {
669
+ octal += raw[i + 1];
670
+ i++;
671
+ count++;
672
+ }
673
+ out += String.fromCharCode(parseInt(octal, 8));
674
+ } else if (n === "\n" || n === "\r") {
675
+ i++;
676
+ if (raw[i + 1] === "\n") i++;
677
+ } else if (n !== undefined) {
678
+ out += n;
679
+ i++;
680
+ }
681
+ } else {
682
+ out += c;
683
+ }
684
+ }
685
+ return out;
686
+ }
687
+
688
+ async function extractPdfTextFallback(data: Buffer): Promise<string> {
689
+ const text = data.toString("latin1");
690
+ if (/\/Encrypt\b/.test(text)) throw new PdfParseError();
691
+ const streams: Buffer[] = [];
692
+ const streamRe = /stream\r?\n([\s\S]*?)\r?\nendstream/g;
693
+ let match: RegExpExecArray | null;
694
+ while ((match = streamRe.exec(text)) !== null) {
695
+ const start = match.index;
696
+ const dictStart = Math.max(0, text.lastIndexOf("<<", start));
697
+ const dictText = text.slice(dictStart, start);
698
+ const isFlate = /\/Filter\s*\/FlateDecode|\/FlateDecode/.test(dictText);
699
+ const raw = Buffer.from(match[1], "latin1");
700
+ let bytes = raw;
701
+ if (isFlate) {
702
+ try {
703
+ bytes = inflateSync(raw);
704
+ } catch {
705
+ continue;
706
+ }
707
+ }
708
+ streams.push(bytes);
709
+ }
710
+ if (!streams.length) throw new PdfParseError();
711
+ const totalPages = streams.length;
712
+ const pages = streams.slice(0, MAX_WEB_PDF_PAGES).map((stream, i) => ({
713
+ text: extractTextOps(stream),
714
+ pageNumber: i + 1,
715
+ }));
716
+ return assemblePages(pages, totalPages > MAX_WEB_PDF_PAGES);
717
+ }