Compare commits

..

No commits in common. "8d0c2532c42c07ab482057e6f91eeaf6dc1ccbb2" and "41adf77c825c269612bd5d4fb82e32474e565076" have entirely different histories.

29 changed files with 80 additions and 6638 deletions

View file

@ -1,21 +0,0 @@
---
active: true
iteration: 2
session_id: b236681f-560d-497b-aa7d-07eab42bba2b
max_iterations: 12
completion_promise: "HEUREKA"
workplan_id: EANCH-WP-0001
workplan_file: workplans/EANCH-WP-0001-intent-placeholder.md
started_at: "2026-07-08T18:31:44Z"
---
Read the workplan at `workplans/EANCH-WP-0001-intent-placeholder.md`.
If every task has `status: done` AND frontmatter `status: done`:
run `rm -f .claude/ralph-loop.local.md` first (deactivates the loop so the stop hook exits cleanly),
then output <promise>HEUREKA</promise>.
Otherwise implement the next `todo` task as described in the workplan.
Set task `in_progress` when starting, `done` when complete.
When all tasks are done set frontmatter `status: done`.

5
.gitignore vendored
View file

@ -1,8 +1,3 @@
# ---> Node.js / TypeScript
node_modules/
*.tsbuildinfo
.eslintcache
# ---> Python # ---> Python
# Byte-compiled / optimized / DLL files # Byte-compiled / optimized / DLL files
__pycache__/ __pycache__/

1
.nvmrc
View file

@ -1 +0,0 @@
20.10.0

View file

@ -1,92 +1,16 @@
# evidence-anchor # evidence-anchor
Selector creation, resolution, and the `DocumentViewerAdapter` contract that Selector creation, resolution, and the `DocumentViewerAdapter` contract that
every document viewer in the citation-evidence workspace implements. This repo every document viewer in the workspace implements.
turns annotations from static marks into durable, reopenable source references.
- **Owns:** selector *behavior* — `createSelectors`, `resolveSelectors`, PDF ## MVP status: INTENT only
selector math, the viewer-adapter contract, and highlight/scroll helpers.
- **Does not own:** selector *type interfaces* — those live in `citation-engine`
(`shared/selector`). See `ADR-0006` and `SharedContracts.md` §8.
- **May depend on:** `citation-engine` only (DependencyMap §4). Nothing from
`binder/`, `source/`, or `work/` may flow back into it.
See `SCOPE.md` for the boundary and `INTENT.md` for the long-range intent. During the citation-evidence MVP, code lives upstream in
[`citation-evidence`](../citation-evidence/) under `src/anchor/`. This repo
currently holds `INTENT.md` describing what will move here. Contract
changes belong in
[`citation-evidence/wiki/SharedContracts.md`](../citation-evidence/wiki/SharedContracts.md),
not here.
## Status: extracted; umbrella consumes this package Per the dependency map, anchor depends on `shared/` and `engine/` only;
nothing in `binder/`, `source/`, or `work/` may flow back into it.
The anchor slice has been extracted from the umbrella and now lives here.
`citation-evidence` consumes it as `@citation-evidence/evidence-anchor`
(`link:../evidence-anchor`); its former `src/anchor/` directory is gone and the
`@anchor` alias is retired. Shared-contract changes still happen in the umbrella
(`citation-evidence/wiki/`), not here.
Remaining anchor work beyond this MVP slice (stale/orphan semantics, fuzzy
re-anchoring, HTML/Markdown selectors, promoting the PDF spike to a production
adapter) is tracked as follow-on work — see `workplans/`.
## Install model
Sibling-checkout, linked-package model — this repo is checked out next to its
consumers and consumed via a local link (e.g. `link:../evidence-anchor`), not
published to a registry during MVP. Its only shared-type dependency is
`citation-engine`, imported through the engine's public `shared` entrypoint
(`@citation-evidence/engine/shared`) rather than umbrella-only `@shared/*`
aliases.
## Package layout (initial extracted version)
```text
src/
index.ts public entrypoint — full barrel (core + pdf)
types.ts adapter-side types: SelectionCapture,
ResolvedAnchorTarget, AnchorResolution,
HighlightRenderOptions, DocumentViewerAdapter
css.d.ts ambient decl for side-effect .css imports
selectors/ pure core — no viewer/UI deps
index.ts createSelectors, resolveSelectors, DEFAULT_CONTEXT_CHARS
create.ts selector creation from a captured selection
resolve.ts resolution + the exact-match confidence ladder
create.test.ts
resolve.test.ts
pdf/ adapter boundary — the only place viewer libs live
index.ts subpath entry `evidence-anchor/pdf`
pdf-selector-math.ts pure page + normalized-rect math (capture↔selectors)
pdf-selector-math.test.ts
pdf-viewer-adapter-spike.tsx concrete PDF adapter (PdfSpikeViewer)
scroll-job.ts retryable scroll-to-highlight helper
scroll-job.test.ts
highlight-styles.css highlight rendering styles
debug-textlayer.css optional text-layer debugging styles
```
Boundary rules for the layout (enforced by `eslint.config.js`):
- viewer-library imports (`pdfjs-dist`, `react`, `react-pdf-highlighter-plus`)
are confined to `src/pdf/` — the pure zone (`src/selectors/**`, `src/types.ts`)
may not import them;
- `src/selectors/` is pure and depends only on `citation-engine` shared types;
- the **root** entrypoint (`evidence-anchor`) is the full barrel — selector
creation/resolution, the adapter types/contract, and the PDF adapter — so
consumers resolve every anchor symbol (and their test `vi.mock(...)` calls)
from a single specifier, matching the umbrella's prior `@anchor/index`;
- a focused, viewer-free entry is also published at **`evidence-anchor/pdf`**
for consumers that want the PDF surface explicitly. The concrete adapter is
still the explicitly-named `PdfSpikeViewer` spike; promoting it to a
production `PDFViewerAdapter` is registered follow-on work (T06).
## Public API (target surface)
```ts
import {
createSelectors,
resolveSelectors,
type DocumentViewerAdapter,
type AnchorResolution,
} from "@citation-evidence/evidence-anchor";
```
Resolution is explicit about uncertainty — `AnchorResolution.status` is one of
`resolved` / `ambiguous` / `unresolved` / `stale` with a `0..1` confidence, so a
caller can highlight, ask the user to confirm, or mark a citation stale rather
than silently highlight the wrong passage.

137
SCOPE.md
View file

@ -8,143 +8,130 @@
## One-liner ## One-liner
Selector creation, resolution, re-anchoring, and highlight/scroll contracts that <!-- Describe the purpose of this repository in one precise sentence. -->
keep citation-evidence annotations attached to their source passages. <!-- Example: "Provides a lightweight event router for Kubernetes-native systems." -->
--- ---
## Core Idea ## Core Idea
An **anchor** is a resolvable reference to a passage in a document, represented <!-- What is the main capability or idea behind this repository? -->
by several complementary *selectors* (visual, text, structural, context). Given <!-- What problem does it try to solve? -->
a stored selector set and a document representation, this repo answers: *where is
this passage now, and how confident are we?* — returning a confidence-scored
result (`resolved` / `ambiguous` / `unresolved` / `stale`) rather than a silent
guess. It also owns the `DocumentViewerAdapter` contract every viewer implements.
--- ---
## In Scope ## In Scope
- selector **creation** from a captured viewer selection (`createSelectors`) <!-- What this repository is responsible for. -->
- selector **resolution** against a document representation (`resolveSelectors`) <!-- Be explicit and concrete. -->
- the exact-match confidence ladder and selector-redundancy rules
- PDF selector math (page number + normalized page rectangles, page-local text)
- the format-neutral `DocumentViewerAdapter` contract and the first PDF adapter
- scroll-to-target and highlight-render contracts + helpers
- anchor confidence scoring and ambiguous/unresolved/stale reporting
Selector *behavior* lives here; selector *type interfaces* live in -
`citation-engine` (see ADR-0006 and SharedContracts §8). -
-
--- ---
## Out of Scope ## Out of Scope
- selector *type definitions* — owned by `citation-engine` (`shared/selector`) <!-- What this repository deliberately does NOT do. -->
- persistence policy, the canonical evidence domain model, citation-card rendering <!-- This is often more important than "In Scope". -->
- document ingestion, OCR, metadata extraction, external source lookup
- form-field binding semantics, guide-overlay UI, application shell / deployment
- becoming a viewer implementation itself (it defines the adapter, not the viewer)
During the umbrella-first MVP these belong to the appropriate citation-evidence -
subsystem. This repo may depend on **`citation-engine` only** (DependencyMap §4). -
-
### Not in this extraction slice (EANCH-WP-0001)
- full HTML/Markdown (`DomSelectionCapture`) selector implementation
- production-grade fuzzy / recovery-oriented re-anchoring
- `stale` and orphaned-annotation semantics beyond what upstream already ships
These are real INTENT.md goals but are **registered as follow-on work**, not
built during the initial extract-and-verify pass.
--- ---
## Relevant When ## Relevant When
- creating anchors from a user text selection in a document viewer <!-- When should someone consider using or exploring this repository? -->
- resolving stored selectors back into a representation to reopen a citation
- implementing a new `DocumentViewerAdapter` (PDF today; HTML/Markdown later) -
- scrolling to and highlighting a cited passage -
-
--- ---
## Not Relevant When ## Not Relevant When
- you need selector *type* shapes only — import from `citation-engine` <!-- When should someone ignore this repository? -->
- you are working on ingestion, persistence, binding, or the app shell
- you need the integrated review workspace UX — that is `citation-work` / umbrella -
-
-
--- ---
## Current State ## Current State
- Status: experimental — extracting from the umbrella into a standalone package <!-- Rough indication of maturity. No strict format required. -->
- Implementation: partial — pure selector + PDF adapter slice exists upstream in
`../citation-evidence/src/anchor/`; extraction tracked by EANCH-WP-0001 - Status: <!-- e.g. concept / experimental / active / stable / deprecated -->
- Stability: evolving — shared contracts stable, this repo's package API forming - Implementation: <!-- e.g. idea / partial / substantial / complete -->
- Usage: internal (consumed by `citation-evidence` during MVP) - Stability: <!-- e.g. unstable / evolving / stable -->
- Usage: <!-- e.g. none / personal / internal / production -->
<!-- Add any notes that help set expectations. -->
--- ---
## How It Fits ## How It Fits
- Upstream dependencies: `citation-engine` (shared selector/document/annotation types) <!-- Where does this repository sit in the bigger picture? -->
- Downstream consumers: `citation-evidence` (umbrella), later `citation-work`,
`evidence-source`, `evidence-binder` - Upstream dependencies:
- Often used with: a concrete document viewer (PDF.js + react-pdf-highlighter-plus) - Downstream consumers:
- Often used with:
--- ---
## Terminology ## Terminology
- Preferred terms: anchor, selector, resolution, re-anchoring, highlight contract <!-- Terms that are important to understand this repo. -->
- Also known as: "the anchoring layer" <!-- Especially useful if naming differs from other repos. -->
- Potentially confusing terms: `Selector` (the *type* is engine-owned; the
*behavior* is anchor-owned — see ADR-0006) - Preferred terms:
- Also known as:
- Potentially confusing terms:
--- ---
## Related / Overlapping Repositories ## Related / Overlapping Repositories
- `citation-engine` — owns shared selector/document/annotation type interfaces <!-- List repositories that have similar or adjacent responsibilities. -->
- `citation-evidence` — umbrella; current home of the code being extracted <!-- Helps detect duplication and navigate the ecosystem. -->
- `evidence-source` — document ingestion / representations (consumer)
- `citation-work` — review workspace UX (consumer) - <repo-name> — <!-- how it relates -->
- `evidence-binder` — evidence-to-target binding (consumer)
--- ---
## Getting Oriented ## Getting Oriented
- Start with: `INTENT.md` (intended responsibilities), then this file <!-- If someone decides to look deeper, where should they start? -->
- Key files / directories: upstream `../citation-evidence/src/anchor/` is the
extraction source; post-extraction the package layout is in `README.md` - Start with:
- Entry points: `createSelectors`, `resolveSelectors`, `DocumentViewerAdapter` - Key files / directories:
- Entry points:
--- ---
## Provided Capabilities ## Provided Capabilities
```capability <!-- What can this repo's domain provide to other domains on request? -->
type: library <!-- Each capability block is parsed by the state-hub capability catalog ingest. -->
title: Durable citation anchoring (selectors + resolution) <!-- Remove the examples and add your own, or leave empty if none. -->
description: Create redundant selectors for a document passage and resolve them back into a representation with a confidence-scored result, so citations survive zoom, re-render, and light source edits.
keywords: [anchor, selector, resolution, citation, highlight, pdf]
```
<!--
```capability ```capability
type: interface type: infrastructure
title: DocumentViewerAdapter contract title: Example capability title
description: A format-neutral viewer adapter contract (selection capture, selector creation, resolution, scroll-to-target, highlight render) that any document viewer implements; PDF adapter provided first. description: What this capability provides, in one or two sentences.
keywords: [viewer, adapter, highlight, scroll, pdf, contract] keywords: [keyword1, keyword2, keyword3]
``` ```
-->
--- ---
## Notes ## Notes
Shared contracts are maintained in the umbrella and must not be contradicted here: <!-- Anything else worth knowing. Keep it short. -->
`citation-evidence/wiki/SharedContracts.md`, `wiki/DependencyMap.md`,
`docs/decisions/ADR-0006-selector-ownership-split.md`.

