generated from coulomb/repo-seed
Implement CE-WP-0002 T01-T02: engine types + PDF viewer adapter spike
T01: shared engine types (Document, Selector union, Annotation, EvidenceItem, branded IDs with newId factory) per wiki/SharedContracts.md §1-§3. T02: react-pdf-highlighter-plus v1.1.4 spike behind the §5 DocumentViewerAdapter contract in src/anchor/. Pure round-trip math extracted to pdf-selector-math.ts with 11 unit tests proving lossless capture → selectors → JSON → restored-rects. ADR-0004 accepted; full user-flow Playwright verification deferred to T09. Adds Vite app shell (index.html, src/app/SpikeApp.tsx) so the spike is exercisable via pnpm dev. tsconfig --noEmit prevents tsc -b from littering src/ with stray .js outputs. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
79
src/anchor/pdf-selector-math.ts
Normal file
79
src/anchor/pdf-selector-math.ts
Normal file
@@ -0,0 +1,79 @@
|
||||
/**
|
||||
* 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 };
|
||||
}
|
||||
Reference in New Issue
Block a user