EANCH-WP-0001 T03: extract pure selector creation + resolution core
Move types.ts, selectors/{index,create,resolve}.ts, and pdf-selector-math.ts
out of citation-evidence/src/anchor/ into src/ (pdf math under src/pdf/).
Rewrite all @shared/* imports to @citation-evidence/engine/shared; wire the
public entrypoint (src/index.ts) and the ./pdf subpath. Port the three unit
suites. Confidence ladder and selector-redundancy rules preserved verbatim.
Verified: pnpm test (29 passed), typecheck, and lint all green.
Also adds Node/TS .gitignore entries and the pnpm lockfile.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
parent
0e93b68982
commit
bd7f56c111
13 changed files with 5298 additions and 13 deletions
5
.gitignore
vendored
5
.gitignore
vendored
|
|
@ -1,3 +1,8 @@
|
|||
# ---> Node.js / TypeScript
|
||||
node_modules/
|
||||
*.tsbuildinfo
|
||||
.eslintcache
|
||||
|
||||
# ---> Python
|
||||
# Byte-compiled / optimized / DLL files
|
||||
__pycache__/
|
||||
|
|
|
|||
4284
pnpm-lock.yaml
generated
Normal file
4284
pnpm-lock.yaml
generated
Normal file
File diff suppressed because it is too large
Load diff
20
src/index.ts
20
src/index.ts
|
|
@ -1,10 +1,16 @@
|
|||
// Public entrypoint for `evidence-anchor`.
|
||||
//
|
||||
// The stable surface consumers depend on is re-exported from here:
|
||||
// - selector creation/resolution: ./selectors (T03)
|
||||
// - adapter-side types + contract: ./types (T03)
|
||||
// - the concrete PDF adapter: ./pdf (T04, also exposed at `evidence-anchor/pdf`)
|
||||
// The stable surface consumers depend on:
|
||||
// - adapter-side types + the viewer contract (./types)
|
||||
// - selector creation / resolution (./selectors)
|
||||
//
|
||||
// Populated during the extraction tasks; kept as an explicit placeholder so the
|
||||
// package resolves and typechecks before the code lands.
|
||||
export {};
|
||||
// The concrete PDF adapter lives behind the `evidence-anchor/pdf` subpath
|
||||
// (./pdf) so pure consumers do not pull PDF.js or React into their bundle.
|
||||
|
||||
export * from "./types";
|
||||
export {
|
||||
createSelectors,
|
||||
resolveSelectors,
|
||||
DEFAULT_CONTEXT_CHARS,
|
||||
type CreateSelectorsOptions,
|
||||
} from "./selectors";
|
||||
|
|
|
|||
|
|
@ -1,6 +1,13 @@
|
|||
// Subpath entrypoint `evidence-anchor/pdf` — the concrete PDF viewer adapter and
|
||||
// its helpers. Keeping the adapter behind a subpath lets pure consumers use the
|
||||
// selector/resolution core without pulling PDF.js or React into their bundle.
|
||||
// Subpath entrypoint `evidence-anchor/pdf` — the PDF viewer adapter, its
|
||||
// helpers, and the pure capture→selector math. Keeping this behind a subpath
|
||||
// lets pure consumers use the selector/resolution core without pulling PDF.js
|
||||
// or React into their bundle.
|
||||
//
|
||||
// Populated in T04 (PDF adapter extraction); explicit placeholder for now.
|
||||
export {};
|
||||
// The concrete `DocumentViewerAdapter` implementation is added in T04.
|
||||
|
||||
export {
|
||||
selectorsFromPdfCapture,
|
||||
findPdfRectSelector,
|
||||
findTextQuoteSelector,
|
||||
unionRect,
|
||||
} from "./pdf-selector-math";
|
||||
|
|
|
|||
111
src/pdf/pdf-selector-math.test.ts
Normal file
111
src/pdf/pdf-selector-math.test.ts
Normal file
|
|
@ -0,0 +1,111 @@
|
|||
/**
|
||||
* Round-trip tests for the spike's pure transformation layer.
|
||||
*
|
||||
* These tests are CE-WP-0002-T02's machine-verifiable evidence that the
|
||||
* adapter's data round-trip is lossless: a captured PDF selection becomes
|
||||
* a `Selector[]`, the `Selector[]` round-trips through JSON
|
||||
* (localStorage-equivalent), and the reconstructed PDF rect + page match
|
||||
* the original. The browser-side selection-capture path is exercised in
|
||||
* T09 against production code.
|
||||
*/
|
||||
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
findPdfRectSelector,
|
||||
findTextQuoteSelector,
|
||||
selectorsFromPdfCapture,
|
||||
unionRect,
|
||||
} from "./pdf-selector-math";
|
||||
import type { PdfSelectionCapture } from "../types";
|
||||
import type { NormalizedRect, Selector } from "@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);
|
||||
});
|
||||
});
|
||||
79
src/pdf/pdf-selector-math.ts
Normal file
79
src/pdf/pdf-selector-math.ts
Normal file
|
|
@ -0,0 +1,79 @@
|
|||
/**
|
||||
* Pure, library-free transformations between the adapter's
|
||||
* `PdfSelectionCapture` and the shared `Selector[]` shapes.
|
||||
*
|
||||
* Extracted from `pdf-viewer-adapter-spike.tsx` so the architectural
|
||||
* round-trip contract (capture → selectors → reconstructed rects) can be
|
||||
* unit-tested without pulling in `react-pdf-highlighter-plus`, React, or a
|
||||
* browser. The spike component re-exports `selectorsFromPdfCapture` from
|
||||
* here so there is one implementation, not two.
|
||||
*
|
||||
* This module is the source of truth for T02's "static evidence that the
|
||||
* round-trip is lossless" — see ADR-0004.
|
||||
*/
|
||||
|
||||
import type {
|
||||
NormalizedRect,
|
||||
PdfRectSelector,
|
||||
Selector,
|
||||
TextQuoteSelector,
|
||||
} from "@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 };
|
||||
}
|
||||
136
src/selectors/create.test.ts
Normal file
136
src/selectors/create.test.ts
Normal file
|
|
@ -0,0 +1,136 @@
|
|||
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));
|
||||
});
|
||||
});
|
||||
157
src/selectors/create.ts
Normal file
157
src/selectors/create.ts
Normal file
|
|
@ -0,0 +1,157 @@
|
|||
/**
|
||||
* 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 } : {}),
|
||||
};
|
||||
}
|
||||
6
src/selectors/index.ts
Normal file
6
src/selectors/index.ts
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
export {
|
||||
createSelectors,
|
||||
DEFAULT_CONTEXT_CHARS,
|
||||
type CreateSelectorsOptions,
|
||||
} from "./create";
|
||||
export { resolveSelectors } from "./resolve";
|
||||
137
src/selectors/resolve.test.ts
Normal file
137
src/selectors/resolve.test.ts
Normal file
|
|
@ -0,0 +1,137 @@
|
|||
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([]);
|
||||
});
|
||||
});
|
||||
260
src/selectors/resolve.ts
Normal file
260
src/selectors/resolve.ts
Normal file
|
|
@ -0,0 +1,260 @@
|
|||
/**
|
||||
* 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
Normal file
97
src/types.ts
Normal file
|
|
@ -0,0 +1,97 @@
|
|||
/**
|
||||
* Adapter-side types owned by `evidence-anchor`.
|
||||
*
|
||||
* Implements the contract surface from `wiki/SharedContracts.md` §5 and the
|
||||
* resolution result shape from `wiki/ArchitectureOverview.md` §3.3 / §7.
|
||||
*
|
||||
* Anything that mentions a concrete viewer library (pdfjs, react-pdf-highlighter-plus)
|
||||
* lives *behind* this surface, never on it. `src/shared/` and `src/engine/`
|
||||
* must never import this file.
|
||||
*/
|
||||
|
||||
import type { Document, DocumentRepresentation } from "@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[]>;
|
||||
}
|
||||
|
|
@ -185,7 +185,7 @@ package with only `citation-engine` as a shared-type dependency.
|
|||
|
||||
```task
|
||||
id: EANCH-WP-0001-T03
|
||||
status: todo
|
||||
status: done
|
||||
priority: critical
|
||||
depends_on: [T02]
|
||||
state_hub_task_id: "d7bff928-a022-4cc4-a151-950ffaaf622b"
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue