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:
2026-05-25 02:21:31 +02:00
parent 2f25f99cae
commit 2a7b05c190
22 changed files with 2538 additions and 13 deletions

View File

@@ -1 +1,7 @@
export {};
export * from "./types";
export {
PdfSpikeViewer,
selectorsFromPdfCapture,
type PdfSpikeViewerProps,
type StoredAnnotation,
} from "./pdf-viewer-adapter-spike";

View File

@@ -0,0 +1,111 @@
/**
* Round-trip tests for the spike's pure transformation layer.
*
* These tests are CE-WP-0002-T02's machine-verifiable evidence that the
* adapter's data round-trip is lossless: a captured PDF selection becomes
* a `Selector[]`, the `Selector[]` round-trips through JSON
* (localStorage-equivalent), and the reconstructed PDF rect + page match
* the original. The browser-side selection-capture path is exercised in
* T09 against production code.
*/
import { describe, expect, it } from "vitest";
import {
findPdfRectSelector,
findTextQuoteSelector,
selectorsFromPdfCapture,
unionRect,
} from "./pdf-selector-math";
import type { PdfSelectionCapture } from "./types";
import type { NormalizedRect, Selector } from "@shared/selector";
const SAMPLE_CAPTURE: PdfSelectionCapture = {
kind: "pdf",
text: "Mitglied beim Lohnsteuerhilfeverein Vereinigte Lohnsteuerhilfe e.V.",
page: 1,
rects: [
{ x: 0.12, y: 0.34, width: 0.55, height: 0.02 },
{ x: 0.12, y: 0.37, width: 0.31, height: 0.02 },
],
boundingRect: { x: 0.12, y: 0.34, width: 0.55, height: 0.05 },
};
describe("selectorsFromPdfCapture", () => {
it("produces a TextQuoteSelector and PdfRectSelector from a normal capture", () => {
const sels = selectorsFromPdfCapture(SAMPLE_CAPTURE);
expect(sels.map((s) => s.type)).toEqual(["TextQuoteSelector", "PdfRectSelector"]);
});
it("includes the verbatim quote on the TextQuoteSelector", () => {
const tq = findTextQuoteSelector(selectorsFromPdfCapture(SAMPLE_CAPTURE));
expect(tq?.exact).toBe(SAMPLE_CAPTURE.text);
});
it("preserves page + rects 1:1 on the PdfRectSelector", () => {
const rect = findPdfRectSelector(selectorsFromPdfCapture(SAMPLE_CAPTURE));
expect(rect?.page).toBe(SAMPLE_CAPTURE.page);
expect(rect?.rects).toEqual(SAMPLE_CAPTURE.rects);
});
it("omits TextQuoteSelector when text is empty", () => {
const sels = selectorsFromPdfCapture({ ...SAMPLE_CAPTURE, text: "" });
expect(sels.map((s) => s.type)).toEqual(["PdfRectSelector"]);
});
it("omits PdfRectSelector when no rects are present", () => {
const sels = selectorsFromPdfCapture({ ...SAMPLE_CAPTURE, rects: [] });
expect(sels.map((s) => s.type)).toEqual(["TextQuoteSelector"]);
});
});
describe("Selector[] JSON round-trip", () => {
it("survives JSON.stringify/parse without loss (the localStorage path)", () => {
const original = selectorsFromPdfCapture(SAMPLE_CAPTURE);
const blob = JSON.stringify(original);
const restored = JSON.parse(blob) as Selector[];
expect(restored).toEqual(original);
});
it("the restored PdfRectSelector still resolves to the same page and rects", () => {
const restored = JSON.parse(JSON.stringify(selectorsFromPdfCapture(SAMPLE_CAPTURE))) as Selector[];
const rect = findPdfRectSelector(restored);
expect(rect).not.toBeNull();
expect(rect?.page).toBe(SAMPLE_CAPTURE.page);
expect(rect?.rects).toEqual(SAMPLE_CAPTURE.rects);
});
});
describe("unionRect", () => {
it("returns null for an empty input", () => {
expect(unionRect([])).toBeNull();
});
it("returns the single rect when given exactly one", () => {
const r: NormalizedRect = { x: 0.1, y: 0.2, width: 0.3, height: 0.4 };
const u = unionRect([r]);
expect(u).not.toBeNull();
expect(u!.x).toBeCloseTo(r.x, 9);
expect(u!.y).toBeCloseTo(r.y, 9);
expect(u!.width).toBeCloseTo(r.width, 9);
expect(u!.height).toBeCloseTo(r.height, 9);
});
it("computes the bounding box of multi-line text rects", () => {
const u = unionRect(SAMPLE_CAPTURE.rects);
expect(u).not.toBeNull();
expect(u!.x).toBeCloseTo(0.12, 5);
expect(u!.y).toBeCloseTo(0.34, 5);
expect(u!.width).toBeCloseTo(0.55, 5);
expect(u!.height).toBeCloseTo(0.05, 5);
});
it("is order-independent", () => {
const reversed = [...SAMPLE_CAPTURE.rects].reverse();
const forward = unionRect(SAMPLE_CAPTURE.rects)!;
const back = unionRect(reversed)!;
expect(back.x).toBeCloseTo(forward.x, 9);
expect(back.y).toBeCloseTo(forward.y, 9);
expect(back.width).toBeCloseTo(forward.width, 9);
expect(back.height).toBeCloseTo(forward.height, 9);
});
});

