@agent-native/core 0.157.8 → 0.157.9

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.
Files changed (69) hide show
  1. package/corpus/README.md +1 -1
  2. package/corpus/templates/analytics/actions/purge-first-party-analytics-postgres.ts +26 -8
  3. package/corpus/templates/analytics/scripts/first-party-analytics-postgres-retention-dry-run.sql +258 -0
  4. package/corpus/templates/analytics/server/lib/first-party-analytics-purge.ts +59 -27
  5. package/corpus/templates/analytics/server/lib/workspace-feature-flags.ts +4 -1
  6. package/corpus/templates/slides/actions/export-pptx.ts +261 -71
  7. package/corpus/templates/slides/actions/import-file.ts +12 -0
  8. package/corpus/templates/slides/actions/import-pptx.ts +9 -0
  9. package/corpus/templates/slides/app/lib/export-pptx-client.ts +45 -5
  10. package/corpus/templates/slides/server/handlers/import/html-converter.ts +69 -1
  11. package/corpus/templates/slides/server/handlers/import/pdf-fidelity-parser.ts +316 -64
  12. package/corpus/templates/slides/server/lib/source-import.ts +10 -1
  13. package/dist/agent/harness/migrations.d.ts +4 -0
  14. package/dist/agent/harness/migrations.js +39 -0
  15. package/dist/agent/production-agent.d.ts +2 -0
  16. package/dist/agent/production-agent.js +4 -0
  17. package/dist/agent/run-loop-with-resume.d.ts +1 -0
  18. package/dist/agent/run-loop-with-resume.js +45 -30
  19. package/dist/agent/run-migrations.d.ts +8 -0
  20. package/dist/agent/run-migrations.js +61 -0
  21. package/dist/chat-threads/migrations.d.ts +1 -0
  22. package/dist/chat-threads/migrations.js +1 -0
  23. package/dist/chat-threads/schema-migrations.d.ts +8 -0
  24. package/dist/chat-threads/schema-migrations.js +60 -0
  25. package/dist/client/AssistantChat.js +4 -0
  26. package/dist/client/chat/message-components.d.ts +2 -0
  27. package/dist/client/chat/message-components.js +11 -1
  28. package/dist/client/observability/ThumbsFeedback.js +35 -22
  29. package/dist/client/observability/useObservability.d.ts +1 -1
  30. package/dist/client/observability/useObservability.js +6 -3
  31. package/dist/collab/awareness.d.ts +2 -2
  32. package/dist/collab/struct-routes.d.ts +1 -1
  33. package/dist/feature-flags/a2a-action-route.js +4 -2
  34. package/dist/feature-flags/permissions.d.ts +2 -0
  35. package/dist/feature-flags/permissions.js +6 -1
  36. package/dist/ingestion/pptx.d.ts +14 -1
  37. package/dist/ingestion/pptx.js +222 -41
  38. package/dist/localization/core-messages/ar-SA.js +4 -0
  39. package/dist/localization/core-messages/de-DE.js +4 -0
  40. package/dist/localization/core-messages/en-US.d.ts +4 -0
  41. package/dist/localization/core-messages/en-US.js +4 -0
  42. package/dist/localization/core-messages/es-ES.js +4 -0
  43. package/dist/localization/core-messages/fr-FR.js +4 -0
  44. package/dist/localization/core-messages/hi-IN.js +4 -0
  45. package/dist/localization/core-messages/ja-JP.js +4 -0
  46. package/dist/localization/core-messages/ko-KR.js +4 -0
  47. package/dist/localization/core-messages/pt-BR.js +4 -0
  48. package/dist/localization/core-messages/zh-CN.js +4 -0
  49. package/dist/localization/core-messages/zh-TW.js +4 -0
  50. package/dist/localization/core-messages.d.ts +4 -0
  51. package/dist/notifications/routes.d.ts +1 -1
  52. package/dist/observability/feedback.js +40 -16
  53. package/dist/observability/routes.d.ts +6 -6
  54. package/dist/observability/routes.js +15 -3
  55. package/dist/observability/store.js +2 -0
  56. package/dist/observability/traces.js +5 -1
  57. package/dist/provider-api/actions/custom-provider-registration.d.ts +12 -12
  58. package/dist/provider-api/actions/provider-api.d.ts +10 -10
  59. package/dist/secrets/routes.d.ts +9 -9
  60. package/dist/server/action-routes.d.ts +7 -5
  61. package/dist/server/action-routes.js +9 -4
  62. package/dist/server/agent-engine-api-key-route.d.ts +1 -1
  63. package/dist/server/better-auth-instance.js +4 -0
  64. package/dist/server/realtime-voice.js +2 -1
  65. package/dist/server/release-migrations.js +16 -0
  66. package/dist/server/transcribe-voice.d.ts +1 -1
  67. package/dist/usage/migrations.d.ts +4 -0
  68. package/dist/usage/migrations.js +45 -0
  69. package/package.json +3 -3
@@ -19,14 +19,16 @@ export interface PdfFidelityPage {
19
19
  heightEmu: number;
20
20
  /** The page's own painted background, when a full-page fill was found; undefined means "plain paper" (render white). */
21
21
  backgroundColor: string | undefined;
22
- /** Images first (so they paint as backgrounds), then text blocks on top. */
22
+ /** Sorted by the PDF's own real paint order, so an image painted over text (or vice versa) keeps its real stacking instead of images always sitting behind. */
23
23
  elements: ParsedElement[];
24
+ /** Images detected on this page whose pixel data could not be matched to a paint placement (extraction failed, or no canvas renderer was available) — intentionally size-filtered placements are not counted. */
25
+ imagesSkipped: number;
24
26
  }
25
27
 
26
28
  /** An embedded raster image already resolved by `pdf.getImage()`, keyed by its page. */
27
29
  export interface PdfPageImage {
28
30
  pageNumber: number;
29
- images: { data: Uint8Array }[];
31
+ images: { data: Uint8Array; name: string }[];
30
32
  }
31
33
 
32
34
  interface Rect {
@@ -36,6 +38,8 @@ interface Rect {
36
38
  bottom: number;
37
39
  }
38
40
 
41
+ type PdfOperatorList = Awaited<ReturnType<PDFPageProxy["getOperatorList"]>>;
42
+
39
43
  export function applyPoint(m: Mat, x: number, y: number): [number, number] {
40
44
  const p: [number, number] = [x, y];
41
45
  Util.applyTransform(p, m);
@@ -80,6 +84,9 @@ export interface TextRunBox extends Rect {
80
84
  color: string;
81
85
  underline: boolean;
82
86
  href: string | undefined;
87
+ fontFamily: string | undefined;
88
+ /** Index in this page's real content-stream paint order, shared with image paint events — lets the final element list be z-ordered by how the PDF actually painted it instead of images-always-first. */
89
+ paintOrder: number;
83
90
  }
84
91
 
85
92
  /** A thin filled/stroked rect from the content stream — the usual way PDFs draw an underline (there's no inline "underline" text attribute). */
@@ -161,6 +168,10 @@ const TEXT_SHOWING_OPS = new Set([
161
168
  OPS.nextLineSetSpacingShowText,
162
169
  ]);
163
170
 
171
+ function isImagePaintOp(fn: number): boolean {
172
+ return fn === OPS.paintImageXObject || fn === OPS.paintInlineImageXObject;
173
+ }
174
+
164
175
  const FILL_PAINT_OPS = new Set([
165
176
  OPS.fill,
166
177
  OPS.eoFill,
@@ -195,6 +206,30 @@ const BACKGROUND_COVERAGE_RATIO = 0.9;
195
206
  */
196
207
  const UNTRACKED_COLOR_OPS = new Set([OPS.setFillColorSpace, OPS.shadingFill]);
197
208
 
209
+ /** A PDF-embedded subset font name is prefixed with a 6-letter tag plus "+" (e.g. "MUFUZY+Poppins-Bold") — that tag and the PostScript style suffix after it are never real CSS font-family values. */
210
+ const SUBSET_TAG_PATTERN = /^[a-z]{6}\+/i;
211
+
212
+ /**
213
+ * Maps a PDF's real (subset-tag-stripped) font name onto the nearest
214
+ * websafe CSS family by a simple keyword match, since the raw PostScript
215
+ * name (e.g. "TimesNewRomanPSMT") usually isn't installed or resolvable as
216
+ * a browser font-family at all. Returns undefined for an unresolved font id
217
+ * so the caller falls back to the deck's default font instead of rendering
218
+ * a literal empty family.
219
+ */
220
+ function toWebSafeFontFamily(fontName: string): string | undefined {
221
+ const base = fontName.replace(SUBSET_TAG_PATTERN, "").split(/[-,]/)[0].trim();
222
+ if (!base) return undefined;
223
+ const normalized = base.toLowerCase();
224
+ if (/times|georgia|garamond|cambria|palatino|serif/.test(normalized)) {
225
+ return "Georgia";
226
+ }
227
+ if (/courier|mono|consolas/.test(normalized)) {
228
+ return "Courier New";
229
+ }
230
+ return base;
231
+ }
232
+
198
233
  /**
199
234
  * `getTextContent()` gives baseline position + font size per run but no
200
235
  * paragraph structure — PDFs don't have one. Each run's placed box is
@@ -244,6 +279,11 @@ export function textItemToBox(
244
279
  color,
245
280
  underline: false,
246
281
  href: undefined,
282
+ fontFamily: toWebSafeFontFamily(fontName),
283
+ // Real paint order is only known once this box is sliced from its
284
+ // originating content-stream op in `sliceTextItemBySegments` — callers
285
+ // overwrite this with the real value; 0 is just a type-satisfying default.
286
+ paintOrder: 0,
247
287
  };
248
288
  }
249
289
 
@@ -279,6 +319,8 @@ export function mergeLine(items: TextRunBox[]): TextRunBox {
279
319
  color: sorted[0].color,
280
320
  underline: sorted.some((s) => s.underline),
281
321
  href: sorted.find((s) => s.href)?.href,
322
+ fontFamily: sorted[0].fontFamily,
323
+ paintOrder: Math.min(...sorted.map((s) => s.paintOrder)),
282
324
  };
283
325
  }
284
326
 
@@ -331,20 +373,112 @@ export function annotateLineDecorations(
331
373
 
332
374
  /** Group same-baseline runs into lines, in the order pdf.js emitted them (reading order for typical single-column pages). */
333
375
  export function groupIntoLines(items: TextRunBox[]): TextRunBox[] {
334
- return groupByBaseline(items).map(mergeLine);
376
+ return groupByBaseline(items, detectColumnGutter(items)).map(mergeLine);
377
+ }
378
+
379
+ /** Buckets scanned across a page's text-item x-span when looking for a recurring empty column gutter. */
380
+ const COLUMN_GUTTER_BUCKET_COUNT = 40;
381
+ /** A candidate gutter must span at least this fraction of the page's text width — an ordinary word/paragraph gap never gets close. */
382
+ const MIN_COLUMN_GUTTER_WIDTH_FRACTION = 0.06;
383
+ /** Each side of a candidate gutter needs at least this many items, or it's a stray gap rather than a second column. */
384
+ const MIN_ITEMS_PER_COLUMN = 3;
385
+
386
+ interface ColumnGutter {
387
+ start: number;
388
+ end: number;
389
+ }
390
+
391
+ /**
392
+ * Looks for an x-band that no text item's [left, right] range ever crosses,
393
+ * strictly inside the page's overall text span (not a page margin). A band
394
+ * that wide and that consistently empty across every line on the page — not
395
+ * just one line's word gap, which some other line would fill in — is a real
396
+ * column boundary.
397
+ *
398
+ * ponytail: one global gutter per page, not per text-region — a page with
399
+ * more than two columns, or columns that don't share a single vertical
400
+ * gutter, won't be detected. Upgrade to a real column-layout pass if that
401
+ * shows up in practice.
402
+ */
403
+ function detectColumnGutter(items: Rect[]): ColumnGutter | undefined {
404
+ if (items.length < MIN_ITEMS_PER_COLUMN * 2) return undefined;
405
+ const minX = Math.min(...items.map((i) => i.left));
406
+ const maxX = Math.max(...items.map((i) => i.right));
407
+ const span = maxX - minX;
408
+ if (span <= 0) return undefined;
409
+ const bucketWidth = span / COLUMN_GUTTER_BUCKET_COUNT;
410
+ const covered = new Array<boolean>(COLUMN_GUTTER_BUCKET_COUNT).fill(false);
411
+ for (const item of items) {
412
+ const startBucket = Math.max(
413
+ 0,
414
+ Math.floor((item.left - minX) / bucketWidth),
415
+ );
416
+ const endBucket = Math.min(
417
+ COLUMN_GUTTER_BUCKET_COUNT - 1,
418
+ Math.floor((item.right - minX) / bucketWidth),
419
+ );
420
+ for (let b = startBucket; b <= endBucket; b++) covered[b] = true;
421
+ }
422
+ let bestStart = -1;
423
+ let bestLength = 0;
424
+ let runStart = -1;
425
+ // Buckets 0 and the last one are page margins, not a gutter between columns.
426
+ for (let b = 1; b < COLUMN_GUTTER_BUCKET_COUNT - 1; b++) {
427
+ if (covered[b]) {
428
+ runStart = -1;
429
+ continue;
430
+ }
431
+ if (runStart === -1) runStart = b;
432
+ const length = b - runStart + 1;
433
+ if (length > bestLength) {
434
+ bestLength = length;
435
+ bestStart = runStart;
436
+ }
437
+ }
438
+ const minBuckets = Math.ceil(
439
+ COLUMN_GUTTER_BUCKET_COUNT * MIN_COLUMN_GUTTER_WIDTH_FRACTION,
440
+ );
441
+ if (bestStart === -1 || bestLength < minBuckets) return undefined;
442
+ const start = minX + bestStart * bucketWidth;
443
+ const end = minX + (bestStart + bestLength) * bucketWidth;
444
+ const leftCount = items.filter((i) => i.right <= start).length;
445
+ const rightCount = items.filter((i) => i.left >= end).length;
446
+ if (leftCount < MIN_ITEMS_PER_COLUMN || rightCount < MIN_ITEMS_PER_COLUMN) {
447
+ return undefined;
448
+ }
449
+ return { start, end };
450
+ }
451
+
452
+ /** Which side of a detected column gutter an item falls on; undefined when there's no gutter or the item straddles it. */
453
+ function columnSideOf(
454
+ item: Rect,
455
+ gutter: ColumnGutter | undefined,
456
+ ): 0 | 1 | undefined {
457
+ if (!gutter) return undefined;
458
+ if (item.right <= gutter.start) return 0;
459
+ if (item.left >= gutter.end) return 1;
460
+ return undefined;
335
461
  }
336
462
 
337
- /** Shared by `groupIntoLines` and `groupIntoStyledLines`: cluster items whose baselines are close enough to read as the same physical line. */
338
- function groupByBaseline(items: TextRunBox[]): TextRunBox[][] {
463
+ /** Shared by `groupIntoLines` and `groupIntoStyledLines`: cluster items whose baselines are close enough to read as the same physical line, but never across a detected column gutter even when two columns' baselines coincide. */
464
+ function groupByBaseline(
465
+ items: TextRunBox[],
466
+ columnGutter?: ColumnGutter,
467
+ ): TextRunBox[][] {
339
468
  const lines: TextRunBox[][] = [];
340
469
  let current: TextRunBox[] = [];
341
470
  for (const item of items) {
342
471
  const prev = current[current.length - 1];
343
- if (
344
- prev &&
472
+ const differentBaseline =
473
+ prev !== undefined &&
345
474
  Math.abs(item.top - prev.top) >
346
- Math.max(item.fontSize, prev.fontSize) * 0.4
347
- ) {
475
+ Math.max(item.fontSize, prev.fontSize) * 0.4;
476
+ const differentColumn =
477
+ prev !== undefined &&
478
+ columnSideOf(prev, columnGutter) !== undefined &&
479
+ columnSideOf(item, columnGutter) !== undefined &&
480
+ columnSideOf(prev, columnGutter) !== columnSideOf(item, columnGutter);
481
+ if (prev && (differentBaseline || differentColumn)) {
348
482
  lines.push(current);
349
483
  current = [item];
350
484
  } else {
@@ -379,7 +513,8 @@ export function mergeLineRuns(items: TextRunBox[]): TextRunBox[] {
379
513
  prev.bold === item.bold &&
380
514
  prev.italic === item.italic &&
381
515
  prev.underline === item.underline &&
382
- prev.href === item.href;
516
+ prev.href === item.href &&
517
+ prev.fontFamily === item.fontFamily;
383
518
  const needsSpace =
384
519
  prev !== undefined && item.left - prev.right > item.fontSize * 0.25;
385
520
  if (prev && sameStyle) {
@@ -387,6 +522,7 @@ export function mergeLineRuns(items: TextRunBox[]): TextRunBox[] {
387
522
  prev.right = Math.max(prev.right, item.right);
388
523
  prev.top = Math.min(prev.top, item.top);
389
524
  prev.bottom = Math.max(prev.bottom, item.bottom);
525
+ prev.paintOrder = Math.min(prev.paintOrder, item.paintOrder);
390
526
  } else {
391
527
  // A word-sized gap has to survive a style change too. Only the
392
528
  // same-style branch used to re-add it, so a heading whose colour
@@ -404,7 +540,8 @@ export function mergeLineRuns(items: TextRunBox[]): TextRunBox[] {
404
540
 
405
541
  /** Like `groupIntoLines`, but keeps each line's distinct-styled runs separate — needed once a line can mix colors/weights within itself. */
406
542
  export function groupIntoStyledLines(items: TextRunBox[]): TextLine[] {
407
- return groupByBaseline(items).map((lineItems) => ({
543
+ const columnGutter = detectColumnGutter(items);
544
+ return groupByBaseline(items, columnGutter).map((lineItems) => ({
408
545
  ...rectFromCorners(
409
546
  lineItems.flatMap(
410
547
  (s) =>
@@ -468,6 +605,36 @@ export function lineSpacingBeforePt(
468
605
  return previousLine ? Math.max(0, line.top - previousLine.bottom) : 0;
469
606
  }
470
607
 
608
+ /** How close a line's own midpoint/edge has to sit to the block's for the whole block to read as centered/right-aligned rather than left. */
609
+ const TEXT_ALIGNMENT_TOLERANCE_PT = 3;
610
+
611
+ /**
612
+ * A block's lines already carry their exact `left`/`right` geometry — rather
613
+ * than always rendering as left-aligned, compare each line against the
614
+ * block's own bounding box to recover which alignment actually produced
615
+ * that geometry. A single line can't disambiguate center from left (every
616
+ * line trivially "centers" on itself), so it's left as-is.
617
+ */
618
+ export function detectBlockAlignment(
619
+ blockLines: Rect[],
620
+ blockLeft: number,
621
+ blockRight: number,
622
+ ): "left" | "center" | "right" {
623
+ if (blockLines.length < 2) return "left";
624
+ const midpoint = (blockLeft + blockRight) / 2;
625
+ const centered = blockLines.every(
626
+ (line) =>
627
+ Math.abs((line.left + line.right) / 2 - midpoint) <=
628
+ TEXT_ALIGNMENT_TOLERANCE_PT,
629
+ );
630
+ if (centered) return "center";
631
+ const rightAligned = blockLines.every(
632
+ (line) => Math.abs(line.right - blockRight) <= TEXT_ALIGNMENT_TOLERANCE_PT,
633
+ );
634
+ if (rightAligned) return "right";
635
+ return "left";
636
+ }
637
+
471
638
  /** A run needs to overlap most of a candidate background image to inherit its "assume dark" contrast — a run merely near a small inset photo shouldn't be treated as sitting on top of it. */
472
639
  const BACKGROUND_IMAGE_OVERLAP_RATIO = 0.5;
473
640
 
@@ -517,7 +684,13 @@ function sliceTextItemBySegments(
517
684
  if (segments.length <= 1) {
518
685
  const color = segments[0]?.color;
519
686
  const box = textItemToBox(item, viewportTransform, color);
520
- return box ? [{ box, colorKnown: color !== undefined }] : [];
687
+ if (!box) return [];
688
+ return [
689
+ {
690
+ box: { ...box, paintOrder: segments[0]?.paintOrder ?? 0 },
691
+ colorKnown: color !== undefined,
692
+ },
693
+ ];
521
694
  }
522
695
  const fullBox = textItemToBox(item, viewportTransform, DEFAULT_TEXT_COLOR);
523
696
  if (!fullBox) return [];
@@ -537,6 +710,7 @@ function sliceTextItemBySegments(
537
710
  right: fullBox.left + spanX * endFraction,
538
711
  text,
539
712
  color: segment.color ?? DEFAULT_TEXT_COLOR,
713
+ paintOrder: segment.paintOrder,
540
714
  },
541
715
  colorKnown: segment.color !== undefined,
542
716
  });
@@ -555,7 +729,7 @@ async function buildTextElements(
555
729
  backgroundImageRect: Rect | undefined,
556
730
  underlineRects: UnderlineRect[],
557
731
  linkRects: LinkRect[],
558
- ): Promise<ParsedElement[]> {
732
+ ): Promise<{ element: ParsedElement; paintOrder: number }[]> {
559
733
  const content = await page.getTextContent();
560
734
  const rawItems = content.items.filter(
561
735
  (item): item is TextItem => "str" in item && item.str.trim().length > 0,
@@ -589,15 +763,25 @@ async function buildTextElements(
589
763
  // ligature collapsing multiple glyphs into fewer output
590
764
  // characters). Split what fits and leave the remainder queued for
591
765
  // the next item instead of corrupting this item's text.
592
- itemSegments.push({ length: remaining, color: segment.color });
766
+ itemSegments.push({
767
+ length: remaining,
768
+ color: segment.color,
769
+ paintOrder: segment.paintOrder,
770
+ });
593
771
  segmentQueue[0] = { ...segment, length: segment.length - remaining };
594
772
  consumed = item.str.length;
595
773
  }
596
774
  }
597
775
  if (consumed < item.str.length) {
776
+ // Ran out of tracked color/paint-order segments before this item's
777
+ // text did — reuse the last known paint order (falling back to the
778
+ // very start of the page) rather than leaving it unset.
779
+ const lastPaintOrder =
780
+ itemSegments[itemSegments.length - 1]?.paintOrder ?? 0;
598
781
  itemSegments.push({
599
782
  length: item.str.length - consumed,
600
783
  color: undefined,
784
+ paintOrder: lastPaintOrder,
601
785
  });
602
786
  }
603
787
  return sliceTextItemBySegments(
@@ -642,6 +826,7 @@ async function buildTextElements(
642
826
  const top = Math.min(...blockLines.map((l) => l.top));
643
827
  const right = Math.max(...blockLines.map((l) => l.right));
644
828
  const bottom = Math.max(...blockLines.map((l) => l.bottom));
829
+ const alignment = detectBlockAlignment(blockLines, left, right);
645
830
  const paragraphs: ParsedParagraph[] = blockLines.map((line, lineIndex) => {
646
831
  const spaceBeforePt = lineSpacingBeforePt(
647
832
  line,
@@ -656,19 +841,26 @@ async function buildTextElements(
656
841
  italic: run.italic,
657
842
  underline: run.underline,
658
843
  href: run.href,
844
+ fontFamily: run.fontFamily,
659
845
  })),
660
- alignment: "left",
846
+ alignment,
661
847
  ...(spaceBeforePt > 0 ? { spaceBeforePt } : {}),
662
848
  };
663
849
  });
850
+ const paintOrder = Math.min(
851
+ ...blockLines.flatMap((line) => line.runs.map((run) => run.paintOrder)),
852
+ );
664
853
  return {
665
- id: `pdf-text-${pageNumber}-${index}`,
666
- kind: "text",
667
- x: left * EMU_PER_POINT,
668
- y: top * EMU_PER_POINT,
669
- width: Math.max(1, right - left) * EMU_PER_POINT,
670
- height: Math.max(1, bottom - top) * EMU_PER_POINT,
671
- paragraphs,
854
+ element: {
855
+ id: `pdf-text-${pageNumber}-${index}`,
856
+ kind: "text",
857
+ x: left * EMU_PER_POINT,
858
+ y: top * EMU_PER_POINT,
859
+ width: Math.max(1, right - left) * EMU_PER_POINT,
860
+ height: Math.max(1, bottom - top) * EMU_PER_POINT,
861
+ paragraphs,
862
+ },
863
+ paintOrder,
672
864
  };
673
865
  });
674
866
  }
@@ -677,10 +869,19 @@ async function buildTextElements(
677
869
  export interface TextColorRun {
678
870
  length: number;
679
871
  color: string | undefined;
872
+ /** Index in this page's real content-stream paint order, shared with image paint events. */
873
+ paintOrder: number;
874
+ }
875
+
876
+ /** An image's placed rect plus its index in this page's real paint order, shared with `TextColorRun.paintOrder` so images and text can be z-ordered by how the PDF actually painted them instead of images always going first. */
877
+ export interface PaintedImageRect extends Rect {
878
+ paintOrder: number;
879
+ /** The PDF resource name shared with the corresponding `pdf.getImage()` result. */
880
+ imageName: string | undefined;
680
881
  }
681
882
 
682
883
  interface PageGraphics {
683
- imageRects: Rect[];
884
+ imageRects: PaintedImageRect[];
684
885
  /** The earliest fill covering most of the page — almost always the deck's background. */
685
886
  backgroundColor: string | undefined;
686
887
  /** Fill color + character length of each real text-showing op, in operator-list order; `color` is undefined when it wasn't recoverable. */
@@ -702,12 +903,16 @@ interface PageGraphics {
702
903
  export async function walkPageGraphics(
703
904
  page: PDFPageProxy,
704
905
  viewport: { transform: Mat; width: number; height: number },
906
+ operatorList?: PdfOperatorList,
705
907
  ): Promise<PageGraphics> {
706
908
  const viewportTransform = viewport.transform;
707
- const opList = await page.getOperatorList();
708
- const imageRects: Rect[] = [];
909
+ const opList = operatorList ?? (await page.getOperatorList());
910
+ const imageRects: PaintedImageRect[] = [];
709
911
  const textRuns: TextColorRun[] = [];
710
912
  const underlineRects: UnderlineRect[] = [];
913
+ // Shared by images and text so both can be sorted back into one real
914
+ // paint order afterward, instead of images always sitting behind text.
915
+ let paintOrder = 0;
711
916
  let backgroundColor: string | undefined;
712
917
  let fillColor = DEFAULT_TEXT_COLOR;
713
918
  // Starts true: the PDF spec's initial nonstroking color IS black, so text
@@ -735,10 +940,7 @@ export async function walkPageGraphics(
735
940
  }
736
941
  } else if (fn === OPS.transform) {
737
942
  ctm = Util.transform(ctm, args as Mat) as Mat;
738
- } else if (
739
- fn === OPS.paintImageXObject ||
740
- fn === OPS.paintInlineImageXObject
741
- ) {
943
+ } else if (isImagePaintOp(fn)) {
742
944
  const unitCorners: [number, number][] = [
743
945
  [0, 0],
744
946
  [1, 0],
@@ -749,7 +951,11 @@ export async function walkPageGraphics(
749
951
  const [ux, uy] = applyPoint(ctm, x, y);
750
952
  return applyPoint(viewportTransform, ux, uy);
751
953
  });
752
- imageRects.push(rectFromCorners(deviceCorners));
954
+ imageRects.push({
955
+ ...rectFromCorners(deviceCorners),
956
+ paintOrder: paintOrder++,
957
+ imageName: typeof args[0] === "string" ? args[0] : undefined,
958
+ });
753
959
  } else if (TEXT_SHOWING_OPS.has(fn)) {
754
960
  // Count only the glyph descriptors, not the raw kerning-adjustment
755
961
  // numbers a `TJ` array can interleave between them, so this length
@@ -767,6 +973,7 @@ export async function walkPageGraphics(
767
973
  textRuns.push({
768
974
  length,
769
975
  color: fillColorKnown ? fillColor : undefined,
976
+ paintOrder: paintOrder++,
770
977
  });
771
978
  }
772
979
  } else if (fn === OPS.constructPath) {
@@ -879,26 +1086,30 @@ export async function parsePdfFidelity(
879
1086
  doc: PDFDocumentProxy,
880
1087
  imagesByPage: PdfPageImage[],
881
1088
  ): Promise<PdfFidelityPage[]> {
882
- const imageBytesByPage = new Map<number, Uint8Array[]>();
1089
+ const imageBytesByPage = new Map<number, PdfPageImage["images"]>();
883
1090
  for (const entry of imagesByPage) {
884
- imageBytesByPage.set(
885
- entry.pageNumber,
886
- entry.images.map((img) => img.data),
887
- );
1091
+ imageBytesByPage.set(entry.pageNumber, entry.images);
888
1092
  }
889
1093
 
890
1094
  const pages: PdfFidelityPage[] = [];
891
1095
  for (let pageNumber = 1; pageNumber <= doc.numPages; pageNumber++) {
1096
+ let imagePaintCount = 0;
892
1097
  try {
893
1098
  const page = await doc.getPage(pageNumber);
894
1099
  const viewport = page.getViewport({ scale: 1, rotation: page.rotate });
895
1100
  const viewportTransform = viewport.transform as Mat;
1101
+ const operatorList = await page.getOperatorList();
1102
+ imagePaintCount = operatorList.fnArray.filter(isImagePaintOp).length;
896
1103
 
897
- const graphics = await walkPageGraphics(page, {
898
- transform: viewportTransform,
899
- width: viewport.width,
900
- height: viewport.height,
901
- });
1104
+ const graphics = await walkPageGraphics(
1105
+ page,
1106
+ {
1107
+ transform: viewportTransform,
1108
+ width: viewport.width,
1109
+ height: viewport.height,
1110
+ },
1111
+ operatorList,
1112
+ );
902
1113
  const linkRects = await collectLinkRects(page, viewportTransform);
903
1114
  const imageRects = graphics.imageRects;
904
1115
  // The largest image that covers a substantial share of the page is
@@ -911,29 +1122,61 @@ export async function parsePdfFidelity(
911
1122
  .filter((rect) => rectArea(rect) >= pageArea * 0.4)
912
1123
  .sort((a, b) => rectArea(b) - rectArea(a))[0];
913
1124
  const imageBytes = imageBytesByPage.get(pageNumber) ?? [];
914
- const imageElements: ParsedElement[] = imageRects
915
- .map((rect, index) => ({ rect, data: imageBytes[index] }))
916
- .filter(
917
- ({ rect, data }) =>
918
- data &&
919
- rect.right - rect.left >= MIN_IMAGE_POINTS &&
920
- rect.bottom - rect.top >= MIN_IMAGE_POINTS,
921
- )
922
- .map(({ rect, data }, index) => ({
923
- id: `pdf-img-${pageNumber}-${index}`,
924
- kind: "image" as const,
925
- x: rect.left * EMU_PER_POINT,
926
- y: rect.top * EMU_PER_POINT,
927
- width: Math.max(1, rect.right - rect.left) * EMU_PER_POINT,
928
- height: Math.max(1, rect.bottom - rect.top) * EMU_PER_POINT,
929
- image: {
930
- data: data as Uint8Array,
931
- mimeType: "image/png",
932
- name: `image-${index}`,
933
- },
934
- }));
935
-
936
- const textElements = await buildTextElements(
1125
+ const rectsByImageName = new Map<string, PaintedImageRect[]>();
1126
+ for (const rect of imageRects) {
1127
+ if (rect.imageName === undefined) continue;
1128
+ const rects = rectsByImageName.get(rect.imageName) ?? [];
1129
+ rects.push(rect);
1130
+ rectsByImageName.set(rect.imageName, rects);
1131
+ }
1132
+ const bytesByImageName = new Map<string, PdfPageImage["images"]>();
1133
+ for (const image of imageBytes) {
1134
+ const images = bytesByImageName.get(image.name) ?? [];
1135
+ images.push(image);
1136
+ bytesByImageName.set(image.name, images);
1137
+ }
1138
+ const imageDataByRect = new Map<PaintedImageRect, Uint8Array>();
1139
+ for (const [imageName, rects] of rectsByImageName) {
1140
+ const images = bytesByImageName.get(imageName);
1141
+ // A resource can be painted more than once. If extraction returned a
1142
+ // different number of occurrences, there is no safe way to know which
1143
+ // placement is missing, so omit all of them rather than misplacing one.
1144
+ if (!images || images.length !== rects.length) continue;
1145
+ for (let index = 0; index < rects.length; index++) {
1146
+ const data = images[index].data;
1147
+ if (data.byteLength > 0) imageDataByRect.set(rects[index], data);
1148
+ }
1149
+ }
1150
+ const imageResults: { element: ParsedElement; paintOrder: number }[] =
1151
+ imageRects
1152
+ .map((rect) => ({ rect, data: imageDataByRect.get(rect) }))
1153
+ .filter(
1154
+ ({ rect, data }) =>
1155
+ data &&
1156
+ rect.right - rect.left >= MIN_IMAGE_POINTS &&
1157
+ rect.bottom - rect.top >= MIN_IMAGE_POINTS,
1158
+ )
1159
+ .map(({ rect, data }, index) => ({
1160
+ element: {
1161
+ id: `pdf-img-${pageNumber}-${index}`,
1162
+ kind: "image" as const,
1163
+ x: rect.left * EMU_PER_POINT,
1164
+ y: rect.top * EMU_PER_POINT,
1165
+ width: Math.max(1, rect.right - rect.left) * EMU_PER_POINT,
1166
+ height: Math.max(1, rect.bottom - rect.top) * EMU_PER_POINT,
1167
+ image: {
1168
+ data: data as Uint8Array,
1169
+ mimeType: "image/png",
1170
+ name: `image-${index}`,
1171
+ },
1172
+ },
1173
+ paintOrder: rect.paintOrder,
1174
+ }));
1175
+ const imagesSkipped = imageRects.filter(
1176
+ (rect) => !imageDataByRect.has(rect),
1177
+ ).length;
1178
+
1179
+ const textResults = await buildTextElements(
937
1180
  page,
938
1181
  viewportTransform,
939
1182
  pageNumber,
@@ -944,12 +1187,17 @@ export async function parsePdfFidelity(
944
1187
  linkRects,
945
1188
  );
946
1189
 
1190
+ const elements = [...imageResults, ...textResults]
1191
+ .sort((a, b) => a.paintOrder - b.paintOrder)
1192
+ .map((result) => result.element);
1193
+
947
1194
  pages.push({
948
1195
  pageNumber,
949
1196
  widthEmu: viewport.width * EMU_PER_POINT,
950
1197
  heightEmu: viewport.height * EMU_PER_POINT,
951
1198
  backgroundColor: graphics.backgroundColor,
952
- elements: [...imageElements, ...textElements],
1199
+ elements,
1200
+ imagesSkipped,
953
1201
  });
954
1202
  } catch (err) {
955
1203
  console.warn(
@@ -962,6 +1210,10 @@ export async function parsePdfFidelity(
962
1210
  heightEmu: 0,
963
1211
  backgroundColor: undefined,
964
1212
  elements: [],
1213
+ // A parse failure after operator-list inspection must remain visibly
1214
+ // partial. If inspection itself failed, keep the failure loud rather
1215
+ // than allowing a text-only fallback to look source-faithful.
1216
+ imagesSkipped: Math.max(imagePaintCount, 1),
965
1217
  });
966
1218
  }
967
1219
  }
@@ -17,6 +17,8 @@ export interface SourceImportMetadata {
17
17
  slideIds: string[];
18
18
  slides: SourceImportSlideSnapshot[];
19
19
  imagesSkipped?: number;
20
+ /** Count of PPTX `graphicFrame` shapes (tables, or the charts/SmartArt/OLE objects that have no shape structure to reconstruct) that could not be fully converted — same fidelity-signal contract as `imagesSkipped`. */
21
+ tablesDegraded?: number;
20
22
  }
21
23
 
22
24
  export function buildSourceImportMetadata(args: {
@@ -24,18 +26,22 @@ export function buildSourceImportMetadata(args: {
24
26
  importedAt?: string;
25
27
  slides: SourceImportSlideSnapshot[];
26
28
  imagesSkipped?: number;
29
+ tablesDegraded?: number;
27
30
  }): SourceImportMetadata {
28
31
  const importedAt = args.importedAt ?? new Date().toISOString();
29
32
  const imagesSkipped = args.imagesSkipped ?? 0;
33
+ const tablesDegraded = args.tablesDegraded ?? 0;
30
34
  return {
31
35
  mode: "source-preserving",
32
36
  format: args.format,
33
- fidelity: imagesSkipped > 0 ? "partial" : "source-faithful",
37
+ fidelity:
38
+ imagesSkipped > 0 || tablesDegraded > 0 ? "partial" : "source-faithful",
34
39
  importedAt,
35
40
  slideCount: args.slides.length,
36
41
  slideIds: args.slides.map((slide) => slide.id),
37
42
  slides: args.slides,
38
43
  ...(imagesSkipped > 0 ? { imagesSkipped } : {}),
44
+ ...(tablesDegraded > 0 ? { tablesDegraded } : {}),
39
45
  };
40
46
  }
41
47
 
@@ -56,6 +62,8 @@ export function mergeSourceImportMetadata(
56
62
  const slides = [...slidesById.values()];
57
63
  const imagesSkipped =
58
64
  (existing.imagesSkipped ?? 0) + (incoming.imagesSkipped ?? 0);
65
+ const tablesDegraded =
66
+ (existing.tablesDegraded ?? 0) + (incoming.tablesDegraded ?? 0);
59
67
 
60
68
  return {
61
69
  ...incoming,
@@ -67,6 +75,7 @@ export function mergeSourceImportMetadata(
67
75
  slideIds: slides.map((slide) => slide.id),
68
76
  slides,
69
77
  ...(imagesSkipped > 0 ? { imagesSkipped } : {}),
78
+ ...(tablesDegraded > 0 ? { tablesDegraded } : {}),
70
79
  };
71
80
  }
72
81
 
@@ -0,0 +1,4 @@
1
+ import type { MigrationEntry } from "../../db/migrations.js";
2
+ export declare const AGENT_HARNESS_SESSION_MIGRATIONS_TABLE = "_agent_harness_session_migrations";
3
+ /** Authoritative release-time schema for persisted hosted harness sessions. */
4
+ export declare const AGENT_HARNESS_SESSION_MIGRATIONS: MigrationEntry[];