@agent-native/core 0.146.0 → 0.146.2

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 (120) hide show
  1. package/corpus/README.md +1 -1
  2. package/corpus/templates/analytics/.agents/skills/turn-into-app/SKILL.md +4 -0
  3. package/corpus/templates/analytics/.agents/skills/turn-into-app/references/fresh-project.md +8 -0
  4. package/corpus/templates/analytics/actions/compose-dashboard.ts +2 -27
  5. package/corpus/templates/analytics/actions/get-sql-dashboard.ts +7 -8
  6. package/corpus/templates/analytics/actions/migrate-first-party-analytics-to-bigquery.ts +18 -2
  7. package/corpus/templates/analytics/actions/mutate-dashboard.ts +16 -24
  8. package/corpus/templates/analytics/actions/rename-dashboard.ts +2 -24
  9. package/corpus/templates/analytics/actions/reorder-dashboard-panels.ts +2 -24
  10. package/corpus/templates/analytics/actions/restore-dashboard-revision.ts +2 -25
  11. package/corpus/templates/analytics/actions/update-dashboard.ts +4 -32
  12. package/corpus/templates/analytics/app/components/layout/CommandPalette.tsx +1 -40
  13. package/corpus/templates/analytics/changelog/2026-08-07-language-selection-now-lives-in-settings-instead-of-the-comm.md +6 -0
  14. package/corpus/templates/analytics/netlify.toml +7 -0
  15. package/corpus/templates/analytics/seeds/dashboards/agent-native-templates-first-party.json +13 -13
  16. package/corpus/templates/analytics/server/jobs/analytics-bigquery-backfill.ts +4 -2
  17. package/corpus/templates/analytics/server/lib/canonical-first-party-dashboard-repair.ts +0 -35
  18. package/corpus/templates/analytics/server/lib/dashboard-catalog.ts +5 -3
  19. package/corpus/templates/analytics/server/lib/dashboard-collab-sync.ts +77 -0
  20. package/corpus/templates/analytics/server/lib/demo-dashboards.ts +2 -23
  21. package/corpus/templates/analytics/server/lib/first-party-metric-catalog.ts +28 -74
  22. package/corpus/templates/analytics/server/routes/api/dashboard-agent-context.json.get.ts +5 -9
  23. package/corpus/templates/assets/.agents/skills/turn-into-app/SKILL.md +4 -0
  24. package/corpus/templates/assets/.agents/skills/turn-into-app/references/fresh-project.md +8 -0
  25. package/corpus/templates/brain/.agents/skills/turn-into-app/SKILL.md +4 -0
  26. package/corpus/templates/brain/.agents/skills/turn-into-app/references/fresh-project.md +8 -0
  27. package/corpus/templates/calendar/.agents/skills/turn-into-app/SKILL.md +4 -0
  28. package/corpus/templates/calendar/.agents/skills/turn-into-app/references/fresh-project.md +8 -0
  29. package/corpus/templates/chat/.agents/skills/turn-into-app/SKILL.md +4 -0
  30. package/corpus/templates/chat/.agents/skills/turn-into-app/references/fresh-project.md +8 -0
  31. package/corpus/templates/clips/.agents/skills/turn-into-app/SKILL.md +4 -0
  32. package/corpus/templates/clips/.agents/skills/turn-into-app/references/fresh-project.md +8 -0
  33. package/corpus/templates/clips/app/components/player/video-player.tsx +1 -1
  34. package/corpus/templates/clips/changelog/2026-08-07-fixed-trimmed-clips-durations-so-the-main-player-display-mat.md +6 -0
  35. package/corpus/templates/clips/chrome-extension/STORE_DRAFT.md +1 -1
  36. package/corpus/templates/clips/chrome-extension/public/manifest.json +1 -1
  37. package/corpus/templates/content/.agents/skills/turn-into-app/SKILL.md +4 -0
  38. package/corpus/templates/content/.agents/skills/turn-into-app/references/fresh-project.md +8 -0
  39. package/corpus/templates/crm/.agents/skills/turn-into-app/SKILL.md +4 -0
  40. package/corpus/templates/crm/.agents/skills/turn-into-app/references/fresh-project.md +8 -0
  41. package/corpus/templates/design/.agents/skills/turn-into-app/SKILL.md +4 -0
  42. package/corpus/templates/design/.agents/skills/turn-into-app/references/fresh-project.md +8 -0
  43. package/corpus/templates/dispatch/.agents/skills/turn-into-app/SKILL.md +4 -0
  44. package/corpus/templates/dispatch/.agents/skills/turn-into-app/references/fresh-project.md +8 -0
  45. package/corpus/templates/dispatch/changelog/2026-08-07-language-selection-now-lives-in-settings-instead-of-the-disp.md +6 -0
  46. package/corpus/templates/factory/.agents/skills/turn-into-app/SKILL.md +4 -0
  47. package/corpus/templates/factory/.agents/skills/turn-into-app/references/fresh-project.md +8 -0
  48. package/corpus/templates/factory/server/plugins/factory-scheduler-job.ts +59 -22
  49. package/corpus/templates/forms/.agents/skills/turn-into-app/SKILL.md +4 -0
  50. package/corpus/templates/forms/.agents/skills/turn-into-app/references/fresh-project.md +8 -0
  51. package/corpus/templates/macros/.agents/skills/turn-into-app/SKILL.md +4 -0
  52. package/corpus/templates/macros/.agents/skills/turn-into-app/references/fresh-project.md +8 -0
  53. package/corpus/templates/mail/.agents/skills/turn-into-app/SKILL.md +4 -0
  54. package/corpus/templates/mail/.agents/skills/turn-into-app/references/fresh-project.md +8 -0
  55. package/corpus/templates/plan/.agents/skills/turn-into-app/SKILL.md +4 -0
  56. package/corpus/templates/plan/.agents/skills/turn-into-app/references/fresh-project.md +8 -0
  57. package/corpus/templates/slides/.agents/skills/image-generation-via-a2a/SKILL.md +10 -1
  58. package/corpus/templates/slides/.agents/skills/slide-images/SKILL.md +10 -3
  59. package/corpus/templates/slides/.agents/skills/turn-into-app/SKILL.md +4 -0
  60. package/corpus/templates/slides/.agents/skills/turn-into-app/references/fresh-project.md +8 -0
  61. package/corpus/templates/slides/actions/generate-image-api.ts +123 -0
  62. package/corpus/templates/slides/actions/import-file.ts +144 -99
  63. package/corpus/templates/slides/app/components/deck/DeckCard.tsx +10 -2
  64. package/corpus/templates/slides/app/components/editor/ImageGenPanel.tsx +1 -1
  65. package/corpus/templates/slides/changelog/2026-08-07-deleting-a-deck-no-longer-freezes-the-slides-browser.md +6 -0
  66. package/corpus/templates/slides/changelog/2026-08-07-slides-verify-generated-images-are-persisted-on-the-target-s.md +6 -0
  67. package/corpus/templates/slides/package.json +1 -0
  68. package/corpus/templates/slides/server/handlers/import/html-converter.ts +9 -0
  69. package/corpus/templates/slides/server/handlers/import/pdf-fidelity-parser.ts +960 -0
  70. package/corpus/templates/slides/server/lib/slide-image-insertion.ts +141 -0
  71. package/corpus/templates/slides/server/plugins/agent-chat.ts +2 -0
  72. package/corpus/templates/tasks/.agents/skills/turn-into-app/SKILL.md +4 -0
  73. package/corpus/templates/tasks/.agents/skills/turn-into-app/references/fresh-project.md +8 -0
  74. package/dist/client/AgentPanel.d.ts +10 -4
  75. package/dist/client/AgentPanel.js +29 -82
  76. package/dist/collab/awareness.d.ts +2 -2
  77. package/dist/collab/struct-routes.d.ts +1 -1
  78. package/dist/db/migrations.js +22 -4
  79. package/dist/deploy/build.d.ts +1 -0
  80. package/dist/deploy/build.js +27 -22
  81. package/dist/file-upload/actions/upload-image.d.ts +1 -1
  82. package/dist/ingestion/pptx.d.ts +1 -0
  83. package/dist/ingestion/pptx.js +353 -28
  84. package/dist/notifications/routes.d.ts +2 -2
  85. package/dist/observability/routes.d.ts +1 -1
  86. package/dist/progress/routes.d.ts +1 -1
  87. package/dist/resources/handlers.d.ts +1 -1
  88. package/dist/secrets/routes.d.ts +6 -6
  89. package/dist/server/builder-browser.d.ts +34 -0
  90. package/dist/server/builder-browser.js +179 -0
  91. package/dist/server/core-routes-plugin.d.ts +5 -0
  92. package/dist/server/core-routes-plugin.js +24 -8
  93. package/dist/server/index.d.ts +1 -1
  94. package/dist/server/index.js +1 -1
  95. package/dist/shared/cache-control.d.ts +2 -2
  96. package/dist/shared/cache-control.js +2 -2
  97. package/dist/templates/chat/.agents/skills/turn-into-app/SKILL.md +4 -0
  98. package/dist/templates/chat/.agents/skills/turn-into-app/references/fresh-project.md +8 -0
  99. package/dist/templates/default/.agents/skills/turn-into-app/SKILL.md +4 -0
  100. package/dist/templates/default/.agents/skills/turn-into-app/references/fresh-project.md +8 -0
  101. package/dist/templates/headless/.agents/skills/turn-into-app/SKILL.md +4 -0
  102. package/dist/templates/headless/.agents/skills/turn-into-app/references/fresh-project.md +8 -0
  103. package/dist/templates/workspace-core/.agents/skills/turn-into-app/SKILL.md +4 -0
  104. package/dist/templates/workspace-core/.agents/skills/turn-into-app/references/fresh-project.md +8 -0
  105. package/dist/vite/client.js +13 -1
  106. package/docs/content/drop-in-agent.mdx +3 -3
  107. package/docs/content/getting-started-actions.mdx +1 -2
  108. package/docs/content/getting-started-pages.mdx +1 -2
  109. package/docs/content/getting-started.mdx +0 -4
  110. package/docs/content/what-is-agent-native.mdx +4 -4
  111. package/package.json +3 -3
  112. package/src/templates/chat/.agents/skills/turn-into-app/SKILL.md +4 -0
  113. package/src/templates/chat/.agents/skills/turn-into-app/references/fresh-project.md +8 -0
  114. package/src/templates/default/.agents/skills/turn-into-app/SKILL.md +4 -0
  115. package/src/templates/default/.agents/skills/turn-into-app/references/fresh-project.md +8 -0
  116. package/src/templates/headless/.agents/skills/turn-into-app/SKILL.md +4 -0
  117. package/src/templates/headless/.agents/skills/turn-into-app/references/fresh-project.md +8 -0
  118. package/src/templates/workspace-core/.agents/skills/turn-into-app/SKILL.md +4 -0
  119. package/src/templates/workspace-core/.agents/skills/turn-into-app/references/fresh-project.md +8 -0
  120. package/docs/content/getting-started-database.mdx +0 -173
