@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.
- package/corpus/README.md +1 -1
- package/corpus/templates/analytics/.agents/skills/turn-into-app/SKILL.md +4 -0
- package/corpus/templates/analytics/.agents/skills/turn-into-app/references/fresh-project.md +8 -0
- package/corpus/templates/analytics/actions/compose-dashboard.ts +2 -27
- package/corpus/templates/analytics/actions/get-sql-dashboard.ts +7 -8
- package/corpus/templates/analytics/actions/migrate-first-party-analytics-to-bigquery.ts +18 -2
- package/corpus/templates/analytics/actions/mutate-dashboard.ts +16 -24
- package/corpus/templates/analytics/actions/rename-dashboard.ts +2 -24
- package/corpus/templates/analytics/actions/reorder-dashboard-panels.ts +2 -24
- package/corpus/templates/analytics/actions/restore-dashboard-revision.ts +2 -25
- package/corpus/templates/analytics/actions/update-dashboard.ts +4 -32
- package/corpus/templates/analytics/app/components/layout/CommandPalette.tsx +1 -40
- package/corpus/templates/analytics/changelog/2026-08-07-language-selection-now-lives-in-settings-instead-of-the-comm.md +6 -0
- package/corpus/templates/analytics/netlify.toml +7 -0
- package/corpus/templates/analytics/seeds/dashboards/agent-native-templates-first-party.json +13 -13
- package/corpus/templates/analytics/server/jobs/analytics-bigquery-backfill.ts +4 -2
- package/corpus/templates/analytics/server/lib/canonical-first-party-dashboard-repair.ts +0 -35
- package/corpus/templates/analytics/server/lib/dashboard-catalog.ts +5 -3
- package/corpus/templates/analytics/server/lib/dashboard-collab-sync.ts +77 -0
- package/corpus/templates/analytics/server/lib/demo-dashboards.ts +2 -23
- package/corpus/templates/analytics/server/lib/first-party-metric-catalog.ts +28 -74
- package/corpus/templates/analytics/server/routes/api/dashboard-agent-context.json.get.ts +5 -9
- package/corpus/templates/assets/.agents/skills/turn-into-app/SKILL.md +4 -0
- package/corpus/templates/assets/.agents/skills/turn-into-app/references/fresh-project.md +8 -0
- package/corpus/templates/brain/.agents/skills/turn-into-app/SKILL.md +4 -0
- package/corpus/templates/brain/.agents/skills/turn-into-app/references/fresh-project.md +8 -0
- package/corpus/templates/calendar/.agents/skills/turn-into-app/SKILL.md +4 -0
- package/corpus/templates/calendar/.agents/skills/turn-into-app/references/fresh-project.md +8 -0
- package/corpus/templates/chat/.agents/skills/turn-into-app/SKILL.md +4 -0
- package/corpus/templates/chat/.agents/skills/turn-into-app/references/fresh-project.md +8 -0
- package/corpus/templates/clips/.agents/skills/turn-into-app/SKILL.md +4 -0
- package/corpus/templates/clips/.agents/skills/turn-into-app/references/fresh-project.md +8 -0
- package/corpus/templates/clips/app/components/player/video-player.tsx +1 -1
- package/corpus/templates/clips/changelog/2026-08-07-fixed-trimmed-clips-durations-so-the-main-player-display-mat.md +6 -0
- package/corpus/templates/clips/chrome-extension/STORE_DRAFT.md +1 -1
- package/corpus/templates/clips/chrome-extension/public/manifest.json +1 -1
- package/corpus/templates/content/.agents/skills/turn-into-app/SKILL.md +4 -0
- package/corpus/templates/content/.agents/skills/turn-into-app/references/fresh-project.md +8 -0
- package/corpus/templates/crm/.agents/skills/turn-into-app/SKILL.md +4 -0
- package/corpus/templates/crm/.agents/skills/turn-into-app/references/fresh-project.md +8 -0
- package/corpus/templates/design/.agents/skills/turn-into-app/SKILL.md +4 -0
- package/corpus/templates/design/.agents/skills/turn-into-app/references/fresh-project.md +8 -0
- package/corpus/templates/dispatch/.agents/skills/turn-into-app/SKILL.md +4 -0
- package/corpus/templates/dispatch/.agents/skills/turn-into-app/references/fresh-project.md +8 -0
- package/corpus/templates/dispatch/changelog/2026-08-07-language-selection-now-lives-in-settings-instead-of-the-disp.md +6 -0
- package/corpus/templates/factory/.agents/skills/turn-into-app/SKILL.md +4 -0
- package/corpus/templates/factory/.agents/skills/turn-into-app/references/fresh-project.md +8 -0
- package/corpus/templates/factory/server/plugins/factory-scheduler-job.ts +59 -22
- package/corpus/templates/forms/.agents/skills/turn-into-app/SKILL.md +4 -0
- package/corpus/templates/forms/.agents/skills/turn-into-app/references/fresh-project.md +8 -0
- package/corpus/templates/macros/.agents/skills/turn-into-app/SKILL.md +4 -0
- package/corpus/templates/macros/.agents/skills/turn-into-app/references/fresh-project.md +8 -0
- package/corpus/templates/mail/.agents/skills/turn-into-app/SKILL.md +4 -0
- package/corpus/templates/mail/.agents/skills/turn-into-app/references/fresh-project.md +8 -0
- package/corpus/templates/plan/.agents/skills/turn-into-app/SKILL.md +4 -0
- package/corpus/templates/plan/.agents/skills/turn-into-app/references/fresh-project.md +8 -0
- package/corpus/templates/slides/.agents/skills/image-generation-via-a2a/SKILL.md +10 -1
- package/corpus/templates/slides/.agents/skills/slide-images/SKILL.md +10 -3
- package/corpus/templates/slides/.agents/skills/turn-into-app/SKILL.md +4 -0
- package/corpus/templates/slides/.agents/skills/turn-into-app/references/fresh-project.md +8 -0
- package/corpus/templates/slides/actions/generate-image-api.ts +123 -0
- package/corpus/templates/slides/actions/import-file.ts +144 -99
- package/corpus/templates/slides/app/components/deck/DeckCard.tsx +10 -2
- package/corpus/templates/slides/app/components/editor/ImageGenPanel.tsx +1 -1
- package/corpus/templates/slides/changelog/2026-08-07-deleting-a-deck-no-longer-freezes-the-slides-browser.md +6 -0
- package/corpus/templates/slides/changelog/2026-08-07-slides-verify-generated-images-are-persisted-on-the-target-s.md +6 -0
- package/corpus/templates/slides/package.json +1 -0
- package/corpus/templates/slides/server/handlers/import/html-converter.ts +9 -0
- package/corpus/templates/slides/server/handlers/import/pdf-fidelity-parser.ts +960 -0
- package/corpus/templates/slides/server/lib/slide-image-insertion.ts +141 -0
- package/corpus/templates/slides/server/plugins/agent-chat.ts +2 -0
- package/corpus/templates/tasks/.agents/skills/turn-into-app/SKILL.md +4 -0
- package/corpus/templates/tasks/.agents/skills/turn-into-app/references/fresh-project.md +8 -0
- package/dist/client/AgentPanel.d.ts +10 -4
- package/dist/client/AgentPanel.js +29 -82
- package/dist/collab/awareness.d.ts +2 -2
- package/dist/collab/struct-routes.d.ts +1 -1
- package/dist/db/migrations.js +22 -4
- package/dist/deploy/build.d.ts +1 -0
- package/dist/deploy/build.js +27 -22
- package/dist/file-upload/actions/upload-image.d.ts +1 -1
- package/dist/ingestion/pptx.d.ts +1 -0
- package/dist/ingestion/pptx.js +353 -28
- package/dist/notifications/routes.d.ts +2 -2
- package/dist/observability/routes.d.ts +1 -1
- package/dist/progress/routes.d.ts +1 -1
- package/dist/resources/handlers.d.ts +1 -1
- package/dist/secrets/routes.d.ts +6 -6
- package/dist/server/builder-browser.d.ts +34 -0
- package/dist/server/builder-browser.js +179 -0
- package/dist/server/core-routes-plugin.d.ts +5 -0
- package/dist/server/core-routes-plugin.js +24 -8
- package/dist/server/index.d.ts +1 -1
- package/dist/server/index.js +1 -1
- package/dist/shared/cache-control.d.ts +2 -2
- package/dist/shared/cache-control.js +2 -2
- package/dist/templates/chat/.agents/skills/turn-into-app/SKILL.md +4 -0
- package/dist/templates/chat/.agents/skills/turn-into-app/references/fresh-project.md +8 -0
- package/dist/templates/default/.agents/skills/turn-into-app/SKILL.md +4 -0
- package/dist/templates/default/.agents/skills/turn-into-app/references/fresh-project.md +8 -0
- package/dist/templates/headless/.agents/skills/turn-into-app/SKILL.md +4 -0
- package/dist/templates/headless/.agents/skills/turn-into-app/references/fresh-project.md +8 -0
- package/dist/templates/workspace-core/.agents/skills/turn-into-app/SKILL.md +4 -0
- package/dist/templates/workspace-core/.agents/skills/turn-into-app/references/fresh-project.md +8 -0
- package/dist/vite/client.js +13 -1
- package/docs/content/drop-in-agent.mdx +3 -3
- package/docs/content/getting-started-actions.mdx +1 -2
- package/docs/content/getting-started-pages.mdx +1 -2
- package/docs/content/getting-started.mdx +0 -4
- package/docs/content/what-is-agent-native.mdx +4 -4
- package/package.json +3 -3
- package/src/templates/chat/.agents/skills/turn-into-app/SKILL.md +4 -0
- package/src/templates/chat/.agents/skills/turn-into-app/references/fresh-project.md +8 -0
- package/src/templates/default/.agents/skills/turn-into-app/SKILL.md +4 -0
- package/src/templates/default/.agents/skills/turn-into-app/references/fresh-project.md +8 -0
- package/src/templates/headless/.agents/skills/turn-into-app/SKILL.md +4 -0
- package/src/templates/headless/.agents/skills/turn-into-app/references/fresh-project.md +8 -0
- package/src/templates/workspace-core/.agents/skills/turn-into-app/SKILL.md +4 -0
- package/src/templates/workspace-core/.agents/skills/turn-into-app/references/fresh-project.md +8 -0
- 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
|
+
}
|