View 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 };
}

View File

@@ -0,0 +1,192 @@
/**
* Throwaway PDF viewer adapter spike (CE-WP-0002-T02).
*
* Purpose: prove that `react-pdf-highlighter-plus` can implement the §5
* `DocumentViewerAdapter` contract end-to-end (select → save selectors →
* reload → resolve → scroll → render highlight) without leaking PDF.js
* types into `src/shared/` or `src/engine/`.
*
* This module is the only place in the codebase that imports
* `react-pdf-highlighter-plus`. The exported React component is consumed
* by `src/app/SpikeApp.tsx`.
*
* Replace before production. T03 (source ingest) + T04 (anchor resolution)
* will build the real PDFViewerAdapter on top of this lessons-learned.
*/
import { useEffect, useMemo, useRef, useState, type ReactNode } from "react";
import {
PdfHighlighter,
PdfLoader,
TextHighlight,
MonitoredHighlightContainer,
useHighlightContainerContext,
type Highlight,
type PdfHighlighterUtils,
type PdfSelection,
type ScaledPosition,
} from "react-pdf-highlighter-plus";
import "react-pdf-highlighter-plus/style/style.css";
import "react-pdf-highlighter-plus/style/pdf_viewer.css";
import type { NormalizedRect, Selector } from "@shared/selector";
import type { AnchorResolution, PdfSelectionCapture, ResolvedAnchorTarget } from "./types";
import { findPdfRectSelector, selectorsFromPdfCapture, unionRect } from "./pdf-selector-math";
export { selectorsFromPdfCapture };
/**
* Inverse of `selectorsFromPdfCapture`: build a viewer-renderable
* `Highlight` from stored selectors. The spike's reload path leans on
* `PdfRectSelector` since it carries page + page-relative rects directly.
* T04 will own the production resolver and add the text-only paths.
*/
function highlightFromSelectors(
id: string,
text: string,
selectors: readonly Selector[],
): Highlight | null {
const rectSel = findPdfRectSelector(selectors);
if (!rectSel) return null;
const boundingRect = unionRect(rectSel.rects);
if (!boundingRect) return null;
const scaledRects = rectSel.rects.map((r) => toScaled(r, rectSel.page));
return {
id,
type: "text",
content: { text },
position: {
boundingRect: toScaled(boundingRect, rectSel.page),
rects: scaledRects,
} satisfies ScaledPosition,
};
}
/**
* Convert the adapter's `NormalizedRect` (page-relative 0..1) to the
* `Scaled` shape react-pdf-highlighter-plus expects (also normalized 0..1
* via width/height). We use a unit page-space of 1×1 — the library
* computes pixel coords from `pageNumber` and the renderer's actual page
* dimensions.
*/
function toScaled(r: NormalizedRect, page: number) {
return {
x1: r.x,
y1: r.y,
x2: r.x + r.width,
y2: r.y + r.height,
width: 1,
height: 1,
pageNumber: page,
};
}
/** PdfSelection → our domain-neutral `PdfSelectionCapture`. */
function captureFromPdfSelection(sel: PdfSelection): PdfSelectionCapture {
const page = sel.position.boundingRect.pageNumber;
const rects = sel.position.rects.map<NormalizedRect>((r) => ({
x: r.x1 / r.width,
y: r.y1 / r.height,
width: (r.x2 - r.x1) / r.width,
height: (r.y2 - r.y1) / r.height,
}));
const br = sel.position.boundingRect;
const boundingRect: NormalizedRect = {
x: br.x1 / br.width,
y: br.y1 / br.height,
width: (br.x2 - br.x1) / br.width,
height: (br.y2 - br.y1) / br.height,
};
return {
kind: "pdf",
text: sel.content.text ?? "",
page,
rects,
boundingRect,
};
}
/**
* Trivial container that renders every stored highlight as a TextHighlight.
* For the spike, no editing tooling — just visual proof of "did the saved
* coordinates land on the right passage on the right page after reload?"
*/
function SpikeHighlightContainer(): ReactNode {
const { highlight, isScrolledTo } = useHighlightContainerContext();
return (
<MonitoredHighlightContainer>
<TextHighlight highlight={highlight} isScrolledTo={isScrolledTo} />
</MonitoredHighlightContainer>
);
}
export interface PdfSpikeViewerProps {
/** URL of the PDF to load (served by Vite dev server). */
readonly pdfUrl: string;
/** Previously-saved selector sets to restore on mount. */
readonly storedAnnotations: readonly StoredAnnotation[];
/** Called when the user produces a new selection. */
onSelectionCaptured(capture: PdfSelectionCapture, selectors: Selector[]): void;
/** Annotation id to scroll to and highlight on mount, if any. */
readonly scrollToAnnotationId?: string;
}
export interface StoredAnnotation {
readonly id: string;
readonly text: string;
readonly selectors: readonly Selector[];
}
/**
* The spike's React component. Renders a PDF and:
* - emits `onSelectionCaptured(capture, selectors)` on every fresh selection
* - reconstructs and renders `storedAnnotations` immediately on load
* - scrolls to `scrollToAnnotationId` if its highlight can be reconstructed
*/
export function PdfSpikeViewer(props: PdfSpikeViewerProps) {
const { pdfUrl, storedAnnotations, onSelectionCaptured, scrollToAnnotationId } = props;
const utilsRef = useRef<PdfHighlighterUtils | null>(null);
const [didScroll, setDidScroll] = useState<string | null>(null);
const highlights = useMemo<Highlight[]>(() => {
const out: Highlight[] = [];
for (const a of storedAnnotations) {
const h = highlightFromSelectors(a.id, a.text, a.selectors);
if (h) out.push(h);
}
return out;
}, [storedAnnotations]);
useEffect(() => {
if (!scrollToAnnotationId || didScroll === scrollToAnnotationId) return;
const utils = utilsRef.current;
const target = highlights.find((h) => h.id === scrollToAnnotationId);
if (!utils || !target) return;
utils.scrollToHighlight(target);
setDidScroll(scrollToAnnotationId);
}, [scrollToAnnotationId, highlights, didScroll]);
return (
<PdfLoader document={pdfUrl}>
{(pdfDocument) => (
<PdfHighlighter
pdfDocument={pdfDocument}
highlights={highlights}
utilsRef={(u) => {
utilsRef.current = u;
}}
onSelection={(selection) => {
const capture = captureFromPdfSelection(selection);
const selectors = selectorsFromPdfCapture(capture);
onSelectionCaptured(capture, selectors);
}}
>
<SpikeHighlightContainer />
</PdfHighlighter>
)}
</PdfLoader>
);
}
// Re-export the §5 contract surface so callers see anchor as one entry point.
export type { AnchorResolution, ResolvedAnchorTarget, PdfSelectionCapture };

