Add Debug text layer toggle for diagnosing PDF selection issues

PDF text selection misbehaviour (some glyphs unselectable, selections
jumping to other positions) is almost always caused by misalignment
between the visible canvas-rendered glyphs and the invisible text
layer that PDF.js overlays for selection. There's no way to see this
without devtools — which makes it hard for end users to tell whether
a specific PDF is OCR-noisy, has bad font fallbacks, or has no text
layer at all.

This adds a developer-facing toggle in the SessionMenu ("Debug text
layer") that:

- paints every text-layer span yellow with a blue outline so it's
  obvious where text is selectable and where it isn't, and
- logs every onSelection event to the browser console with the
  captured text, page, normalized rects, and the selectors the
  pipeline derived from it.

Preference persists in localStorage under
`citation-evidence:debug:textLayer`. Surfaced via a new
`useDebugFlag()` hook in @work so the SessionMenu (app layer) and the
ViewerShell (work layer) can both subscribe without breaching the
boundary plugin.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
2026-05-26 21:43:15 +02:00
parent 67bcc2423c
commit 0638c441c4
6 changed files with 154 additions and 22 deletions

View File

@@ -0,0 +1,24 @@
/*
* Debug overlay for PDF text layer alignment.
*
* The text layer is normally invisible (`opacity: 0`) and selectable.
* When `.ce-debug-textlayer` is on a parent, every text span becomes a
* yellow highlight so it's obvious where text is selectable and where it
* isn't — useful for diagnosing OCR misalignment, scan-only PDFs, and
* text-layer shift caused by font fallbacks.
*
* Toggle via the "Debug text layer" entry in SessionMenu.
*/
.ce-debug-textlayer .textLayer {
outline: 1px dashed rgba(255, 0, 0, 0.5);
}
.ce-debug-textlayer .textLayer > span,
.ce-debug-textlayer .textLayer > br {
background: rgba(255, 220, 0, 0.35) !important;
color: rgba(0, 0, 100, 0.85) !important;
opacity: 1 !important;
/* Reveal the per-glyph span borders so misalignment is visible. */
outline: 1px solid rgba(0, 100, 255, 0.25);
}

View File

@@ -28,6 +28,7 @@ import {
} from "react-pdf-highlighter-plus";
import "react-pdf-highlighter-plus/style/style.css";
import "react-pdf-highlighter-plus/style/pdf_viewer.css";
import "./debug-textlayer.css";
import type { NormalizedRect, Selector } from "@shared/selector";
import type { AnchorResolution, PdfSelectionCapture, ResolvedAnchorTarget } from "./types";
@@ -166,6 +167,12 @@ export interface PdfSpikeViewerProps {
onSelectionCaptured(capture: PdfSelectionCapture, selectors: Selector[]): void;
/** Annotation id to scroll to and highlight on mount, if any. */
readonly scrollToAnnotationId?: string;
/**
* When true, paint the PDF text-layer spans in yellow 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;
}
export interface StoredAnnotation {
@@ -181,7 +188,7 @@ export interface StoredAnnotation {
* - scrolls to `scrollToAnnotationId` if its highlight can be reconstructed
*/
export function PdfSpikeViewer(props: PdfSpikeViewerProps) {
const { pdfUrl, storedAnnotations, onSelectionCaptured, scrollToAnnotationId } = props;
const { pdfUrl, storedAnnotations, onSelectionCaptured, scrollToAnnotationId, debugTextLayer } = props;
const utilsRef = useRef<PdfHighlighterUtils | null>(null);
const [didScroll, setDidScroll] = useState<string | null>(null);
@@ -204,24 +211,38 @@ export function PdfSpikeViewer(props: PdfSpikeViewerProps) {
}, [scrollToAnnotationId, highlights, didScroll]);
return (
<PdfLoader document={pdfUrl}>
{(pdfDocument) => (
<PdfHighlighter
pdfDocument={pdfDocument}
highlights={highlights}
utilsRef={(u) => {
utilsRef.current = u;
}}
onSelection={(selection) => {
const capture = captureFromPdfSelection(selection);
const selectors = selectorsFromPdfCapture(capture);
onSelectionCaptured(capture, selectors);
}}
>
<SpikeHighlightContainer />
</PdfHighlighter>
)}
</PdfLoader>
<div
className={debugTextLayer ? "ce-debug-textlayer" : undefined}
style={{ height: "100%" }}
>
<PdfLoader document={pdfUrl}>
{(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);
}}
>
<SpikeHighlightContainer />
</PdfHighlighter>
)}
</PdfLoader>
</div>
);
}

View File

