@semiont/content 0.5.24 → 0.5.26

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