97
src/anchor/types.ts Normal file
View File

@@ -0,0 +1,97 @@
/**
* Adapter-side types owned by `evidence-anchor`.
*
* Implements the contract surface from `wiki/SharedContracts.md` §5 and the
* resolution result shape from `wiki/ArchitectureOverview.md` §3.3 / §7.
*
* Anything that mentions a concrete viewer library (pdfjs, react-pdf-highlighter-plus)
* lives *behind* this surface, never on it. `src/shared/` and `src/engine/`
* must never import this file.
*/
import type { Document, DocumentRepresentation } from "@shared/document";
import type { Selector } from "@shared/selector";
import type { AnnotationResolutionStatus } from "@shared/annotation";
import type { NormalizedRect } from "@shared/selector";
/**
* The raw selection captured from a viewer adapter — an opaque payload that
* the adapter understands. The shape is intentionally permissive: each
* concrete adapter narrows the `kind` discriminator and adds its own
* payload. The shared layer never inspects the payload directly.
*/
export type SelectionCapture =
| PdfSelectionCapture
| DomSelectionCapture;
export interface PdfSelectionCapture {
readonly kind: "pdf";
/** Verbatim selected text, before canonical normalisation. */
readonly text: string;
/** 1-indexed physical page number the selection started on. */
readonly page: number;
/** Page-relative normalized rectangles covering the selection (0..1). */
readonly rects: readonly NormalizedRect[];
/** Optional bounding rectangle (page-relative, normalized). */
readonly boundingRect?: NormalizedRect;
}
/** Reserved for the HTML/Markdown adapter. Not implementable in MVP. */
export type DomSelectionCapture = never;
/**
* A passage located inside a representation, ready to be scrolled to and
* highlighted.
*/
export interface ResolvedAnchorTarget {
readonly representationId: string;
/** 1-indexed page (PDF) or undefined for HTML/Markdown. */
readonly page?: number;
/** Page-relative normalized rectangles to highlight. */
readonly rects?: readonly NormalizedRect[];
/** Canonical-text offsets, when known. */
readonly textPosition?: { readonly start: number; readonly end: number };
}
/**
* The outcome of asking the adapter to resolve a `Selector[]`.
* Matches `wiki/ArchitectureOverview.md` §3.3.
*/
export interface AnchorResolution {
readonly status: AnnotationResolutionStatus;
/** 0..1 confidence in the best candidate. */
readonly confidence: number;
readonly candidates: readonly ResolvedAnchorTarget[];
/** Names of the selector kinds that produced a usable candidate. */
readonly usedSelectorTypes: readonly string[];
readonly warnings?: readonly string[];
}
export interface HighlightRenderOptions {
readonly color?: string;
readonly opacity?: number;
}
/**
* The format-neutral viewer adapter contract from `wiki/SharedContracts.md` §5.
*
* Concrete implementations live alongside the viewer they wrap (e.g. the
* PDF spike in `src/anchor/pdf-viewer-adapter-spike.tsx`). The shared/engine
* layers depend only on this interface.
*/
export interface DocumentViewerAdapter {
readonly mediaTypes: readonly string[];
load(document: Document, representation?: DocumentRepresentation): Promise<void>;
getCurrentSelection(): Promise<SelectionCapture | null>;
createSelectorsFromSelection(selection: SelectionCapture): Promise<Selector[]>;
resolveSelectors(selectors: readonly Selector[]): Promise<AnchorResolution>;
scrollToResolvedTarget(
target: ResolvedAnchorTarget,
opts?: { readonly center?: boolean; readonly behavior?: "auto" | "smooth" },
): Promise<void>;
renderHighlight(
target: ResolvedAnchorTarget,
opts?: HighlightRenderOptions,
): Promise<void>;
getHighlightClientRects(annotationId: string): Promise<readonly DOMRect[]>;
}