View file

@ -1,49 +0,0 @@
// ESLint flat config — keeps viewer-library imports behind the PDF adapter
// boundary and the selector/resolution core pure.
import js from "@eslint/js";
import tseslint from "typescript-eslint";
import importPlugin from "eslint-plugin-import";
import globals from "globals";
const VIEWER_LIBS = ["pdfjs-dist", "react", "react-dom", "react-pdf-highlighter-plus"];
export default tseslint.config(
{
ignores: ["dist/", "node_modules/", "coverage/", "**/*.d.ts"],
},
js.configs.recommended,
...tseslint.configs.recommended,
{
files: ["src/**/*.{ts,tsx}"],
languageOptions: {
ecmaVersion: 2022,
sourceType: "module",
globals: { ...globals.node, ...globals.browser },
},
plugins: {
import: importPlugin,
},
settings: {
"import/resolver": {
typescript: { project: "./tsconfig.json" },
},
},
},
{
// The pure core (selectors, resolution, adapter-side types) must not pull
// in any concrete viewer library — those live only under src/pdf/.
files: ["src/selectors/**/*.ts", "src/types.ts"],
rules: {
"no-restricted-imports": [
"error",
{
paths: VIEWER_LIBS.map((name) => ({
name,
message: "Viewer libraries are confined to src/pdf/ (adapter boundary).",
})),
},
],
},
},
);

View file

@ -1,61 +0,0 @@
{
"name": "@citation-evidence/evidence-anchor",
"version": "0.1.0",
"private": true,
"description": "Selector creation, resolution, re-anchoring, and highlight/scroll contracts for the citation-evidence ecosystem.",
"license": "Apache-2.0",
"type": "module",
"packageManager": "pnpm@9.15.0",
"engines": {
"node": ">=20.10.0"
},
"files": [
"src",
"README.md",
"SCOPE.md",
"INTENT.md",
"LICENSE"
],
"exports": {
".": "./src/index.ts",
"./selectors": "./src/selectors/index.ts",
"./types": "./src/types.ts",
"./pdf": "./src/pdf/index.ts"
},
"scripts": {
"test": "vitest run",
"test:watch": "vitest",
"lint": "eslint .",
"typecheck": "tsc -b --noEmit"
},
"dependencies": {
"@citation-evidence/engine": "link:../citation-engine"
},
"peerDependencies": {
"pdfjs-dist": "^4.4.168",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react-pdf-highlighter-plus": "^1.1.4"
},
"devDependencies": {
"@testing-library/dom": "^10.4.1",
"@testing-library/react": "^16.3.2",
"@testing-library/user-event": "^14.6.1",
"@types/node": "^20.14.0",
"@types/react": "^18.3.3",
"@types/react-dom": "^18.3.0",
"@vitejs/plugin-react": "^4.3.1",
"eslint": "^9.7.0",
"eslint-import-resolver-typescript": "^3.6.3",
"eslint-plugin-import": "^2.30.0",
"globals": "^15.9.0",
"happy-dom": "^20.9.0",
"pdfjs-dist": "^4.4.168",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react-pdf-highlighter-plus": "^1.1.4",
"typescript": "^5.5.4",
"typescript-eslint": "^8.0.0",
"vitest": "^2.0.5"
}
}

4284
pnpm-lock.yaml generated

File diff suppressed because it is too large Load diff

3
src/css.d.ts vendored
View file

@ -1,3 +0,0 @@
// Side-effect CSS imports (viewer styles). The bundler handles these; for the
// typechecker they resolve to an empty module. Confined to the PDF adapter.
declare module "*.css";

View file

@ -1,38 +0,0 @@
// Public entrypoint for `evidence-anchor` — the full barrel.
//
// Mirrors the surface the umbrella previously consumed as `@anchor/index`, so
// consumers (and their `vi.mock(...)` calls) resolve every anchor symbol from a
// single specifier. Viewer *code* still lives only under `src/pdf/` (enforced
// by eslint); this barrel merely re-exports it.
//
// A focused, viewer-free entry is also available at `evidence-anchor/pdf` for
// consumers that want the PDF surface explicitly — but importing the package
// root pulls in the PDF adapter, matching the pre-extraction behaviour.
// Adapter-side types + the viewer contract.
export * from "./types";
// Selector creation / resolution (pure core).
export {
createSelectors,
resolveSelectors,
DEFAULT_CONTEXT_CHARS,
type CreateSelectorsOptions,
} from "./selectors";
// PDF adapter + helpers (re-exported from the src/pdf/ boundary).
export {
PdfSpikeViewer,
getHighlightClientRects,
selectorsFromPdfCapture,
findPdfRectSelector,
findTextQuoteSelector,
unionRect,
runScrollToHighlightJob,
DEFAULT_SCROLL_ATTEMPTS,
type PdfSpikeViewerProps,
type StoredAnnotation,
type ScrollToHighlightJob,
type ScrollToHighlightDeps,
type ScrollToHighlightState,
} from "./pdf";

View file