@@ -15,7 +15,12 @@ import type { CSSProperties } from "react";
import type { SessionId } from "@shared/ids";
import type { Session } from "@shared/session";
import { useActiveSession, useSessionListTick, useSessionService } from "@work/index";
import {
useActiveSession,
useDebugFlag,
useSessionListTick,
useSessionService,
} from "@work/index";
import { clearAllSessionData } from "@engine/index";
import { navigateTo } from "./routing";
@@ -30,6 +35,7 @@ export function SessionMenu({ onExportZip, onImportZip, onOpenSamples }: Session
const service = useSessionService();
const tick = useSessionListTick();
const active = useActiveSession();
const [debugTextLayer, setDebugTextLayer] = useDebugFlag("textLayer");
const [open, setOpen] = useState(false);
const [newName, setNewName] = useState("");
@@ -399,6 +405,25 @@ export function SessionMenu({ onExportZip, onImportZip, onOpenSamples }: Session
)}
<hr style={dividerStyle} />
<label
data-testid="session-menu-debug-textlayer"
style={{
...menuItemStyle,
display: "flex",
alignItems: "center",
gap: 6,
cursor: "pointer",
}}
title="Paint the PDF text-layer spans in yellow so you can see what's selectable. Also logs every selection event to the browser console."
>
<input
type="checkbox"
checked={debugTextLayer}
onChange={(e) => setDebugTextLayer(e.target.checked)}
style={{ margin: 0 }}
/>
Debug text layer
</label>
<button
type="button"
role="menuitem"

View File

@@ -22,12 +22,14 @@ import {
useScrollToAnnotation,
} from "./EngineContext";
import { AnnotationToolbar } from "./AnnotationToolbar";
import { useDebugFlag } from "./useDebugFlags";
export function ViewerShell() {
const engine = useEngine();
const { document, representation } = useActiveDocument();
const { set: setPending } = usePendingSelection();
const { id: scrollToId, version: scrollVersion } = useScrollToAnnotation();
const [debugTextLayer] = useDebugFlag("textLayer");
// The viewer needs to re-fetch its highlight list whenever annotations
// change. The tick is included in the memo deps so the list re-resolves.
@@ -86,11 +88,15 @@ export function ViewerShell() {
<div style={{ flex: 1, overflow: "hidden", position: "relative" }}>
<PdfSpikeViewer
// Re-key on scrollVersion so clicking the same item twice still
// triggers the viewer's mount-time scroll effect.
key={`${document.id}#${scrollVersion}`}
// triggers the viewer's mount-time scroll effect. Also re-key on
// debugTextLayer so toggling it remounts the viewer (the CSS
// class is on a parent, but re-mounting is the simplest way to
// make sure the text layer is re-painted with the new style).
key={`${document.id}#${scrollVersion}#${debugTextLayer ? "d" : "n"}`}
pdfUrl={fileUrl}
storedAnnotations={annotations}
{...(scrollToId ? { scrollToAnnotationId: scrollToId } : {})}
debugTextLayer={debugTextLayer}
onSelectionCaptured={(capture, selectors) => {
setPending({ capture, selectors });
}}

View File

@@ -8,6 +8,7 @@ export {
type ExportResult,
} from "./useExportEvidence";
export { AnnotationToolbar } from "./AnnotationToolbar";
export { useDebugFlag, type DebugFlag } from "./useDebugFlags";
export {
EngineProvider,
useEngine,

55
src/work/useDebugFlags.ts Normal file
View File

@@ -0,0 +1,55 @@
/**
* `useDebugFlags` — read/write the small set of developer-facing
* toggles. Persisted in `localStorage` under
* `citation-evidence:debug:<flag>` so a reload preserves them.
*
* Used by the SessionMenu (to render a checkbox) and by the viewer
* shell (to decide whether to paint the PDF text layer + log
* selection events). Kept in `work/` so both the app layer (toggle UI)
* and the anchor consumer (viewer adapter prop) reach it via the
* existing boundary chain.
*/
import { useCallback, useEffect, useState } from "react";
const KEY_PREFIX = "citation-evidence:debug:";
const STORAGE_EVENT = "ce-debug-flag-change";
export type DebugFlag = "textLayer";
function storageKey(flag: DebugFlag): string {
return `${KEY_PREFIX}${flag}`;
}
function read(flag: DebugFlag): boolean {
if (typeof localStorage === "undefined") return false;
return localStorage.getItem(storageKey(flag)) === "1";
}
export function useDebugFlag(flag: DebugFlag): readonly [boolean, (next: boolean) => void] {
const [value, setValue] = useState<boolean>(() => read(flag));
useEffect(() => {
if (typeof window === "undefined") return;
const handler = (e: Event) => {
const detail = (e as CustomEvent<{ flag: DebugFlag }>).detail;
if (!detail || detail.flag !== flag) return;
setValue(read(flag));
};
window.addEventListener(STORAGE_EVENT, handler);
return () => window.removeEventListener(STORAGE_EVENT, handler);
}, [flag]);
const setter = useCallback(
(next: boolean) => {
if (typeof localStorage === "undefined") return;
if (next) localStorage.setItem(storageKey(flag), "1");
else localStorage.removeItem(storageKey(flag));
setValue(next);
window.dispatchEvent(new CustomEvent(STORAGE_EVENT, { detail: { flag } }));
},
[flag],
);
return [value, setter] as const;
}