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