Compare commits
No commits in common. "8d0c2532c42c07ab482057e6f91eeaf6dc1ccbb2" and "41adf77c825c269612bd5d4fb82e32474e565076" have entirely different histories.
8d0c2532c4
...
41adf77c82
29 changed files with 80 additions and 6638 deletions
|
|
@ -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
5
.gitignore
vendored
|
|
@ -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
1
.nvmrc
|
|
@ -1 +0,0 @@
|
||||||
20.10.0
|
|
||||||
96
README.md
96
README.md
|
|
@ -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
137
SCOPE.md
|
|
@ -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`.
|
|
||||||
|
|
|
||||||
|
|
@ -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).",
|
|
||||||
})),
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
);
|
|
||||||
61
package.json
61
package.json
|
|
@ -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
4284
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load diff
3
src/css.d.ts
vendored
3
src/css.d.ts
vendored
|
|
@ -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";
|
|
||||||
38
src/index.ts
38
src/index.ts
|
|
@ -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";
|
|
||||||
|
|
@ -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;
|
|
||||||
}
|
|
||||||
|
|
@ -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;
|
|
||||||
}
|
|
||||||
|
|
@ -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";
|
|
||||||
|
|
@ -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);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
@ -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 };
|
|
||||||
}
|
|
||||||
|
|
@ -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 };
|
|
||||||
|
|
@ -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();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
@ -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;
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
@ -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));
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
@ -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 } : {}),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
@ -1,6 +0,0 @@
|
||||||
export {
|
|
||||||
createSelectors,
|
|
||||||
DEFAULT_CONTEXT_CHARS,
|
|
||||||
type CreateSelectorsOptions,
|
|
||||||
} from "./create";
|
|
||||||
export { resolveSelectors } from "./resolve";
|
|
||||||
|
|
@ -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([]);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
@ -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 } : {}),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
97
src/types.ts
97
src/types.ts
|
|
@ -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[]>;
|
|
||||||
}
|
|
||||||
|
|
@ -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"]
|
|
||||||
}
|
|
||||||
|
|
@ -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,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
@ -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`.
|
|
||||||
|
|
|
||||||
|
|
@ -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.
|
|
||||||
|
|
@ -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.
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue