diff --git a/package.json b/package.json
index d442926..d004a0e 100644
--- a/package.json
+++ b/package.json
@@ -20,7 +20,8 @@
".": "./src/index.ts",
"./selectors": "./src/selectors/index.ts",
"./types": "./src/types.ts",
- "./pdf": "./src/pdf/index.ts"
+ "./pdf": "./src/pdf/index.ts",
+ "./dom": "./src/dom/index.ts"
},
"scripts": {
"test": "vitest run",
diff --git a/src/dom/dom-path.test.ts b/src/dom/dom-path.test.ts
new file mode 100644
index 0000000..03c98ac
--- /dev/null
+++ b/src/dom/dom-path.test.ts
@@ -0,0 +1,30 @@
+import { describe, expect, it } from "vitest";
+import { Window } from "happy-dom";
+import { captureFromRange, domPathToNode, nodeAtPath } from "./dom-path";
+
+describe("dom-path", () => {
+ it("round-trips a child-index path", () => {
+ const win = new Window();
+ const doc = win.document;
+ doc.body.innerHTML = "Hello world
";
+ const bold = doc.querySelector("b")!;
+ const root = doc.body as unknown as Node;
+ const path = domPathToNode(root, bold as unknown as Node);
+ expect(path).toBeDefined();
+ expect(nodeAtPath(root, path!)).toBe(bold);
+ });
+
+ it("captures a range as DomSelectionCapture", () => {
+ const win = new Window();
+ const doc = win.document;
+ doc.body.innerHTML = "
The quick brown fox
";
+ const textNode = doc.querySelector("p")!.firstChild!;
+ const range = doc.createRange();
+ range.setStart(textNode, 4);
+ range.setEnd(textNode, 14);
+ const capture = captureFromRange(doc.body as unknown as Node, range as unknown as Range, "quick brown");
+ expect(capture?.kind).toBe("dom");
+ expect(capture?.text).toBe("quick brown");
+ expect(capture?.startPath).toBeDefined();
+ });
+});
\ No newline at end of file
diff --git a/src/dom/dom-path.ts b/src/dom/dom-path.ts
new file mode 100644
index 0000000..79fb4ca
--- /dev/null
+++ b/src/dom/dom-path.ts
@@ -0,0 +1,58 @@
+import type { DomNodePath } from "@citation-evidence/engine/shared";
+import type { DomSelectionCapture } from "../types";
+
+/** Walk a DOM tree and return the child-index path to `node`. */
+export function domPathToNode(root: Node, node: Node): DomNodePath | null {
+ if (root === node) return [];
+ const path: number[] = [];
+
+ function walk(current: Node, target: Node): boolean {
+ if (current === target) return true;
+ const children = childNodes(current);
+ for (let i = 0; i < children.length; i++) {
+ path.push(i);
+ if (walk(children[i]!, target)) return true;
+ path.pop();
+ }
+ return false;
+ }
+
+ return walk(root, node) ? path : null;
+}
+
+/** Resolve a child-index path from `root` to a node, or null if invalid. */
+export function nodeAtPath(root: Node, path: DomNodePath): Node | null {
+ let current: Node = root;
+ for (const index of path) {
+ const children = childNodes(current);
+ const child = children[index];
+ if (!child) return null;
+ current = child;
+ }
+ return current;
+}
+
+function childNodes(node: Node): Node[] {
+ return Array.from(node.childNodes);
+}
+
+/** Build a `DomSelectionCapture` from a browser `Range` relative to `root`. */
+export function captureFromRange(
+ root: Node,
+ range: Range,
+ text: string,
+ structuralPath?: DomSelectionCapture["structuralPath"],
+): DomSelectionCapture | null {
+ const startPath = domPathToNode(root, range.startContainer);
+ const endPath = domPathToNode(root, range.endContainer);
+ if (!startPath || !endPath) return null;
+ return {
+ kind: "dom",
+ text,
+ startPath,
+ startOffset: range.startOffset,
+ endPath,
+ endOffset: range.endOffset,
+ ...(structuralPath ? { structuralPath } : {}),
+ };
+}
\ No newline at end of file
diff --git a/src/dom/html-viewer-adapter.tsx b/src/dom/html-viewer-adapter.tsx
new file mode 100644
index 0000000..d6f43a2
--- /dev/null
+++ b/src/dom/html-viewer-adapter.tsx
@@ -0,0 +1,156 @@
+/**
+ * HTML/Markdown viewer adapter for non-paginated documents.
+ *
+ * Renders canonical HTML, captures DOM selections, and supports scroll/highlight
+ * via resolved text positions. Viewer-specific code stays under `src/dom/`.
+ */
+
+import {
+ useCallback,
+ useEffect,
+ useMemo,
+ useRef,
+ type ReactNode,
+} from "react";
+
+import type { DocumentRepresentation, Selector } from "@citation-evidence/engine/shared";
+import { createSelectors, resolveSelectors } from "../selectors";
+import type {
+ AnchorResolution,
+ DomSelectionCapture,
+ HighlightRenderOptions,
+ ResolvedAnchorTarget,
+} from "../types";
+import { captureFromRange } from "./dom-path";
+
+export interface HtmlViewerDebugOptions {
+ readonly logSelections?: boolean;
+}
+
+export interface HtmlViewerAdapterProps {
+ readonly html: string;
+ readonly representation: DocumentRepresentation;
+ readonly storedAnnotations?: readonly StoredHtmlAnnotation[];
+ readonly scrollToAnnotationId?: string;
+ readonly activeAnnotationId?: string | null;
+ readonly debug?: HtmlViewerDebugOptions;
+ onSelectionCaptured?(capture: DomSelectionCapture, selectors: Selector[]): void;
+ onHighlightClicked?(annotationId: string): void;
+}
+
+export interface StoredHtmlAnnotation {
+ readonly id: string;
+ readonly text: string;
+ readonly selectors: readonly Selector[];
+}
+
+export function HtmlViewerAdapter(props: HtmlViewerAdapterProps): ReactNode {
+ const {
+ html,
+ representation,
+ storedAnnotations = [],
+ scrollToAnnotationId,
+ activeAnnotationId,
+ debug,
+ onSelectionCaptured,
+ onHighlightClicked,
+ } = props;
+
+ const rootRef = useRef(null);
+
+ const handleMouseUp = useCallback(() => {
+ const root = rootRef.current;
+ if (!root || typeof window === "undefined") return;
+ const selection = window.getSelection();
+ if (!selection || selection.isCollapsed || selection.rangeCount === 0) return;
+ const range = selection.getRangeAt(0);
+ if (!root.contains(range.commonAncestorContainer)) return;
+ const text = selection.toString();
+ if (!text) return;
+ const capture = captureFromRange(root, range, text);
+ if (!capture) return;
+ const selectors = createSelectors(capture, representation);
+ if (debug?.logSelections) {
+ console.log("[evidence-anchor/dom] selection", { capture, selectors });
+ }
+ onSelectionCaptured?.(capture, selectors);
+ }, [representation, debug, onSelectionCaptured]);
+
+ const renderedHtml = useMemo(() => {
+ let body = html;
+ for (const ann of storedAnnotations) {
+ const resolution = resolveSelectors(ann.selectors, representation);
+ const span = resolution.candidates[0]?.textPosition;
+ if (!span || !representation.canonicalText) continue;
+ const quote = representation.canonicalText.slice(span.start, span.end);
+ if (!quote) continue;
+ const mark = `${quote}`;
+ body = body.replace(quote, mark);
+ }
+ return body;
+ }, [html, storedAnnotations, representation, activeAnnotationId]);
+
+ useEffect(() => {
+ if (!scrollToAnnotationId || !rootRef.current) return;
+ const mark = rootRef.current.querySelector(
+ `[data-highlight-id="${CSS.escape(scrollToAnnotationId)}"]`,
+ );
+ mark?.scrollIntoView({ block: "center", behavior: "smooth" });
+ }, [scrollToAnnotationId, renderedHtml]);
+
+ return (
+ {
+ const target = (e.target as HTMLElement).closest("[data-highlight-id]");
+ if (target instanceof HTMLElement && target.dataset.highlightId) {
+ onHighlightClicked?.(target.dataset.highlightId);
+ }
+ }}
+ dangerouslySetInnerHTML={{ __html: renderedHtml }}
+ />
+ );
+}
+
+export async function resolveHtmlSelectors(
+ selectors: readonly Selector[],
+ representation: DocumentRepresentation,
+): Promise
{
+ return resolveSelectors(selectors, representation);
+}
+
+export async function scrollToHtmlTarget(
+ root: HTMLElement,
+ target: ResolvedAnchorTarget,
+ representation: DocumentRepresentation,
+): Promise {
+ const span = target.textPosition;
+ if (!span || !representation.canonicalText) return;
+ const quote = representation.canonicalText.slice(span.start, span.end);
+ const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT);
+ let node: Node | null;
+ while ((node = walker.nextNode())) {
+ const text = node.textContent ?? "";
+ const idx = text.indexOf(quote);
+ if (idx === -1) continue;
+ const range = document.createRange();
+ range.setStart(node, idx);
+ range.setEnd(node, idx + quote.length);
+ const rect = range.getBoundingClientRect();
+ if (rect.height > 0) {
+ range.startContainer.parentElement?.scrollIntoView({ block: "center", behavior: "smooth" });
+ return;
+ }
+ }
+}
+
+export async function renderHtmlHighlight(
+ target: ResolvedAnchorTarget,
+ opts?: HighlightRenderOptions,
+): Promise {
+ void target;
+ void opts;
+ // Highlights are rendered inline during `HtmlViewerAdapter` render via stored annotations.
+}
\ No newline at end of file
diff --git a/src/dom/index.ts b/src/dom/index.ts
new file mode 100644
index 0000000..b5e691b
--- /dev/null
+++ b/src/dom/index.ts
@@ -0,0 +1,11 @@
+export {
+ HtmlViewerAdapter,
+ resolveHtmlSelectors,
+ scrollToHtmlTarget,
+ renderHtmlHighlight,
+ type HtmlViewerAdapterProps,
+ type HtmlViewerDebugOptions,
+ type StoredHtmlAnnotation,
+} from "./html-viewer-adapter";
+
+export { captureFromRange, domPathToNode, nodeAtPath } from "./dom-path";
\ No newline at end of file
diff --git a/src/index.ts b/src/index.ts
index b668299..2eb8021 100644
--- a/src/index.ts
+++ b/src/index.ts
@@ -22,8 +22,11 @@ export {
// PDF adapter + helpers (re-exported from the src/pdf/ boundary).
export {
+ PdfViewerAdapter,
PdfSpikeViewer,
getHighlightClientRects,
+ type PdfViewerAdapterProps,
+ type PdfViewerDebugOptions,
selectorsFromPdfCapture,
findPdfRectSelector,
findTextQuoteSelector,
diff --git a/src/pdf/index.ts b/src/pdf/index.ts
index 570f3bd..2161def 100644
--- a/src/pdf/index.ts
+++ b/src/pdf/index.ts
@@ -26,10 +26,13 @@ export {
type ScrollToHighlightState,
} from "./scroll-job";
-// Concrete PDF viewer adapter (spike) + its contract surface.
+// Production PDF viewer adapter + its contract surface.
export {
+ PdfViewerAdapter,
PdfSpikeViewer,
getHighlightClientRects,
+ type PdfViewerAdapterProps,
+ type PdfViewerDebugOptions,
type PdfSpikeViewerProps,
type StoredAnnotation,
} from "./pdf-viewer-adapter-spike";
diff --git a/src/pdf/pdf-viewer-adapter-spike.tsx b/src/pdf/pdf-viewer-adapter-spike.tsx
index 4e2aa03..f9039d8 100644
--- a/src/pdf/pdf-viewer-adapter-spike.tsx
+++ b/src/pdf/pdf-viewer-adapter-spike.tsx
@@ -46,7 +46,12 @@ 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 {
+ DocumentRepresentation,
+ NormalizedRect,
+ Selector,
+} from "@citation-evidence/engine/shared";
+import { resolveSelectors } from "../selectors";
import type { AnchorResolution, PdfSelectionCapture, ResolvedAnchorTarget } from "../types";
import { findPdfRectSelector, selectorsFromPdfCapture, unionRect } from "./pdf-selector-math";
import { runScrollToHighlightJob } from "./scroll-job";
@@ -63,7 +68,29 @@ function highlightFromSelectors(
id: string,
text: string,
selectors: readonly Selector[],
+ representation?: DocumentRepresentation,
): Highlight | null {
+ if (representation) {
+ const resolution = resolveSelectors(selectors, representation);
+ const rectSel = findPdfRectSelector(selectors);
+ const page = resolution.candidates[0]?.page ?? rectSel?.page;
+ const rects = resolution.candidates[0]?.rects ?? rectSel?.rects;
+ if (page && rects && rects.length > 0) {
+ const boundingRect = unionRect(rects);
+ if (boundingRect) {
+ const scaledRects = rects.map((r) => toScaled(r, page));
+ return {
+ id,
+ type: "text",
+ content: { text },
+ position: {
+ boundingRect: toScaled(boundingRect, page),
+ rects: scaledRects,
+ } satisfies ScaledPosition,
+ };
+ }
+ }
+ }
const rectSel = findPdfRectSelector(selectors);
if (!rectSel) return null;
const boundingRect = unionRect(rectSel.rects);
@@ -188,9 +215,19 @@ export function getHighlightClientRects(annotationId: string): DOMRect | null {
return new DOMRect(left, top, right - left, bottom - top);
}
-export interface PdfSpikeViewerProps {
+export interface PdfViewerDebugOptions {
+ readonly debugTextLayer?: boolean;
+ readonly hideCanvas?: boolean;
+ readonly hideTextLayer?: boolean;
+ readonly hideAnnotationLayer?: boolean;
+ readonly hideXfaLayer?: boolean;
+}
+
+export interface PdfViewerAdapterProps {
/** URL of the PDF to load (served by Vite dev server). */
readonly pdfUrl: string;
+ /** Document representation for selector resolution (enables text-based reload). */
+ readonly representation?: DocumentRepresentation;
/** Previously-saved selector sets to restore on mount. */
readonly storedAnnotations: readonly StoredAnnotation[];
/** Called when the user produces a new selection. */
@@ -213,23 +250,23 @@ export interface PdfSpikeViewerProps {
* 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.
- */
+ /** Spike-only debug flags — not part of the production adapter contract. */
+ readonly debug?: PdfViewerDebugOptions;
+ /** @deprecated Pass via `debug.debugTextLayer` */
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?").
- */
+ /** @deprecated Pass via `debug.hideCanvas` */
readonly hideCanvas?: boolean;
+ /** @deprecated Pass via `debug.hideTextLayer` */
readonly hideTextLayer?: boolean;
+ /** @deprecated Pass via `debug.hideAnnotationLayer` */
readonly hideAnnotationLayer?: boolean;
+ /** @deprecated Pass via `debug.hideXfaLayer` */
readonly hideXfaLayer?: boolean;
}
+/** @deprecated Use `PdfViewerAdapterProps` */
+export type PdfSpikeViewerProps = PdfViewerAdapterProps;
+
/**
* Nudge the PDF scroll container so `highlight` sits vertically centred.
* Best-effort: depends on highlight layer DOM being present after scroll.
@@ -271,21 +308,28 @@ export interface StoredAnnotation {
* - reconstructs and renders `storedAnnotations` immediately on load
* - scrolls to `scrollToAnnotationId` if its highlight can be reconstructed
*/
-export function PdfSpikeViewer(props: PdfSpikeViewerProps) {
+export function PdfViewerAdapter(props: PdfViewerAdapterProps) {
const {
pdfUrl,
+ representation,
storedAnnotations,
onSelectionCaptured,
scrollToAnnotationId,
scrollRequestKey,
activeAnnotationId,
onHighlightClicked,
- debugTextLayer,
- hideCanvas,
- hideTextLayer,
- hideAnnotationLayer,
- hideXfaLayer,
+ debug,
+ debugTextLayer: debugTextLayerProp,
+ hideCanvas: hideCanvasProp,
+ hideTextLayer: hideTextLayerProp,
+ hideAnnotationLayer: hideAnnotationLayerProp,
+ hideXfaLayer: hideXfaLayerProp,
} = props;
+ const debugTextLayer = debug?.debugTextLayer ?? debugTextLayerProp;
+ const hideCanvas = debug?.hideCanvas ?? hideCanvasProp;
+ const hideTextLayer = debug?.hideTextLayer ?? hideTextLayerProp;
+ const hideAnnotationLayer = debug?.hideAnnotationLayer ?? hideAnnotationLayerProp;
+ const hideXfaLayer = debug?.hideXfaLayer ?? hideXfaLayerProp;
const onHighlightClickedRef = useRef(onHighlightClicked);
onHighlightClickedRef.current = onHighlightClicked;
const handleHighlightClicked = useCallback((annotationId: string) => {
@@ -318,7 +362,7 @@ export function PdfSpikeViewer(props: PdfSpikeViewerProps) {
const out: Highlight[] = [];
const skipped: { id: string; reason: string }[] = [];
for (const a of storedAnnotations) {
- const h = highlightFromSelectors(a.id, a.text, a.selectors);
+ const h = highlightFromSelectors(a.id, a.text, a.selectors, representation);
if (h) out.push(h);
else skipped.push({ id: a.id, reason: "no PdfRectSelector / empty boundingRect" });
}
@@ -336,7 +380,7 @@ export function PdfSpikeViewer(props: PdfSpikeViewerProps) {
});
}
return out;
- }, [storedAnnotations, debugTextLayer]);
+ }, [storedAnnotations, representation, debugTextLayer]);
const highlightsRef = useRef(highlights);
highlightsRef.current = highlights;
@@ -420,5 +464,8 @@ export function PdfSpikeViewer(props: PdfSpikeViewerProps) {
);
}
+/** @deprecated Use `PdfViewerAdapter` */
+export const PdfSpikeViewer = PdfViewerAdapter;
+
// Re-export the §5 contract surface so callers see anchor as one entry point.
export type { AnchorResolution, ResolvedAnchorTarget, PdfSelectionCapture };
diff --git a/src/selectors/create.dom.test.ts b/src/selectors/create.dom.test.ts
new file mode 100644
index 0000000..66b2ab6
--- /dev/null
+++ b/src/selectors/create.dom.test.ts
@@ -0,0 +1,59 @@
+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 {
+ DomRangeSelector,
+ StructuralSelector,
+ TextPositionSelector,
+ TextQuoteSelector,
+} from "@citation-evidence/engine/shared";
+import { createSelectors } from "./create";
+import type { DomSelectionCapture } from "../types";
+
+const text = "## Intro\n\nThe quick brown fox jumps.\n\n## Outro";
+
+function repr(): DocumentRepresentation {
+ return {
+ id: "rep_dom" as RepresentationId,
+ documentId: "doc_dom" as DocumentId,
+ representationType: "html-dom",
+ contentHash: "test",
+ canonicalText: text,
+ structureMap: [
+ {
+ globalStart: 0,
+ globalEnd: text.indexOf("\n\n## Outro"),
+ path: [{ kind: "section", index: 0, label: "Intro" }],
+ containerPath: [0, 0, 0],
+ },
+ ],
+ offsetMap: [{ page: 1, globalStart: 0, globalEnd: text.length, pageLength: text.length }],
+ generatedAt: "2026-07-09T00:00:00.000Z",
+ };
+}
+
+function capture(selected: string): DomSelectionCapture {
+ const start = text.indexOf(selected);
+ return {
+ kind: "dom",
+ text: selected,
+ startPath: [0, 0, 0],
+ startOffset: start,
+ endPath: [0, 0, 0],
+ endOffset: start + selected.length,
+ structuralPath: [{ kind: "section", index: 0, label: "Intro" }],
+ };
+}
+
+describe("createSelectors (dom)", () => {
+ it("emits TextQuote, TextPosition, DomRange, and Structural selectors", () => {
+ const sels = createSelectors(capture("brown fox"), repr());
+ expect(sels.find((s): s is TextQuoteSelector => s.type === "TextQuoteSelector")?.exact).toBe("brown fox");
+ const pos = sels.find((s): s is TextPositionSelector => s.type === "TextPositionSelector");
+ expect(pos?.start).toBe(text.indexOf("brown fox"));
+ expect(sels.find((s): s is DomRangeSelector => s.type === "DomRangeSelector")).toBeDefined();
+ const structural = sels.find((s): s is StructuralSelector => s.type === "StructuralSelector");
+ expect(structural?.path[0]?.kind).toBe("section");
+ expect(structural!.startOffset).toBeGreaterThanOrEqual(0);
+ });
+});
\ No newline at end of file
diff --git a/src/selectors/create.ts b/src/selectors/create.ts
index d4cf50b..f059ac5 100644
--- a/src/selectors/create.ts
+++ b/src/selectors/create.ts
@@ -5,25 +5,21 @@
* 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 {
+ DomRangeSelector,
PdfPageTextSelector,
PdfRectSelector,
Selector,
+ StructuralSelector,
TextPositionSelector,
TextQuoteSelector,
} from "@citation-evidence/engine/shared";
-import type { PdfSelectionCapture, SelectionCapture } from "../types";
+import type { DomSelectionCapture, PdfSelectionCapture, SelectionCapture } from "../types";
/** Default characters of prefix/suffix context stored on TextQuoteSelector. */
export const DEFAULT_CONTEXT_CHARS = 32;
@@ -37,8 +33,9 @@ export function createSelectors(
representation: DocumentRepresentation,
options: CreateSelectorsOptions = {},
): Selector[] {
- // `SelectionCapture` is a discriminated union. The DOM branch is `never`
- // in MVP, so the only runtime shape is `PdfSelectionCapture`.
+ if (capture.kind === "dom") {
+ return createSelectorsFromDomCapture(capture, representation, options);
+ }
return createSelectorsFromPdfCapture(capture, representation, options);
}
@@ -56,14 +53,9 @@ function createSelectorsFromPdfCapture(
? 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)
@@ -71,7 +63,6 @@ function createSelectorsFromPdfCapture(
out.push(quote);
}
- // 2. TextPositionSelector — only when we have a unique-enough match.
if (matchOffset !== null) {
const pos: TextPositionSelector = {
type: "TextPositionSelector",
@@ -81,7 +72,6 @@ function createSelectorsFromPdfCapture(
out.push(pos);
}
- // 3. PdfRectSelector — straight from the capture; viewer-coordinate truth.
if (capture.rects.length > 0) {
const rect: PdfRectSelector = {
type: "PdfRectSelector",
@@ -91,8 +81,6 @@ function createSelectorsFromPdfCapture(
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 = {
@@ -108,6 +96,70 @@ function createSelectorsFromPdfCapture(
return out;
}
+function createSelectorsFromDomCapture(
+ capture: DomSelectionCapture,
+ 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)
+ : [];
+ const matchOffset = positions.length === 1 ? positions[0]! : positions[0] ?? null;
+
+ if (normalizedQuote.length > 0) {
+ const quote = matchOffset !== null
+ ? buildQuoteSelectorWithContext(canonicalText, matchOffset, normalizedQuote, contextChars)
+ : ({ type: "TextQuoteSelector", exact: normalizedQuote } satisfies TextQuoteSelector);
+ out.push(quote);
+ }
+
+ if (matchOffset !== null) {
+ out.push({
+ type: "TextPositionSelector",
+ start: matchOffset,
+ end: matchOffset + normalizedQuote.length,
+ } satisfies TextPositionSelector);
+ }
+
+ out.push({
+ type: "DomRangeSelector",
+ startPath: capture.startPath,
+ startOffset: capture.startOffset,
+ endPath: capture.endPath,
+ endOffset: capture.endOffset,
+ } satisfies DomRangeSelector);
+
+ if (capture.structuralPath && capture.structuralPath.length > 0) {
+ const block = representation.structureMap?.find(
+ (entry) => entry.path.every((seg, i) => {
+ const cap = capture.structuralPath![i];
+ return cap
+ && seg.kind === cap.kind
+ && seg.index === cap.index
+ && seg.level === cap.level;
+ }),
+ );
+ const blockStart = block?.globalStart ?? 0;
+ const relStart = matchOffset !== null ? matchOffset - blockStart : capture.startOffset;
+ const relEnd = matchOffset !== null
+ ? matchOffset + normalizedQuote.length - blockStart
+ : capture.endOffset;
+ out.push({
+ type: "StructuralSelector",
+ path: capture.structuralPath,
+ startOffset: relStart,
+ endOffset: relEnd,
+ } satisfies StructuralSelector);
+ }
+
+ return out;
+}
+
function findAllOccurrences(haystack: string, needle: string): number[] {
if (needle.length === 0) return [];
const out: number[] = [];
@@ -133,8 +185,6 @@ function pickMatch(
);
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]!;
}
@@ -154,4 +204,4 @@ function buildQuoteSelectorWithContext(
...(prefix.length > 0 ? { prefix } : {}),
...(suffix.length > 0 ? { suffix } : {}),
};
-}
+}
\ No newline at end of file
diff --git a/src/selectors/dom-path.ts b/src/selectors/dom-path.ts
new file mode 100644
index 0000000..9263206
--- /dev/null
+++ b/src/selectors/dom-path.ts
@@ -0,0 +1,89 @@
+import type {
+ DomNodePath,
+ DomRangeSelector,
+ StructuralPathSegment,
+ StructuralSelector,
+} from "@citation-evidence/engine/shared";
+import type { StructureRange } from "@citation-evidence/engine/shared";
+
+export function pathsEqual(a: DomNodePath, b: DomNodePath): boolean {
+ if (a.length !== b.length) return false;
+ for (let i = 0; i < a.length; i++) {
+ if (a[i] !== b[i]) return false;
+ }
+ return true;
+}
+
+export function pathStartsWith(path: DomNodePath, prefix: DomNodePath): boolean {
+ if (prefix.length > path.length) return false;
+ for (let i = 0; i < prefix.length; i++) {
+ if (path[i] !== prefix[i]) return false;
+ }
+ return true;
+}
+
+export function structuralPathsEqual(
+ a: readonly StructuralPathSegment[],
+ b: readonly StructuralPathSegment[],
+): boolean {
+ if (a.length !== b.length) return false;
+ for (let i = 0; i < a.length; i++) {
+ const left = a[i]!;
+ const right = b[i]!;
+ if (
+ left.kind !== right.kind
+ || left.index !== right.index
+ || left.level !== right.level
+ ) {
+ return false;
+ }
+ }
+ return true;
+}
+
+export function findStructureRange(
+ structureMap: readonly StructureRange[],
+ path: readonly StructuralPathSegment[],
+): StructureRange | null {
+ return structureMap.find((entry) => structuralPathsEqual(entry.path, path)) ?? null;
+}
+
+export function findStructureRangeForDomRange(
+ structureMap: readonly StructureRange[],
+ selector: DomRangeSelector,
+): StructureRange | null {
+ for (const entry of structureMap) {
+ if (!entry.containerPath) continue;
+ if (
+ pathStartsWith(selector.startPath, entry.containerPath)
+ && pathStartsWith(selector.endPath, entry.containerPath)
+ ) {
+ return entry;
+ }
+ }
+ return null;
+}
+
+export function resolveStructuralSpan(
+ structureMap: readonly StructureRange[],
+ selector: StructuralSelector,
+): { start: number; end: number } | null {
+ const entry = findStructureRange(structureMap, selector.path);
+ if (!entry) return null;
+ const start = entry.globalStart + selector.startOffset;
+ const end = entry.globalStart + selector.endOffset;
+ if (start < entry.globalStart || end > entry.globalEnd || start >= end) return null;
+ return { start, end };
+}
+
+export function resolveDomRangeSpan(
+ structureMap: readonly StructureRange[],
+ selector: DomRangeSelector,
+): { start: number; end: number } | null {
+ const entry = findStructureRangeForDomRange(structureMap, selector);
+ if (!entry) return null;
+ const start = entry.globalStart + selector.startOffset;
+ const end = entry.globalStart + selector.endOffset;
+ if (start < entry.globalStart || end > entry.globalEnd || start >= end) return null;
+ return { start, end };
+}
\ No newline at end of file
diff --git a/src/selectors/fuzzy.test.ts b/src/selectors/fuzzy.test.ts
new file mode 100644
index 0000000..8d5a99d
--- /dev/null
+++ b/src/selectors/fuzzy.test.ts
@@ -0,0 +1,21 @@
+import { describe, expect, it } from "vitest";
+import { findFuzzyQuoteMatches } from "./fuzzy";
+
+describe("findFuzzyQuoteMatches", () => {
+ it("finds a lightly edited quote", () => {
+ const text = "The quick brown foks jumps over the lazy dog.";
+ const matches = findFuzzyQuoteMatches(text, { type: "TextQuoteSelector", exact: "brown fox" });
+ expect(matches.length).toBeGreaterThan(0);
+ expect(matches[0]!.matchedText).toContain("brown");
+ expect(matches[0]!.confidence).toBeLessThan(0.7);
+ expect(matches[0]!.confidence).toBeGreaterThanOrEqual(0.15);
+ });
+
+ it("returns empty for completely unrelated text", () => {
+ const matches = findFuzzyQuoteMatches(
+ "hello world",
+ { type: "TextQuoteSelector", exact: "quantum entanglement" },
+ );
+ expect(matches).toEqual([]);
+ });
+});
\ No newline at end of file
diff --git a/src/selectors/fuzzy.ts b/src/selectors/fuzzy.ts
new file mode 100644
index 0000000..43c3faa
--- /dev/null
+++ b/src/selectors/fuzzy.ts
@@ -0,0 +1,113 @@
+import type { TextQuoteSelector } from "@citation-evidence/engine/shared";
+
+export interface FuzzyQuoteMatch {
+ readonly offset: number;
+ readonly matchedText: string;
+ readonly distance: number;
+ readonly confidence: number;
+}
+
+/** Upper bound on edit distance relative to quote length. */
+export const FUZZY_MAX_DISTANCE_RATIO = 0.2;
+
+/** Confidence ceiling for fuzzy matches — below the exact ladder floor (0.7). */
+export const FUZZY_CONFIDENCE_CEILING = 0.35;
+
+/** Confidence floor for fuzzy matches. */
+export const FUZZY_CONFIDENCE_FLOOR = 0.15;
+
+/**
+ * Find bounded fuzzy quote matches in canonical text.
+ * Returns candidates sorted by ascending distance (best first).
+ */
+export function findFuzzyQuoteMatches(
+ canonicalText: string,
+ quote: TextQuoteSelector,
+): readonly FuzzyQuoteMatch[] {
+ const needle = quote.exact;
+ if (needle.length === 0 || canonicalText.length === 0) return [];
+
+ const maxDistance = Math.max(1, Math.floor(needle.length * FUZZY_MAX_DISTANCE_RATIO));
+ const minLen = Math.max(1, needle.length - maxDistance);
+ const maxLen = needle.length + maxDistance;
+ const matches: FuzzyQuoteMatch[] = [];
+
+ for (let start = 0; start < canonicalText.length; start++) {
+ for (let len = minLen; len <= maxLen && start + len <= canonicalText.length; len++) {
+ const candidate = canonicalText.slice(start, start + len);
+ const distance = levenshtein(needle, candidate);
+ if (distance > maxDistance) continue;
+
+ const confidence = fuzzyConfidence(needle.length, candidate.length, distance);
+ if (confidence < FUZZY_CONFIDENCE_FLOOR) continue;
+
+ if (!prefixSuffixMatchesFuzzy(canonicalText, start, candidate, quote, maxDistance)) {
+ continue;
+ }
+
+ matches.push({ offset: start, matchedText: candidate, distance, confidence });
+ }
+ }
+
+ matches.sort((a, b) => a.distance - b.distance || a.offset - b.offset);
+
+ const deduped: FuzzyQuoteMatch[] = [];
+ for (const m of matches) {
+ if (deduped.some((d) => d.offset === m.offset && d.matchedText === m.matchedText)) continue;
+ deduped.push(m);
+ }
+ return deduped;
+}
+
+function fuzzyConfidence(needleLen: number, candidateLen: number, distance: number): number {
+ const denom = Math.max(needleLen, candidateLen, 1);
+ const raw = 1 - distance / denom;
+ const scaled = FUZZY_CONFIDENCE_FLOOR + raw * (FUZZY_CONFIDENCE_CEILING - FUZZY_CONFIDENCE_FLOOR);
+ return Math.min(FUZZY_CONFIDENCE_CEILING, Math.max(FUZZY_CONFIDENCE_FLOOR, scaled));
+}
+
+function prefixSuffixMatchesFuzzy(
+ canonicalText: string,
+ offset: number,
+ matchedText: string,
+ quote: TextQuoteSelector,
+ maxDistance: number,
+): boolean {
+ if (quote.prefix !== undefined) {
+ const prefixEnd = offset;
+ const prefixStart = Math.max(0, prefixEnd - quote.prefix.length);
+ const actualPrefix = canonicalText.slice(prefixStart, prefixEnd);
+ if (levenshtein(actualPrefix, quote.prefix) > maxDistance) return false;
+ }
+ if (quote.suffix !== undefined) {
+ const suffixStart = offset + matchedText.length;
+ const suffixEnd = Math.min(canonicalText.length, suffixStart + quote.suffix.length);
+ const actualSuffix = canonicalText.slice(suffixStart, suffixEnd);
+ if (levenshtein(actualSuffix, quote.suffix) > maxDistance) return false;
+ }
+ return true;
+}
+
+function levenshtein(a: string, b: string): number {
+ if (a === b) return 0;
+ if (a.length === 0) return b.length;
+ if (b.length === 0) return a.length;
+
+ const rows = a.length + 1;
+ const cols = b.length + 1;
+ const matrix: number[] = new Array(rows * cols);
+
+ for (let i = 0; i < rows; i++) matrix[i * cols] = i;
+ for (let j = 0; j < cols; j++) matrix[j] = j;
+
+ for (let i = 1; i < rows; i++) {
+ for (let j = 1; j < cols; j++) {
+ const cost = a[i - 1] === b[j - 1] ? 0 : 1;
+ const del = matrix[(i - 1) * cols + j]! + 1;
+ const ins = matrix[i * cols + (j - 1)]! + 1;
+ const sub = matrix[(i - 1) * cols + (j - 1)]! + cost;
+ matrix[i * cols + j] = Math.min(del, ins, sub);
+ }
+ }
+ return matrix[(rows - 1) * cols + (cols - 1)]!;
+}
\ No newline at end of file
diff --git a/src/selectors/index.ts b/src/selectors/index.ts
index f47543c..a7182d5 100644
--- a/src/selectors/index.ts
+++ b/src/selectors/index.ts
@@ -4,3 +4,9 @@ export {
type CreateSelectorsOptions,
} from "./create";
export { resolveSelectors } from "./resolve";
+export { isOrphanedResolution } from "./orphan";
+export {
+ findFuzzyQuoteMatches,
+ FUZZY_CONFIDENCE_CEILING,
+ FUZZY_CONFIDENCE_FLOOR,
+} from "./fuzzy";
diff --git a/src/selectors/orphan.test.ts b/src/selectors/orphan.test.ts
new file mode 100644
index 0000000..deea410
--- /dev/null
+++ b/src/selectors/orphan.test.ts
@@ -0,0 +1,34 @@
+import { describe, expect, it } from "vitest";
+import { isOrphanedResolution } from "./orphan";
+
+describe("isOrphanedResolution", () => {
+ it("returns true for unresolved with no candidates", () => {
+ expect(isOrphanedResolution({
+ status: "unresolved",
+ confidence: 0,
+ candidates: [],
+ usedSelectorTypes: [],
+ orphaned: true,
+ })).toBe(true);
+ });
+
+ it("returns false for stale resolutions", () => {
+ expect(isOrphanedResolution({
+ status: "stale",
+ confidence: 0,
+ candidates: [],
+ usedSelectorTypes: [],
+ orphaned: false,
+ })).toBe(false);
+ });
+
+ it("returns false when fuzzy produced a candidate", () => {
+ expect(isOrphanedResolution({
+ status: "stale",
+ confidence: 0.2,
+ candidates: [{ representationId: "rep" }],
+ usedSelectorTypes: ["TextQuoteSelector"],
+ orphaned: false,
+ })).toBe(false);
+ });
+});
\ No newline at end of file
diff --git a/src/selectors/orphan.ts b/src/selectors/orphan.ts
new file mode 100644
index 0000000..4899968
--- /dev/null
+++ b/src/selectors/orphan.ts
@@ -0,0 +1,10 @@
+import type { AnchorResolution } from "../types";
+
+/**
+ * True when no selector could place the anchor at all (distinct from stale,
+ * which may still offer fuzzy recovery candidates).
+ */
+export function isOrphanedResolution(resolution: AnchorResolution): boolean {
+ if (resolution.orphaned !== undefined) return resolution.orphaned;
+ return resolution.status === "unresolved" && resolution.candidates.length === 0;
+}
\ No newline at end of file
diff --git a/src/selectors/resolve.dom.test.ts b/src/selectors/resolve.dom.test.ts
new file mode 100644
index 0000000..7c67c9a
--- /dev/null
+++ b/src/selectors/resolve.dom.test.ts
@@ -0,0 +1,77 @@
+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";
+
+const blockText = "The quick brown fox jumps over the lazy dog.";
+const text = `## Section\n\n${blockText}`;
+
+function repr(structureStart = text.indexOf(blockText)): DocumentRepresentation {
+ return {
+ id: "rep_dom" as RepresentationId,
+ documentId: "doc_dom" as DocumentId,
+ representationType: "markdown-rendered",
+ contentHash: "test",
+ canonicalText: text,
+ structureMap: [
+ {
+ globalStart: structureStart,
+ globalEnd: structureStart + blockText.length,
+ path: [{ kind: "section", index: 0, label: "Section" }],
+ containerPath: [0, 1, 0],
+ },
+ ],
+ generatedAt: "2026-07-09T00:00:00.000Z",
+ };
+}
+
+describe("resolveSelectors (dom)", () => {
+ it("resolves DomRangeSelector via structureMap", () => {
+ const foxStart = text.indexOf("brown fox");
+ const selectors: Selector[] = [
+ {
+ type: "DomRangeSelector",
+ startPath: [0, 1, 0],
+ startOffset: foxStart - text.indexOf(blockText),
+ endPath: [0, 1, 0],
+ endOffset: foxStart - text.indexOf(blockText) + "brown fox".length,
+ },
+ ];
+ const r = resolveSelectors(selectors, repr());
+ expect(r.status).toBe("resolved");
+ expect(r.confidence).toBe(0.75);
+ expect(r.candidates[0]?.textPosition?.start).toBe(foxStart);
+ });
+
+ it("resolves StructuralSelector when offsets shift within the block", () => {
+ const shifted = "The quick brown fox jumps over a sleepy dog.";
+ const shiftedDoc = `## Section\n\n${shifted}`;
+ const blockStart = shiftedDoc.indexOf(shifted);
+ const selectors: Selector[] = [
+ {
+ type: "StructuralSelector",
+ path: [{ kind: "section", index: 0, label: "Section" }],
+ startOffset: blockText.indexOf("brown fox"),
+ endOffset: blockText.indexOf("brown fox") + "brown fox".length,
+ },
+ ];
+ const representation: DocumentRepresentation = {
+ ...repr(blockStart),
+ canonicalText: shiftedDoc,
+ structureMap: [
+ {
+ globalStart: blockStart,
+ globalEnd: blockStart + shifted.length,
+ path: [{ kind: "section", index: 0, label: "Section" }],
+ },
+ ],
+ };
+ const r = resolveSelectors(selectors, representation);
+ expect(r.status).toBe("resolved");
+ expect(r.confidence).toBe(0.65);
+ expect(r.candidates[0]?.textPosition?.start).toBe(
+ representation.canonicalText!.indexOf("brown fox"),
+ );
+ });
+});
\ No newline at end of file
diff --git a/src/selectors/resolve.test.ts b/src/selectors/resolve.test.ts
index ce45d55..a08c56f 100644
--- a/src/selectors/resolve.test.ts
+++ b/src/selectors/resolve.test.ts
@@ -133,5 +133,33 @@ describe("resolveSelectors", () => {
expect(r.status).toBe("unresolved");
expect(r.confidence).toBe(0);
expect(r.candidates).toEqual([]);
+ expect(r.orphaned).toBe(true);
+ });
+
+ it("returns stale when position is in-range but quote no longer exists", () => {
+ const r = resolveSelectors(
+ [
+ { type: "TextPositionSelector", start: brownFoxStart, end: brownFoxEnd },
+ { type: "TextQuoteSelector", exact: "brown fox" },
+ ],
+ repr("The quick grey wolf jumps over the lazy dog."),
+ );
+ expect(r.status).toBe("stale");
+ expect(r.confidence).toBe(0);
+ expect(r.candidates).toEqual([]);
+ expect(r.orphaned).toBe(false);
+ expect(r.warnings?.some((w) => /no longer matches/i.test(w))).toBe(true);
+ });
+
+ it("returns stale with fuzzy candidate when quote is lightly edited", () => {
+ const r = resolveSelectors(
+ [{ type: "TextQuoteSelector", exact: "brown fox" }],
+ repr("The quick brown foks jumps over the lazy dog."),
+ );
+ expect(r.status).toBe("stale");
+ expect(r.confidence).toBeGreaterThan(0);
+ expect(r.confidence).toBeLessThan(0.7);
+ expect(r.candidates[0]?.textPosition?.start).toBeGreaterThanOrEqual(0);
+ expect(r.warnings?.some((w) => /fuzzy/i.test(w))).toBe(true);
});
});
diff --git a/src/selectors/resolve.ts b/src/selectors/resolve.ts
index c72a71f..63a954a 100644
--- a/src/selectors/resolve.ts
+++ b/src/selectors/resolve.ts
@@ -1,8 +1,7 @@
/**
* Resolve a `Selector[]` against a `DocumentRepresentation`.
*
- * Implements the resolution strategy from `wiki/ArchitectureOverview.md` §7,
- * MVP-trimmed:
+ * Implements the resolution strategy from `wiki/ArchitectureOverview.md` §7:
*
* 1. Try `TextPositionSelector` (cheapest — direct slice).
* 2. Verify with `TextQuoteSelector` at that position.
@@ -10,30 +9,40 @@
* 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.
+ * 6. Try `DomRangeSelector` / `StructuralSelector` for non-paginated docs.
+ * 7. Try fuzzy quote matching (bounded edit distance).
+ * 8. Return `stale` when representation changed but quote is meaningful;
+ * `unresolved` when nothing matches at all.
*
- * Fuzzy matching is out of scope here; a later workplan owns it.
- *
- * Confidence ladder (0..1):
+ * Confidence ladder (0..1) for exact tiers:
* 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.75 — DomRangeSelector resolved via structureMap
* 0.70 — PdfRectSelector only (page+rects, no text verification)
+ * 0.65 — StructuralSelector resolved via structureMap
+ *
+ * Fuzzy floor: 0.15–0.35 (never reaches exact-tier confidence).
*/
import type { DocumentRepresentation } from "@citation-evidence/engine/shared";
import type {
+ DomRangeSelector,
PdfPageTextSelector,
PdfRectSelector,
Selector,
SelectorType,
+ StructuralSelector,
TextPositionSelector,
TextQuoteSelector,
} from "@citation-evidence/engine/shared";
import type { AnchorResolution, ResolvedAnchorTarget } from "../types";
+import { resolveDomRangeSpan, resolveStructuralSpan } from "./dom-path";
+import { findFuzzyQuoteMatches } from "./fuzzy";
+import { isOrphanedResolution } from "./orphan";
export function resolveSelectors(
selectors: readonly Selector[],
@@ -41,11 +50,13 @@ export function resolveSelectors(
): AnchorResolution {
const canonicalText = representation.canonicalText ?? "";
const offsetMap = representation.offsetMap ?? [];
+ const structureMap = representation.structureMap ?? [];
const representationId = representation.id;
const byType = indexByType(selectors);
const used: SelectorType[] = [];
const warnings: string[] = [];
+ let staleSignal = false;
// 1 & 2. Try TextPositionSelector, verify with TextQuoteSelector.
if (byType.TextPositionSelector && canonicalText.length > 0) {
@@ -56,24 +67,62 @@ export function resolveSelectors(
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,
+ return finish(
+ 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.",
);
+ const quoteResult = resolveByQuote(canonicalText, quote);
+ if (quoteResult) {
+ used.push("TextQuoteSelector");
+ return finish(
+ resolved(
+ {
+ representationId,
+ textPosition: {
+ start: quoteResult.offset,
+ end: quoteResult.offset + quote.exact.length,
+ },
+ ...pageFor(
+ { start: quoteResult.offset, end: quoteResult.offset + quote.exact.length },
+ offsetMap,
+ ),
+ },
+ quoteResult.confidence,
+ used,
+ warnings,
+ quoteResult.status,
+ ),
+ );
+ }
+ staleSignal = true;
+ warnings.push(
+ "Stored TextPositionSelector no longer matches canonical text and TextQuoteSelector.exact was not found.",
+ );
} 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,
+ return finish(
+ resolved(
+ {
+ representationId,
+ textPosition: { start: pos.start, end: pos.end },
+ ...pageFor(pos, offsetMap),
+ },
+ 0.85,
+ used,
+ warnings,
+ ),
);
}
}
@@ -84,16 +133,27 @@ export function resolveSelectors(
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,
+ return finish(
+ 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,
+ ),
);
}
}
@@ -106,15 +166,17 @@ export function resolveSelectors(
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,
+ return finish(
+ resolved(
+ {
+ representationId,
+ page: pageText.page,
+ textPosition: { start: globalStart, end: globalEnd },
+ },
+ 0.8,
+ used,
+ warnings,
+ ),
);
}
}
@@ -123,15 +185,113 @@ export function resolveSelectors(
if (byType.PdfRectSelector) {
const rect = byType.PdfRectSelector;
used.push("PdfRectSelector");
- return resolved(
- { representationId, page: rect.page, rects: rect.rects },
- 0.7,
- used,
- warnings,
+ return finish(
+ resolved(
+ { representationId, page: rect.page, rects: rect.rects },
+ 0.7,
+ used,
+ warnings,
+ ),
);
}
- return unresolved(warnings);
+ // 6a. DomRangeSelector for non-paginated representations.
+ if (byType.DomRangeSelector && structureMap.length > 0) {
+ const span = resolveDomRangeSpan(structureMap, byType.DomRangeSelector);
+ if (span) {
+ used.push("DomRangeSelector");
+ return finish(
+ resolved(
+ {
+ representationId,
+ textPosition: span,
+ },
+ 0.75,
+ used,
+ warnings,
+ ),
+ );
+ }
+ }
+
+ // 6b. StructuralSelector fallback.
+ if (byType.StructuralSelector && structureMap.length > 0) {
+ const span = resolveStructuralSpan(structureMap, byType.StructuralSelector);
+ if (span) {
+ used.push("StructuralSelector");
+ return finish(
+ resolved(
+ {
+ representationId,
+ textPosition: span,
+ },
+ 0.65,
+ used,
+ warnings,
+ ),
+ );
+ }
+ }
+
+ // 7. Fuzzy quote matching.
+ if (byType.TextQuoteSelector && canonicalText.length > 0) {
+ const fuzzyMatches = findFuzzyQuoteMatches(canonicalText, byType.TextQuoteSelector);
+ if (fuzzyMatches.length > 0) {
+ const bestDistance = fuzzyMatches[0]!.distance;
+ const bestCandidates = fuzzyMatches.filter((m) => m.distance === bestDistance);
+ const distinctOffsets = new Set(bestCandidates.map((m) => m.offset));
+ const match = bestCandidates[0]!;
+ used.push("TextQuoteSelector");
+ if (distinctOffsets.size === 1) {
+ warnings.push("Resolved via fuzzy quote matching; representation may have changed.");
+ return finish(
+ resolved(
+ {
+ representationId,
+ textPosition: {
+ start: match.offset,
+ end: match.offset + match.matchedText.length,
+ },
+ ...pageFor(
+ { start: match.offset, end: match.offset + match.matchedText.length },
+ offsetMap,
+ ),
+ },
+ match.confidence,
+ used,
+ warnings,
+ "stale",
+ ),
+ );
+ }
+ warnings.push("Multiple fuzzy quote candidates; manual confirmation recommended.");
+ return finish(
+ resolved(
+ {
+ representationId,
+ textPosition: {
+ start: match.offset,
+ end: match.offset + match.matchedText.length,
+ },
+ ...pageFor(
+ { start: match.offset, end: match.offset + match.matchedText.length },
+ offsetMap,
+ ),
+ },
+ match.confidence,
+ used,
+ warnings,
+ "ambiguous",
+ ),
+ );
+ }
+ }
+
+ if (staleSignal) {
+ return finish(stale(warnings));
+ }
+
+ return finish(unresolved(warnings));
}
interface QuoteResolutionResult {
@@ -146,7 +306,6 @@ function resolveByQuote(canonicalText: string, quote: TextQuoteSelector): QuoteR
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" };
@@ -154,7 +313,6 @@ function resolveByQuote(canonicalText: string, quote: TextQuoteSelector): QuoteR
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" };
}
@@ -183,6 +341,8 @@ interface SelectorIndex {
TextPositionSelector?: TextPositionSelector;
PdfRectSelector?: PdfRectSelector;
PdfPageTextSelector?: PdfPageTextSelector;
+ DomRangeSelector?: DomRangeSelector;
+ StructuralSelector?: StructuralSelector;
}
function indexByType(selectors: readonly Selector[]): SelectorIndex {
@@ -201,6 +361,12 @@ function indexByType(selectors: readonly Selector[]): SelectorIndex {
case "PdfPageTextSelector":
idx.PdfPageTextSelector = s;
break;
+ case "DomRangeSelector":
+ idx.DomRangeSelector = s;
+ break;
+ case "StructuralSelector":
+ idx.StructuralSelector = s;
+ break;
}
}
return idx;
@@ -238,23 +404,46 @@ function resolved(
confidence: number,
used: readonly SelectorType[],
warnings: readonly string[],
- status: "resolved" | "ambiguous" = "resolved",
+ status: "resolved" | "ambiguous" | "stale" = "resolved",
): AnchorResolution {
return {
status,
confidence,
candidates: [target],
usedSelectorTypes: used,
+ orphaned: false,
...(warnings.length > 0 ? { warnings } : {}),
};
}
+function stale(warnings: readonly string[]): AnchorResolution {
+ return {
+ status: "stale",
+ confidence: 0,
+ candidates: [],
+ usedSelectorTypes: [],
+ orphaned: false,
+ warnings: [...warnings],
+ };
+}
+
function unresolved(warnings: readonly string[]): AnchorResolution {
return {
status: "unresolved",
confidence: 0,
candidates: [],
usedSelectorTypes: [],
+ orphaned: true,
...(warnings.length > 0 ? { warnings } : {}),
};
}
+
+function finish(resolution: AnchorResolution): AnchorResolution {
+ if (resolution.orphaned === undefined) {
+ return {
+ ...resolution,
+ orphaned: isOrphanedResolution(resolution),
+ };
+ }
+ return resolution;
+}
\ No newline at end of file
diff --git a/src/types.ts b/src/types.ts
index dfb1757..39e57e5 100644
--- a/src/types.ts
+++ b/src/types.ts
@@ -12,7 +12,11 @@
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";
+import type {
+ DomNodePath,
+ NormalizedRect,
+ StructuralPathSegment,
+} from "@citation-evidence/engine/shared";
/**
* The raw selection captured from a viewer adapter — an opaque payload that
@@ -36,8 +40,17 @@ export interface PdfSelectionCapture {
readonly boundingRect?: NormalizedRect;
}
-/** Reserved for the HTML/Markdown adapter. Not implementable in MVP. */
-export type DomSelectionCapture = never;
+/** Selection captured from a rendered HTML/Markdown viewer. */
+export interface DomSelectionCapture {
+ readonly kind: "dom";
+ /** Verbatim selected text, before canonical normalisation. */
+ readonly text: string;
+ readonly startPath: DomNodePath;
+ readonly startOffset: number;
+ readonly endPath: DomNodePath;
+ readonly endOffset: number;
+ readonly structuralPath?: readonly StructuralPathSegment[];
+}
/**
* A passage located inside a representation, ready to be scrolled to and
@@ -65,6 +78,11 @@ export interface AnchorResolution {
/** Names of the selector kinds that produced a usable candidate. */
readonly usedSelectorTypes: readonly string[];
readonly warnings?: readonly string[];
+ /**
+ * True when no selector could place the anchor at all. Distinct from `stale`,
+ * which signals representation drift and may still return fuzzy candidates.
+ */
+ readonly orphaned?: boolean;
}
export interface HighlightRenderOptions {
diff --git a/workplans/EANCH-WP-0002-anchor-resolution-hardening.md b/workplans/EANCH-WP-0002-anchor-resolution-hardening.md
index 373b5db..43c9969 100644
--- a/workplans/EANCH-WP-0002-anchor-resolution-hardening.md
+++ b/workplans/EANCH-WP-0002-anchor-resolution-hardening.md
@@ -4,11 +4,11 @@ type: workplan
title: "Anchor resolution hardening: stale/orphan semantics, fuzzy re-anchoring, production PDF adapter"
domain: infotech
repo: evidence-anchor
-status: proposed
+status: finished
owner: codex
topic_slug: citation_evidence_mvp
created: "2026-07-08"
-updated: "2026-07-08"
+updated: "2026-07-09"
spec_refs:
- INTENT.md
- SCOPE.md
@@ -48,7 +48,7 @@ Current extracted behavior (`src/selectors/resolve.ts`):
```task
id: EANCH-WP-0002-T01
-status: todo
+status: done
priority: high
state_hub_task_id: "940b03ba-4b2f-4d1a-b7bc-72813817549d"
```
@@ -63,7 +63,7 @@ quote/context on the result. Add unit tests covering stale vs. unresolved.
```task
id: EANCH-WP-0002-T02
-status: todo
+status: done
priority: medium
depends_on: [T01]
state_hub_task_id: "f75b94cd-bfb8-47f9-bd89-0d605f925290"
@@ -78,7 +78,7 @@ enum unchanged unless a contract change is agreed in the umbrella wiki first.
```task
id: EANCH-WP-0002-T03
-status: todo
+status: done
priority: high
depends_on: [T01]
state_hub_task_id: "8548fa44-5bb3-40cf-b8f8-e745deae7dda"
@@ -95,7 +95,7 @@ floor.
```task
id: EANCH-WP-0002-T04
-status: todo
+status: done
priority: medium
depends_on: [T03]
state_hub_task_id: "6a7163f5-39ca-48fa-8941-8459c563198f"
@@ -110,7 +110,7 @@ umbrella green through the change. Update `evidence-anchor/pdf` exports and docs
```task
id: EANCH-WP-0002-T05
-status: todo
+status: done
priority: high
depends_on: [T02, T04]
state_hub_task_id: "1ea0885a-c541-438f-a4fd-b8be0773b373"
@@ -118,3 +118,8 @@ state_hub_task_id: "1ea0885a-c541-438f-a4fd-b8be0773b373"
`pnpm test`/`typecheck`/`lint` green here; umbrella typecheck/test/build green
after any contract-visible change; `fix-consistency` clean; progress note.
+
+**Verification (2026-07-09):**
+
+- `evidence-anchor`: 42 tests (9 files), typecheck + lint clean.
+- `citation-evidence`: typecheck clean, 51 integration tests passed.
diff --git a/workplans/EANCH-WP-0003-non-pdf-selectors.md b/workplans/EANCH-WP-0003-non-pdf-selectors.md
index c01011f..d4156c8 100644
--- a/workplans/EANCH-WP-0003-non-pdf-selectors.md
+++ b/workplans/EANCH-WP-0003-non-pdf-selectors.md
@@ -4,11 +4,11 @@ type: workplan
title: "Non-PDF selectors: HTML/Markdown DOM range + structural anchoring"
domain: infotech
repo: evidence-anchor
-status: proposed
+status: finished
owner: codex
topic_slug: citation_evidence_mvp
created: "2026-07-08"
-updated: "2026-07-08"
+updated: "2026-07-09"
spec_refs:
- INTENT.md
- SCOPE.md
@@ -47,7 +47,7 @@ enum — coordinate contract changes in the umbrella wiki first.
```task
id: EANCH-WP-0003-T01
-status: todo
+status: done
priority: high
state_hub_task_id: "de7df94f-2ddf-4920-949e-6e693131a9de"
```
@@ -61,7 +61,7 @@ Land this in the engine + `SharedContracts.md` before writing anchor behavior.
```task
id: EANCH-WP-0003-T02
-status: todo
+status: done
priority: high
depends_on: [T01]
state_hub_task_id: "aa1bc68d-c15a-41e0-813d-c4ccec4914c9"
@@ -75,7 +75,7 @@ DOM selection. Add unit tests mirroring the PDF create tests.
```task
id: EANCH-WP-0003-T03
-status: todo
+status: done
priority: high
depends_on: [T02]
state_hub_task_id: "2bdedc91-1ed2-41ed-b4a0-a9a0d7a6b4b0"
@@ -90,7 +90,7 @@ re-render stability (structural fallback when offsets shift).
```task
id: EANCH-WP-0003-T04
-status: todo
+status: done
priority: medium
depends_on: [T03]
state_hub_task_id: "286267fe-d348-4494-bbe2-dd7818bcadbd"
@@ -105,7 +105,7 @@ to that boundary.
```task
id: EANCH-WP-0003-T05
-status: todo
+status: done
priority: high
depends_on: [T04]
state_hub_task_id: "9ff39c83-eca8-46a1-9b28-8a728ddf220d"
@@ -114,3 +114,8 @@ state_hub_task_id: "9ff39c83-eca8-46a1-9b28-8a728ddf220d"
Package green (`pnpm test`/`typecheck`/`lint`); engine contract change verified
in `citation-engine`; any umbrella consumer green; `fix-consistency` clean;
progress note.
+
+**Verification (2026-07-09):**
+
+- `citation-engine`: DomRange/Structural types + StructureMap promoted; 89 tests passed.
+- `evidence-anchor`: DOM create/resolve + `evidence-anchor/dom` export; 42 tests passed.