@agent-native/core 0.157.8 → 0.157.10

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 (154) 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 +261 -0
  4. package/corpus/templates/analytics/server/lib/first-party-analytics-backend.ts +3 -1
  5. package/corpus/templates/analytics/server/lib/first-party-analytics-purge.ts +75 -31
  6. package/corpus/templates/analytics/server/lib/workspace-feature-flags.ts +4 -1
  7. package/corpus/templates/analytics/server/plugins/db.ts +139 -0
  8. package/corpus/templates/slides/AGENTS.md +14 -10
  9. package/corpus/templates/slides/actions/export-pptx.ts +1311 -121
  10. package/corpus/templates/slides/actions/import-file.ts +28 -1
  11. package/corpus/templates/slides/actions/import-pptx.ts +69 -42
  12. package/corpus/templates/slides/app/components/deck/SlideRenderer.tsx +222 -29
  13. package/corpus/templates/slides/app/components/editor/ExportMenu.tsx +71 -0
  14. package/corpus/templates/slides/app/global.css +19 -1
  15. package/corpus/templates/slides/app/lib/export-google-slides-client.ts +44 -5
  16. package/corpus/templates/slides/app/lib/export-pdf-client.ts +13 -6
  17. package/corpus/templates/slides/app/lib/export-pptx-client.ts +601 -30
  18. package/corpus/templates/slides/app/pages/DeckEditor.tsx +18 -1
  19. package/corpus/templates/slides/server/handlers/import/html-converter.ts +1240 -22
  20. package/corpus/templates/slides/server/handlers/import/pdf-fidelity-parser.ts +329 -64
  21. package/corpus/templates/slides/server/handlers/import/pptx-assets.ts +24 -0
  22. package/corpus/templates/slides/server/lib/source-import.ts +10 -1
  23. package/corpus/templates/slides/shared/aspect-ratios.ts +13 -1
  24. package/dist/agent/harness/migrations.d.ts +4 -0
  25. package/dist/agent/harness/migrations.js +60 -0
  26. package/dist/agent/production-agent.d.ts +2 -0
  27. package/dist/agent/production-agent.js +15 -0
  28. package/dist/agent/run-loop-with-resume.d.ts +1 -0
  29. package/dist/agent/run-loop-with-resume.js +44 -12
  30. package/dist/agent/run-migrations.d.ts +8 -0
  31. package/dist/agent/run-migrations.js +61 -0
  32. package/dist/automations/service.d.ts +6 -0
  33. package/dist/automations/service.js +35 -0
  34. package/dist/chat-threads/migrations.d.ts +1 -0
  35. package/dist/chat-threads/migrations.js +1 -0
  36. package/dist/chat-threads/schema-migrations.d.ts +8 -0
  37. package/dist/chat-threads/schema-migrations.js +60 -0
  38. package/dist/cli/code-agent-connector.d.ts +4 -1
  39. package/dist/cli/code-agent-connector.js +154 -23
  40. package/dist/cli/code-agent-executor.js +255 -13
  41. package/dist/cli/code-agent-runs.d.ts +1 -0
  42. package/dist/cli/code-agent-runs.js +2 -1
  43. package/dist/cli/portal-workspace.d.ts +49 -0
  44. package/dist/cli/portal-workspace.js +320 -0
  45. package/dist/client/AssistantChat.d.ts +1 -1
  46. package/dist/client/AssistantChat.js +43 -61
  47. package/dist/client/ConnectBuilderCard.js +33 -5
  48. package/dist/client/agent-chat-adapter.js +4 -0
  49. package/dist/client/analytics-platform.d.ts +2 -0
  50. package/dist/client/analytics-platform.js +12 -0
  51. package/dist/client/analytics.d.ts +3 -0
  52. package/dist/client/analytics.js +10 -1
  53. package/dist/client/chat/index.d.ts +1 -0
  54. package/dist/client/chat/index.js +1 -0
  55. package/dist/client/chat/message-components.d.ts +2 -0
  56. package/dist/client/chat/message-components.js +11 -1
  57. package/dist/client/chat/run-recovery.d.ts +2 -1
  58. package/dist/client/chat/run-recovery.js +2 -2
  59. package/dist/client/chat-first/apps-rail.d.ts +1 -1
  60. package/dist/client/chat-first/apps-rail.js +11 -11
  61. package/dist/client/chat-first/surface-tabs.d.ts +1 -1
  62. package/dist/client/chat-first/surface-tabs.js +38 -36
  63. package/dist/client/chat-first/types.d.ts +5 -0
  64. package/dist/client/chat-first.d.ts +1 -1
  65. package/dist/client/chat-first.js +4 -4
  66. package/dist/client/desktop-local-code-change.d.ts +10 -0
  67. package/dist/client/desktop-local-code-change.js +16 -0
  68. package/dist/client/index.d.ts +1 -0
  69. package/dist/client/index.js +1 -0
  70. package/dist/client/observability/ThumbsFeedback.js +53 -26
  71. package/dist/client/observability/useObservability.d.ts +1 -1
  72. package/dist/client/observability/useObservability.js +6 -3
  73. package/dist/client/terminal/AgentTerminal.d.ts +9 -1
  74. package/dist/client/terminal/AgentTerminal.js +38 -3
  75. package/dist/client/terminal/index.d.ts +1 -1
  76. package/dist/client/terminal/index.js +1 -1
  77. package/dist/client/track.js +9 -1
  78. package/dist/client/use-action.js +3 -0
  79. package/dist/collab/awareness.d.ts +2 -2
  80. package/dist/deploy/build.js +1 -1
  81. package/dist/feature-flags/a2a-action-route.js +4 -2
  82. package/dist/feature-flags/permissions.d.ts +2 -0
  83. package/dist/feature-flags/permissions.js +6 -1
  84. package/dist/ingestion/pptx.d.ts +92 -1
  85. package/dist/ingestion/pptx.js +1180 -115
  86. package/dist/integrations/index.d.ts +3 -3
  87. package/dist/integrations/index.js +2 -2
  88. package/dist/integrations/plugin.js +95 -6
  89. package/dist/integrations/remote-commands-store.d.ts +5 -0
  90. package/dist/integrations/remote-commands-store.js +63 -22
  91. package/dist/integrations/remote-devices-store.d.ts +2 -1
  92. package/dist/integrations/remote-devices-store.js +46 -0
  93. package/dist/integrations/remote-types.d.ts +20 -0
  94. package/dist/jobs/actions/list-recurring-jobs.d.ts +3 -0
  95. package/dist/jobs/actions/list-recurring-jobs.js +5 -1
  96. package/dist/jobs/frontmatter.d.ts +16 -0
  97. package/dist/jobs/frontmatter.js +53 -0
  98. package/dist/jobs/remote-execution.d.ts +42 -0
  99. package/dist/jobs/remote-execution.js +287 -0
  100. package/dist/jobs/scheduler.js +102 -3
  101. package/dist/jobs/tools.js +50 -3
  102. package/dist/localization/core-messages/ar-SA.js +4 -0
  103. package/dist/localization/core-messages/de-DE.js +4 -0
  104. package/dist/localization/core-messages/en-US.d.ts +4 -0
  105. package/dist/localization/core-messages/en-US.js +4 -0
  106. package/dist/localization/core-messages/es-ES.js +4 -0
  107. package/dist/localization/core-messages/fr-FR.js +4 -0
  108. package/dist/localization/core-messages/hi-IN.js +4 -0
  109. package/dist/localization/core-messages/ja-JP.js +4 -0
  110. package/dist/localization/core-messages/ko-KR.js +4 -0
  111. package/dist/localization/core-messages/pt-BR.js +4 -0
  112. package/dist/localization/core-messages/zh-CN.js +4 -0
  113. package/dist/localization/core-messages/zh-TW.js +4 -0
  114. package/dist/localization/core-messages.d.ts +4 -0
  115. package/dist/notifications/routes.d.ts +3 -3
  116. package/dist/observability/feedback.js +40 -16
  117. package/dist/observability/routes.d.ts +6 -6
  118. package/dist/observability/routes.js +15 -3
  119. package/dist/observability/store.js +2 -0
  120. package/dist/observability/traces.js +5 -1
  121. package/dist/provider-api/actions/custom-provider-registration.d.ts +16 -16
  122. package/dist/provider-api/actions/provider-api.d.ts +13 -13
  123. package/dist/provider-api/corpus-jobs.d.ts +2 -2
  124. package/dist/resources/handlers.d.ts +1 -1
  125. package/dist/secrets/routes.d.ts +9 -9
  126. package/dist/server/action-routes.d.ts +7 -5
  127. package/dist/server/action-routes.js +13 -6
  128. package/dist/server/agent-chat-plugin.js +6 -0
  129. package/dist/server/agent-engine-api-key-route.d.ts +1 -1
  130. package/dist/server/agent-run-context.d.ts +1 -0
  131. package/dist/server/agent-run-context.js +9 -0
  132. package/dist/server/better-auth-instance.js +4 -0
  133. package/dist/server/core-routes-plugin.js +8 -1
  134. package/dist/server/realtime-voice.js +2 -1
  135. package/dist/server/release-migrations.js +16 -0
  136. package/dist/server/request-context.d.ts +2 -0
  137. package/dist/server/transcribe-voice.d.ts +1 -1
  138. package/dist/shared/analytics-platform.d.ts +6 -0
  139. package/dist/shared/analytics-platform.js +14 -0
  140. package/dist/shared/mcp-embed-headers.d.ts +1 -1
  141. package/dist/shared/mcp-embed-headers.js +1 -1
  142. package/dist/styles/agent-native.css +83 -103
  143. package/dist/terminal/cli-registry.js +5 -0
  144. package/dist/tracking/registry.js +9 -1
  145. package/dist/triggers/actions/list-automations.d.ts +3 -0
  146. package/dist/triggers/actions/list-automations.js +3 -0
  147. package/dist/triggers/actions/manage-automation.d.ts +9 -0
  148. package/dist/triggers/actions/manage-automation.js +14 -2
  149. package/dist/triggers/actions.js +82 -7
  150. package/dist/usage/migrations.d.ts +4 -0
  151. package/dist/usage/migrations.js +45 -0
  152. package/docs/content/template-content-developers.mdx +48 -0
  153. package/docs/content/toolkit-ui.mdx +57 -0
  154. 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,49 @@ 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
