80 lines
2.5 KiB
TypeScript
80 lines
2.5 KiB
TypeScript
|
|
/**
|
||
|
|
* Pure, library-free transformations between the adapter's
|
||
|
|
* `PdfSelectionCapture` and the shared `Selector[]` shapes.
|
||
|
|
*
|
||
|
|
* Extracted from `pdf-viewer-adapter-spike.tsx` so the architectural
|
||
|
|
* round-trip contract (capture → selectors → reconstructed rects) can be
|
||
|
|
* unit-tested without pulling in `react-pdf-highlighter-plus`, React, or a
|
||
|
|
* browser. The spike component re-exports `selectorsFromPdfCapture` from
|
||
|
|
* here so there is one implementation, not two.
|
||
|
|
*
|
||
|
|
* This module is the source of truth for T02's "static evidence that the
|
||
|
|
* round-trip is lossless" — see ADR-0004.
|
||
|
|
*/
|
||
|
|
|
||
|
|
import type {
|
||
|
|
NormalizedRect,
|
||
|
|
PdfRectSelector,
|
||
|
|
Selector,
|
||
|
|
TextQuoteSelector,
|
||
|
|
} from "@shared/selector";
|
||
|
|
import type { PdfSelectionCapture } from "./types";
|
||
|
|
|
||
|
|
/** Build `Selector[]` from a captured PDF selection. */
|
||
|
|
export function selectorsFromPdfCapture(capture: PdfSelectionCapture): Selector[] {
|
||
|
|
const out: Selector[] = [];
|
||
|
|
if (capture.text.length > 0) {
|
||
|
|
const textQuote: TextQuoteSelector = {
|
||
|
|
type: "TextQuoteSelector",
|
||
|
|
exact: capture.text,
|
||
|
|
};
|
||
|
|
out.push(textQuote);
|
||
|
|
}
|
||
|
|
if (capture.rects.length > 0) {
|
||
|
|
const rect: PdfRectSelector = {
|
||
|
|
type: "PdfRectSelector",
|
||
|
|
page: capture.page,
|
||
|
|
rects: capture.rects,
|
||
|
|
};
|
||
|
|
out.push(rect);
|
||
|
|
}
|
||
|
|
return out;
|
||
|
|
}
|
||
|
|
|
||
|
|
/** Find the `PdfRectSelector` in a selector list, if any. */
|
||
|
|
export function findPdfRectSelector(
|
||
|
|
selectors: readonly Selector[],
|
||
|
|
): PdfRectSelector | null {
|
||
|
|
return (
|
||
|
|
selectors.find((s): s is PdfRectSelector => s.type === "PdfRectSelector") ?? null
|
||
|
|
);
|
||
|
|
}
|
||
|
|
|
||
|
|
/** Find the `TextQuoteSelector` in a selector list, if any. */
|
||
|
|
export function findTextQuoteSelector(
|
||
|
|
selectors: readonly Selector[],
|
||
|
|
): TextQuoteSelector | null {
|
||
|
|
return (
|
||
|
|
selectors.find((s): s is TextQuoteSelector => s.type === "TextQuoteSelector") ??
|
||
|
|
null
|
||
|
|
);
|
||
|
|
}
|
||
|
|
|
||
|
|
/** Bounding rectangle of a non-empty list of normalized rects. */
|
||
|
|
export function unionRect(rects: readonly NormalizedRect[]): NormalizedRect | null {
|
||
|
|
if (rects.length === 0) return null;
|
||
|
|
const first = rects[0]!;
|
||
|
|
let minX = first.x;
|
||
|
|
let minY = first.y;
|
||
|
|
let maxX = first.x + first.width;
|
||
|
|
let maxY = first.y + first.height;
|
||
|
|
for (let i = 1; i < rects.length; i++) {
|
||
|
|
const r = rects[i]!;
|
||
|
|
if (r.x < minX) minX = r.x;
|
||
|
|
if (r.y < minY) minY = r.y;
|
||
|
|
if (r.x + r.width > maxX) maxX = r.x + r.width;
|
||
|
|
if (r.y + r.height > maxY) maxY = r.y + r.height;
|
||
|
|
}
|
||
|
|
return { x: minX, y: minY, width: maxX - minX, height: maxY - minY };
|
||
|
|
}
|