@semiont/content 0.5.23 → 0.5.25

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/dist/index.js CHANGED
@@ -202,111 +202,830 @@ function deriveStorageUri(name, format) {
202
202
  return `file://${slug}${MEDIA_TYPES[format].extension}`;
203
203
  }
204
204
 
205
+ // src/content-extractor.ts
206
+ import { decodeRepresentation } from "@semiont/core";
207
+
208
+ // src/pdf-extractor.ts
209
+ import { isObject as isObject4 } from "@semiont/core";
210
+
205
211
  // src/extract-pdf-text-layer.ts
206
212
  import * as pdfjs from "pdfjs-dist/legacy/build/pdf.mjs";
213
+ import { isObject, isString, isNumber, isArray, anchorRuns, isTextRun } from "@semiont/core";
214
+ function toFormField(entry) {
215
+ if (!isObject(entry)) return null;
216
+ const { name, value, page, rect } = entry;
217
+ if (!isString(name) || !isString(value) || !value.trim()) return null;
218
+ if (!isNumber(page) || page < 0) return null;
219
+ if (!isArray(rect) || rect.length < 4 || !rect.every(isNumber)) return null;
220
+ const [x1, y1, x2, y2] = rect;
221
+ return {
222
+ name,
223
+ value: value.trim(),
224
+ page: page + 1,
225
+ // pdf.js reports 0-indexed; PdfTextItem is 1-indexed
226
+ x: Math.min(x1, x2),
227
+ y: Math.min(y1, y2),
228
+ width: Math.abs(x2 - x1),
229
+ height: Math.abs(y2 - y1)
230
+ };
231
+ }
232
+ async function readFormFields(doc) {
233
+ const fieldObjects = await doc.getFieldObjects();
234
+ if (!fieldObjects) return [];
235
+ const byName = /* @__PURE__ */ new Map();
236
+ for (const entries of Object.values(fieldObjects)) {
237
+ if (!isArray(entries)) continue;
238
+ for (const entry of entries) {
239
+ const field = toFormField(entry);
240
+ if (field && !byName.has(field.name)) byName.set(field.name, field);
241
+ }
242
+ }
243
+ return [...byName.values()];
244
+ }
207
245
  async function extractPdfTextLayer(bytes) {
208
- const loadingTask = pdfjs.getDocument({ data: bytes });
209
- const doc = await loadingTask.promise;
246
+ const data = new Uint8Array(bytes);
247
+ const loadingTask = pdfjs.getDocument({ data });
210
248
  try {
249
+ const doc = await loadingTask.promise;
211
250
  const pages = [];
212
251
  const items = [];
213
252
  let text = "";
214
- let hasAnyTextItems = false;
215
253
  for (let pageNum = 1; pageNum <= doc.numPages; pageNum++) {
216
254
  const page = await doc.getPage(pageNum);
217
255
  const viewport = page.getViewport({ scale: 1 });
218
256
  const content = await page.getTextContent();
257
+ const pageTextStart = text.length;
258
+ const page1 = anchorRuns(content.items.filter(isTextRun), pageNum);
259
+ for (const item of page1.items) {
260
+ items.push({ ...item, start: item.start + pageTextStart, end: item.end + pageTextStart });
261
+ }
262
+ text += page1.text;
263
+ text += "\n";
219
264
  pages.push({
220
265
  pageNumber: pageNum,
221
266
  widthPt: viewport.width,
222
- heightPt: viewport.height
267
+ heightPt: viewport.height,
268
+ textStart: pageTextStart,
269
+ textEnd: text.length,
270
+ hasTextLayer: page1.items.length > 0
271
+ });
272
+ }
273
+ if (!pages.some((page) => page.hasTextLayer)) return null;
274
+ return { pages, text, items, fields: await readFormFields(doc) };
275
+ } finally {
276
+ await loadingTask.destroy();
277
+ }
278
+ }
279
+
280
+ // src/pdf-tables.ts
281
+ var MIN_ROWS = 3;
282
+ var MIN_COLUMNS = 2;
283
+ var ROW_TOLERANCE = 0.5;
284
+ var CELL_GAP = 0.8;
285
+ function median(values) {
286
+ const sorted = [...values].sort((a, b) => a - b);
287
+ return sorted[Math.floor(sorted.length / 2)] ?? 0;
288
+ }
289
+ function groupRows(items, tolerance) {
290
+ const rows = [];
291
+ for (const item of [...items].sort((a, b) => b.y - a.y)) {
292
+ const row = rows[rows.length - 1];
293
+ if (row && Math.abs(row[0].y - item.y) <= tolerance) row.push(item);
294
+ else rows.push([item]);
295
+ }
296
+ return rows;
297
+ }
298
+ function toCell(runs, text) {
299
+ const x = Math.min(...runs.map((r) => r.x));
300
+ const y = Math.min(...runs.map((r) => r.y));
301
+ const right = Math.max(...runs.map((r) => r.x + r.width));
302
+ const top = Math.max(...runs.map((r) => r.y + r.height));
303
+ return {
304
+ text: runs.map((r) => text.slice(r.start, r.end)).join(" ").trim(),
305
+ x,
306
+ y,
307
+ width: right - x,
308
+ height: top - y
309
+ };
310
+ }
311
+ function toCells(row, gap, text) {
312
+ const cells = [];
313
+ let current = [];
314
+ for (const item of [...row].sort((a, b) => a.x - b.x)) {
315
+ const previous = current[current.length - 1];
316
+ if (previous && item.x - (previous.x + previous.width) > gap) {
317
+ cells.push(toCell(current, text));
318
+ current = [];
319
+ }
320
+ current.push(item);
321
+ }
322
+ if (current.length > 0) cells.push(toCell(current, text));
323
+ return cells;
324
+ }
325
+ function detectTable(items, text) {
326
+ if (items.length === 0) return null;
327
+ const unit = median(items.map((i) => i.height).filter((h) => h > 0)) || 12;
328
+ const rows = groupRows(items, unit * ROW_TOLERANCE).map((row) => toCells(row, unit * CELL_GAP, text));
329
+ if (rows.length < MIN_ROWS) return null;
330
+ const columnCount = rows[0].length;
331
+ if (columnCount < MIN_COLUMNS) return null;
332
+ if (!rows.every((row) => row.length === columnCount)) return null;
333
+ for (let column = 0; column < columnCount; column++) {
334
+ const lefts = rows.map((row) => row[column].x);
335
+ if (Math.max(...lefts) - Math.min(...lefts) > unit) return null;
336
+ }
337
+ if (rows.some((row) => row.some((cell) => cell.text.length === 0))) return null;
338
+ return rows;
339
+ }
340
+ function renderTable(rows, page, offset) {
341
+ let text = "";
342
+ const items = [];
343
+ rows.forEach((row, rowIndex) => {
344
+ text += "|";
345
+ for (const cell of row) {
346
+ text += " ";
347
+ const start = offset + text.length;
348
+ text += cell.text;
349
+ items.push({
350
+ start,
351
+ end: offset + text.length,
352
+ page,
353
+ x: cell.x,
354
+ y: cell.y,
355
+ width: cell.width,
356
+ height: cell.height
223
357
  });
224
- for (const item of content.items) {
225
- if (!("str" in item)) continue;
226
- if (item.str.trim()) {
227
- hasAnyTextItems = true;
358
+ text += " |";
359
+ }
360
+ text += "\n";
361
+ if (rowIndex === 0) text += `|${" --- |".repeat(row.length)}
362
+ `;
363
+ });
364
+ return { text, items };
365
+ }
366
+
367
+ // src/pdf-page-images.ts
368
+ import * as pdfjs2 from "pdfjs-dist/legacy/build/pdf.mjs";
369
+ import { isObject as isObject2, isNumber as isNumber2, isString as isString2, isArray as isArray2 } from "@semiont/core";
370
+
371
+ // src/png-encode.ts
372
+ import zlib from "zlib";
373
+ var CRC_TABLE = (() => {
374
+ const table = new Int32Array(256);
375
+ for (let n = 0; n < 256; n++) {
376
+ let c = n;
377
+ for (let k = 0; k < 8; k++) c = c & 1 ? 3988292384 ^ c >>> 1 : c >>> 1;
378
+ table[n] = c;
379
+ }
380
+ return table;
381
+ })();
382
+ function crc32(buf) {
383
+ let c = -1;
384
+ for (const byte of buf) c = CRC_TABLE[(c ^ byte) & 255] ^ c >>> 8;
385
+ return (c ^ -1) >>> 0;
386
+ }
387
+ function chunk(type, data) {
388
+ const length = Buffer.alloc(4);
389
+ length.writeUInt32BE(data.length);
390
+ const body = Buffer.concat([Buffer.from(type, "ascii"), data]);
391
+ const crc = Buffer.alloc(4);
392
+ crc.writeUInt32BE(crc32(body));
393
+ return Buffer.concat([length, body, crc]);
394
+ }
395
+ function encodePng(width, height, rgb) {
396
+ const stride = width * 3 + 1;
397
+ const raw = Buffer.alloc(stride * height);
398
+ for (let y = 0; y < height; y++) {
399
+ raw[y * stride] = 0;
400
+ Buffer.from(rgb.buffer, rgb.byteOffset + y * width * 3, width * 3).copy(raw, y * stride + 1);
401
+ }
402
+ const ihdr = Buffer.alloc(13);
403
+ ihdr.writeUInt32BE(width, 0);
404
+ ihdr.writeUInt32BE(height, 4);
405
+ ihdr[8] = 8;
406
+ ihdr[9] = 2;
407
+ return Buffer.concat([
408
+ Buffer.from([137, 80, 78, 71, 13, 10, 26, 10]),
409
+ chunk("IHDR", ihdr),
410
+ chunk("IDAT", zlib.deflateSync(raw, { level: 9 })),
411
+ chunk("IEND", Buffer.alloc(0))
412
+ ]);
413
+ }
414
+
415
+ // src/pdf-page-images.ts
416
+ var GRAYSCALE_1BPP = 1;
417
+ var RGB_24BPP = 2;
418
+ var RGBA_32BPP = 3;
419
+ var IDENTITY = [1, 0, 0, 1, 0, 0];
420
+ var MAX_IMAGE_PIXELS = 48e6;
421
+ function withinPixelBudget(width, height) {
422
+ if (!Number.isFinite(width) || !Number.isFinite(height)) return false;
423
+ if (width <= 0 || height <= 0) return false;
424
+ return width * height <= MAX_IMAGE_PIXELS;
425
+ }
426
+ function findPlacedImages(fnArray, argsArray) {
427
+ const placed = [];
428
+ const stack = [];
429
+ let ctm = [...IDENTITY];
430
+ for (let i = 0; i < fnArray.length; i++) {
431
+ const op = fnArray[i];
432
+ const args = argsArray[i];
433
+ if (op === pdfjs2.OPS.save) {
434
+ stack.push([...ctm]);
435
+ } else if (op === pdfjs2.OPS.restore) {
436
+ ctm = stack.pop() ?? [...IDENTITY];
437
+ } else if (op === pdfjs2.OPS.transform) {
438
+ if (isArray2(args) && args.length >= 6 && args.every(isNumber2)) {
439
+ ctm = pdfjs2.Util.transform(ctm, args);
440
+ }
441
+ } else if (op === pdfjs2.OPS.paintImageXObject) {
442
+ const ref = args?.[0];
443
+ const width = args?.[1];
444
+ const height = args?.[2];
445
+ if (isString2(ref) && isNumber2(width) && isNumber2(height)) {
446
+ placed.push({ ref, width, height, ctm: [...ctm] });
447
+ }
448
+ }
449
+ }
450
+ return placed;
451
+ }
452
+ function asBytes(data) {
453
+ if (data instanceof Uint8Array) return data;
454
+ if (data instanceof Uint8ClampedArray) return new Uint8Array(data.buffer, data.byteOffset, data.length);
455
+ return null;
456
+ }
457
+ function toRgb(image) {
458
+ if (!isObject2(image)) return null;
459
+ const { width, height, kind } = image;
460
+ const data = asBytes(image.data);
461
+ if (!isNumber2(width) || !isNumber2(height) || !data) return null;
462
+ if (width <= 0 || height <= 0) return null;
463
+ if (kind === RGB_24BPP) {
464
+ return data.length >= width * height * 3 ? { width, height, rgb: data } : null;
465
+ }
466
+ if (kind === RGBA_32BPP) {
467
+ if (data.length < width * height * 4) return null;
468
+ const rgb = new Uint8Array(width * height * 3);
469
+ for (let i = 0, o = 0; o < rgb.length; i += 4, o += 3) {
470
+ rgb[o] = data[i];
471
+ rgb[o + 1] = data[i + 1];
472
+ rgb[o + 2] = data[i + 2];
473
+ }
474
+ return { width, height, rgb };
475
+ }
476
+ if (kind === GRAYSCALE_1BPP) {
477
+ const rowBytes = Math.ceil(width / 8);
478
+ if (data.length < rowBytes * height) return null;
479
+ const rgb = new Uint8Array(width * height * 3);
480
+ for (let y = 0; y < height; y++) {
481
+ for (let x = 0; x < width; x++) {
482
+ const bit = data[y * rowBytes + (x >> 3)] & 128 >> (x & 7);
483
+ const value = bit ? 255 : 0;
484
+ const o = (y * width + x) * 3;
485
+ rgb[o] = value;
486
+ rgb[o + 1] = value;
487
+ rgb[o + 2] = value;
488
+ }
489
+ }
490
+ return { width, height, rgb };
491
+ }
492
+ return null;
493
+ }
494
+ var IMAGE_RESOLVE_TIMEOUT_MS = 3e4;
495
+ function resolveImage(page, ref) {
496
+ const scope = ref.startsWith("g_") ? page.commonObjs : page.objs;
497
+ return new Promise((resolve) => {
498
+ const timer = setTimeout(() => resolve(null), IMAGE_RESOLVE_TIMEOUT_MS);
499
+ const settle = (value) => {
500
+ clearTimeout(timer);
501
+ resolve(value);
502
+ };
503
+ try {
504
+ scope.get(ref, settle);
505
+ } catch {
506
+ settle(null);
507
+ }
508
+ });
509
+ }
510
+ async function extractPageImages(bytes, pageNumbers) {
511
+ const wanted = pageNumbers ? new Set(pageNumbers) : null;
512
+ const loadingTask = pdfjs2.getDocument({ data: new Uint8Array(bytes) });
513
+ const byPage = /* @__PURE__ */ new Map();
514
+ try {
515
+ const doc = await loadingTask.promise;
516
+ for (let pageNum = 1; pageNum <= doc.numPages; pageNum++) {
517
+ if (wanted && !wanted.has(pageNum)) continue;
518
+ const page = await doc.getPage(pageNum);
519
+ const ops = await page.getOperatorList();
520
+ const images = [];
521
+ for (const placement of findPlacedImages(ops.fnArray, ops.argsArray)) {
522
+ if (!withinPixelBudget(placement.width, placement.height)) continue;
523
+ const rgb = toRgb(await resolveImage(page, placement.ref));
524
+ if (!rgb) continue;
525
+ images.push({
526
+ png: encodePng(rgb.width, rgb.height, rgb.rgb),
527
+ width: rgb.width,
528
+ height: rgb.height,
529
+ ctm: placement.ctm
530
+ });
531
+ }
532
+ if (images.length > 0) byPage.set(pageNum, images);
533
+ }
534
+ return byPage;
535
+ } finally {
536
+ await loadingTask.destroy();
537
+ }
538
+ }
539
+
540
+ // src/ocr.ts
541
+ import { createRequire } from "module";
542
+ import { createWorker } from "tesseract.js";
543
+ import { isObject as isObject3, isString as isString3 } from "@semiont/core";
544
+ var cachedLangPath;
545
+ function langPath() {
546
+ if (cachedLangPath) return cachedLangPath;
547
+ const data = createRequire(import.meta.url)("@tesseract.js-data/eng");
548
+ if (!isObject3(data) || !isString3(data.langPath)) {
549
+ throw new Error(
550
+ "Vendored OCR language data is missing or malformed: @tesseract.js-data/eng did not export a langPath"
551
+ );
552
+ }
553
+ cachedLangPath = data.langPath;
554
+ return cachedLangPath;
555
+ }
556
+ function assemblePage(blocks) {
557
+ let text = "";
558
+ const words = [];
559
+ for (const block of blocks ?? []) {
560
+ for (const paragraph of block.paragraphs ?? []) {
561
+ for (const line of paragraph.lines ?? []) {
562
+ let wroteWord = false;
563
+ for (const word of line.words ?? []) {
564
+ const value = word.text.trim();
565
+ if (!value) continue;
566
+ if (wroteWord) text += " ";
228
567
  const start = text.length;
229
- text += item.str;
230
- const end = text.length;
231
- const [, , , , x, y] = item.transform;
232
- items.push({
568
+ text += value;
569
+ words.push({
570
+ text: value,
233
571
  start,
234
- end,
235
- page: pageNum,
236
- x,
237
- y,
238
- width: item.width,
239
- height: item.height
572
+ end: text.length,
573
+ // Horizontal extent from the word, vertical from the
574
+ // line. OCR boxes hug their glyphs, so a descender
575
+ // ('page') sits lower than its neighbours — and
576
+ // `locate()` groups items into lines by comparing `y`
577
+ // within a couple of points, a threshold that holds
578
+ // because NATIVE runs take y from the shared baseline.
579
+ // Passing per-word descenders through would split one
580
+ // visual line into several rects and draw a highlight
581
+ // as stacked fragments. Nothing is lost: `locate()`
582
+ // bounds each line anyway, so per-word vertical extent
583
+ // never reaches an annotation.
584
+ bbox: {
585
+ x0: word.bbox.x0,
586
+ x1: word.bbox.x1,
587
+ y0: line.bbox.y0,
588
+ y1: line.bbox.y1
589
+ },
590
+ confidence: word.confidence
240
591
  });
241
- text += item.hasEOL ? "\n" : " ";
242
- } else if (item.hasEOL) {
243
- text += "\n";
592
+ wroteWord = true;
244
593
  }
594
+ if (wroteWord) text += "\n";
245
595
  }
246
596
  text += "\n";
247
597
  }
248
- if (!hasAnyTextItems) return null;
249
- return { pages, text, items };
598
+ }
599
+ return { text: text.trimEnd(), words };
600
+ }
601
+ async function recognizeImages(images) {
602
+ if (images.length === 0) return [];
603
+ const worker = await createWorker("eng", void 0, {
604
+ langPath: langPath(),
605
+ cacheMethod: "none"
606
+ });
607
+ try {
608
+ const results = [];
609
+ for (const image of images) {
610
+ const { data } = await worker.recognize(image, {}, { blocks: true, text: false });
611
+ results.push(assemblePage(data.blocks));
612
+ }
613
+ return results;
250
614
  } finally {
251
- await loadingTask.destroy();
615
+ await worker.terminate();
252
616
  }
253
617
  }
254
618
 
255
- // src/locate.ts
256
- var SAME_LINE_THRESHOLD_PT = 2;
257
- function locate(layer, start, end) {
258
- const overlap = layer.items.filter(
259
- (item) => item.start < end && item.end > start
260
- );
261
- if (overlap.length === 0) return { rects: [], overlap };
262
- const pages = groupItemsByPage(overlap);
263
- const rects = [];
264
- for (const [page, pageItems] of pages) {
265
- const lines = groupItemsByLine(pageItems, SAME_LINE_THRESHOLD_PT);
266
- for (const lineItems of lines) {
267
- const x = Math.min(...lineItems.map((i) => i.x));
268
- const right = Math.max(...lineItems.map((i) => i.x + i.width));
269
- const y = Math.min(...lineItems.map((i) => i.y));
270
- const top = Math.max(...lineItems.map((i) => i.y + i.height));
271
- rects.push({ page, x, y, width: right - x, height: top - y });
619
+ // src/ocr-geometry.ts
620
+ import * as pdfjs3 from "pdfjs-dist/legacy/build/pdf.mjs";
621
+ function toPagePoint(px, py, placement) {
622
+ const point = [px / placement.width, 1 - py / placement.height];
623
+ pdfjs3.Util.applyTransform(point, placement.ctm);
624
+ return point;
625
+ }
626
+ function mapWordsToItems(words, placement, page, textOffset) {
627
+ if (placement.width <= 0 || placement.height <= 0) return [];
628
+ return words.map((word) => {
629
+ const [ax, ay] = toPagePoint(word.bbox.x0, word.bbox.y0, placement);
630
+ const [bx, by] = toPagePoint(word.bbox.x1, word.bbox.y1, placement);
631
+ const x = Math.min(ax, bx);
632
+ const y = Math.min(ay, by);
633
+ return {
634
+ start: word.start + textOffset,
635
+ end: word.end + textOffset,
636
+ page,
637
+ x,
638
+ y,
639
+ width: Math.abs(bx - ax),
640
+ height: Math.abs(by - ay)
641
+ };
642
+ });
643
+ }
644
+
645
+ // src/pdf-extractor.ts
646
+ var MAX_PDF_BYTES = 200 * 1024 * 1024;
647
+ function withinByteBudget(bytes) {
648
+ return Number.isFinite(bytes) && bytes >= 0 && bytes <= MAX_PDF_BYTES;
649
+ }
650
+ var LOW_CONFIDENCE = 60;
651
+ function summarize(confidences) {
652
+ if (confidences.length === 0) return void 0;
653
+ const total = confidences.reduce((sum, c) => sum + c, 0);
654
+ return {
655
+ mean: Math.round(total / confidences.length * 10) / 10,
656
+ lowConfidenceWords: confidences.filter((c) => c < LOW_CONFIDENCE).length,
657
+ totalWords: confidences.length
658
+ };
659
+ }
660
+ async function ocrPages(content, pageNumbers) {
661
+ const imagesByPage = await extractPageImages(content, pageNumbers);
662
+ if (imagesByPage.size === 0) return { text: "", items: [], confidences: [] };
663
+ const pages = [...imagesByPage.keys()].sort((a, b) => a - b);
664
+ const batch = pages.flatMap((page) => imagesByPage.get(page).map((image) => image.png));
665
+ const recognized = await recognizeImages(batch);
666
+ const byPage = /* @__PURE__ */ new Map();
667
+ let cursor = 0;
668
+ for (const page of pages) {
669
+ const images = imagesByPage.get(page);
670
+ let text = "";
671
+ const items = [];
672
+ const confidences = [];
673
+ for (const image of images) {
674
+ const result = recognized[cursor++];
675
+ if (!result?.text.trim()) continue;
676
+ if (text) text += "\n";
677
+ items.push(...mapWordsToItems(result.words, image, page, text.length));
678
+ confidences.push(...result.words.map((word) => word.confidence));
679
+ text += result.text;
272
680
  }
681
+ if (text) byPage.set(page, { text, items, confidences });
273
682
  }
274
- return { rects, overlap };
683
+ return joinPages(byPage, 0);
275
684
  }
276
- function groupItemsByPage(items) {
277
- const map = /* @__PURE__ */ new Map();
278
- for (const item of items) {
279
- const existing = map.get(item.page);
280
- if (existing) {
281
- existing.push(item);
685
+ function joinPages(byPage, baseOffset) {
686
+ let text = "";
687
+ const items = [];
688
+ const confidences = [];
689
+ for (const [, page] of [...byPage.entries()].sort((a, b) => a[0] - b[0])) {
690
+ if (text) text += "\n\n";
691
+ const shift = baseOffset + text.length;
692
+ for (const item of page.items) {
693
+ items.push({ ...item, start: item.start + shift, end: item.end + shift });
694
+ }
695
+ confidences.push(...page.confidences);
696
+ text += page.text;
697
+ }
698
+ return { text, items, confidences };
699
+ }
700
+ function classifyPdfError(error) {
701
+ return isObject4(error) && error.name === "PasswordException" ? "encrypted" : "corrupt";
702
+ }
703
+ function foldFormFields(layer) {
704
+ let text = layer.text;
705
+ const items = [...layer.items];
706
+ for (const field of layer.fields) {
707
+ const start = text.length + `${field.name}: `.length;
708
+ text += `${field.name}: ${field.value}
709
+ `;
710
+ items.push({
711
+ start,
712
+ end: start + field.value.length,
713
+ page: field.page,
714
+ x: field.x,
715
+ y: field.y,
716
+ width: field.width,
717
+ height: field.height
718
+ });
719
+ }
720
+ return { text, items, method: "form", pdfClass: "E" };
721
+ }
722
+ function shapeTables(layer) {
723
+ const pages = layer.pages.map((page) => {
724
+ const pageItems = layer.items.filter((item) => item.page === page.pageNumber);
725
+ return { page, pageItems, table: detectTable(pageItems, layer.text) };
726
+ });
727
+ if (!pages.some((p) => p.table)) return null;
728
+ let text = "";
729
+ const items = [];
730
+ for (const { page, pageItems, table } of pages) {
731
+ if (table) {
732
+ const rendered = renderTable(table, page.pageNumber, text.length);
733
+ text += rendered.text;
734
+ items.push(...rendered.items);
282
735
  } else {
283
- map.set(item.page, [item]);
736
+ const shift = text.length - page.textStart;
737
+ text += layer.text.slice(page.textStart, page.textEnd);
738
+ for (const item of pageItems) {
739
+ items.push({ ...item, start: item.start + shift, end: item.end + shift });
740
+ }
741
+ }
742
+ }
743
+ return { text, items, method: "table", pdfClass: "D" };
744
+ }
745
+ var pdfExtractor = {
746
+ // Every non-declined PDF extraction carries positioned runs — native text
747
+ // layers and OCR both anchor by page geometry.
748
+ yieldsGeometry: true,
749
+ async extract(content, _mediaType, cache) {
750
+ const hit = await cache?.store.read(cache.key);
751
+ if (hit) return hit;
752
+ const outcome = await extractPdf(content);
753
+ if (cache) {
754
+ if ("declined" in outcome) await cache.store.write(cache.key, outcome);
755
+ else if (outcome.items) await cache.store.write(cache.key, { ...outcome, items: outcome.items });
284
756
  }
757
+ return outcome;
758
+ }
759
+ };
760
+ async function extractPdf(content) {
761
+ if (!withinByteBudget(content.length)) return { declined: "too-large" };
762
+ let layer;
763
+ try {
764
+ layer = await extractPdfTextLayer(content);
765
+ } catch (error) {
766
+ return { declined: classifyPdfError(error) };
767
+ }
768
+ if (!layer) {
769
+ const ocr2 = await ocrPages(content);
770
+ if (!ocr2.text) return { declined: "no-text-layer" };
771
+ const confidence2 = summarize(ocr2.confidences);
772
+ return {
773
+ text: ocr2.text,
774
+ items: ocr2.items,
775
+ method: "ocr",
776
+ pdfClass: "B",
777
+ ...confidence2 ? { ocrConfidence: confidence2 } : {}
778
+ };
285
779
  }
286
- return map;
780
+ const shaped = layer.fields.length > 0 ? foldFormFields(layer) : shapeTables(layer) ?? { text: layer.text, items: layer.items, method: "pdf-text-layer", pdfClass: "A" };
781
+ const unreadPages = layer.pages.filter((page) => !page.hasTextLayer).map((page) => page.pageNumber);
782
+ if (unreadPages.length === 0) return shaped;
783
+ const recovered = await ocrPages(content, unreadPages);
784
+ const readPages = new Set(recovered.items.map((item) => item.page));
785
+ const stillUnread = unreadPages.filter((page) => !readPages.has(page));
786
+ const hybridClass = shaped.pdfClass === "A" ? "C" : shaped.pdfClass;
787
+ if (!recovered.text) {
788
+ return { ...shaped, unreadPages: stillUnread, pdfClass: hybridClass };
789
+ }
790
+ const shift = shaped.text.length;
791
+ const ocr = {
792
+ text: recovered.text,
793
+ items: recovered.items.map((item) => ({ ...item, start: item.start + shift, end: item.end + shift })),
794
+ confidences: recovered.confidences
795
+ };
796
+ const confidence = summarize(ocr.confidences);
797
+ return {
798
+ ...shaped,
799
+ text: `${shaped.text}${ocr.text}
800
+ `,
801
+ items: [...shaped.items ?? [], ...ocr.items],
802
+ method: "ocr",
803
+ pdfClass: hybridClass,
804
+ ...confidence ? { ocrConfidence: confidence } : {},
805
+ ...stillUnread.length > 0 ? { unreadPages: stillUnread } : {}
806
+ };
807
+ }
808
+
809
+ // src/content-extractor.ts
810
+ var passthroughExtractor = {
811
+ yieldsGeometry: false,
812
+ async extract(content, mediaType) {
813
+ return { text: decodeRepresentation(content, mediaType), method: "text-passthrough" };
814
+ }
815
+ };
816
+ var EXTRACTORS = {
817
+ "decode": passthroughExtractor,
818
+ "pdf-text-layer": pdfExtractor,
819
+ "none": null
820
+ };
821
+
822
+ // src/anchored-text-store.ts
823
+ import fs2 from "fs";
824
+ import path2 from "path";
825
+ import { createRequire as createRequire2 } from "module";
826
+ import { getShardPath, isObject as isObject5, isString as isString4, isNumber as isNumber3, isArray as isArray3 } from "@semiont/core";
827
+ function buildStamp() {
828
+ const require2 = createRequire2(import.meta.url);
829
+ const version = (specifier) => {
830
+ try {
831
+ const pkg = require2(specifier);
832
+ return isObject5(pkg) && isString4(pkg.version) ? pkg.version : "unknown";
833
+ } catch {
834
+ return "unknown";
835
+ }
836
+ };
837
+ return `content-${version("../package.json")}+pdfjs-${version("pdfjs-dist/package.json")}+tesseract-${version("tesseract.js/package.json")}+eng-${version("@tesseract.js-data/eng/package.json")}`;
287
838
  }
288
- function groupItemsByLine(items, sameLineThreshold) {
289
- const sorted = [...items].sort((a, b) => b.y - a.y || a.x - b.x);
839
+ var STAMP = buildStamp();
840
+ function encodeLines(items) {
290
841
  const lines = [];
291
- let currentLine = [];
292
- for (const item of sorted) {
293
- if (currentLine.length === 0 || Math.abs(item.y - currentLine[0].y) <= sameLineThreshold) {
294
- currentLine.push(item);
842
+ for (const item of items) {
843
+ const last = lines[lines.length - 1];
844
+ if (last && last.p === item.page && last.y === item.y && last.h === item.height) {
845
+ last.words.push([item.x, item.width, item.start, item.end]);
295
846
  } else {
296
- lines.push(currentLine);
297
- currentLine = [item];
847
+ lines.push({ p: item.page, y: item.y, h: item.height, words: [[item.x, item.width, item.start, item.end]] });
298
848
  }
299
849
  }
300
- if (currentLine.length > 0) lines.push(currentLine);
301
850
  return lines;
302
851
  }
852
+ function decodeLines(lines) {
853
+ const items = [];
854
+ for (const line of lines) {
855
+ for (const [x, width, start, end] of line.words) {
856
+ items.push({ start, end, page: line.p, x, y: line.y, width, height: line.h });
857
+ }
858
+ }
859
+ return items;
860
+ }
861
+ function isCached(value) {
862
+ if (!isObject5(value) || value.v !== 2 || !isString4(value.stamp)) return false;
863
+ if (isString4(value.declined)) return true;
864
+ if (!isString4(value.text) || !isString4(value.method) || !isArray3(value.lines)) return false;
865
+ return value.lines.every((line) => isObject5(line) && isNumber3(line.p) && isNumber3(line.y) && isNumber3(line.h) && isArray3(line.words) && line.words.every((w) => isArray3(w) && w.length === 4 && w.every(isNumber3)));
866
+ }
867
+ var VALID_KEY = /^[A-Za-z0-9_-]+$/;
868
+ function createAnchoredTextStore(dir, logger) {
869
+ const fileFor = (key) => {
870
+ if (!VALID_KEY.test(key)) return null;
871
+ const [ab, cd] = getShardPath(key);
872
+ return path2.join(dir, ab, cd, `${key}.json`);
873
+ };
874
+ return {
875
+ async read(key) {
876
+ let hit = null;
877
+ try {
878
+ const file = fileFor(key);
879
+ if (file === null) throw new Error("invalid key");
880
+ const parsed = JSON.parse(await fs2.promises.readFile(file, "utf8"));
881
+ if (isCached(parsed) && parsed.stamp === STAMP) hit = parsed;
882
+ } catch {
883
+ hit = null;
884
+ }
885
+ logger?.debug("Anchored-text cache", {
886
+ outcome: hit ? "hit" : "miss",
887
+ key,
888
+ ...hit ? "declined" in hit ? { declined: hit.declined } : { lines: hit.lines.length } : {}
889
+ });
890
+ if (!hit) return null;
891
+ if ("declined" in hit) return { declined: hit.declined };
892
+ const { v: _v, stamp: _stamp, lines, text, ...provenance } = hit;
893
+ return { text, items: decodeLines(lines), ...provenance };
894
+ },
895
+ async write(key, outcome) {
896
+ const target = fileFor(key);
897
+ if (target === null) {
898
+ logger?.debug("Anchored-text cache: refusing invalid key", { key });
899
+ return;
900
+ }
901
+ const entry = "declined" in outcome ? { v: 2, stamp: STAMP, declined: outcome.declined } : (() => {
902
+ const { text, items, ...provenance } = outcome;
903
+ return { v: 2, stamp: STAMP, text, lines: encodeLines(items), ...provenance };
904
+ })();
905
+ const temp = `${target}.${process.pid}.tmp`;
906
+ try {
907
+ await fs2.promises.mkdir(path2.dirname(target), { recursive: true });
908
+ await fs2.promises.writeFile(temp, JSON.stringify(entry), "utf8");
909
+ await fs2.promises.rename(temp, target);
910
+ } catch {
911
+ await fs2.promises.rm(temp, { force: true }).catch(() => {
912
+ });
913
+ }
914
+ },
915
+ async list() {
916
+ const prefix = JSON.stringify({ v: 2, stamp: STAMP }).slice(0, -1) + ",";
917
+ let rootNames;
918
+ try {
919
+ rootNames = await fs2.promises.readdir(dir);
920
+ } catch {
921
+ return [];
922
+ }
923
+ let swept = 0;
924
+ for (const name of rootNames) {
925
+ if (!name.endsWith(".json")) continue;
926
+ await fs2.promises.rm(path2.join(dir, name), { force: true }).then(() => {
927
+ swept += 1;
928
+ }, () => {
929
+ });
930
+ }
931
+ if (swept > 0) logger?.info("Anchored-text cache: swept pre-P1 flat entries", { swept });
932
+ const keys = [];
933
+ let sweptInterim = 0;
934
+ for (const ab of rootNames) {
935
+ if (!/^[0-9a-f]{2}$/.test(ab)) continue;
936
+ let cdNames;
937
+ try {
938
+ cdNames = await fs2.promises.readdir(path2.join(dir, ab));
939
+ } catch {
940
+ continue;
941
+ }
942
+ for (const cd of cdNames) {
943
+ if (!/^[0-9a-f]{2}$/.test(cd)) continue;
944
+ let names;
945
+ try {
946
+ names = await fs2.promises.readdir(path2.join(dir, ab, cd));
947
+ } catch {
948
+ continue;
949
+ }
950
+ for (const name of names) {
951
+ if (!name.endsWith(".json")) continue;
952
+ const base = name.slice(0, -".json".length);
953
+ if (/^[0-9a-f]{32}$/.test(base)) {
954
+ await fs2.promises.rm(path2.join(dir, ab, cd, name), { force: true }).then(() => {
955
+ sweptInterim += 1;
956
+ }, () => {
957
+ });
958
+ continue;
959
+ }
960
+ let handle = null;
961
+ try {
962
+ handle = await fs2.promises.open(path2.join(dir, ab, cd, name), "r");
963
+ const buf = Buffer.alloc(prefix.length);
964
+ const { bytesRead } = await handle.read(buf, 0, prefix.length, 0);
965
+ if (bytesRead === prefix.length && buf.toString("utf8") === prefix) {
966
+ keys.push(base);
967
+ }
968
+ } catch {
969
+ } finally {
970
+ await handle?.close().catch(() => {
971
+ });
972
+ }
973
+ }
974
+ }
975
+ }
976
+ if (sweptInterim > 0) logger?.info("Anchored-text cache: swept interim resource-id entries", { swept: sweptInterim });
977
+ return keys;
978
+ }
979
+ };
980
+ }
981
+
982
+ // src/anchored-text-store-adapter.ts
983
+ function anchoredTextStoreOverTransport(content, logger) {
984
+ return {
985
+ async read(key) {
986
+ try {
987
+ return await content.getAnchoredTextByChecksum(key);
988
+ } catch (error) {
989
+ logger?.debug("Anchored-text cache: transport read failed \u2014 treating as miss", {
990
+ key,
991
+ reason: error instanceof Error ? error.message : String(error)
992
+ });
993
+ return null;
994
+ }
995
+ },
996
+ async write(key, outcome) {
997
+ try {
998
+ await content.putAnchoredText(key, outcome);
999
+ } catch (error) {
1000
+ logger?.debug("Anchored-text cache: transport write failed \u2014 entry not stored", {
1001
+ key,
1002
+ reason: error instanceof Error ? error.message : String(error)
1003
+ });
1004
+ }
1005
+ },
1006
+ async list() {
1007
+ try {
1008
+ return await content.listAnchoredTextKeys();
1009
+ } catch (error) {
1010
+ logger?.debug("Anchored-text cache: transport list failed \u2014 treating as empty", {
1011
+ reason: error instanceof Error ? error.message : String(error)
1012
+ });
1013
+ return [];
1014
+ }
1015
+ }
1016
+ };
1017
+ }
303
1018
  export {
304
1019
  ChecksumMismatchError,
1020
+ EXTRACTORS,
1021
+ MAX_PDF_BYTES,
305
1022
  WorkingTreeStore,
1023
+ anchoredTextStoreOverTransport,
306
1024
  calculateChecksum,
1025
+ createAnchoredTextStore,
307
1026
  deriveStorageUri,
308
1027
  extractPdfTextLayer,
309
- locate,
310
- verifyChecksum
1028
+ verifyChecksum,
1029
+ withinByteBudget
311
1030
  };
312
1031
  //# sourceMappingURL=index.js.map