+ // The block bounds come from these same lines. Equal-width lines therefore
625
+ // have identical left edges, right edges, and midpoints whether they were
626
+ // left-aligned or centered; keep that ambiguous case left-aligned rather
627
+ // than moving ordinary paragraphs to the center.
628
+ const widths = blockLines.map((line) => line.right - line.left);
629
+ const minWidth = Math.min(...widths);
630
+ const maxWidth = Math.max(...widths);
631
+ const sameBounds = blockLines.every(
632
+ (line) => line.left === blockLeft && line.right === blockRight,
633
+ );
634
+ if (maxWidth - minWidth <= TEXT_ALIGNMENT_TOLERANCE_PT && sameBounds) {
635
+ return "left";
636
+ }
637
+ const midpoint = (blockLeft + blockRight) / 2;
638
+ const centered = blockLines.every(
639
+ (line) =>
640
+ Math.abs((line.left + line.right) / 2 - midpoint) <=
641
+ TEXT_ALIGNMENT_TOLERANCE_PT,
642
+ );
643
+ if (centered) return "center";
644
+ const rightAligned = blockLines.every(
645
+ (line) => Math.abs(line.right - blockRight) <= TEXT_ALIGNMENT_TOLERANCE_PT,
646
+ );
647
+ if (rightAligned) return "right";
648
+ return "left";
649
+ }
650
+
471
651
  /** 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
652
  const BACKGROUND_IMAGE_OVERLAP_RATIO = 0.5;
473
653
 
@@ -517,7 +697,13 @@ function sliceTextItemBySegments(
517
697
  if (segments.length <= 1) {
518
698
  const color = segments[0]?.color;
519
699
  const box = textItemToBox(item, viewportTransform, color);
520
- return box ? [{ box, colorKnown: color !== undefined }] : [];
700
+ if (!box) return [];
701
+ return [
702
+ {
703
+ box: { ...box, paintOrder: segments[0]?.paintOrder ?? 0 },
704
+ colorKnown: color !== undefined,
705
+ },
706
+ ];
521
707
  }
522
708
  const fullBox = textItemToBox(item, viewportTransform, DEFAULT_TEXT_COLOR);
523
709
  if (!fullBox) return [];
@@ -537,6 +723,7 @@ function sliceTextItemBySegments(
537
723
  right: fullBox.left + spanX * endFraction,
538
724
  text,
539
725
  color: segment.color ?? DEFAULT_TEXT_COLOR,
726
+ paintOrder: segment.paintOrder,
540
727
  },
541
728
  colorKnown: segment.color !== undefined,
542
729
  });
@@ -555,7 +742,7 @@ async function buildTextElements(
555
742
  backgroundImageRect: Rect | undefined,
556
743
  underlineRects: UnderlineRect[],
557
744
  linkRects: LinkRect[],
558
- ): Promise<ParsedElement[]> {
745
+ ): Promise<{ element: ParsedElement; paintOrder: number }[]> {
559
746
  const content = await page.getTextContent();
560
747
  const rawItems = content.items.filter(
561
748
  (item): item is TextItem => "str" in item && item.str.trim().length > 0,
@@ -589,15 +776,25 @@ async function buildTextElements(
589
776
  // ligature collapsing multiple glyphs into fewer output
590
777
  // characters). Split what fits and leave the remainder queued for
591
778
  // the next item instead of corrupting this item's text.
592
- itemSegments.push({ length: remaining, color: segment.color });
779
+ itemSegments.push({
780
+ length: remaining,
781
+ color: segment.color,
782
+ paintOrder: segment.paintOrder,
783
+ });
593
784
  segmentQueue[0] = { ...segment, length: segment.length - remaining };
594
785
  consumed = item.str.length;
595
786
  }
596
787
  }
597
788
  if (consumed < item.str.length) {
789
+ // Ran out of tracked color/paint-order segments before this item's
790
+ // text did — reuse the last known paint order (falling back to the
791
+ // very start of the page) rather than leaving it unset.
792
+ const lastPaintOrder =
793
+ itemSegments[itemSegments.length - 1]?.paintOrder ?? 0;
598
794
  itemSegments.push({
599
795
  length: item.str.length - consumed,
600
796
  color: undefined,
797
+ paintOrder: lastPaintOrder,
601
798
  });
602
799
  }
603
800
  return sliceTextItemBySegments(
@@ -642,6 +839,7 @@ async function buildTextElements(
642
839
  const top = Math.min(...blockLines.map((l) => l.top));
643
840
  const right = Math.max(...blockLines.map((l) => l.right));
644
841
  const bottom = Math.max(...blockLines.map((l) => l.bottom));
842
+ const alignment = detectBlockAlignment(blockLines, left, right);
645
843
  const paragraphs: ParsedParagraph[] = blockLines.map((line, lineIndex) => {
646
844
  const spaceBeforePt = lineSpacingBeforePt(
647
845
  line,
@@ -656,19 +854,26 @@ async function buildTextElements(
656
854
  italic: run.italic,
657
855
  underline: run.underline,
658
856
  href: run.href,
857
+ fontFamily: run.fontFamily,
659
858
  })),
660
- alignment: "left",
859
+ alignment,
661
860
  ...(spaceBeforePt > 0 ? { spaceBeforePt } : {}),
662
861
  };
663
862
  });
863
+ const paintOrder = Math.min(
864
+ ...blockLines.flatMap((line) => line.runs.map((run) => run.paintOrder)),
865
+ );
664
866
  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,
867
+ element: {
868
+ id: `pdf-text-${pageNumber}-${index}`,
869
+ kind: "text",
870
+ x: left * EMU_PER_POINT,
871
+ y: top * EMU_PER_POINT,
872
+ width: Math.max(1, right - left) * EMU_PER_POINT,
873
+ height: Math.max(1, bottom - top) * EMU_PER_POINT,
874
+ paragraphs,
875
+ },
876
+ paintOrder,
672
877
  };
673
878
  });
674
879
  }
@@ -677,10 +882,19 @@ async function buildTextElements(
677
882
  export interface TextColorRun {
678
883
  length: number;
679
884
  color: string | undefined;
885
+ /** Index in this page's real content-stream paint order, shared with image paint events. */
886
+ paintOrder: number;
887
+ }
888
+
889
+ /** 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. */
890
+ export interface PaintedImageRect extends Rect {
891
+ paintOrder: number;
892
+ /** The PDF resource name shared with the corresponding `pdf.getImage()` result. */
893
+ imageName: string | undefined;
680
894
  }
681
895
 
682
896
  interface PageGraphics {
683
- imageRects: Rect[];
897
+ imageRects: PaintedImageRect[];
684
898
  /** The earliest fill covering most of the page — almost always the deck's background. */
685
899
  backgroundColor: string | undefined;
686
900
  /** Fill color + character length of each real text-showing op, in operator-list order; `color` is undefined when it wasn't recoverable. */
@@ -702,12 +916,16 @@ interface PageGraphics {
702
916
  export async function walkPageGraphics(
703
917
  page: PDFPageProxy,
704
918
  viewport: { transform: Mat; width: number; height: number },
919
+ operatorList?: PdfOperatorList,
705
920
  ): Promise<PageGraphics> {
706
921
  const viewportTransform = viewport.transform;
707
- const opList = await page.getOperatorList();
708
- const imageRects: Rect[] = [];
922
+ const opList = operatorList ?? (await page.getOperatorList());
923
+ const imageRects: PaintedImageRect[] = [];
709
924
  const textRuns: TextColorRun[] = [];
710
925
  const underlineRects: UnderlineRect[] = [];
926
+ // Shared by images and text so both can be sorted back into one real
927
+ // paint order afterward, instead of images always sitting behind text.
928
+ let paintOrder = 0;
711
929
  let backgroundColor: string | undefined;
712
930
  let fillColor = DEFAULT_TEXT_COLOR;
713
931
  // Starts true: the PDF spec's initial nonstroking color IS black, so text
@@ -735,10 +953,7 @@ export async function walkPageGraphics(
735
953
  }
736
954
  } else if (fn === OPS.transform) {
737
955
  ctm = Util.transform(ctm, args as Mat) as Mat;
738
- } else if (
739
- fn === OPS.paintImageXObject ||
740
- fn === OPS.paintInlineImageXObject
741
- ) {
956
+ } else if (isImagePaintOp(fn)) {
742
957
  const unitCorners: [number, number][] = [
743
958
  [0, 0],
744
959
  [1, 0],
@@ -749,7 +964,11 @@ export async function walkPageGraphics(
749
964
  const [ux, uy] = applyPoint(ctm, x, y);
750
965
  return applyPoint(viewportTransform, ux, uy);
751
966
  });
752
- imageRects.push(rectFromCorners(deviceCorners));
967
+ imageRects.push({
968
+ ...rectFromCorners(deviceCorners),
969
+ paintOrder: paintOrder++,
970
+ imageName: typeof args[0] === "string" ? args[0] : undefined,
971
+ });
753
972
  } else if (TEXT_SHOWING_OPS.has(fn)) {
754
973
  // Count only the glyph descriptors, not the raw kerning-adjustment
755
974
  // numbers a `TJ` array can interleave between them, so this length
@@ -767,6 +986,7 @@ export async function walkPageGraphics(
767
986
  textRuns.push({
768
987
  length,
769
988
  color: fillColorKnown ? fillColor : undefined,
989
+ paintOrder: paintOrder++,
770
990
  });
771
991
  }
772
992
  } else if (fn === OPS.constructPath) {
@@ -879,26 +1099,30 @@ export async function parsePdfFidelity(
879
1099
  doc: PDFDocumentProxy,
880
1100
  imagesByPage: PdfPageImage[],
881
1101
  ): Promise<PdfFidelityPage[]> {
882
- const imageBytesByPage = new Map<number, Uint8Array[]>();
1102
+ const imageBytesByPage = new Map<number, PdfPageImage["images"]>();
883
1103
  for (const entry of imagesByPage) {
884
- imageBytesByPage.set(
885
- entry.pageNumber,
886
- entry.images.map((img) => img.data),
887
- );
1104
+ imageBytesByPage.set(entry.pageNumber, entry.images);
888
1105
  }
889
1106
 
890
1107
  const pages: PdfFidelityPage[] = [];
891
1108
  for (let pageNumber = 1; pageNumber <= doc.numPages; pageNumber++) {
1109
+ let imagePaintCount = 0;
892
1110
  try {
893
1111
  const page = await doc.getPage(pageNumber);
894
1112
  const viewport = page.getViewport({ scale: 1, rotation: page.rotate });
895
1113
  const viewportTransform = viewport.transform as Mat;
1114
+ const operatorList = await page.getOperatorList();
1115
+ imagePaintCount = operatorList.fnArray.filter(isImagePaintOp).length;
896
1116
 
897
- const graphics = await walkPageGraphics(page, {
898
- transform: viewportTransform,
899
- width: viewport.width,
900
- height: viewport.height,
901
- });
1117
+ const graphics = await walkPageGraphics(
1118
+ page,
1119
+ {
1120
+ transform: viewportTransform,
1121
+ width: viewport.width,
1122
+ height: viewport.height,
1123
+ },
1124
+ operatorList,
1125
+ );
902
1126
  const linkRects = await collectLinkRects(page, viewportTransform);
903
1127
  const imageRects = graphics.imageRects;
904
1128
  // The largest image that covers a substantial share of the page is
@@ -911,29 +1135,61 @@ export async function parsePdfFidelity(
911
1135
  .filter((rect) => rectArea(rect) >= pageArea * 0.4)
912
1136
  .sort((a, b) => rectArea(b) - rectArea(a))[0];
913
1137
  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(
1138
+ const rectsByImageName = new Map<string, PaintedImageRect[]>();
1139
+ for (const rect of imageRects) {
1140
+ if (rect.imageName === undefined) continue;
1141
+ const rects = rectsByImageName.get(rect.imageName) ?? [];
1142
+ rects.push(rect);
1143
+ rectsByImageName.set(rect.imageName, rects);
1144
+ }
1145
+ const bytesByImageName = new Map<string, PdfPageImage["images"]>();
1146
+ for (const image of imageBytes) {
1147
+ const images = bytesByImageName.get(image.name) ?? [];
1148
+ images.push(image);
1149
+ bytesByImageName.set(image.name, images);
1150
+ }
1151
+ const imageDataByRect = new Map<PaintedImageRect, Uint8Array>();
1152
+ for (const [imageName, rects] of rectsByImageName) {
1153
+ const images = bytesByImageName.get(imageName);
1154
+ // A resource can be painted more than once. If extraction returned a
1155
+ // different number of occurrences, there is no safe way to know which
1156
+ // placement is missing, so omit all of them rather than misplacing one.
1157
+ if (!images || images.length !== rects.length) continue;
1158
+ for (let index = 0; index < rects.length; index++) {
1159
+ const data = images[index].data;
1160
+ if (data.byteLength > 0) imageDataByRect.set(rects[index], data);
1161
+ }
1162
+ }
1163
+ const imageResults: { element: ParsedElement; paintOrder: number }[] =
1164
+ imageRects
1165
+ .map((rect) => ({ rect, data: imageDataByRect.get(rect) }))
1166
+ .filter(
1167
+ ({ rect, data }) =>
1168
+ data &&
1169
+ rect.right - rect.left >= MIN_IMAGE_POINTS &&
1170
+ rect.bottom - rect.top >= MIN_IMAGE_POINTS,
1171
+ )
1172
+ .map(({ rect, data }, index) => ({
1173
+ element: {
1174
+ id: `pdf-img-${pageNumber}-${index}`,
1175
+ kind: "image" as const,
1176
+ x: rect.left * EMU_PER_POINT,
1177
+ y: rect.top * EMU_PER_POINT,
1178
+ width: Math.max(1, rect.right - rect.left) * EMU_PER_POINT,
1179
+ height: Math.max(1, rect.bottom - rect.top) * EMU_PER_POINT,
1180
+ image: {
1181
+ data: data as Uint8Array,
1182
+ mimeType: "image/png",
1183
+ name: `image-${index}`,
1184
+ },
1185
+ },
1186
+ paintOrder: rect.paintOrder,
1187
+ }));
1188
+ const imagesSkipped = imageRects.filter(
1189
+ (rect) => !imageDataByRect.has(rect),
1190
+ ).length;
1191
+
1192
+ const textResults = await buildTextElements(
937
1193
  page,
938
1194
  viewportTransform,
939
1195
  pageNumber,
@@ -944,12 +1200,17 @@ export async function parsePdfFidelity(
944
1200
  linkRects,
945
1201
  );
946
1202
 
1203
+ const elements = [...imageResults, ...textResults]
1204
+ .sort((a, b) => a.paintOrder - b.paintOrder)
1205
+ .map((result) => result.element);
1206
+
947
1207
  pages.push({
948
1208
  pageNumber,
949
1209
  widthEmu: viewport.width * EMU_PER_POINT,
950
1210
  heightEmu: viewport.height * EMU_PER_POINT,
951
1211
  backgroundColor: graphics.backgroundColor,
952
- elements: [...imageElements, ...textElements],
1212
+ elements,
1213
+ imagesSkipped,
953
1214
  });
954
1215
  } catch (err) {
955
1216
  console.warn(
@@ -962,6 +1223,10 @@ export async function parsePdfFidelity(
962
1223
  heightEmu: 0,
963
1224
  backgroundColor: undefined,
964
1225
  elements: [],
1226
+ // A parse failure after operator-list inspection must remain visibly
1227
+ // partial. If inspection itself failed, keep the failure loud rather
1228
+ // than allowing a text-only fallback to look source-faithful.
1229
+ imagesSkipped: Math.max(imagePaintCount, 1),
965
1230
  });
966
1231
  }
967
1232
  }
@@ -12,6 +12,30 @@ const BROWSER_RENDERABLE_IMAGE_MIME_TYPES = new Set([
12
12
  "image/bmp",
13
13
  ]);
14
14
 
15
+ /**
16
+ * Validate every image before any upload starts. PPTX imports reject partial
17
+ * fidelity, so an unsupported image must not leave earlier slide uploads
18
+ * orphaned when the action eventually throws.
19
+ */
20
+ export function assertPptxImagesRenderable(
21
+ slides: readonly ParsedSlide[],
22
+ ): void {
23
+ const unsupported = new Set<string>();
24
+ for (const slide of slides) {
25
+ for (const element of slide.elements ?? []) {
26
+ if (element.kind !== "image" || !element.image) continue;
27
+ if (!BROWSER_RENDERABLE_IMAGE_MIME_TYPES.has(element.image.mimeType)) {
28
+ unsupported.add(element.image.mimeType || "unknown");
29
+ }
30
+ }
31
+ }
32
+ if (unsupported.size > 0) {
33
+ throw new Error(
34
+ `Source-faithful PPTX import cannot preserve unsupported image type(s): ${[...unsupported].join(", ")}. No images were uploaded. Re-export the deck with browser-renderable images or use a PDF export for page-faithful preservation.`,
35
+ );
36
+ }
37
+ }
38
+
15
39
  export async function uploadPptxSlideImages(args: {
16
40
  slide: ParsedSlide;
17
41
  slideIndex: number;