0 ? wrapperClasses : undefined}
- style={{ height: "100%" }}
- >
-
- {(pdfDocument) => (
-
-
- {
- utilsRef.current = u;
- }}
- onSelection={(selection) => {
- const capture = captureFromPdfSelection(selection);
- const selectors = selectorsFromPdfCapture(capture);
- if (debugTextLayer) {
- console.log("[ce] onSelection", {
- text: capture.text,
- page: capture.page,
- rects: capture.rects,
- selectorTypes: selectors.map((s) => s.type),
- raw: selection,
- });
- }
- onSelectionCaptured(capture, selectors);
- }}
- >
-
-
-
-
- )}
-
-
- );
-}
-
-// Re-export the §5 contract surface so callers see anchor as one entry point.
-export type { AnchorResolution, ResolvedAnchorTarget, PdfSelectionCapture };
diff --git a/src/pdf/scroll-job.test.ts b/src/pdf/scroll-job.test.ts
deleted file mode 100644
index 5e9c0bb..0000000
--- a/src/pdf/scroll-job.test.ts
+++ /dev/null
@@ -1,73 +0,0 @@
-/**
- * CE-WP-0008-T02 — scroll job retries until utils and highlight exist.
- */
-
-import { describe, expect, it, vi } from "vitest";
-import type { Highlight, PdfHighlighterUtils } from "react-pdf-highlighter-plus";
-
-import { runScrollToHighlightJob } from "./scroll-job";
-
-const TARGET = {
- id: "ann_test",
- type: "text",
- content: { text: "quote" },
- position: {
- boundingRect: {
- x1: 0,
- y1: 0,
- x2: 1,
- y2: 1,
- width: 1,
- height: 1,
- pageNumber: 2,
- },
- rects: [],
- },
-} as Highlight;
-
-describe("runScrollToHighlightJob (CE-WP-0008-T02)", () => {
- it("retries until utils and highlight are available", () => {
- const frames: Array<() => void> = [];
- const scrollToHighlight = vi.fn();
- const centerHighlight = vi.fn();
- let utils: PdfHighlighterUtils | null = null;
- const highlightRef: { current: Highlight | undefined } = { current: undefined };
-
- const state = { lastCompletedKey: null as string | null };
-
- const cancel = runScrollToHighlightJob(
- { requestKey: "ann_test:1", annotationId: "ann_test" },
- {
- getUtils: () => utils,
- findHighlight: (id) => (id === "ann_test" ? highlightRef.current : undefined),
- scrollToHighlight: (_u, target) => scrollToHighlight(target),
- centerHighlight,
- scheduleFrame: (fn) => {
- frames.push(fn);
- return frames.length;
- },
- maxAttempts: 5,
- },
- state,
- );
-
- expect(scrollToHighlight).not.toHaveBeenCalled();
-
- // First two frames: still missing utils / highlight.
- frames.shift()?.();
- frames.shift()?.();
- expect(scrollToHighlight).not.toHaveBeenCalled();
-
- utils = { scrollToHighlight: vi.fn() } as unknown as PdfHighlighterUtils;
- highlightRef.current = TARGET;
- frames.shift()?.();
-
- expect(scrollToHighlight).toHaveBeenCalledWith(TARGET);
- expect(state.lastCompletedKey).toBe("ann_test:1");
-
- frames.shift()?.();
- expect(centerHighlight).toHaveBeenCalledWith(utils, TARGET);
-
- cancel();
- });
-});
\ No newline at end of file
diff --git a/src/pdf/scroll-job.ts b/src/pdf/scroll-job.ts
deleted file mode 100644
index a9d3c2d..0000000
--- a/src/pdf/scroll-job.ts
+++ /dev/null
@@ -1,73 +0,0 @@
-/**
- * Retryable scroll-to-highlight job for PdfSpikeViewer.
- *
- * The PDF highlighter's utils ref and highlight DOM are not always ready on
- * the first effect tick (especially for page-2+ passages). This helper retries
- * via rAF until both are available or attempts are exhausted.
- */
-
-import type { Highlight, PdfHighlighterUtils } from "react-pdf-highlighter-plus";
-
-export const DEFAULT_SCROLL_ATTEMPTS = 40;
-
-export interface ScrollToHighlightJob {
- readonly requestKey: string;
- readonly annotationId: string;
-}
-
-export interface ScrollToHighlightDeps {
- readonly getUtils: () => PdfHighlighterUtils | null;
- readonly findHighlight: (annotationId: string) => Highlight | undefined;
- readonly scrollToHighlight: (
- utils: PdfHighlighterUtils,
- target: Highlight,
- ) => void;
- readonly centerHighlight: (
- utils: PdfHighlighterUtils,
- target: Highlight,
- ) => void;
- readonly scheduleFrame: (fn: () => void) => number;
- readonly maxAttempts?: number;
-}
-
-export interface ScrollToHighlightState {
- lastCompletedKey: string | null;
-}
-
-/**
- * Attempt scroll for `job`. Returns a cancel function. Sets
- * `state.lastCompletedKey` only after a successful scroll.
- */
-export function runScrollToHighlightJob(
- job: ScrollToHighlightJob,
- deps: ScrollToHighlightDeps,
- state: ScrollToHighlightState,
-): () => void {
- let cancelled = false;
- let attempt = 0;
- const maxAttempts = deps.maxAttempts ?? DEFAULT_SCROLL_ATTEMPTS;
-
- const tick = () => {
- if (cancelled) return;
- if (state.lastCompletedKey === job.requestKey) return;
-
- const utils = deps.getUtils();
- const target = deps.findHighlight(job.annotationId);
- if (!utils || !target) {
- if (attempt < maxAttempts) {
- attempt += 1;
- deps.scheduleFrame(tick);
- }
- return;
- }
-
- deps.scrollToHighlight(utils, target);
- state.lastCompletedKey = job.requestKey;
- deps.scheduleFrame(() => deps.centerHighlight(utils, target));
- };
-
- tick();
- return () => {
- cancelled = true;
- };
-}
\ No newline at end of file
diff --git a/src/selectors/create.test.ts b/src/selectors/create.test.ts
deleted file mode 100644
index aee8855..0000000
--- a/src/selectors/create.test.ts
+++ /dev/null
@@ -1,136 +0,0 @@
-import { describe, expect, it } from "vitest";
-import type { DocumentRepresentation } from "@citation-evidence/engine/shared";
-import type { DocumentId, RepresentationId } from "@citation-evidence/engine/shared";
-import type {
- PdfPageTextSelector,
- PdfRectSelector,
- TextPositionSelector,
- TextQuoteSelector,
-} from "@citation-evidence/engine/shared";
-import { createSelectors } from "./create";
-import type { PdfSelectionCapture } from "../types";
-
-function repr(canonicalText: string): DocumentRepresentation {
- const pageLength = canonicalText.length;
- return {
- id: "rep_test" as RepresentationId,
- documentId: "doc_test" as DocumentId,
- representationType: "pdf-text",
- contentHash: "test",
- canonicalText,
- pageMap: [{ page: 1, width: 595, height: 842 }],
- offsetMap: [
- { page: 1, globalStart: 0, globalEnd: pageLength, pageLength },
- ],
- generatedAt: "2026-05-25T00:00:00.000Z",
- };
-}
-
-function capture(text: string, page = 1, rectsCount = 1): PdfSelectionCapture {
- return {
- kind: "pdf",
- text,
- page,
- rects: Array.from({ length: rectsCount }, (_, i) => ({
- x: 0.1,
- y: 0.2 + i * 0.05,
- width: 0.5,
- height: 0.04,
- })),
- boundingRect: { x: 0.1, y: 0.2, width: 0.5, height: 0.04 * rectsCount },
- };
-}
-
-describe("createSelectors", () => {
- const text = "The quick brown fox jumps over the lazy dog near the river bank.";
- const representation = repr(text);
-
- it("always includes a TextQuoteSelector with prefix and suffix from canonical text", () => {
- const sels = createSelectors(capture("brown fox"), representation);
- const quote = sels.find((s): s is TextQuoteSelector => s.type === "TextQuoteSelector");
- expect(quote).toBeDefined();
- expect(quote!.exact).toBe("brown fox");
- expect(quote!.prefix).toBe("The quick ");
- expect(quote!.suffix).toBe(" jumps over the lazy dog near th");
- });
-
- it("includes a TextPositionSelector pointing at the matched offset", () => {
- const sels = createSelectors(capture("brown fox"), representation);
- const pos = sels.find((s): s is TextPositionSelector => s.type === "TextPositionSelector");
- expect(pos).toBeDefined();
- expect(pos!.start).toBe(text.indexOf("brown fox"));
- expect(pos!.end).toBe(text.indexOf("brown fox") + "brown fox".length);
- });
-
- it("includes a PdfRectSelector mirroring the capture's page and rects", () => {
- const c = capture("brown fox", 1, 2);
- const sels = createSelectors(c, representation);
- const rect = sels.find((s): s is PdfRectSelector => s.type === "PdfRectSelector");
- expect(rect).toBeDefined();
- expect(rect!.page).toBe(1);
- expect(rect!.rects).toEqual(c.rects);
- });
-
- it("includes a PdfPageTextSelector when the match falls inside the capture's page range", () => {
- const sels = createSelectors(capture("brown fox"), representation);
- const pageText = sels.find((s): s is PdfPageTextSelector => s.type === "PdfPageTextSelector");
- expect(pageText).toBeDefined();
- expect(pageText!.page).toBe(1);
- expect(pageText!.start).toBe(text.indexOf("brown fox"));
- });
-
- it("omits the TextPositionSelector when the quote cannot be found in canonical text", () => {
- const sels = createSelectors(capture("nonexistent phrase"), representation);
- const pos = sels.find((s) => s.type === "TextPositionSelector");
- expect(pos).toBeUndefined();
- const quote = sels.find((s): s is TextQuoteSelector => s.type === "TextQuoteSelector");
- expect(quote!.exact).toBe("nonexistent phrase");
- expect(quote!.prefix).toBeUndefined();
- expect(quote!.suffix).toBeUndefined();
- });
-
- it("clamps prefix at the start of the canonical text", () => {
- const sels = createSelectors(capture("The quick"), representation);
- const quote = sels.find((s): s is TextQuoteSelector => s.type === "TextQuoteSelector")!;
- expect(quote.prefix).toBeUndefined();
- expect(quote.suffix).toBe(" brown fox jumps over the lazy d");
- });
-
- it("clamps suffix at the end of the canonical text", () => {
- const sels = createSelectors(capture("river bank."), representation);
- const quote = sels.find((s): s is TextQuoteSelector => s.type === "TextQuoteSelector")!;
- expect(quote.prefix).toBe("umps over the lazy dog near the ");
- expect(quote.suffix).toBeUndefined();
- });
-
- it("honors a custom contextChars option", () => {
- const sels = createSelectors(capture("brown fox"), representation, { contextChars: 4 });
- const quote = sels.find((s): s is TextQuoteSelector => s.type === "TextQuoteSelector")!;
- expect(quote.prefix).toBe("ick ");
- expect(quote.suffix).toBe(" jum");
- });
-
- it("prefers the on-page match when the quote appears on multiple pages", () => {
- // Two-page representation where the quote appears once per page.
- const canonical = "alpha echo bravo" + "\n\n" + "charlie echo delta";
- const rep: DocumentRepresentation = {
- id: "rep_multi" as RepresentationId,
- documentId: "doc_multi" as DocumentId,
- representationType: "pdf-text",
- contentHash: "h",
- canonicalText: canonical,
- pageMap: [
- { page: 1, width: 100, height: 100 },
- { page: 2, width: 100, height: 100 },
- ],
- offsetMap: [
- { page: 1, globalStart: 0, globalEnd: 18, pageLength: 18 },
- { page: 2, globalStart: 18, globalEnd: canonical.length, pageLength: canonical.length - 18 },
- ],
- generatedAt: "2026-05-25T00:00:00.000Z",
- };
- const sels = createSelectors(capture("echo", 2), rep);
- const pos = sels.find((s): s is TextPositionSelector => s.type === "TextPositionSelector")!;
- expect(pos.start).toBe(canonical.indexOf("echo", 18));
- });
-});
diff --git a/src/selectors/create.ts b/src/selectors/create.ts
deleted file mode 100644
index d4cf50b..0000000
--- a/src/selectors/create.ts
+++ /dev/null
@@ -1,157 +0,0 @@
-/**
- * Build the maximal `Selector[]` from a viewer's `SelectionCapture`.
- *
- * Implements the "always store all selector types that are available" rule
- * from `wiki/SharedContracts.md` §3 (selector redundancy) and the create
- * half of the `AnchorAdapter` contract in
- * `wiki/ArchitectureOverview.md` §3.3.
- *
- * Output guarantee: every returned `Selector[]` includes a
- * `TextQuoteSelector` (always) and adds `TextPositionSelector`,
- * `PdfRectSelector`, `PdfPageTextSelector` only when the underlying data
- * actually supports them. Resolvers can rely on the union being trimmed —
- * a missing selector means "not available", not "skipped".
- */
-
-import type { DocumentRepresentation } from "@citation-evidence/engine/shared";
-import { normalize } from "@citation-evidence/engine/shared";
-import type {
- PdfPageTextSelector,
- PdfRectSelector,
- Selector,
- TextPositionSelector,
- TextQuoteSelector,
-} from "@citation-evidence/engine/shared";
-
-import type { PdfSelectionCapture, SelectionCapture } from "../types";
-
-/** Default characters of prefix/suffix context stored on TextQuoteSelector. */
-export const DEFAULT_CONTEXT_CHARS = 32;
-
-export interface CreateSelectorsOptions {
- readonly contextChars?: number;
-}
-
-export function createSelectors(
- capture: SelectionCapture,
- representation: DocumentRepresentation,
- options: CreateSelectorsOptions = {},
-): Selector[] {
- // `SelectionCapture` is a discriminated union. The DOM branch is `never`
- // in MVP, so the only runtime shape is `PdfSelectionCapture`.
- return createSelectorsFromPdfCapture(capture, representation, options);
-}
-
-function createSelectorsFromPdfCapture(
- capture: PdfSelectionCapture,
- representation: DocumentRepresentation,
- options: CreateSelectorsOptions,
-): Selector[] {
- const contextChars = options.contextChars ?? DEFAULT_CONTEXT_CHARS;
- const normalizedQuote = normalize(capture.text).text;
- const out: Selector[] = [];
-
- const canonicalText = representation.canonicalText ?? "";
- const positions = canonicalText.length > 0 && normalizedQuote.length > 0
- ? findAllOccurrences(canonicalText, normalizedQuote)
- : [];
-
- // Locate the match that falls on the capture's page (when offsetMap is
- // known); otherwise fall back to the first match. If there is no match,
- // we still emit a quote-only TextQuoteSelector so the annotation is
- // recoverable later if the representation is rebuilt.
- const pageRange = representation.offsetMap?.find((r) => r.page === capture.page);
- const matchOffset = pickMatch(positions, pageRange);
-
- // 1. TextQuoteSelector — always included.
- if (normalizedQuote.length > 0) {
- const quote = matchOffset !== null
- ? buildQuoteSelectorWithContext(canonicalText, matchOffset, normalizedQuote, contextChars)
- : ({ type: "TextQuoteSelector", exact: normalizedQuote } satisfies TextQuoteSelector);
- out.push(quote);
- }
-
- // 2. TextPositionSelector — only when we have a unique-enough match.
- if (matchOffset !== null) {
- const pos: TextPositionSelector = {
- type: "TextPositionSelector",
- start: matchOffset,
- end: matchOffset + normalizedQuote.length,
- };
- out.push(pos);
- }
-
- // 3. PdfRectSelector — straight from the capture; viewer-coordinate truth.
- if (capture.rects.length > 0) {
- const rect: PdfRectSelector = {
- type: "PdfRectSelector",
- page: capture.page,
- rects: capture.rects,
- };
- out.push(rect);
- }
-
- // 4. PdfPageTextSelector — when we have offsetMap and a unique-enough match
- // that falls inside the capture's page range.
- if (matchOffset !== null && pageRange) {
- if (matchOffset >= pageRange.globalStart && matchOffset + normalizedQuote.length <= pageRange.globalEnd) {
- const pageText: PdfPageTextSelector = {
- type: "PdfPageTextSelector",
- page: capture.page,
- start: matchOffset - pageRange.globalStart,
- end: matchOffset - pageRange.globalStart + normalizedQuote.length,
- };
- out.push(pageText);
- }
- }
-
- return out;
-}
-
-function findAllOccurrences(haystack: string, needle: string): number[] {
- if (needle.length === 0) return [];
- const out: number[] = [];
- let from = 0;
- for (;;) {
- const idx = haystack.indexOf(needle, from);
- if (idx === -1) break;
- out.push(idx);
- from = idx + 1;
- }
- return out;
-}
-
-function pickMatch(
- positions: readonly number[],
- pageRange: { globalStart: number; globalEnd: number } | undefined,
-): number | null {
- if (positions.length === 0) return null;
- if (positions.length === 1) return positions[0]!;
- if (pageRange) {
- const onPage = positions.find(
- (p) => p >= pageRange.globalStart && p < pageRange.globalEnd,
- );
- if (onPage !== undefined) return onPage;
- }
- // Multiple matches and no page hint — return the first; resolve.ts will
- // need prefix/suffix to disambiguate.
- return positions[0]!;
-}
-
-function buildQuoteSelectorWithContext(
- canonicalText: string,
- matchOffset: number,
- exact: string,
- contextChars: number,
-): TextQuoteSelector {
- const prefixStart = Math.max(0, matchOffset - contextChars);
- const suffixEnd = Math.min(canonicalText.length, matchOffset + exact.length + contextChars);
- const prefix = canonicalText.slice(prefixStart, matchOffset);
- const suffix = canonicalText.slice(matchOffset + exact.length, suffixEnd);
- return {
- type: "TextQuoteSelector",
- exact,
- ...(prefix.length > 0 ? { prefix } : {}),
- ...(suffix.length > 0 ? { suffix } : {}),
- };
-}
diff --git a/src/selectors/index.ts b/src/selectors/index.ts
deleted file mode 100644
index f47543c..0000000
--- a/src/selectors/index.ts
+++ /dev/null
@@ -1,6 +0,0 @@
-export {
- createSelectors,
- DEFAULT_CONTEXT_CHARS,
- type CreateSelectorsOptions,
-} from "./create";
-export { resolveSelectors } from "./resolve";
diff --git a/src/selectors/resolve.test.ts b/src/selectors/resolve.test.ts
deleted file mode 100644
index ce45d55..0000000
--- a/src/selectors/resolve.test.ts
+++ /dev/null
@@ -1,137 +0,0 @@
-import { describe, expect, it } from "vitest";
-import type { DocumentRepresentation } from "@citation-evidence/engine/shared";
-import type { DocumentId, RepresentationId } from "@citation-evidence/engine/shared";
-import type { Selector } from "@citation-evidence/engine/shared";
-import { resolveSelectors } from "./resolve";
-
-function repr(canonicalText: string, pages = 1): DocumentRepresentation {
- const segmentLen = pages === 1
- ? canonicalText.length
- : Math.floor(canonicalText.length / pages);
- const offsetMap = [];
- for (let i = 0; i < pages; i++) {
- const start = i * segmentLen;
- const end = i === pages - 1 ? canonicalText.length : start + segmentLen;
- offsetMap.push({ page: i + 1, globalStart: start, globalEnd: end, pageLength: end - start });
- }
- return {
- id: "rep_test" as RepresentationId,
- documentId: "doc_test" as DocumentId,
- representationType: "pdf-text",
- contentHash: "test",
- canonicalText,
- pageMap: Array.from({ length: pages }, (_, i) => ({ page: i + 1, width: 595, height: 842 })),
- offsetMap,
- generatedAt: "2026-05-25T00:00:00.000Z",
- };
-}
-
-describe("resolveSelectors", () => {
- const text = "The quick brown fox jumps over the lazy dog.";
- const representation = repr(text);
- const brownFoxStart = text.indexOf("brown fox");
- const brownFoxEnd = brownFoxStart + "brown fox".length;
-
- it("returns 1.0 confidence when position and quote agree exactly", () => {
- const selectors: Selector[] = [
- { type: "TextPositionSelector", start: brownFoxStart, end: brownFoxEnd },
- { type: "TextQuoteSelector", exact: "brown fox" },
- ];
- const r = resolveSelectors(selectors, representation);
- expect(r.status).toBe("resolved");
- expect(r.confidence).toBe(1.0);
- expect(r.candidates[0]?.textPosition).toEqual({ start: brownFoxStart, end: brownFoxEnd });
- expect(r.candidates[0]?.page).toBe(1);
- expect(r.usedSelectorTypes).toEqual(["TextPositionSelector", "TextQuoteSelector"]);
- });
-
- it("falls back to quote search when position is stale, and records a warning", () => {
- const selectors: Selector[] = [
- { type: "TextPositionSelector", start: 0, end: 9 }, // "The quick"
- { type: "TextQuoteSelector", exact: "brown fox" },
- ];
- const r = resolveSelectors(selectors, representation);
- expect(r.status).toBe("resolved");
- expect(r.confidence).toBe(0.95);
- expect(r.candidates[0]?.textPosition).toEqual({ start: brownFoxStart, end: brownFoxEnd });
- expect(r.warnings?.[0]).toMatch(/did not match/);
- expect(r.usedSelectorTypes).toEqual(["TextQuoteSelector"]);
- });
-
- it("returns 0.85 for a position-only selector with no quote to verify", () => {
- const selectors: Selector[] = [
- { type: "TextPositionSelector", start: brownFoxStart, end: brownFoxEnd },
- ];
- const r = resolveSelectors(selectors, representation);
- expect(r.status).toBe("resolved");
- expect(r.confidence).toBe(0.85);
- });
-
- it("returns 0.95 when only TextQuoteSelector is present and the quote is unique", () => {
- const r = resolveSelectors(
- [{ type: "TextQuoteSelector", exact: "brown fox" }],
- representation,
- );
- expect(r.status).toBe("resolved");
- expect(r.confidence).toBe(0.95);
- });
-
- it("returns 0.9 when a duplicated quote is disambiguated by prefix/suffix", () => {
- const dup = "alpha echo bravo charlie echo delta";
- const r = resolveSelectors(
- [{ type: "TextQuoteSelector", exact: "echo", prefix: "charlie ", suffix: " delta" }],
- repr(dup),
- );
- expect(r.status).toBe("resolved");
- expect(r.confidence).toBe(0.9);
- expect(r.candidates[0]?.textPosition?.start).toBe(dup.indexOf("echo", 10));
- });
-
- it("returns ambiguous when a duplicated quote cannot be disambiguated", () => {
- const dup = "echo and echo";
- const r = resolveSelectors(
- [{ type: "TextQuoteSelector", exact: "echo" }],
- repr(dup),
- );
- expect(r.status).toBe("ambiguous");
- expect(r.confidence).toBe(0.5);
- });
-
- it("falls back to PdfPageTextSelector via the OffsetMap", () => {
- // Single page, "brown fox" at offset 10..19.
- const r = resolveSelectors(
- [{ type: "PdfPageTextSelector", page: 1, start: brownFoxStart, end: brownFoxEnd }],
- representation,
- );
- expect(r.status).toBe("resolved");
- expect(r.confidence).toBe(0.8);
- expect(r.candidates[0]?.textPosition).toEqual({ start: brownFoxStart, end: brownFoxEnd });
- expect(r.candidates[0]?.page).toBe(1);
- });
-
- it("falls back to PdfRectSelector with page+rects only at 0.7 confidence", () => {
- const r = resolveSelectors(
- [{
- type: "PdfRectSelector",
- page: 2,
- rects: [{ x: 0.1, y: 0.2, width: 0.3, height: 0.04 }],
- }],
- repr(text, 1),
- );
- expect(r.status).toBe("resolved");
- expect(r.confidence).toBe(0.7);
- expect(r.candidates[0]?.page).toBe(2);
- expect(r.candidates[0]?.textPosition).toBeUndefined();
- expect(r.candidates[0]?.rects).toHaveLength(1);
- });
-
- it("returns unresolved when nothing matches", () => {
- const r = resolveSelectors(
- [{ type: "TextQuoteSelector", exact: "missing string" }],
- representation,
- );
- expect(r.status).toBe("unresolved");
- expect(r.confidence).toBe(0);
- expect(r.candidates).toEqual([]);
- });
-});
diff --git a/src/selectors/resolve.ts b/src/selectors/resolve.ts
deleted file mode 100644
index c72a71f..0000000
--- a/src/selectors/resolve.ts
+++ /dev/null
@@ -1,260 +0,0 @@
-/**
- * Resolve a `Selector[]` against a `DocumentRepresentation`.
- *
- * Implements the resolution strategy from `wiki/ArchitectureOverview.md` §7,
- * MVP-trimmed:
- *
- * 1. Try `TextPositionSelector` (cheapest — direct slice).
- * 2. Verify with `TextQuoteSelector` at that position.
- * 3. Try `TextQuoteSelector` on its own. If multiple matches, disambiguate
- * by prefix/suffix.
- * 4. Try `PdfPageTextSelector` (page-local offsets through the OffsetMap).
- * 5. Fall back to `PdfRectSelector` for a page+rects-only target.
- * 6. Return `unresolved` if nothing above succeeds.
- *
- * Fuzzy matching is out of scope here; a later workplan owns it.
- *
- * Confidence ladder (0..1):
- * 1.00 — TextPosition + TextQuote agree exactly
- * 0.95 — TextQuote unique match (no position to cross-check)
- * 0.90 — TextQuote disambiguated by prefix/suffix
- * 0.85 — TextPosition only (no quote to cross-check)
- * 0.80 — PdfPageTextSelector resolved via OffsetMap
- * 0.70 — PdfRectSelector only (page+rects, no text verification)
- */
-
-import type { DocumentRepresentation } from "@citation-evidence/engine/shared";
-import type {
- PdfPageTextSelector,
- PdfRectSelector,
- Selector,
- SelectorType,
- TextPositionSelector,
- TextQuoteSelector,
-} from "@citation-evidence/engine/shared";
-
-import type { AnchorResolution, ResolvedAnchorTarget } from "../types";
-
-export function resolveSelectors(
- selectors: readonly Selector[],
- representation: DocumentRepresentation,
-): AnchorResolution {
- const canonicalText = representation.canonicalText ?? "";
- const offsetMap = representation.offsetMap ?? [];
- const representationId = representation.id;
-
- const byType = indexByType(selectors);
- const used: SelectorType[] = [];
- const warnings: string[] = [];
-
- // 1 & 2. Try TextPositionSelector, verify with TextQuoteSelector.
- if (byType.TextPositionSelector && canonicalText.length > 0) {
- const pos = byType.TextPositionSelector;
- const slice = sliceSafely(canonicalText, pos.start, pos.end);
- if (slice !== null) {
- const quote = byType.TextQuoteSelector;
- if (quote) {
- if (slice === quote.exact) {
- used.push("TextPositionSelector", "TextQuoteSelector");
- return resolved(
- { representationId, textPosition: { start: pos.start, end: pos.end }, ...pageFor(pos, offsetMap) },
- 1.0,
- used,
- warnings,
- );
- }
- warnings.push(
- "TextPositionSelector slice did not match TextQuoteSelector.exact; falling back to quote search.",
- );
- } else {
- // Position with no quote to verify — accept at lower confidence.
- used.push("TextPositionSelector");
- return resolved(
- { representationId, textPosition: { start: pos.start, end: pos.end }, ...pageFor(pos, offsetMap) },
- 0.85,
- used,
- warnings,
- );
- }
- }
- }
-
- // 3. TextQuoteSelector on its own (or after the position fallback above).
- if (byType.TextQuoteSelector && canonicalText.length > 0) {
- const quoteResult = resolveByQuote(canonicalText, byType.TextQuoteSelector);
- if (quoteResult) {
- used.push("TextQuoteSelector");
- return resolved(
- {
- representationId,
- textPosition: { start: quoteResult.offset, end: quoteResult.offset + byType.TextQuoteSelector.exact.length },
- ...pageFor({ start: quoteResult.offset, end: quoteResult.offset + byType.TextQuoteSelector.exact.length }, offsetMap),
- },
- quoteResult.confidence,
- used,
- warnings,
- quoteResult.status,
- );
- }
- }
-
- // 4. PdfPageTextSelector through OffsetMap.
- if (byType.PdfPageTextSelector && offsetMap.length > 0) {
- const pageText = byType.PdfPageTextSelector;
- const range = offsetMap.find((r) => r.page === pageText.page);
- if (range && pageText.start >= 0 && pageText.end <= range.pageLength && pageText.start < pageText.end) {
- const globalStart = range.globalStart + pageText.start;
- const globalEnd = range.globalStart + pageText.end;
- used.push("PdfPageTextSelector");
- return resolved(
- {
- representationId,
- page: pageText.page,
- textPosition: { start: globalStart, end: globalEnd },
- },
- 0.8,
- used,
- warnings,
- );
- }
- }
-
- // 5. PdfRectSelector fallback (no text verification possible).
- if (byType.PdfRectSelector) {
- const rect = byType.PdfRectSelector;
- used.push("PdfRectSelector");
- return resolved(
- { representationId, page: rect.page, rects: rect.rects },
- 0.7,
- used,
- warnings,
- );
- }
-
- return unresolved(warnings);
-}
-
-interface QuoteResolutionResult {
- readonly offset: number;
- readonly confidence: number;
- readonly status: "resolved" | "ambiguous";
-}
-
-function resolveByQuote(canonicalText: string, quote: TextQuoteSelector): QuoteResolutionResult | null {
- const positions = findAllOccurrences(canonicalText, quote.exact);
- if (positions.length === 0) return null;
- if (positions.length === 1) {
- return { offset: positions[0]!, confidence: 0.95, status: "resolved" };
- }
- // Multiple matches — try to disambiguate by prefix/suffix.
- const filtered = positions.filter((p) => prefixSuffixMatches(canonicalText, p, quote));
- if (filtered.length === 1) {
- return { offset: filtered[0]!, confidence: 0.9, status: "resolved" };
- }
- if (filtered.length > 1) {
- return { offset: filtered[0]!, confidence: 0.5, status: "ambiguous" };
- }
- // No prefix/suffix info or no matches with context — return ambiguous on first.
- return { offset: positions[0]!, confidence: 0.5, status: "ambiguous" };
-}
-
-function prefixSuffixMatches(
- canonicalText: string,
- offset: number,
- quote: TextQuoteSelector,
-): boolean {
- if (quote.prefix !== undefined) {
- const prefixEnd = offset;
- const prefixStart = Math.max(0, prefixEnd - quote.prefix.length);
- const actualPrefix = canonicalText.slice(prefixStart, prefixEnd);
- if (!actualPrefix.endsWith(quote.prefix)) return false;
- }
- if (quote.suffix !== undefined) {
- const suffixStart = offset + quote.exact.length;
- const suffixEnd = Math.min(canonicalText.length, suffixStart + quote.suffix.length);
- const actualSuffix = canonicalText.slice(suffixStart, suffixEnd);
- if (!actualSuffix.startsWith(quote.suffix)) return false;
- }
- return true;
-}
-
-interface SelectorIndex {
- TextQuoteSelector?: TextQuoteSelector;
- TextPositionSelector?: TextPositionSelector;
- PdfRectSelector?: PdfRectSelector;
- PdfPageTextSelector?: PdfPageTextSelector;
-}
-
-function indexByType(selectors: readonly Selector[]): SelectorIndex {
- const idx: SelectorIndex = {};
- for (const s of selectors) {
- switch (s.type) {
- case "TextQuoteSelector":
- idx.TextQuoteSelector = s;
- break;
- case "TextPositionSelector":
- idx.TextPositionSelector = s;
- break;
- case "PdfRectSelector":
- idx.PdfRectSelector = s;
- break;
- case "PdfPageTextSelector":
- idx.PdfPageTextSelector = s;
- break;
- }
- }
- return idx;
-}
-
-function sliceSafely(text: string, start: number, end: number): string | null {
- if (start < 0 || end > text.length || start >= end) return null;
- return text.slice(start, end);
-}
-
-function pageFor(
- span: { start: number; end: number },
- offsetMap: readonly { page: number; globalStart: number; globalEnd: number }[],
-): { page?: number } {
- if (offsetMap.length === 0) return {};
- const range = offsetMap.find((r) => span.start >= r.globalStart && span.end <= r.globalEnd);
- return range ? { page: range.page } : {};
-}
-
-function findAllOccurrences(haystack: string, needle: string): number[] {
- if (needle.length === 0) return [];
- const out: number[] = [];
- let from = 0;
- for (;;) {
- const idx = haystack.indexOf(needle, from);
- if (idx === -1) break;
- out.push(idx);
- from = idx + 1;
- }
- return out;
-}
-
-function resolved(
- target: ResolvedAnchorTarget,
- confidence: number,
- used: readonly SelectorType[],
- warnings: readonly string[],
- status: "resolved" | "ambiguous" = "resolved",
-): AnchorResolution {
- return {
- status,
- confidence,
- candidates: [target],
- usedSelectorTypes: used,
- ...(warnings.length > 0 ? { warnings } : {}),
- };
-}
-
-function unresolved(warnings: readonly string[]): AnchorResolution {
- return {
- status: "unresolved",
- confidence: 0,
- candidates: [],
- usedSelectorTypes: [],
- ...(warnings.length > 0 ? { warnings } : {}),
- };
-}
diff --git a/src/types.ts b/src/types.ts
deleted file mode 100644
index dfb1757..0000000
--- a/src/types.ts
+++ /dev/null
@@ -1,97 +0,0 @@
-/**
- * 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 "@citation-evidence/engine/shared";
-import type { Selector } from "@citation-evidence/engine/shared";
-import type { AnnotationResolutionStatus } from "@citation-evidence/engine/shared";
-import type { NormalizedRect } from "@citation-evidence/engine/shared";
-
-/**
- * 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