This repository has been archived on 2026-07-08. You can view files and clone it. You cannot open issues or pull requests or push a commit.
Files
citation-evidence/src/anchor/pdf-viewer-adapter-spike.tsx
tegwick 8082aaf7ec Load pdfjs-dist's own pdf_viewer.css so text-layer spans position correctly
The version of pdf_viewer.css bundled with react-pdf-highlighter-plus
is only a minimal *override* (≈40 lines: opacity, z-index, blend
mode). It's missing the foundational rules that PDF.js's TextLayer
relies on — `position: absolute`, `inset: 0`, and the entire
`--scale-factor` CSS-variable machinery that PDF.js 4.x uses to
position each glyph.

Without those rules, each text-layer span gets rendered with default
positioning context and `font-size: calc(<base> * var(--scale-factor))`
collapses to 0 → spans either pile up at the top-left of the page or
land at wrong y-coordinates regardless of where the glyph actually
sits on the canvas. The reported symptom ("origin seems to be the top
of the page always") matches exactly.

Importing `pdfjs-dist/web/pdf_viewer.css` first, then the library's
overrides on top, gives PDF.js the CSS it expects.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-27 01:34:03 +02:00

354 lines
12 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* Throwaway PDF viewer adapter spike (CE-WP-0002-T02).
*
* Purpose: prove that `react-pdf-highlighter-plus` can implement the §5
* `DocumentViewerAdapter` contract end-to-end (select → save selectors →
* reload → resolve → scroll → render highlight) without leaking PDF.js
* types into `src/shared/` or `src/engine/`.
*
* This module is the only place in the codebase that imports
* `react-pdf-highlighter-plus`. The exported React component is consumed
* by `src/app/SpikeApp.tsx`.
*
* Replace before production. T03 (source ingest) + T04 (anchor resolution)
* will build the real PDFViewerAdapter on top of this lessons-learned.
*/
import { useEffect, useMemo, useRef, useState, type ReactNode } from "react";
import {
PdfHighlighter,
PdfLoader,
TextHighlight,
MonitoredHighlightContainer,
useHighlightContainerContext,
type Highlight,
type PdfHighlighterUtils,
type PdfSelection,
type ScaledPosition,
} from "react-pdf-highlighter-plus";
// pdfjs-dist's own pdf_viewer.css is the authoritative source for
// text-layer positioning. The version bundled with
// react-pdf-highlighter-plus is a minimal *override* (missing
// `position: absolute`, `inset: 0`, and PDF.js 4.x's
// `--scale-factor` handling) — load the real one first, then the
// library's overrides on top.
import "pdfjs-dist/web/pdf_viewer.css";
import "react-pdf-highlighter-plus/style/style.css";
import "react-pdf-highlighter-plus/style/pdf_viewer.css";
import "./highlight-styles.css";
import "./debug-textlayer.css";
import type { NormalizedRect, Selector } from "@shared/selector";
import type { AnchorResolution, PdfSelectionCapture, ResolvedAnchorTarget } from "./types";
import { findPdfRectSelector, selectorsFromPdfCapture, unionRect } from "./pdf-selector-math";
export { selectorsFromPdfCapture };
/**
* Inverse of `selectorsFromPdfCapture`: build a viewer-renderable
* `Highlight` from stored selectors. The spike's reload path leans on
* `PdfRectSelector` since it carries page + page-relative rects directly.
* T04 will own the production resolver and add the text-only paths.
*/
function highlightFromSelectors(
id: string,
text: string,
selectors: readonly Selector[],
): Highlight | null {
const rectSel = findPdfRectSelector(selectors);
if (!rectSel) return null;
const boundingRect = unionRect(rectSel.rects);
if (!boundingRect) return null;
const scaledRects = rectSel.rects.map((r) => toScaled(r, rectSel.page));
return {
id,
type: "text",
content: { text },
position: {
boundingRect: toScaled(boundingRect, rectSel.page),
rects: scaledRects,
} satisfies ScaledPosition,
};
}
/**
* Convert the adapter's `NormalizedRect` (page-relative 0..1) to the
* `Scaled` shape react-pdf-highlighter-plus expects (also normalized 0..1
* via width/height). We use a unit page-space of 1×1 — the library
* computes pixel coords from `pageNumber` and the renderer's actual page
* dimensions.
*/
function toScaled(r: NormalizedRect, page: number) {
return {
x1: r.x,
y1: r.y,
x2: r.x + r.width,
y2: r.y + r.height,
width: 1,
height: 1,
pageNumber: page,
};
}
/** PdfSelection → our domain-neutral `PdfSelectionCapture`. */
function captureFromPdfSelection(sel: PdfSelection): PdfSelectionCapture {
const page = sel.position.boundingRect.pageNumber;
const rects = sel.position.rects.map<NormalizedRect>((r) => ({
x: r.x1 / r.width,
y: r.y1 / r.height,
width: (r.x2 - r.x1) / r.width,
height: (r.y2 - r.y1) / r.height,
}));
const br = sel.position.boundingRect;
const boundingRect: NormalizedRect = {
x: br.x1 / br.width,
y: br.y1 / br.height,
width: (br.x2 - br.x1) / br.width,
height: (br.y2 - br.y1) / br.height,
};
return {
kind: "pdf",
text: sel.content.text ?? "",
page,
rects,
boundingRect,
};
}
/**
* Trivial container that renders every stored highlight as a TextHighlight.
* For the spike, no editing tooling — just visual proof of "did the saved
* coordinates land on the right passage on the right page after reload?"
*/
interface HighlightContainerProps {
readonly activeAnnotationId: string | null | undefined;
readonly onHighlightClicked: ((annotationId: string) => void) | undefined;
}
function makeSpikeHighlightContainer(props: HighlightContainerProps) {
return function SpikeHighlightContainer(): ReactNode {
const { highlight, isScrolledTo } = useHighlightContainerContext();
const isActive = props.activeAnnotationId === highlight.id;
return (
<div
data-highlight-id={highlight.id}
data-ce-active={isActive ? "true" : "false"}
style={{ display: "contents" }}
onClickCapture={(e) => {
// Click-capture so the click registers before the library's
// built-in selection-clearing logic eats it. Stop propagation
// so the highlight click doesn't also start a new selection.
e.stopPropagation();
props.onHighlightClicked?.(highlight.id);
}}
>
<MonitoredHighlightContainer>
<TextHighlight highlight={highlight} isScrolledTo={isScrolledTo} />
</MonitoredHighlightContainer>
</div>
);
};
}
/**
* Resolve the rendered DOM rect for a highlight by data attribute, or
* `null` if the highlight isn't currently rendered (e.g. its page hasn't
* scrolled into view). Used by `app/forms/HighlightRectBridge` to feed
* the rect registry as kind="highlight".
*
* `display: contents` on the wrapper means it has no box of its own; we
* union the rects of its children. For TextHighlight that's typically
* one rect per line.
*/
export function getHighlightClientRects(annotationId: string): DOMRect | null {
if (typeof document === "undefined") return null;
const wrapper = document.querySelector(`[data-highlight-id="${CSS.escape(annotationId)}"]`);
if (!wrapper) return null;
const rects = wrapper.getClientRects();
if (rects.length === 0) return null;
let left = Infinity;
let top = Infinity;
let right = -Infinity;
let bottom = -Infinity;
for (const r of Array.from(rects)) {
left = Math.min(left, r.left);
top = Math.min(top, r.top);
right = Math.max(right, r.right);
bottom = Math.max(bottom, r.bottom);
}
if (!isFinite(left)) return null;
return new DOMRect(left, top, right - left, bottom - top);
}
export interface PdfSpikeViewerProps {
/** URL of the PDF to load (served by Vite dev server). */
readonly pdfUrl: string;
/** Previously-saved selector sets to restore on mount. */
readonly storedAnnotations: readonly StoredAnnotation[];
/** Called when the user produces a new selection. */
onSelectionCaptured(capture: PdfSelectionCapture, selectors: Selector[]): void;
/** Annotation id to scroll to and highlight on mount, if any. */
readonly scrollToAnnotationId?: string;
/**
* Annotation id currently focused. The matching highlight gets a
* thicker border (see highlight-styles.css). `null`/undefined means
* "no active highlight".
*/
readonly activeAnnotationId?: string | null;
/**
* Called when the user clicks an existing highlight in the page.
* The receiver typically activates the matching evidence item.
*/
onHighlightClicked?(annotationId: string): void;
/**
* When true, paint the PDF text-layer spans in light grey so it's
* obvious which glyphs have a selectable text overlay and which are
* image-only. Also logs every onSelection event to the console.
*/
readonly debugTextLayer?: boolean;
/**
* Hide specific PDF.js layers so you can see what sits underneath.
* Helps diagnose layer-stacking issues (e.g. "is the text layer
* covering the canvas content?").
*/
readonly hideCanvas?: boolean;
readonly hideTextLayer?: boolean;
readonly hideAnnotationLayer?: boolean;
readonly hideXfaLayer?: boolean;
}
export interface StoredAnnotation {
readonly id: string;
readonly text: string;
readonly selectors: readonly Selector[];
}
/**
* The spike's React component. Renders a PDF and:
* - emits `onSelectionCaptured(capture, selectors)` on every fresh selection
* - reconstructs and renders `storedAnnotations` immediately on load
* - scrolls to `scrollToAnnotationId` if its highlight can be reconstructed
*/
export function PdfSpikeViewer(props: PdfSpikeViewerProps) {
const {
pdfUrl,
storedAnnotations,
onSelectionCaptured,
scrollToAnnotationId,
activeAnnotationId,
onHighlightClicked,
debugTextLayer,
hideCanvas,
hideTextLayer,
hideAnnotationLayer,
hideXfaLayer,
} = props;
const HighlightContainer = useMemo(
() => makeSpikeHighlightContainer({ activeAnnotationId, onHighlightClicked }),
[activeAnnotationId, onHighlightClicked],
);
const wrapperClasses = [
debugTextLayer ? "ce-debug-textlayer" : null,
hideCanvas ? "ce-hide-canvas" : null,
hideTextLayer ? "ce-hide-text-layer" : null,
hideAnnotationLayer ? "ce-hide-annotation-layer" : null,
hideXfaLayer ? "ce-hide-xfa-layer" : null,
]
.filter((c): c is string => c !== null)
.join(" ");
const utilsRef = useRef<PdfHighlighterUtils | null>(null);
const [didScroll, setDidScroll] = useState<string | null>(null);
const highlights = useMemo<Highlight[]>(() => {
const out: Highlight[] = [];
const skipped: { id: string; reason: string }[] = [];
for (const a of storedAnnotations) {
const h = highlightFromSelectors(a.id, a.text, a.selectors);
if (h) out.push(h);
else skipped.push({ id: a.id, reason: "no PdfRectSelector / empty boundingRect" });
}
if (debugTextLayer) {
console.log("[ce] viewer highlights", {
in: storedAnnotations.length,
rendered: out.length,
rendered_detail: out.map((h) => ({
id: h.id,
page: h.position.boundingRect.pageNumber,
bounding: h.position.boundingRect,
rectCount: h.position.rects.length,
})),
skipped,
});
}
return out;
}, [storedAnnotations, debugTextLayer]);
useEffect(() => {
if (!scrollToAnnotationId || didScroll === scrollToAnnotationId) return;
const utils = utilsRef.current;
const target = highlights.find((h) => h.id === scrollToAnnotationId);
if (debugTextLayer) {
console.log("[ce] scrollToAnnotation requested", {
id: scrollToAnnotationId,
utilsAvailable: !!utils,
targetFound: !!target,
knownIds: highlights.map((h) => h.id),
});
}
if (!utils || !target) return;
utils.scrollToHighlight(target);
setDidScroll(scrollToAnnotationId);
}, [scrollToAnnotationId, highlights, didScroll, debugTextLayer]);
return (
<div
className={wrapperClasses.length > 0 ? wrapperClasses : undefined}
style={{ height: "100%" }}
>
<PdfLoader
document={{
url: pdfUrl,
// Without these two, PDFs with CID fonts (most CJK + many
// European court documents) or unembedded standard fonts get
// rendered with substitute metrics, which shifts every
// text-layer span out of alignment with the canvas glyphs.
// vite.config.ts copies both directories from pdfjs-dist into
// the served root.
cMapUrl: "/cmaps/",
cMapPacked: true,
standardFontDataUrl: "/standard_fonts/",
}}
>
{(pdfDocument) => (
<PdfHighlighter
pdfDocument={pdfDocument}
highlights={highlights}
utilsRef={(u) => {
utilsRef.current = u;
}}
onSelection={(selection) => {
const capture = captureFromPdfSelection(selection);
const selectors = selectorsFromPdfCapture(capture);
if (debugTextLayer) {
console.log("[ce] onSelection", {
text: capture.text,
page: capture.page,
rects: capture.rects,
selectorTypes: selectors.map((s) => s.type),
raw: selection,
});
}
onSelectionCaptured(capture, selectors);
}}
>
<HighlightContainer />
</PdfHighlighter>
)}
</PdfLoader>
</div>
);
}
// Re-export the §5 contract surface so callers see anchor as one entry point.
export type { AnchorResolution, ResolvedAnchorTarget, PdfSelectionCapture };