@ -1,59 +0,0 @@
/*
* Debug overlay for PDF text layer alignment.
*
* The text layer is normally invisible (`opacity: 0`) and selectable.
* When `.ce-debug-textlayer` is on a parent, every text node becomes a
* light grey box so it's obvious where text is selectable and where it
* isn't — useful for diagnosing OCR misalignment, scan-only PDFs, and
* text-layer shift caused by font fallbacks.
*
* Light grey was chosen so the debug overlay does not clash with the
* citation-yellow used for evidence highlights (see highlight-styles.css).
*
* Toggle via the "Debug text layer" entry in SessionMenu.
*/
.ce-debug-textlayer .textLayer {
outline: 2px dashed rgba(120, 120, 120, 0.55);
background: rgba(120, 120, 120, 0.06);
}
/* PDF.js 4.x wraps marked content in nested spans/divs — cover every
descendant so the entire selectable area is visible regardless of how
the renderer nested things. */
.ce-debug-textlayer .textLayer * {
background: rgba(170, 170, 170, 0.4) !important;
color: rgba(40, 40, 40, 0.85) !important;
opacity: 1 !important;
outline: 1px solid rgba(100, 100, 100, 0.35);
}
/* Dim the canvas-rendered layer slightly so the debug overlay stands
out by contrast. */
.ce-debug-textlayer canvas {
opacity: 0.4;
}
/*
* Layer-visibility toggles. Each `.ce-hide-<layer>` class is applied
* to the same viewer-wrapper element so a single parent can hide any
* combination of layers. Useful for diagnosing layer stacking issues
* (e.g. "is the textLayer covering the canvas?") by elimination.
*/
.ce-hide-canvas canvas {
display: none !important;
}
.ce-hide-text-layer .textLayer {
display: none !important;
}
.ce-hide-annotation-layer .annotationLayer,
.ce-hide-annotation-layer .annotationEditorLayer {
display: none !important;
}
.ce-hide-xfa-layer .xfaLayer {
display: none !important;
}

View file

@ -1,38 +0,0 @@
/*
* Evidence highlight styling — matches the sidebar's "evidence card"
* palette so the viewer and the sidebar speak the same visual language.
*
* .TextHighlight__part inactive highlight (light yellow fill,
* thin amber border)
* .TextHighlight--active … the currently-focused evidence — same
* fill, thicker border
*
* The "active" class is applied by the spike viewer when the parent
* wrapper is marked with `data-ce-active="true"` so a single
* `activeAnnotationId` prop drives the entire viewer's focus state
* without per-highlight component coupling.
*
* We override the library's red `--scrolledTo` box-shadow so an
* activation doesn't flash a red ring that doesn't match the palette.
*/
.TextHighlight__part {
background: #fff8d6 !important;
outline: 1px solid #e0c050 !important;
outline-offset: 0;
cursor: pointer;
transition: outline 0.15s ease;
}
[data-ce-active="true"] .TextHighlight__part {
outline: 3px solid #b78b1c !important;
background: #fff5b8 !important;
}
/* The library applies `--scrolledTo` after a programmatic scroll. We
override its red box-shadow so the "you just landed on this" cue
sticks with the yellow palette. The thicker border from
`data-ce-active` already conveys focus. */
.TextHighlight--scrolledTo .TextHighlight__part {
box-shadow: none !important;
}

View file

@ -1,35 +0,0 @@
// Subpath entrypoint `evidence-anchor/pdf` — the PDF viewer adapter, its
// scroll/highlight helpers, and the pure capture→selector math. Keeping this
// behind a subpath (rather than the package root) confines PDF.js, React, and
// react-pdf-highlighter-plus to consumers that actually render a PDF; the pure
// selector/resolution core stays importable without them.
//
// The concrete adapter is still the explicitly-named `PdfSpikeViewer` spike
// that validated the round-trip end-to-end. Promoting it to a production
// `PDFViewerAdapter` is registered follow-on work (see EANCH-WP-0001 T06), not
// part of this extraction.
// Pure capture → selector math (no viewer libraries).
export {
selectorsFromPdfCapture,
findPdfRectSelector,
findTextQuoteSelector,
unionRect,
} from "./pdf-selector-math";
// Retryable scroll-to-highlight job (react-pdf-highlighter-plus types).
export {
runScrollToHighlightJob,
DEFAULT_SCROLL_ATTEMPTS,
type ScrollToHighlightJob,
type ScrollToHighlightDeps,
type ScrollToHighlightState,
} from "./scroll-job";
// Concrete PDF viewer adapter (spike) + its contract surface.
export {
PdfSpikeViewer,
getHighlightClientRects,
type PdfSpikeViewerProps,
type StoredAnnotation,
} from "./pdf-viewer-adapter-spike";

View file

@ -1,111 +0,0 @@
/**
* 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 "@citation-evidence/engine/shared";
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

@ -1,79 +0,0 @@
/**
* 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 "@citation-evidence/engine/shared";
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

@ -1,424 +0,0 @@
/**
* 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 {
createContext,
useCallback,
useContext,
useEffect,
useMemo,
useRef,
type ReactNode,
} from "react";
import {
PdfHighlighter,
PdfLoader,
TextHighlight,
MonitoredHighlightContainer,
useHighlightContainerContext,
type Highlight,
type PdfHighlighterUtils,
type PdfSelection,
type ScaledPosition,
} from "react-pdf-highlighter-plus";
// pdfjs-dist's own pdf_viewer.css is the authoritative source for
// text-layer positioning. The version bundled with
// react-pdf-highlighter-plus is a minimal *override* (missing
// `position: absolute`, `inset: 0`, and PDF.js 4.x's
// `--scale-factor` handling) — load the real one first, then the
// library's overrides on top.
import "pdfjs-dist/web/pdf_viewer.css";
import "react-pdf-highlighter-plus/style/style.css";
import "react-pdf-highlighter-plus/style/pdf_viewer.css";
import "./highlight-styles.css";
import "./debug-textlayer.css";
import type { NormalizedRect, Selector } from "@citation-evidence/engine/shared";
import type { AnchorResolution, PdfSelectionCapture, ResolvedAnchorTarget } from "../types";
import { findPdfRectSelector, selectorsFromPdfCapture, unionRect } from "./pdf-selector-math";
import { runScrollToHighlightJob } from "./scroll-job";
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,
};
}
const ActiveAnnotationContext = createContext<string | null | undefined>(
undefined,
);
const HighlightClickContext = createContext<((annotationId: string) => void) | undefined>(
undefined,
);
/**
* Stable highlight row — component type never changes so PdfHighlighter does
* not remount highlight layers on activation changes (which disturbs scroll).
* Active/focus styling reads from context instead.
*/
function SpikeHighlightContainer(): ReactNode {
const activeAnnotationId = useContext(ActiveAnnotationContext);
const onHighlightClicked = useContext(HighlightClickContext);
const { highlight, isScrolledTo } = useHighlightContainerContext();
const isActive = activeAnnotationId === highlight.id;
return (
<div
data-highlight-id={highlight.id}
data-ce-active={isActive ? "true" : "false"}
style={{ display: "contents" }}
onClickCapture={(e) => {
e.stopPropagation();
onHighlightClicked?.(highlight.id);
}}
>
<MonitoredHighlightContainer>
<TextHighlight highlight={highlight} isScrolledTo={isScrolledTo} />
</MonitoredHighlightContainer>
</div>
);
}
/**
* Resolve the rendered DOM rect for a highlight by data attribute, or
* `null` if the highlight isn't currently rendered (e.g. its page hasn't
* scrolled into view). Used by `app/forms/HighlightRectBridge` to feed
* the rect registry as kind="highlight".
*
* `display: contents` on the wrapper means it has no box of its own; we
* union the rects of its children. For TextHighlight that's typically
* one rect per line.
*/
export function getHighlightClientRects(annotationId: string): DOMRect | null {
if (typeof document === "undefined") return null;
const wrapper = document.querySelector(`[data-highlight-id="${CSS.escape(annotationId)}"]`);
if (!wrapper) return null;
const rects = wrapper.getClientRects();
if (rects.length === 0) return null;
let left = Infinity;
let top = Infinity;
let right = -Infinity;
let bottom = -Infinity;
for (const r of Array.from(rects)) {
left = Math.min(left, r.left);
top = Math.min(top, r.top);
right = Math.max(right, r.right);
bottom = Math.max(bottom, r.bottom);
}
if (!isFinite(left)) return null;
return new DOMRect(left, top, right - left, bottom - top);
}
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;
/**
* Bumps when the same annotation should be re-scrolled (e.g. repeat click).
* Format is opaque — typically `${annotationId}:${version}`.
*/
readonly scrollRequestKey?: string;
/**
* Annotation id currently focused. The matching highlight gets a
* thicker border (see highlight-styles.css). `null`/undefined means
* "no active highlight".
*/
readonly activeAnnotationId?: string | null;
/**
* Called when the user clicks an existing highlight in the page.
* The receiver typically activates the matching evidence item.
*/
onHighlightClicked?(annotationId: string): void;
/**
* When true, paint the PDF text-layer spans in light grey so it's
* obvious which glyphs have a selectable text overlay and which are
* image-only. Also logs every onSelection event to the console.
*/
readonly debugTextLayer?: boolean;
/**
* Hide specific PDF.js layers so you can see what sits underneath.
* Helps diagnose layer-stacking issues (e.g. "is the text layer
* covering the canvas content?").
*/
readonly hideCanvas?: boolean;
readonly hideTextLayer?: boolean;
readonly hideAnnotationLayer?: boolean;
readonly hideXfaLayer?: boolean;
}
/**
* Nudge the PDF scroll container so `highlight` sits vertically centred.
* Best-effort: depends on highlight layer DOM being present after scroll.
*/
function centerHighlightInViewer(
utils: PdfHighlighterUtils,
highlight: Highlight,
attempt = 0,
): void {
const viewer = utils.getViewer();
const container = viewer?.container as HTMLElement | undefined;
if (!container) return;
const rect = getHighlightClientRects(highlight.id);
if (!rect) {
if (attempt < 12) {
requestAnimationFrame(() =>
centerHighlightInViewer(utils, highlight, attempt + 1),
);
}
return;
}
const cRect = container.getBoundingClientRect();
const highlightCenterY = rect.top + rect.height / 2;
const containerCenterY = cRect.top + cRect.height / 2;
const delta = highlightCenterY - containerCenterY;
if (Math.abs(delta) < 4) return;
container.scrollTop += delta;
}
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,
scrollRequestKey,
activeAnnotationId,
onHighlightClicked,
debugTextLayer,
hideCanvas,
hideTextLayer,
hideAnnotationLayer,
hideXfaLayer,
} = props;
const onHighlightClickedRef = useRef(onHighlightClicked);
onHighlightClickedRef.current = onHighlightClicked;
const handleHighlightClicked = useCallback((annotationId: string) => {
onHighlightClickedRef.current?.(annotationId);
}, []);
const pdfLoaderDocument = useMemo(
() => ({
url: pdfUrl,
// PdfLoader's effect depends on `document` by reference — must be
// stable across re-renders or the PDF reloads and scroll resets to top.
cMapUrl: "/cmaps/",
cMapPacked: true,
standardFontDataUrl: "/standard_fonts/",
}),
[pdfUrl],
);
const wrapperClasses = [
debugTextLayer ? "ce-debug-textlayer" : null,
hideCanvas ? "ce-hide-canvas" : null,
hideTextLayer ? "ce-hide-text-layer" : null,
hideAnnotationLayer ? "ce-hide-annotation-layer" : null,
hideXfaLayer ? "ce-hide-xfa-layer" : null,
]
.filter((c): c is string => c !== null)
.join(" ");
const utilsRef = useRef<PdfHighlighterUtils | null>(null);
const scrollStateRef = useRef({ lastCompletedKey: null as string | null });
const highlights = useMemo<Highlight[]>(() => {
const out: Highlight[] = [];
const skipped: { id: string; reason: string }[] = [];
for (const a of storedAnnotations) {
const h = highlightFromSelectors(a.id, a.text, a.selectors);
if (h) out.push(h);
else skipped.push({ id: a.id, reason: "no PdfRectSelector / empty boundingRect" });
}
if (debugTextLayer) {
console.log("[ce] viewer highlights", {
in: storedAnnotations.length,
rendered: out.length,
rendered_detail: out.map((h) => ({
id: h.id,
page: h.position.boundingRect.pageNumber,
bounding: h.position.boundingRect,
rectCount: h.position.rects.length,
})),
skipped,
});
}
return out;
}, [storedAnnotations, debugTextLayer]);
const highlightsRef = useRef(highlights);
highlightsRef.current = highlights;
const highlightsSignature = useMemo(
() => highlights.map((h) => h.id).join(","),
[highlights],
);
// Re-render highlight layers when focus moves so `data-ce-active` updates.
const highlightsForViewer = useMemo(
() => highlights,
[highlights, activeAnnotationId],
);
useEffect(() => {
const requestKey = scrollRequestKey ?? scrollToAnnotationId ?? null;
if (!requestKey || !scrollToAnnotationId) return;
if (scrollStateRef.current.lastCompletedKey === requestKey) return;
if (debugTextLayer) {
console.log("[ce] scrollToAnnotation requested", {
id: scrollToAnnotationId,
requestKey,
utilsAvailable: !!utilsRef.current,
targetFound: !!highlightsRef.current.find((h) => h.id === scrollToAnnotationId),
knownIds: highlightsRef.current.map((h) => h.id),
});
}
return runScrollToHighlightJob(
{ requestKey, annotationId: scrollToAnnotationId },
{
getUtils: () => utilsRef.current,
findHighlight: (id) => highlightsRef.current.find((h) => h.id === id),
scrollToHighlight: (utils, target) => utils.scrollToHighlight(target),
centerHighlight: (utils, target) => centerHighlightInViewer(utils, target),
scheduleFrame: (fn) => requestAnimationFrame(fn),
},
scrollStateRef.current,
);
}, [scrollToAnnotationId, scrollRequestKey, highlightsSignature, debugTextLayer]);
return (
<div
className={wrapperClasses.length > 0 ? wrapperClasses : undefined}
style={{ height: "100%" }}
>
<PdfLoader document={pdfLoaderDocument}>
{(pdfDocument) => (
<ActiveAnnotationContext.Provider value={activeAnnotationId}>
<HighlightClickContext.Provider value={handleHighlightClicked}>
<PdfHighlighter
pdfDocument={pdfDocument}
highlights={highlightsForViewer}
utilsRef={(u) => {
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);
}}
>
<SpikeHighlightContainer />
</PdfHighlighter>
</HighlightClickContext.Provider>
</ActiveAnnotationContext.Provider>
)}
</PdfLoader>
</div>
);
}
// Re-export the §5 contract surface so callers see anchor as one entry point.
export type { AnchorResolution, ResolvedAnchorTarget, PdfSelectionCapture };

View file

@ -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();
});
});

View file

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

View file

@ -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));
});
});

View file

@ -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 } : {}),
};
}

View file

@ -1,6 +0,0 @@
export {
createSelectors,
DEFAULT_CONTEXT_CHARS,
type CreateSelectorsOptions,
} from "./create";
export { resolveSelectors } from "./resolve";

View file

@ -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([]);
});
});

View file

@ -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 } : {}),
};
}

View file

@ -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<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[]>;
}

View file

@ -1,26 +0,0 @@
{
"compilerOptions": {
"target": "ES2022",
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"module": "ESNext",
"moduleResolution": "Bundler",
"jsx": "react-jsx",
"strict": true,
"noImplicitOverride": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedIndexedAccess": true,
"exactOptionalPropertyTypes": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noEmit": true,
"esModuleInterop": true,
"forceConsistentCasingInFileNames": true,
"isolatedModules": true,
"skipLibCheck": true,
"resolveJsonModule": true,
"verbatimModuleSyntax": true,
"baseUrl": "."
},
"include": ["src", "vitest.config.ts"],
"exclude": ["node_modules", "dist"]
}

View file

@ -1,17 +0,0 @@
import { defineConfig } from "vitest/config";
import react from "@vitejs/plugin-react";
// Pure selector/resolution tests are node-safe; the scroll/highlight and PDF
// adapter tests touch the DOM, so the default environment is happy-dom. The
// react plugin lets the PDF adapter (.tsx) compile under test.
//
// The one shared-type dependency, `@citation-evidence/engine`, resolves through
// its package `exports` map (`./shared`) via the sibling-checkout link — no
// local alias needed. Nothing here imports `citation-evidence` internals.
export default defineConfig({
plugins: [react()],
test: {
environment: "happy-dom",
globals: false,
},
});

View file

@ -4,7 +4,7 @@ type: workplan
title: "Bootstrap evidence-anchor and extract the current anchor slice from citation-evidence" title: "Bootstrap evidence-anchor and extract the current anchor slice from citation-evidence"
domain: infotech domain: infotech
repo: evidence-anchor repo: evidence-anchor
status: finished status: active
owner: codex owner: codex
topic_slug: citation_evidence_mvp topic_slug: citation_evidence_mvp
created: "2026-06-21" created: "2026-06-21"
@ -104,7 +104,7 @@ T01 boundary + package shape
```task ```task
id: EANCH-WP-0001-T01 id: EANCH-WP-0001-T01
priority: high priority: high
status: done status: todo
state_hub_task_id: "ef88ff6a-590f-4858-a674-33f84c1d6116" state_hub_task_id: "ef88ff6a-590f-4858-a674-33f84c1d6116"
``` ```
@ -144,7 +144,7 @@ here and what must stay elsewhere.
```task ```task
id: EANCH-WP-0001-T02 id: EANCH-WP-0001-T02
status: done status: todo
priority: high priority: high
depends_on: [T01] depends_on: [T01]
state_hub_task_id: "59c07bc6-7a80-4f58-b1cf-ee2f0c26e8ed" state_hub_task_id: "59c07bc6-7a80-4f58-b1cf-ee2f0c26e8ed"
@ -185,7 +185,7 @@ package with only `citation-engine` as a shared-type dependency.
```task ```task
id: EANCH-WP-0001-T03 id: EANCH-WP-0001-T03
status: done status: todo
priority: critical priority: critical
depends_on: [T02] depends_on: [T02]
state_hub_task_id: "d7bff928-a022-4cc4-a151-950ffaaf622b" state_hub_task_id: "d7bff928-a022-4cc4-a151-950ffaaf622b"
@ -233,7 +233,7 @@ depend on the umbrella repo folder structure.
```task ```task
id: EANCH-WP-0001-T04 id: EANCH-WP-0001-T04
status: done status: todo
priority: high priority: high
depends_on: [T03] depends_on: [T03]
state_hub_task_id: "1deca610-8502-44e5-90c7-43e355489f55" state_hub_task_id: "1deca610-8502-44e5-90c7-43e355489f55"
@ -276,7 +276,7 @@ implementation remains behind `DocumentViewerAdapter`.
```task ```task
id: EANCH-WP-0001-T05 id: EANCH-WP-0001-T05
status: done status: todo
priority: high priority: high
depends_on: [T03, T04] depends_on: [T03, T04]
state_hub_task_id: "2fd9bd62-5d79-49b5-aee7-45e0a37313ba" state_hub_task_id: "2fd9bd62-5d79-49b5-aee7-45e0a37313ba"
@ -285,35 +285,6 @@ state_hub_task_id: "2fd9bd62-5d79-49b5-aee7-45e0a37313ba"
Replace the umbrella repo's internal anchor slice with a dependency on this Replace the umbrella repo's internal anchor slice with a dependency on this
repo. repo.
> **RESOLVED (2026-07-08).** The parallel `evidence-source` extraction landed
> (`citation-evidence@4ede979`), leaving `main` clean. The cutover was then
> executed and verified: umbrella typecheck + 95 tests + production build all
> green (`citation-evidence@d145148`). Package published as
> `@citation-evidence/evidence-anchor` with `.`, `./selectors`, `./types`, and
> `./pdf` exports; node-env consumers use the pure subpaths. Original blocker
> context retained below.
>
> **BLOCKED (2026-07-08, ralph loop iteration).** Extraction T01–T04 are
> complete, committed, and verified green in `evidence-anchor` (30 tests,
> typecheck, lint). T05 was **not** attempted because the umbrella
> (`../citation-evidence`) has **another session's uncommitted work in
> progress on `main`** — a parallel `evidence-source` extraction:
> `package.json` adds `@citation-evidence/evidence-source` (link),
> `src/source/pdf/*` is deleted, and `tests/integration/anchor-source-roundtrip.test.ts`
> is already modified — the same shared file this cutover must edit (it imports
> both `@anchor/*` and the new source package). Forcing the anchor cutover into
> that dirty tree would entangle the two extractions and likely commit a
> non-building intermediate state. Needs human coordination: land/park the
> source extraction first, or agree how the two cutovers share the roundtrip
> test and `package.json`. The ralph loop was deactivated at this point.
>
> Cutover plan once unblocked (bounded — 4 import sites, all via `@anchor/index`):
> add `"evidence-anchor": "link:../evidence-anchor"`; repoint
> `PdfSelectionCapture` + `createSelectors` → `evidence-anchor`, and
> `PdfSpikeViewer`/`StoredAnnotation` + `getHighlightClientRects` →
> `evidence-anchor/pdf`; drop the `@anchor` tsconfig/vite alias; delete or thin
> `src/anchor/`; run umbrella typecheck/test/build.
Scope: Scope:
- update `../citation-evidence` to consume `evidence-anchor` through a linked - update `../citation-evidence` to consume `evidence-anchor` through a linked
@ -345,7 +316,7 @@ Done when `citation-evidence` builds and tests against this repo and
```task ```task
id: EANCH-WP-0001-T06 id: EANCH-WP-0001-T06
status: done status: todo
priority: medium priority: medium
depends_on: [T05] depends_on: [T05]
state_hub_task_id: "4e82ee7b-e813-441f-a41f-8f17a75fee57" state_hub_task_id: "4e82ee7b-e813-441f-a41f-8f17a75fee57"
@ -384,26 +355,11 @@ Deliverables:
Done when the extracted package ships with an honest, explicit map of the Done when the extracted package ships with an honest, explicit map of the
remaining anchor work instead of vague future intent. remaining anchor work instead of vague future intent.
**Gap map (registered 2026-07-08):** the two follow-on workplans below cover
everything `INTENT.md` promises beyond the extracted MVP slice. `SCOPE.md`
already lists these under "Not in this extraction slice"; `README.md` points
here. No promised behavior is left as implicit future work.
- `workplans/EANCH-WP-0002-anchor-resolution-hardening.md` — stale/orphan
semantics, fuzzy/recovery re-anchoring, and promoting `PdfSpikeViewer` to a
production `PDFViewerAdapter` (existing PDF/text path).
- `workplans/EANCH-WP-0003-non-pdf-selectors.md` — HTML/Markdown `DomRange` +
`Structural` selectors and a non-PDF viewer adapter (new format track;
starts with a `citation-engine` shared-type contract change).
Both are `status: proposed` and will be registered in the hub by
`fix-consistency` (C-06) during T07 — not by hand.
## T07 — Verification, sync, and close-out evidence ## T07 — Verification, sync, and close-out evidence
```task ```task
id: EANCH-WP-0001-T07 id: EANCH-WP-0001-T07
status: done status: todo
priority: high priority: high
depends_on: [T06] depends_on: [T06]
state_hub_task_id: "e88623a9-38d0-4aeb-a7d7-6ec78342d57b" state_hub_task_id: "e88623a9-38d0-4aeb-a7d7-6ec78342d57b"
@ -435,14 +391,3 @@ Deliverables:
Done when the extraction is verified end-to-end and the workplan can retire Done when the extraction is verified end-to-end and the workplan can retire
cleanly under HEUREKA instead of stopping at "probably finished". cleanly under HEUREKA instead of stopping at "probably finished".
**Verification (2026-07-08):**
- `evidence-anchor`: `pnpm test` 30 passed (4 files), `typecheck` clean,
`lint` clean.
- `citation-evidence` (post-cutover, `d145148`): `pnpm typecheck` clean,
`pnpm test` 95 passed (26 files), `pnpm build` succeeded.
Commits: `08b3105` T01 · `0e93b68` T02 · `bd7f56c` T03 · `5b77dbd` T04 ·
`f32b500` T05 (umbrella `d145148`) · `0871f79` T06. Workplan frontmatter set to
`finished`; follow-on gaps live in `EANCH-WP-0002` and `EANCH-WP-0003`.

View file

@ -1,114 +0,0 @@
---
id: EANCH-WP-0002
type: workplan
title: "Anchor resolution hardening: stale/orphan semantics, fuzzy re-anchoring, production PDF adapter"
domain: infotech
repo: evidence-anchor
status: proposed
owner: codex
topic_slug: citation_evidence_mvp
created: "2026-07-08"
updated: "2026-07-08"
spec_refs:
- INTENT.md
- SCOPE.md
- ../citation-evidence/wiki/SharedContracts.md
- ../citation-evidence/wiki/ArchitectureOverview.md
---
# EANCH-WP-0002 — Anchor Resolution Hardening
## Goal
Close the resolution-quality gaps left open by the MVP extraction
(`EANCH-WP-0001`) on the **existing PDF/text path**. The extracted slice
resolves exact position/quote/PDF selectors with a fixed confidence ladder and
returns `resolved | ambiguous | unresolved`. `INTENT.md` promises more:
`stale` detection, orphaned-annotation handling, fuzzy/recovery re-anchoring,
and human-confirmation workflows for ambiguity. This workplan makes those real
without changing the shared selector *types* (those stay in `citation-engine`).
Non-goals: HTML/Markdown selectors (see `EANCH-WP-0003`); new persistence or
UI shell behavior.
## Background
Current extracted behavior (`src/selectors/resolve.ts`):
- confidence ladder 1.00 → 0.70 across TextPosition/TextQuote/PdfPageText/PdfRect
- multiple undisambiguated quote matches → `ambiguous` at 0.5
- nothing matches → `unresolved` at 0.0
- there is **no** `stale` path (the `AnnotationResolutionStatus` enum includes
`stale`, but resolve.ts never returns it) and **no** fuzzy matching
## Task Breakdown
## T01 — Stale vs. unresolved: distinguish "representation changed" from "never found"
```task
id: EANCH-WP-0002-T01
status: todo
priority: high
```
Return `stale` (not `unresolved`) when a previously-resolvable anchor no longer
matches because the representation's canonical text changed, while the stored
quote is still meaningful. Define the signal (e.g. position selector points
in-range but slice ≠ quote AND quote no longer found) and preserve the original
quote/context on the result. Add unit tests covering stale vs. unresolved.
## T02 — Orphaned-annotation detection surface
```task
id: EANCH-WP-0002-T02
status: todo
priority: medium
depends_on: [T01]
```
Give callers a way to identify annotations whose selectors can no longer be
placed at all (orphaned) versus stale-but-recoverable. Decide whether this is a
distinct status, a flag on `AnchorResolution`, or a helper. Keep the shared
enum unchanged unless a contract change is agreed in the umbrella wiki first.
## T03 — Fuzzy / recovery re-anchoring
```task
id: EANCH-WP-0002-T03
status: todo
priority: high
depends_on: [T01]
```
Add a fuzzy quote-matching fallback (bounded edit-distance / token overlap)
below the exact ladder, returning candidates at appropriately low confidence
with `ambiguous`/`stale` status. Must never silently emit a confident wrong
match (INTENT: "No Silent Misleading Match"). Add tests with lightly-edited
source text. Confirm the ladder's exact tiers are untouched above the fuzzy
floor.
## T04 — Promote the PDF spike to a production adapter
```task
id: EANCH-WP-0002-T04
status: todo
priority: medium
depends_on: [T03]
```
Turn `PdfSpikeViewer` into a supported `PDFViewerAdapter` that implements the
`DocumentViewerAdapter` contract cleanly (remove spike-only debug flags from the
public surface, or gate them), keep viewer libs behind `src/pdf/`, and keep the
umbrella green through the change. Update `evidence-anchor/pdf` exports and docs.
## T05 — Verification + close-out
```task
id: EANCH-WP-0002-T05
status: todo
priority: high
depends_on: [T02, T04]
```
`pnpm test`/`typecheck`/`lint` green here; umbrella typecheck/test/build green
after any contract-visible change; `fix-consistency` clean; progress note.

View file

@ -1,110 +0,0 @@
---
id: EANCH-WP-0003
type: workplan
title: "Non-PDF selectors: HTML/Markdown DOM range + structural anchoring"
domain: infotech
repo: evidence-anchor
status: proposed
owner: codex
topic_slug: citation_evidence_mvp
created: "2026-07-08"
updated: "2026-07-08"
spec_refs:
- INTENT.md
- SCOPE.md
- ../citation-evidence/wiki/SharedContracts.md
- ../citation-evidence/docs/decisions/ADR-0006-selector-ownership-split.md
---
# EANCH-WP-0003 — Non-PDF Selectors (HTML / Markdown)
## Goal
Extend anchoring beyond PDFs to non-paginated documents (HTML, Markdown),
delivering the `DomRangeSelector` and `StructuralSelector` concepts that
`INTENT.md` names but the MVP left as `never`. The extracted package already
models format-neutral resolution (`DocumentViewerAdapter`, canonical-text
selectors); this workplan fills in the DOM branch of `SelectionCapture` and a
concrete HTML/Markdown viewer adapter.
Depends on the extraction (`EANCH-WP-0001`) being complete. Independent of the
resolution-hardening track (`EANCH-WP-0002`) except where both touch the shared
enum — coordinate contract changes in the umbrella wiki first.
## Background
- `src/types.ts`: `DomSelectionCapture = never` (reserved) — the capture union
only has a PDF branch today.
- `citation-engine` shared selector types already declare `DomRangeSelector`
and `StructuralSelector` as `never` placeholders; promoting them is a
shared-contract change and must land in `citation-engine` first (ADR-0006:
selector *types* live in engine).
## Task Breakdown
## T01 — Contract: promote DomRange/Structural selector types in citation-engine
```task
id: EANCH-WP-0003-T01
status: todo
priority: high
```
Define the real `DomRangeSelector` and `StructuralSelector` shapes (DOM path +
range offsets; heading/section/block/AST path) in `citation-engine`'s shared
selector module, plus the `DomSelectionCapture` payload contract in the wiki.
Land this in the engine + `SharedContracts.md` before writing anchor behavior.
## T02 — Selector creation from a DOM/Markdown selection
```task
id: EANCH-WP-0003-T02
status: todo
priority: high
depends_on: [T01]
```
Implement `createSelectors` for the DOM branch: build TextQuote + TextPosition
(against canonical text) plus DomRange + Structural selectors from a captured
DOM selection. Add unit tests mirroring the PDF create tests.
## T03 — Resolution for non-paginated documents
```task
id: EANCH-WP-0003-T03
status: todo
priority: high
depends_on: [T02]
```
Extend `resolveSelectors` to resolve DomRange/Structural selectors and rank
them in the confidence ladder alongside the existing text selectors, keeping
paginated (PDF) and non-paginated resolution on shared concepts. Tests for
re-render stability (structural fallback when offsets shift).
## T04 — HTML/Markdown viewer adapter
```task
id: EANCH-WP-0003-T04
status: todo
priority: medium
depends_on: [T03]
```
Add a `DocumentViewerAdapter` implementation for rendered HTML/Markdown
(selection capture, scroll-to-target, highlight render), behind a subpath
export analogous to `evidence-anchor/pdf`, with viewer-specific deps confined
to that boundary.
## T05 — Verification + close-out
```task
id: EANCH-WP-0003-T05
status: todo
priority: high
depends_on: [T04]
```
Package green (`pnpm test`/`typecheck`/`lint`); engine contract change verified
in `citation-engine`; any umbrella consumer green; `fix-consistency` clean;
progress note.