@@ -0,0 +1,960 @@
1
+ import { OPS, Util } from "pdfjs-dist/legacy/build/pdf.mjs";
2
+ import type {
3
+ PDFDocumentProxy,
4
+ PDFPageProxy,
5
+ } from "pdfjs-dist/legacy/build/pdf.mjs";
6
+ import type { TextItem } from "pdfjs-dist/types/src/display/api.js";
7
+
8
+ import type { ParsedElement, ParsedParagraph } from "./pptx-parser.js";
9
+
10
+ /** 2D affine matrix [a, b, c, d, e, f], the same 6-value form PDF content streams use. */
11
+ export type Mat = [number, number, number, number, number, number];
12
+
13
+ const EMU_PER_POINT = 12700; // 914400 EMU/inch / 72 points/inch
14
+
15
+ /** One page's reconstructed layout, ready to feed into `convertToSlideHtml`. */
16
+ export interface PdfFidelityPage {
17
+ pageNumber: number;
18
+ widthEmu: number;
19
+ heightEmu: number;
20
+ /** The page's own painted background, when a full-page fill was found; undefined means "plain paper" (render white). */
21
+ backgroundColor: string | undefined;
22
+ /** Images first (so they paint as backgrounds), then text blocks on top. */
23
+ elements: ParsedElement[];
24
+ }
25
+
26
+ /** An embedded raster image already resolved by `pdf.getImage()`, keyed by its page. */
27
+ export interface PdfPageImage {
28
+ pageNumber: number;
29
+ images: { data: Uint8Array }[];
30
+ }
31
+
32
+ interface Rect {
33
+ left: number;
34
+ top: number;
35
+ right: number;
36
+ bottom: number;
37
+ }
38
+
39
+ export function applyPoint(m: Mat, x: number, y: number): [number, number] {
40
+ const p: [number, number] = [x, y];
41
+ Util.applyTransform(p, m);
42
+ return p;
43
+ }
44
+
45
+ function rectFromCorners(corners: [number, number][]): Rect {
46
+ const xs = corners.map((c) => c[0]);
47
+ const ys = corners.map((c) => c[1]);
48
+ return {
49
+ left: Math.min(...xs),
50
+ top: Math.min(...ys),
51
+ right: Math.max(...xs),
52
+ bottom: Math.max(...ys),
53
+ };
54
+ }
55
+
56
+ function rectArea(rect: Rect): number {
57
+ return (
58
+ Math.max(0, rect.right - rect.left) * Math.max(0, rect.bottom - rect.top)
59
+ );
60
+ }
61
+
62
+ /** Fraction of `rect`'s own area that falls inside `other` (0 when they don't overlap, 1 when `rect` is fully contained). */
63
+ function rectOverlapFraction(rect: Rect, other: Rect): number {
64
+ const overlapLeft = Math.max(rect.left, other.left);
65
+ const overlapRight = Math.min(rect.right, other.right);
66
+ const overlapTop = Math.max(rect.top, other.top);
67
+ const overlapBottom = Math.min(rect.bottom, other.bottom);
68
+ const overlapWidth = Math.max(0, overlapRight - overlapLeft);
69
+ const overlapHeight = Math.max(0, overlapBottom - overlapTop);
70
+ const rectOwnArea = rectArea(rect);
71
+ if (rectOwnArea === 0) return 0;
72
+ return (overlapWidth * overlapHeight) / rectOwnArea;
73
+ }
74
+
75
+ export interface TextRunBox extends Rect {
76
+ text: string;
77
+ fontSize: number;
78
+ bold: boolean;
79
+ italic: boolean;
80
+ color: string;
81
+ underline: boolean;
82
+ href: string | undefined;
83
+ }
84
+
85
+ /** A thin filled/stroked rect from the content stream — the usual way PDFs draw an underline (there's no inline "underline" text attribute). */
86
+ export type UnderlineRect = Rect;
87
+
88
+ /** A "Link" annotation's clickable area plus its target URL, in device coordinates. */
89
+ export interface LinkRect extends Rect {
90
+ url: string;
91
+ }
92
+
93
+ /** Max stroke/fill thickness (in device px) still considered a plausible underline rather than a divider or a shape. */
94
+ const MAX_UNDERLINE_THICKNESS = 4;
95
+ /** A zero-height stroked hairline still needs to span a real distance to count as an underline, not a stray dot or corner join. */
96
+ const MIN_UNDERLINE_LENGTH = 2;
97
+ /** An underline sits just under a line's baseline; this bounds how far below the text box bottom it can be and still count. */
98
+ const UNDERLINE_PROXIMITY = 0.6;
99
+
100
+ const DEFAULT_TEXT_COLOR = "#000000"; // guard:allow-raw-color - fallback when the page's real fill color can't be determined
101
+
102
+ /**
103
+ * When a run's real fill color can't be recovered (the color timeline
104
+ * didn't line up 1:1 with `getTextContent()`'s items), defaulting to a
105
+ * fixed black is invisible on a dark deck background — this reads black on
106
+ * a light page and white on a dark one instead, using the same background
107
+ * this page already resolved to.
108
+ *
109
+ * `backgroundColor` only ever comes from a page-covering *vector* fill —
110
+ * there's no cheap, reliable way to sample a raster background photo's
111
+ * actual luminance here (decoding it would mean pulling in the same
112
+ * fragile native-canvas path `pdf-parse-setup.ts` deliberately avoids for
113
+ * text extraction). But a full-bleed photo is still almost never "blank
114
+ * white paper", and design decks overwhelmingly lay light text over
115
+ * full-bleed photos — so when a page has no vector background fill AND
116
+ * covers itself edge-to-edge with an image, assume dark rather than
117
+ * defaulting to the invisible black-on-photo case this was written for.
118
+ */
119
+ export function contrastingDefaultColor(
120
+ backgroundColor: string | undefined,
121
+ hasFullBleedImage = false,
122
+ ): string {
123
+ if (backgroundColor === undefined) {
124
+ return hasFullBleedImage ? "#ffffff" : "#000000"; // guard:allow-raw-color - plain-paper vs. full-bleed-photo fallback, not a design-system token
125
+ }
126
+ const r = parseInt(backgroundColor.slice(1, 3), 16) / 255;
127
+ const g = parseInt(backgroundColor.slice(3, 5), 16) / 255;
128
+ const b = parseInt(backgroundColor.slice(5, 7), 16) / 255;
129
+ const luminance = 0.2126 * r + 0.7152 * g + 0.0722 * b;
130
+ return luminance < 0.5 ? "#ffffff" : "#000000"; // guard:allow-raw-color - contrast fallback, not a design-system token
131
+ }
132
+
133
+ const HEX_COLOR_PATTERN = /^#[0-9a-f]{6}$/i;
134
+
135
+ /**
136
+ * Returns the fill color set by this op, or undefined when it isn't
137
+ * decodable. pdf.js's evaluator resolves *every* non-Pattern fill-color
138
+ * operator in the content stream (`g`, `rg`, `k`, and `sc`/`scn` against any
139
+ * non-Pattern colorspace — including ICCBased/CalRGB/Lab/Separation/DeviceN,
140
+ * which professionally-authored PDFs commonly route through `scn`) into an
141
+ * already-computed `"#rrggbb"` string, rewriting the op itself to
142
+ * `OPS.setFillRGBColor` before it ever reaches `getOperatorList()`'s
143
+ * `argsArray` — the original per-component numeric operands the content
144
+ * stream actually contains never show up here. Only `scn` against a genuine
145
+ * Pattern colorspace keeps `OPS.setFillColorN`, with a pattern name (or
146
+ * tiling/shading descriptor) instead of a color string — genuinely
147
+ * undecodable without resolving that pattern object.
148
+ */
149
+ function fillColorFromOp(fn: number, args: unknown[]): string | undefined {
150
+ if (fn !== OPS.setFillRGBColor && fn !== OPS.setFillColorN) return undefined;
151
+ const [value] = args;
152
+ return typeof value === "string" && HEX_COLOR_PATTERN.test(value)
153
+ ? value.toLowerCase()
154
+ : undefined;
155
+ }
156
+
157
+ const TEXT_SHOWING_OPS = new Set([
158
+ OPS.showText,
159
+ OPS.showSpacedText,
160
+ OPS.nextLineShowText,
161
+ OPS.nextLineSetSpacingShowText,
162
+ ]);
163
+
164
+ const FILL_PAINT_OPS = new Set([
165
+ OPS.fill,
166
+ OPS.eoFill,
167
+ OPS.fillStroke,
168
+ OPS.eoFillStroke,
169
+ OPS.closeFillStroke,
170
+ OPS.closeEOFillStroke,
171
+ ]);
172
+
173
+ const STROKE_PAINT_OPS = new Set([
174
+ OPS.stroke,
175
+ OPS.closeStroke,
176
+ OPS.fillStroke,
177
+ OPS.eoFillStroke,
178
+ OPS.closeFillStroke,
179
+ OPS.closeEOFillStroke,
180
+ ]);
181
+
182
+ /** Any op that paints a path (fill or stroke) — an underline can be drawn either way. */
183
+ const PAINT_OPS = new Set([...FILL_PAINT_OPS, ...STROKE_PAINT_OPS]);
184
+
185
+ /** A page-covering fill is almost always the very first thing painted, so keep the earliest match. */
186
+ const BACKGROUND_COVERAGE_RATIO = 0.9;
187
+
188
+ /**
189
+ * Color-affecting ops that always invalidate the tracked fill color: a
190
+ * colorspace change (`cs`) resets the current color to that space's
191
+ * initial value (not necessarily black), and a shading fill (`sh`) paints
192
+ * a gradient with no single color at all. `sc`/`scn` are handled inline in
193
+ * the walk below instead, since whether they're decodable depends on
194
+ * their operands (see `fillColorFromOp`), not just which op it is.
195
+ */
196
+ const UNTRACKED_COLOR_OPS = new Set([OPS.setFillColorSpace, OPS.shadingFill]);
197
+
198
+ /**
199
+ * `getTextContent()` gives baseline position + font size per run but no
200
+ * paragraph structure — PDFs don't have one. Each run's placed box is
201
+ * derived from its own transform (baseline, direction, font size) rather
202
+ * than assumed axis-aligned, so rotated/skewed runs still land in roughly
203
+ * the right place even though the emitted element itself is axis-aligned.
204
+ */
205
+ export function textItemToBox(
206
+ item: Pick<TextItem, "str" | "transform" | "width"> &
207
+ Partial<Pick<TextItem, "fontName">>,
208
+ viewportTransform: Mat,
209
+ color: string = DEFAULT_TEXT_COLOR,
210
+ ): TextRunBox | undefined {
211
+ const text = item.str;
212
+ if (!text || !text.trim()) return undefined;
213
+ const t = item.transform as Mat;
214
+ const fontSize = Math.hypot(t[2], t[3]) || Math.hypot(t[0], t[1]) || 1;
215
+ const angle = Math.atan2(t[1], t[0]);
216
+ const dir: [number, number] = [Math.cos(angle), Math.sin(angle)];
217
+ const perp: [number, number] = [-Math.sin(angle), Math.cos(angle)];
218
+ const [baseX, baseY] = [t[4], t[5]];
219
+ const ascent = fontSize * 0.75;
220
+ const descent = fontSize * 0.25;
221
+ const width = item.width || fontSize * text.length * 0.5;
222
+ const localCorners: [number, number][] = [
223
+ [baseX - perp[0] * descent, baseY - perp[1] * descent],
224
+ [
225
+ baseX + dir[0] * width - perp[0] * descent,
226
+ baseY + dir[1] * width - perp[1] * descent,
227
+ ],
228
+ [
229
+ baseX + dir[0] * width + perp[0] * ascent,
230
+ baseY + dir[1] * width + perp[1] * ascent,
231
+ ],
232
+ [baseX + perp[0] * ascent, baseY + perp[1] * ascent],
233
+ ];
234
+ const deviceCorners = localCorners.map(([x, y]) =>
235
+ applyPoint(viewportTransform, x, y),
236
+ );
237
+ const fontName = item.fontName ?? "";
238
+ return {
239
+ ...rectFromCorners(deviceCorners),
240
+ text,
241
+ fontSize,
242
+ bold: /bold/i.test(fontName),
243
+ italic: /italic|oblique/i.test(fontName),
244
+ color,
245
+ underline: false,
246
+ href: undefined,
247
+ };
248
+ }
249
+
250
+ /** Merge same-line runs left-to-right, inserting a space across word-sized gaps. */
251
+ export function mergeLine(items: TextRunBox[]): TextRunBox {
252
+ const sorted = [...items].sort((a, b) => a.left - b.left);
253
+ let text = "";
254
+ let prevRight: number | undefined;
255
+ for (const item of sorted) {
256
+ if (
257
+ prevRight !== undefined &&
258
+ item.left - prevRight > item.fontSize * 0.25
259
+ ) {
260
+ text += " ";
261
+ }
262
+ text += item.text;
263
+ prevRight = item.right;
264
+ }
265
+ return {
266
+ ...rectFromCorners(
267
+ sorted.flatMap(
268
+ (s) =>
269
+ [
270
+ [s.left, s.top],
271
+ [s.right, s.bottom],
272
+ ] as [number, number][],
273
+ ),
274
+ ),
275
+ text,
276
+ fontSize: sorted[0].fontSize,
277
+ bold: sorted[0].bold,
278
+ italic: sorted[0].italic,
279
+ color: sorted[0].color,
280
+ underline: sorted.some((s) => s.underline),
281
+ href: sorted.find((s) => s.href)?.href,
282
+ };
283
+ }
284
+
285
+ /**
286
+ * PDFs don't carry an inline "underline" run attribute — an underline is
287
+ * just a thin fill/stroke drawn under the text — and a hyperlink is a
288
+ * separate "Link" annotation with its own clickable rect, not part of the
289
+ * text run at all. Both are recovered geometrically: a line is underlined
290
+ * when a thin rect sits directly beneath it, and linked when its box falls
291
+ * inside a Link annotation's rect.
292
+ */
293
+ export function annotateLineDecorations(
294
+ lines: TextRunBox[],
295
+ underlineRects: UnderlineRect[],
296
+ linkRects: LinkRect[],
297
+ ): TextRunBox[] {
298
+ return lines.map((line) => {
299
+ const width = Math.max(1, line.right - line.left);
300
+ const underline = underlineRects.some((rect) => {
301
+ const thickness = rect.bottom - rect.top;
302
+ if (thickness < 0 || thickness > MAX_UNDERLINE_THICKNESS) return false;
303
+ const overlapLeft = Math.max(rect.left, line.left);
304
+ const overlapRight = Math.min(rect.right, line.right);
305
+ const horizontalOverlap = Math.max(0, overlapRight - overlapLeft);
306
+ if (horizontalOverlap < width * 0.5) return false;
307
+ const gap = rect.top - line.bottom;
308
+ return (
309
+ gap >= -MAX_UNDERLINE_THICKNESS &&
310
+ gap <= UNDERLINE_PROXIMITY * line.fontSize
311
+ );
312
+ });
313
+ const href = linkRects.find((rect) => {
314
+ const overlapLeft = Math.max(rect.left, line.left);
315
+ const overlapRight = Math.min(rect.right, line.right);
316
+ const overlapTop = Math.max(rect.top, line.top);
317
+ const overlapBottom = Math.min(rect.bottom, line.bottom);
318
+ const overlapWidth = Math.max(0, overlapRight - overlapLeft);
319
+ const overlapHeight = Math.max(0, overlapBottom - overlapTop);
320
+ const overlapArea = overlapWidth * overlapHeight;
321
+ const lineArea = width * Math.max(1, line.bottom - line.top);
322
+ return overlapArea >= lineArea * 0.4;
323
+ })?.url;
324
+ return {
325
+ ...line,
326
+ underline: underline || line.underline,
327
+ href: href ?? line.href,
328
+ };
329
+ });
330
+ }
331
+
332
+ /** Group same-baseline runs into lines, in the order pdf.js emitted them (reading order for typical single-column pages). */
333
+ export function groupIntoLines(items: TextRunBox[]): TextRunBox[] {
334
+ return groupByBaseline(items).map(mergeLine);
335
+ }
336
+
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[][] {
339
+ const lines: TextRunBox[][] = [];
340
+ let current: TextRunBox[] = [];
341
+ for (const item of items) {
342
+ const prev = current[current.length - 1];
343
+ if (
344
+ prev &&
345
+ Math.abs(item.top - prev.top) >
346
+ Math.max(item.fontSize, prev.fontSize) * 0.4
347
+ ) {
348
+ lines.push(current);
349
+ current = [item];
350
+ } else {
351
+ current.push(item);
352
+ }
353
+ }
354
+ if (current.length) lines.push(current);
355
+ return lines;
356
+ }
357
+
358
+ /** A physical line that may mix more than one styled run (e.g. "Nike NYC: " white next to "Event Details" blue) instead of collapsing the whole line to one style. */
359
+ export interface TextLine extends Rect {
360
+ fontSize: number;
361
+ runs: TextRunBox[];
362
+ }
363
+
364
+ /**
365
+ * Merge same-line runs left-to-right into styled runs: adjacent items
366
+ * sharing the same color/bold/italic/underline/href collapse into one run
367
+ * (joining across word-sized gaps, same as `mergeLine`), but a style change
368
+ * always starts a new run instead of being silently overwritten by
369
+ * whichever item happens to sort first.
370
+ */
371
+ export function mergeLineRuns(items: TextRunBox[]): TextRunBox[] {
372
+ const sorted = [...items].sort((a, b) => a.left - b.left);
373
+ const runs: TextRunBox[] = [];
374
+ for (const item of sorted) {
375
+ const prev = runs[runs.length - 1];
376
+ const sameStyle =
377
+ prev !== undefined &&
378
+ prev.color === item.color &&
379
+ prev.bold === item.bold &&
380
+ prev.italic === item.italic &&
381
+ prev.underline === item.underline &&
382
+ prev.href === item.href;
383
+ if (prev && sameStyle) {
384
+ const needsSpace = item.left - prev.right > item.fontSize * 0.25;
385
+ prev.text += (needsSpace ? " " : "") + item.text;
386
+ prev.right = Math.max(prev.right, item.right);
387
+ prev.top = Math.min(prev.top, item.top);
388
+ prev.bottom = Math.max(prev.bottom, item.bottom);
389
+ } else {
390
+ runs.push({ ...item });
391
+ }
392
+ }
393
+ return runs;
394
+ }
395
+
396
+ /** Like `groupIntoLines`, but keeps each line's distinct-styled runs separate — needed once a line can mix colors/weights within itself. */
397
+ export function groupIntoStyledLines(items: TextRunBox[]): TextLine[] {
398
+ return groupByBaseline(items).map((lineItems) => ({
399
+ ...rectFromCorners(
400
+ lineItems.flatMap(
401
+ (s) =>
402
+ [
403
+ [s.left, s.top],
404
+ [s.right, s.bottom],
405
+ ] as [number, number][],
406
+ ),
407
+ ),
408
+ fontSize: lineItems[0].fontSize,
409
+ runs: mergeLineRuns(lineItems),
410
+ }));
411
+ }
412
+
413
+ /**
414
+ * Group lines into text blocks (paragraph-level elements) so a heading and
415
+ * an unrelated body paragraph don't collapse into one giant text box — a
416
+ * new block starts on a size change, a big left-indent jump, or a vertical
417
+ * gap wider than the previous line's own height. Generic over both a
418
+ * single-style `TextRunBox` line and a multi-run `TextLine`, since both
419
+ * carry the `top`/`bottom`/`left`/`fontSize` this grouping actually reads.
420
+ */
421
+ export function groupIntoBlocks<T extends Rect & { fontSize: number }>(
422
+ lines: T[],
423
+ ): T[][] {
424
+ const blocks: T[][] = [];
425
+ let current: T[] = [];
426
+ for (const line of lines) {
427
+ const prev = current[current.length - 1];
428
+ if (prev) {
429
+ const gap = line.top - prev.bottom;
430
+ const sizeRatio = line.fontSize / prev.fontSize;
431
+ const sameBlock =
432
+ gap < prev.fontSize * 0.9 &&
433
+ sizeRatio > 0.7 &&
434
+ sizeRatio < 1.4 &&
435
+ Math.abs(line.left - prev.left) < prev.fontSize * 3;
436
+ if (!sameBlock) {
437
+ blocks.push(current);
438
+ current = [];
439
+ }
440
+ }
441
+ current.push(line);
442
+ }
443
+ if (current.length) blocks.push(current);
444
+ return blocks;
445
+ }
446
+
447
+ /**
448
+ * A line's own `top`/`bottom` still carry its real position from the PDF at
449
+ * this point in the pipeline — the source's actual line spacing (which can
450
+ * be looser than a flat CSS line-height) shows up as the gap between this
451
+ * line's top and the previous line's bottom. Returns 0 for a block's first
452
+ * line (its position is already the block's own placement, not extra
453
+ * spacing) or when there's no real gap to preserve.
454
+ */
455
+ export function lineSpacingBeforePt(
456
+ line: Rect,
457
+ previousLine: Rect | undefined,
458
+ ): number {
459
+ return previousLine ? Math.max(0, line.top - previousLine.bottom) : 0;
460
+ }
461
+
462
+ /** 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. */
463
+ const BACKGROUND_IMAGE_OVERLAP_RATIO = 0.5;
464
+
465
+ /**
466
+ * `getTextContent()`'s internal font id (e.g. `"g_d0_f1"`) is never the
467
+ * PDF's real font name — its own bold detection needs the actual resolved
468
+ * font object's `.name` (e.g. `"MUFUZY+Poppins-Bold"`), which pdf.js has
469
+ * already loaded into `page.commonObjs` by the time text content resolves.
470
+ */
471
+ function resolveRealFontName(
472
+ page: PDFPageProxy,
473
+ fontName: string | undefined,
474
+ cache: Map<string, string>,
475
+ ): string {
476
+ if (!fontName) return "";
477
+ const cached = cache.get(fontName);
478
+ if (cached !== undefined) return cached;
479
+ let resolved = fontName;
480
+ try {
481
+ if (page.commonObjs.has(fontName)) {
482
+ const font = page.commonObjs.get(fontName) as { name?: string } | null;
483
+ if (font?.name) resolved = font.name;
484
+ }
485
+ } catch {
486
+ // coercion-ok: `commonObjs.get` throwing means the font object isn't
487
+ // resolved yet — `resolved` already defaults to the internal id, so
488
+ // bold detection just won't match for this run instead of failing.
489
+ }
490
+ cache.set(fontName, resolved);
491
+ return resolved;
492
+ }
493
+
494
+ /**
495
+ * A single `getTextContent()` item only ever carries one font/position, but
496
+ * a page's content stream can paint it with more than one fill color —
497
+ * pdf.js merges runs purely by geometric continuity (position + font),
498
+ * never by paint color, so "Nike NYC: " (white) immediately followed by
499
+ * "Event Details" (blue) on the same line is reported as one merged item.
500
+ * Slicing that item's own text and placed width proportionally by
501
+ * character count recovers each color as its own run.
502
+ */
503
+ function sliceTextItemBySegments(
504
+ item: TextItem,
505
+ viewportTransform: Mat,
506
+ segments: TextColorRun[],
507
+ ): { box: TextRunBox; colorKnown: boolean }[] {
508
+ if (segments.length <= 1) {
509
+ const color = segments[0]?.color;
510
+ const box = textItemToBox(item, viewportTransform, color);
511
+ return box ? [{ box, colorKnown: color !== undefined }] : [];
512
+ }
513
+ const fullBox = textItemToBox(item, viewportTransform, DEFAULT_TEXT_COLOR);
514
+ if (!fullBox) return [];
515
+ const totalChars = item.str.length;
516
+ const spanX = fullBox.right - fullBox.left;
517
+ const results: { box: TextRunBox; colorKnown: boolean }[] = [];
518
+ let charOffset = 0;
519
+ for (const segment of segments) {
520
+ const text = item.str.slice(charOffset, charOffset + segment.length);
521
+ if (text.trim().length > 0) {
522
+ const startFraction = charOffset / totalChars;
523
+ const endFraction = (charOffset + segment.length) / totalChars;
524
+ results.push({
525
+ box: {
526
+ ...fullBox,
527
+ left: fullBox.left + spanX * startFraction,
528
+ right: fullBox.left + spanX * endFraction,
529
+ text,
530
+ color: segment.color ?? DEFAULT_TEXT_COLOR,
531
+ },
532
+ colorKnown: segment.color !== undefined,
533
+ });
534
+ }
535
+ charOffset += segment.length;
536
+ }
537
+ return results;
538
+ }
539
+
540
+ async function buildTextElements(
541
+ page: PDFPageProxy,
542
+ viewportTransform: Mat,
543
+ pageNumber: number,
544
+ textRuns: TextColorRun[],
545
+ backgroundColor: string | undefined,
546
+ backgroundImageRect: Rect | undefined,
547
+ underlineRects: UnderlineRect[],
548
+ linkRects: LinkRect[],
549
+ ): Promise<ParsedElement[]> {
550
+ const content = await page.getTextContent();
551
+ const rawItems = content.items.filter(
552
+ (item): item is TextItem => "str" in item && item.str.trim().length > 0,
553
+ );
554
+ const fontNameCache = new Map<string, string>();
555
+
556
+ // Walk `textRuns` alongside `rawItems` by character count rather than by
557
+ // index — the two lists don't share a 1:1 item boundary (see
558
+ // `sliceTextItemBySegments`), so each item claims exactly as many
559
+ // characters worth of segments as its own text is long.
560
+ const segmentQueue = [...textRuns];
561
+ const boxes = rawItems.flatMap((item) => {
562
+ const realFontName = resolveRealFontName(
563
+ page,
564
+ item.fontName,
565
+ fontNameCache,
566
+ );
567
+ const itemForBox = { ...item, fontName: realFontName };
568
+ const itemSegments: TextColorRun[] = [];
569
+ let consumed = 0;
570
+ while (consumed < item.str.length && segmentQueue.length > 0) {
571
+ const segment = segmentQueue[0];
572
+ const remaining = item.str.length - consumed;
573
+ if (segment.length <= remaining) {
574
+ itemSegments.push(segment);
575
+ consumed += segment.length;
576
+ segmentQueue.shift();
577
+ } else {
578
+ // This segment overshoots the item boundary — pdf.js's merge
579
+ // doesn't line up with our op-derived segment counts (e.g. a
580
+ // ligature collapsing multiple glyphs into fewer output
581
+ // characters). Split what fits and leave the remainder queued for
582
+ // the next item instead of corrupting this item's text.
583
+ itemSegments.push({ length: remaining, color: segment.color });
584
+ segmentQueue[0] = { ...segment, length: segment.length - remaining };
585
+ consumed = item.str.length;
586
+ }
587
+ }
588
+ if (consumed < item.str.length) {
589
+ itemSegments.push({
590
+ length: item.str.length - consumed,
591
+ color: undefined,
592
+ });
593
+ }
594
+ return sliceTextItemBySegments(
595
+ itemForBox,
596
+ viewportTransform,
597
+ itemSegments,
598
+ ).map(({ box, colorKnown }) => {
599
+ if (colorKnown) return box;
600
+ // No recovered color for this run — rather than guessing "blank
601
+ // white paper" for the whole page, check what's actually behind
602
+ // THIS run: a title over a background photo needs a different
603
+ // default than body text below it on plain canvas, same page.
604
+ const sitsOnBackgroundImage =
605
+ backgroundImageRect !== undefined &&
606
+ rectOverlapFraction(box, backgroundImageRect) >=
607
+ BACKGROUND_IMAGE_OVERLAP_RATIO;
608
+ return {
609
+ ...box,
610
+ color: contrastingDefaultColor(backgroundColor, sitsOnBackgroundImage),
611
+ };
612
+ });
613
+ });
614
+ if (boxes.length === 0) return [];
615
+
616
+ const styledLines = groupIntoStyledLines(boxes);
617
+ const flatRuns = styledLines.flatMap((line) => line.runs);
618
+ const decoratedRuns = annotateLineDecorations(
619
+ flatRuns,
620
+ underlineRects,
621
+ linkRects,
622
+ );
623
+ let cursor = 0;
624
+ const lines: TextLine[] = styledLines.map((line) => {
625
+ const runs = decoratedRuns.slice(cursor, cursor + line.runs.length);
626
+ cursor += line.runs.length;
627
+ return { ...line, runs };
628
+ });
629
+ const blocks = groupIntoBlocks(lines);
630
+
631
+ return blocks.map((blockLines, index) => {
632
+ const left = Math.min(...blockLines.map((l) => l.left));
633
+ const top = Math.min(...blockLines.map((l) => l.top));
634
+ const right = Math.max(...blockLines.map((l) => l.right));
635
+ const bottom = Math.max(...blockLines.map((l) => l.bottom));
636
+ const paragraphs: ParsedParagraph[] = blockLines.map((line, lineIndex) => {
637
+ const spaceBeforePt = lineSpacingBeforePt(
638
+ line,
639
+ blockLines[lineIndex - 1],
640
+ );
641
+ return {
642
+ runs: line.runs.map((run) => ({
643
+ content: run.text,
644
+ fontSize: run.fontSize,
645
+ color: run.color,
646
+ bold: run.bold,
647
+ italic: run.italic,
648
+ underline: run.underline,
649
+ href: run.href,
650
+ })),
651
+ alignment: "left",
652
+ ...(spaceBeforePt > 0 ? { spaceBeforePt } : {}),
653
+ };
654
+ });
655
+ return {
656
+ id: `pdf-text-${pageNumber}-${index}`,
657
+ kind: "text",
658
+ x: left * EMU_PER_POINT,
659
+ y: top * EMU_PER_POINT,
660
+ width: Math.max(1, right - left) * EMU_PER_POINT,
661
+ height: Math.max(1, bottom - top) * EMU_PER_POINT,
662
+ paragraphs,
663
+ };
664
+ });
665
+ }
666
+
667
+ /** The fill color active during one real (non-empty) text-showing op, plus how many characters it covers — `getTextContent()` can merge several differently-colored ops into a single item (it only splits on position/font continuity, never on paint color), so recovering per-color runs within one item means walking this list by character count rather than by item. */
668
+ export interface TextColorRun {
669
+ length: number;
670
+ color: string | undefined;
671
+ }
672
+
673
+ interface PageGraphics {
674
+ imageRects: Rect[];
675
+ /** The earliest fill covering most of the page — almost always the deck's background. */
676
+ backgroundColor: string | undefined;
677
+ /** Fill color + character length of each real text-showing op, in operator-list order; `color` is undefined when it wasn't recoverable. */
678
+ textRuns: TextColorRun[];
679
+ /** Thin filled/stroked rects found anywhere on the page — underline candidates, matched against text lines afterward. */
680
+ underlineRects: UnderlineRect[];
681
+ }
682
+
683
+ /**
684
+ * Images are painted into a unit square [0,1]x[0,1] transformed by the CTM
685
+ * at the time of the paint op — there is no separate "image extent"
686
+ * operator, so real placement requires walking the operator list and
687
+ * tracking the transform stack through save/restore/transform, exactly
688
+ * like `pdf-parse`'s own internal path-geometry walk does for shapes. The
689
+ * same walk also tracks the current fill color (from `rg`/`g`/`k` ops) so a
690
+ * full-page background fill and each text run's real color can be read off
691
+ * as we pass over them — `getTextContent()` alone carries neither.
692
+ */
693
+ export async function walkPageGraphics(
694
+ page: PDFPageProxy,
695
+ viewport: { transform: Mat; width: number; height: number },
696
+ ): Promise<PageGraphics> {
697
+ const viewportTransform = viewport.transform;
698
+ const opList = await page.getOperatorList();
699
+ const imageRects: Rect[] = [];
700
+ const textRuns: TextColorRun[] = [];
701
+ const underlineRects: UnderlineRect[] = [];
702
+ let backgroundColor: string | undefined;
703
+ let fillColor = DEFAULT_TEXT_COLOR;
704
+ // Starts true: the PDF spec's initial nonstroking color IS black, so text
705
+ // painted before any explicit color-setting operator is legitimately
706
+ // black, not "unknown." This flips to false whenever the color is set
707
+ // through an operator this walk doesn't decode (a pattern/separation/ICC
708
+ // colorspace fill via `scn`/`SCN`, common for exact brand colors) —
709
+ // `fillColor` is then a stale guess, not a real reading, and must not be
710
+ // trusted for text or background detection.
711
+ let fillColorKnown = true;
712
+ let ctm: Mat = [1, 0, 0, 1, 0, 0];
713
+ const stack: { ctm: Mat; fillColor: string; fillColorKnown: boolean }[] = [];
714
+
715
+ for (let i = 0; i < opList.fnArray.length; i++) {
716
+ const fn = opList.fnArray[i];
717
+ const args = opList.argsArray[i] as unknown[];
718
+ if (fn === OPS.save) {
719
+ stack.push({ ctm, fillColor, fillColorKnown });
720
+ } else if (fn === OPS.restore) {
721
+ const restored = stack.pop();
722
+ if (restored) {
723
+ ctm = restored.ctm;
724
+ fillColor = restored.fillColor;
725
+ fillColorKnown = restored.fillColorKnown;
726
+ }
727
+ } else if (fn === OPS.transform) {
728
+ ctm = Util.transform(ctm, args as Mat) as Mat;
729
+ } else if (
730
+ fn === OPS.paintImageXObject ||
731
+ fn === OPS.paintInlineImageXObject
732
+ ) {
733
+ const unitCorners: [number, number][] = [
734
+ [0, 0],
735
+ [1, 0],
736
+ [1, 1],
737
+ [0, 1],
738
+ ];
739
+ const deviceCorners = unitCorners.map(([x, y]) => {
740
+ const [ux, uy] = applyPoint(ctm, x, y);
741
+ return applyPoint(viewportTransform, ux, uy);
742
+ });
743
+ imageRects.push(rectFromCorners(deviceCorners));
744
+ } else if (TEXT_SHOWING_OPS.has(fn)) {
745
+ // Count only the glyph descriptors, not the raw kerning-adjustment
746
+ // numbers a `TJ` array can interleave between them, so this length
747
+ // matches the character count `getTextContent()` will report for the
748
+ // text these glyphs decode to.
749
+ const glyphs = args[0];
750
+ const length = Array.isArray(glyphs)
751
+ ? glyphs.filter((g) => typeof g === "object" && g !== null).length
752
+ : 1;
753
+ // A zero-glyph showText (a leftover empty run from a style-boundary
754
+ // split in the source PDF) never produces any characters in
755
+ // `getTextContent()` — skip it instead of pushing a zero-length
756
+ // segment that would just sit there contributing nothing.
757
+ if (length > 0) {
758
+ textRuns.push({
759
+ length,
760
+ color: fillColorKnown ? fillColor : undefined,
761
+ });
762
+ }
763
+ } else if (fn === OPS.constructPath) {
764
+ const paintOp = args[0];
765
+ const bbox = args[2] as number[] | undefined;
766
+ if (
767
+ bbox &&
768
+ bbox.every((v) => Number.isFinite(v)) &&
769
+ PAINT_OPS.has(paintOp as number)
770
+ ) {
771
+ const [minX, minY, maxX, maxY] = bbox;
772
+ const deviceCorners = [
773
+ [minX, minY],
774
+ [maxX, minY],
775
+ [maxX, maxY],
776
+ [minX, maxY],
777
+ ].map(([x, y]) => {
778
+ const [ux, uy] = applyPoint(ctm, x, y);
779
+ return applyPoint(viewportTransform, ux, uy);
780
+ });
781
+ const rect = rectFromCorners(deviceCorners);
782
+ if (
783
+ backgroundColor === undefined &&
784
+ fillColorKnown &&
785
+ FILL_PAINT_OPS.has(paintOp as number)
786
+ ) {
787
+ const coversPage =
788
+ rect.right - rect.left >=
789
+ viewport.width * BACKGROUND_COVERAGE_RATIO &&
790
+ rect.bottom - rect.top >=
791
+ viewport.height * BACKGROUND_COVERAGE_RATIO;
792
+ if (coversPage) backgroundColor = fillColor;
793
+ }
794
+ const width = rect.right - rect.left;
795
+ const height = rect.bottom - rect.top;
796
+ const thickness = Math.min(width, height);
797
+ const length = Math.max(width, height);
798
+ // A pure stroke ("S"/"s", no fill) draws a hairline whose path bbox
799
+ // has zero thickness — the line width isn't baked into this bbox at
800
+ // all — so `thickness === 0` is the *normal* shape for a stroked
801
+ // underline, not a degenerate case to reject.
802
+ const isLongEnoughForThickness =
803
+ thickness === 0
804
+ ? length >= MIN_UNDERLINE_LENGTH
805
+ : length >= thickness * 3;
806
+ if (thickness <= MAX_UNDERLINE_THICKNESS && isLongEnoughForThickness) {
807
+ underlineRects.push(rect);
808
+ }
809
+ }
810
+ } else if (UNTRACKED_COLOR_OPS.has(fn)) {
811
+ // A colorspace change or a gradient shading fill is never decoded,
812
+ // so the previously tracked fillColor can no longer be trusted
813
+ // until a recognized color-setting op sets it again.
814
+ fillColorKnown = false;
815
+ } else if (fn === OPS.setFillRGBColor || fn === OPS.setFillColorN) {
816
+ // A resolved hex string decodes via fillColorFromOp; a Pattern
817
+ // operand (a name, not a color string) doesn't, and must invalidate
818
+ // the previous color rather than silently keep it.
819
+ const color = fillColorFromOp(fn, args);
820
+ fillColor = color ?? fillColor;
821
+ fillColorKnown = color !== undefined;
822
+ }
823
+ }
824
+ return { imageRects, backgroundColor, textRuns, underlineRects };
825
+ }
826
+
827
+ /**
828
+ * Hyperlinks live outside the content stream entirely, as page-level "Link"
829
+ * annotations with their own clickable rect and target URL — there's no
830
+ * operator-list event for them at all.
831
+ */
832
+ async function collectLinkRects(
833
+ page: PDFPageProxy,
834
+ viewportTransform: Mat,
835
+ ): Promise<LinkRect[]> {
836
+ const annotations = (await page.getAnnotations({ intent: "display" })) as {
837
+ subtype?: string;
838
+ url?: string;
839
+ rect?: number[];
840
+ }[];
841
+ const rects: LinkRect[] = [];
842
+ for (const annotation of annotations) {
843
+ if (annotation.subtype !== "Link" || !annotation.url) continue;
844
+ const rect = annotation.rect;
845
+ if (!rect || rect.length !== 4 || !rect.every((v) => Number.isFinite(v))) {
846
+ continue;
847
+ }
848
+ const [x1, y1, x2, y2] = rect;
849
+ const deviceCorners = [
850
+ [x1, y1],
851
+ [x2, y1],
852
+ [x2, y2],
853
+ [x1, y2],
854
+ ].map(([x, y]) => applyPoint(viewportTransform, x, y));
855
+ rects.push({ ...rectFromCorners(deviceCorners), url: annotation.url });
856
+ }
857
+ return rects;
858
+ }
859
+
860
+ const MIN_IMAGE_POINTS = 4;
861
+
862
+ /**
863
+ * Reconstruct each page's real layout — positioned text blocks at their
864
+ * actual sizes plus every embedded image at its actual placement — instead
865
+ * of flattening the page to one guessed background photo and a canned text
866
+ * template. Falls back to an empty element list for a page that fails to
867
+ * parse; the caller decides how to degrade (e.g. plain extracted text).
868
+ */
869
+ export async function parsePdfFidelity(
870
+ doc: PDFDocumentProxy,
871
+ imagesByPage: PdfPageImage[],
872
+ ): Promise<PdfFidelityPage[]> {
873
+ const imageBytesByPage = new Map<number, Uint8Array[]>();
874
+ for (const entry of imagesByPage) {
875
+ imageBytesByPage.set(
876
+ entry.pageNumber,
877
+ entry.images.map((img) => img.data),
878
+ );
879
+ }
880
+
881
+ const pages: PdfFidelityPage[] = [];
882
+ for (let pageNumber = 1; pageNumber <= doc.numPages; pageNumber++) {
883
+ try {
884
+ const page = await doc.getPage(pageNumber);
885
+ const viewport = page.getViewport({ scale: 1, rotation: page.rotate });
886
+ const viewportTransform = viewport.transform as Mat;
887
+
888
+ const graphics = await walkPageGraphics(page, {
889
+ transform: viewportTransform,
890
+ width: viewport.width,
891
+ height: viewport.height,
892
+ });
893
+ const linkRects = await collectLinkRects(page, viewportTransform);
894
+ const imageRects = graphics.imageRects;
895
+ // The largest image that covers a substantial share of the page is
896
+ // this page's background candidate for contrast purposes — it need
897
+ // not reach every edge (a design's photo can have a slight margin
898
+ // or be one of several stacked decorative layers) to still be what
899
+ // text is actually sitting on top of.
900
+ const pageArea = viewport.width * viewport.height;
901
+ const backgroundImageRect = imageRects
902
+ .filter((rect) => rectArea(rect) >= pageArea * 0.4)
903
+ .sort((a, b) => rectArea(b) - rectArea(a))[0];
904
+ const imageBytes = imageBytesByPage.get(pageNumber) ?? [];
905
+ const imageElements: ParsedElement[] = imageRects
906
+ .map((rect, index) => ({ rect, data: imageBytes[index] }))
907
+ .filter(
908
+ ({ rect, data }) =>
909
+ data &&
910
+ rect.right - rect.left >= MIN_IMAGE_POINTS &&
911
+ rect.bottom - rect.top >= MIN_IMAGE_POINTS,
912
+ )
913
+ .map(({ rect, data }, index) => ({
914
+ id: `pdf-img-${pageNumber}-${index}`,
915
+ kind: "image" as const,
916
+ x: rect.left * EMU_PER_POINT,
917
+ y: rect.top * EMU_PER_POINT,
918
+ width: Math.max(1, rect.right - rect.left) * EMU_PER_POINT,
919
+ height: Math.max(1, rect.bottom - rect.top) * EMU_PER_POINT,
920
+ image: {
921
+ data: data as Uint8Array,
922
+ mimeType: "image/png",
923
+ name: `image-${index}`,
924
+ },
925
+ }));
926
+
927
+ const textElements = await buildTextElements(
928
+ page,
929
+ viewportTransform,
930
+ pageNumber,
931
+ graphics.textRuns,
932
+ graphics.backgroundColor,
933
+ backgroundImageRect,
934
+ graphics.underlineRects,
935
+ linkRects,
936
+ );
937
+
938
+ pages.push({
939
+ pageNumber,
940
+ widthEmu: viewport.width * EMU_PER_POINT,
941
+ heightEmu: viewport.height * EMU_PER_POINT,
942
+ backgroundColor: graphics.backgroundColor,
943
+ elements: [...imageElements, ...textElements],
944
+ });
945
+ } catch (err) {
946
+ console.warn(
947
+ `[import-file] PDF fidelity parse failed for page ${pageNumber}, falling back for this page:`,
948
+ err instanceof Error ? err.message : String(err),
949
+ );
950
+ pages.push({
951
+ pageNumber,
952
+ widthEmu: 0,
953
+ heightEmu: 0,
954
+ backgroundColor: undefined,
955
+ elements: [],
956
+ });
957
+ }
958
+ }
959
+ return pages;
960
+ }