From 215cd80ba21a78888477ecbeff4bcfd1e02fc513 Mon Sep 17 00:00:00 2001 From: ali <117142933+muhammad-ali-e@users.noreply.github.com> Date: Thu, 29 Oct 2026 07:55:41 +0000 Subject: [PATCH 1/3] UN-4504 [FEAT] Add v1 PDF highlight layer and highlight normalisers - lib/pdf/highlights turns every producer's shape into one Highlight {id, page, x, y, w, h, variant, tone, confidence?}, in fractions of the page box, so highlights hold their place at any zoom: - LLMWhisperer line_metadata lines, with line confidence read by type - word boxes, both as a separate list (confidence_metadata) and as the 5th element the agentic table worker sends with page_width; without page_width, x comes from the PDF page's aspect ratio rather than v0's division by page height - Agentic Prompt Studio percentage boxes Padding, zero-size and junk entries are dropped, and boxes are clamped to the page. A box spanning the page reads as a line. - HighlightLayer draws a page's highlights in default, active and low-confidence tones, as buttons reporting onHighlightClick when a handler is given. - PdfPageList takes highlights, activeHighlightId and onHighlightClick and hands each memoised page only its own highlights. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01R7trgcAzu6jxDnit3eeozm --- .../document-viewer/HighlightLayer.test.tsx | 86 +++++ .../document-viewer/HighlightLayer.tsx | 110 ++++++ .../patterns/document-viewer/PdfPage.tsx | 16 +- .../document-viewer/PdfPageList.test.tsx | 42 +++ .../patterns/document-viewer/PdfPageList.tsx | 18 +- frontend/src/v1/lib/pdf/highlights.test.ts | 197 +++++++++++ frontend/src/v1/lib/pdf/highlights.ts | 321 ++++++++++++++++++ 7 files changed, 787 insertions(+), 3 deletions(-) create mode 100644 frontend/src/v1/components/patterns/document-viewer/HighlightLayer.test.tsx create mode 100644 frontend/src/v1/components/patterns/document-viewer/HighlightLayer.tsx create mode 100644 frontend/src/v1/lib/pdf/highlights.test.ts create mode 100644 frontend/src/v1/lib/pdf/highlights.ts diff --git a/frontend/src/v1/components/patterns/document-viewer/HighlightLayer.test.tsx b/frontend/src/v1/components/patterns/document-viewer/HighlightLayer.test.tsx new file mode 100644 index 0000000000..806cd6afea --- /dev/null +++ b/frontend/src/v1/components/patterns/document-viewer/HighlightLayer.test.tsx @@ -0,0 +1,86 @@ +import { render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import type { Highlight } from "@v1/lib/pdf/highlights"; +import { expect, it, vi } from "vitest"; +import { HighlightLayer } from "./HighlightLayer"; + +const line: Highlight = { + id: "line-0", + page: 0, + x: 0, + y: 0.25, + w: 1, + h: 0.02, + variant: "line", + tone: "default", + confidence: 0.97, +}; +const word: Highlight = { + id: "word-0", + page: 0, + x: 0.5, + y: 0.25, + w: 0.1, + h: 0.02, + variant: "box", + tone: "low-confidence", + confidence: 0.62, +}; + +function box(container: HTMLElement, id: string): HTMLElement { + const element = container.querySelector( + `[data-highlight-id="${id}"]`, + ); + if (!element) { + throw new Error(`no highlight ${id}`); + } + return element; +} + +it("places highlights as percentages of the page", () => { + const { container } = render(); + const element = box(container, "word-0"); + expect(element.style.getPropertyValue("--hl-x")).toBe("50%"); + expect(element.style.getPropertyValue("--hl-y")).toBe("25%"); + expect(element.style.getPropertyValue("--hl-w")).toBe("10%"); + expect(element.style.getPropertyValue("--hl-h")).toBe("2%"); + expect(element.getAttribute("data-variant")).toBe("box"); + expect(box(container, "line-0").getAttribute("data-variant")).toBe("line"); +}); + +it("shows each highlight's tone, and the active one as active", () => { + const { container } = render( + , + ); + expect(box(container, "line-0").getAttribute("data-tone")).toBe("active"); + expect(box(container, "word-0").getAttribute("data-tone")).toBe( + "low-confidence", + ); +}); + +it("is not interactive without a click handler", () => { + render(); + expect(screen.queryByRole("button")).toBeNull(); +}); + +it("reports clicks with the highlight, and labels each button", async () => { + const onHighlightClick = vi.fn(); + render( + , + ); + const button = screen.getByRole("button", { + name: "Highlight on page 1, 62% confidence", + }); + expect(button.getAttribute("aria-current")).toBe("true"); + await userEvent.click(button); + expect(onHighlightClick).toHaveBeenCalledWith(word); +}); + +it("renders nothing for a page without highlights", () => { + const { container } = render(); + expect(container.firstChild).toBeNull(); +}); diff --git a/frontend/src/v1/components/patterns/document-viewer/HighlightLayer.tsx b/frontend/src/v1/components/patterns/document-viewer/HighlightLayer.tsx new file mode 100644 index 0000000000..2a2d86128b --- /dev/null +++ b/frontend/src/v1/components/patterns/document-viewer/HighlightLayer.tsx @@ -0,0 +1,110 @@ +import { cn } from "@v1/lib/cn"; +import type { Highlight, HighlightTone } from "@v1/lib/pdf/highlights"; +import { useCallback } from "react"; + +export interface HighlightLayerProps { + /** This page's highlights. */ + highlights: readonly Highlight[]; + /** Shown in the active tone, whatever its own tone. */ + activeId?: string | null; + /** When given, highlights are buttons that report clicks. */ + onHighlightClick?: (highlight: Highlight) => void; +} + +const TONES: Record = { + default: "ui:bg-primary/25", + active: "ui:bg-primary/40 ui:ring-2 ui:ring-primary", + "low-confidence": "ui:bg-destructive/25", +}; + +function percent(fraction: number): string { + return `${fraction * 100}%`; +} + +/** + * Positions a highlight from its page fractions. They are percentages of the + * page box, so the box needs no update when the zoom changes. Set as custom + * properties because v1 bans the style attribute. + */ +function usePlacement(highlight: Highlight) { + const { x, y, w, h } = highlight; + return useCallback( + (element: HTMLElement | null) => { + element?.style.setProperty("--hl-x", percent(x)); + element?.style.setProperty("--hl-y", percent(y)); + element?.style.setProperty("--hl-w", percent(w)); + element?.style.setProperty("--hl-h", percent(h)); + }, + [x, y, w, h], + ); +} + +function label(highlight: Highlight, pageNumber: number): string { + const confidence = + highlight.confidence === undefined + ? "" + : `, ${Math.round(highlight.confidence * 100)}% confidence`; + return `Highlight on page ${pageNumber}${confidence}`; +} + +function HighlightBox({ + highlight, + active, + onClick, +}: { + highlight: Highlight; + active: boolean; + onClick?: (highlight: Highlight) => void; +}) { + const place = usePlacement(highlight); + const tone: HighlightTone = active ? "active" : highlight.tone; + const className = cn( + "ui:absolute ui:top-(--hl-y) ui:left-(--hl-x) ui:h-(--hl-h) ui:w-(--hl-w) ui:rounded-sm ui:mix-blend-multiply", + TONES[tone], + ); + const data = { + "data-highlight-id": highlight.id, + "data-variant": highlight.variant, + "data-tone": tone, + }; + if (onClick === undefined) { + return
; + } + return ( +
); diff --git a/frontend/src/v1/components/patterns/document-viewer/PdfPageList.test.tsx b/frontend/src/v1/components/patterns/document-viewer/PdfPageList.test.tsx index 53d5e2347e..aad2ebad47 100644 --- a/frontend/src/v1/components/patterns/document-viewer/PdfPageList.test.tsx +++ b/frontend/src/v1/components/patterns/document-viewer/PdfPageList.test.tsx @@ -220,3 +220,45 @@ it("draws nothing for a page that leaves before its frame comes", async () => { expect(drawn.has(1)).toBe(false); expect(renderPage.mock.calls.length).toBeLessThanOrEqual(3); }); + +it("draws each highlight on its own page", () => { + const { document } = fakeDocument(3); + const { container } = render( + , + ); + expect( + container.querySelector('[data-page-index="2"] [data-highlight-id="a"]'), + ).not.toBeNull(); + expect( + container.querySelector('[data-page-index="0"] [data-highlight-id="b"]'), + ).not.toBeNull(); + expect( + container.querySelector('[data-page-index="1"] [data-highlight-id]'), + ).toBeNull(); +}); diff --git a/frontend/src/v1/components/patterns/document-viewer/PdfPageList.tsx b/frontend/src/v1/components/patterns/document-viewer/PdfPageList.tsx index 3a3df5e387..0c595b65a0 100644 --- a/frontend/src/v1/components/patterns/document-viewer/PdfPageList.tsx +++ b/frontend/src/v1/components/patterns/document-viewer/PdfPageList.tsx @@ -1,16 +1,23 @@ import { usePageWindow } from "@v1/hooks/usePageWindow"; import { cn } from "@v1/lib/cn"; +import { type Highlight, highlightsByPage } from "@v1/lib/pdf/highlights"; import type { PageSize } from "@v1/lib/pdf/types"; import type { PDFDocumentProxy } from "pdfjs-dist"; -import type { ReactNode } from "react"; +import { type ReactNode, useMemo } from "react"; import { PdfPage } from "./PdfPage"; +const NO_HIGHLIGHTS: readonly Highlight[] = []; + export interface PdfPageListProps { document: PDFDocumentProxy; pageSizes: readonly PageSize[]; /** 1 is 100%. */ zoom: number; - /** Drawn over a page, for highlights; receives the 0-based page index. */ + /** Highlights on any page, from the normalisers in `@v1/lib/pdf/highlights`. */ + highlights?: readonly Highlight[]; + activeHighlightId?: string | null; + onHighlightClick?: (highlight: Highlight) => void; + /** Drawn over a page, above its highlights; receives the 0-based page index. */ renderPageOverlay?: (index: number) => ReactNode; className?: string; } @@ -24,10 +31,14 @@ export function PdfPageList({ document, pageSizes, zoom, + highlights = NO_HIGHLIGHTS, + activeHighlightId, + onHighlightClick, renderPageOverlay, className, }: PdfPageListProps) { const { scrollRef, observePage, rendered } = usePageWindow(pageSizes.length); + const byPage = useMemo(() => highlightsByPage(highlights), [highlights]); return (
{renderPageOverlay?.(index)} diff --git a/frontend/src/v1/lib/pdf/highlights.test.ts b/frontend/src/v1/lib/pdf/highlights.test.ts new file mode 100644 index 0000000000..b94a16e76f --- /dev/null +++ b/frontend/src/v1/lib/pdf/highlights.test.ts @@ -0,0 +1,197 @@ +import { describe, expect, it } from "vitest"; +import { + highlightsByPage, + normaliseAgentic, + normaliseLine, + normaliseLines, + normaliseWords, +} from "./highlights"; + +// US Letter. LLMWhisperer renders page images at 300 dpi: 2550 × 3300. +const LETTER = { width: 612, height: 792 }; +const pageSizes = [LETTER, LETTER, { width: 792, height: 612 }]; + +describe("Prompt Studio: LLMWhisperer line_metadata", () => { + // Shaped like a prompt's highlight_data: one entry per line it cites, with + // line_confidence as the 5th element and [0, 0, 0, 0] padding. + const payload = [ + [0, 412, 34, 3300, 0.98], + [0, 0, 0, 0], + [1, 980, 30, 3300, 0.71], + [2, 120, 20, 2550], + ]; + + it("maps each line to a full-width band ending at its baseline", () => { + const [first] = normaliseLines(payload); + expect(first).toMatchObject({ + id: "line-0", + page: 0, + x: 0, + w: 1, + variant: "line", + tone: "default", + confidence: 0.98, + }); + expect(first?.y).toBeCloseTo((412 - 34) / 3300); + expect(first?.h).toBeCloseTo(34 / 3300); + }); + + it("drops padding, keeps ids tied to the entry's position", () => { + expect(normaliseLines(payload).map((h) => h.id)).toEqual([ + "line-0", + "line-2", + "line-3", + ]); + }); + + it("marks lines below 0.9 confidence as low, and a line without one as default", () => { + const [, low, none] = normaliseLines(payload); + expect(low?.tone).toBe("low-confidence"); + expect(none?.tone).toBe("default"); + expect(none?.confidence).toBeUndefined(); + expect(normaliseLines(payload, { lowConfidenceBelow: 0.5 })[1]?.tone).toBe( + "default", + ); + }); +}); + +describe("HITL: word boxes from confidence_metadata", () => { + const line = [0, 412, 34, 3300, 0.81]; + const words = [ + { text: "Invoice", confidence: 0.62, offset: 1275, width: 255 }, + { text: "No.", confidence: 0.88, offset: 1550, width: 90 }, + ]; + + it("places words on their line, with x from the PDF page's aspect ratio", () => { + const [invoice] = normaliseWords(line, words, { pageSizes }); + // 3300 × 612/792 = 2550 units across the page. + expect(invoice?.x).toBeCloseTo(1275 / 2550); + expect(invoice?.w).toBeCloseTo(255 / 2550); + expect(invoice?.y).toBeCloseTo((412 - 34) / 3300); + expect(invoice).toMatchObject({ + variant: "box", + tone: "low-confidence", + confidence: 0.62, + }); + }); + + it("does not divide x by the page height, as v0 did", () => { + const [invoice] = normaliseWords(line, words, { pageSizes }); + expect(invoice?.x).not.toBeCloseTo(1275 / 3300); + }); + + it("reads the legacy shape with the word appended to the line", () => { + const legacy = [0, 412, 34, 3300, words[0]]; + expect(normaliseLine(legacy, "hitl-0", { pageSizes })).toMatchObject({ + variant: "box", + x: expect.closeTo(0.5), + }); + }); + + it("skips words when the page width cannot be known", () => { + expect(normaliseWords(line, words)).toEqual([]); + }); +}); + +describe("Agentic table: [page, y, height, 792, {offset, width, page_width}]", () => { + // EXPECTED_COORDS in workers/plugins/agentic_table/tests/test_highlight_data.py + const meta = { offset: 0, width: 612, page_width: 612 }; + const payload = [ + [ + 0, + Math.trunc((100 / 3168) * 792), + Math.trunc((20 / 3168) * 792), + 792, + meta, + ], + [ + 1, + Math.trunc((200 / 3168) * 792), + Math.trunc((20 / 3168) * 792), + 792, + meta, + ], + ]; + + it("uses the sent page width and reads a full-width word as a line", () => { + const [first, second] = normaliseLines(payload); + expect(first).toMatchObject({ page: 0, x: 0, w: 1, variant: "line" }); + expect(first?.y).toBeCloseTo((25 - 5) / 792); + expect(second?.page).toBe(1); + }); + + it("uses page_width over the aspect ratio when both are known", () => { + const narrow = [ + 0, + 100, + 20, + 792, + { offset: 306, width: 153, page_width: 612 }, + ]; + expect( + normaliseLine(narrow, "t", { pageSizes: [{ width: 1000, height: 792 }] }), + ).toMatchObject({ x: 0.5, w: 0.25 }); + }); +}); + +describe("Agentic Prompt Studio: [page, left%, top%, width%, height%, hasMetadata]", () => { + // transformHighlightData output in the cloud agentic-prompt-studio plugin. + const payload = [ + [0, 0, 12.5, 100, 2.5, false], + [1, 10, 20, 30, 4, true], + [0, 0, 0, 0, 0, false], + ]; + + it("turns percentages into fractions", () => { + const [line, word] = normaliseAgentic(payload); + expect(line).toMatchObject({ + page: 0, + x: 0, + y: 0.125, + w: 1, + h: expect.closeTo(0.025), + }); + expect(line?.variant).toBe("line"); + expect(word).toMatchObject({ + page: 1, + x: 0.1, + y: 0.2, + w: expect.closeTo(0.3), + h: expect.closeTo(0.04), + }); + expect(word?.variant).toBe("box"); + }); + + it("drops empty boxes", () => { + expect(normaliseAgentic(payload)).toHaveLength(2); + }); +}); + +describe("robustness", () => { + it("clamps boxes that run off the page", () => { + const [box] = normaliseAgentic([[0, 90, -5, 20, 10]]); + expect(box).toMatchObject({ + x: 0.9, + y: 0, + w: expect.closeTo(0.1), + h: 0.05, + }); + }); + + it("ignores junk entries", () => { + expect( + normaliseLines([null, "x", [0, 1, 2], [-1, 10, 10, 100], [0, 10, 10, 0]]), + ).toEqual([]); + }); +}); + +it("groups highlights by page in their original order", () => { + const highlights = normaliseAgentic([ + [1, 0, 0, 10, 10], + [0, 0, 0, 10, 10], + [1, 0, 50, 10, 10], + ]); + const byPage = highlightsByPage(highlights); + expect(byPage.get(1)?.map((h) => h.id)).toEqual(["agentic-0", "agentic-2"]); + expect(byPage.get(0)?.map((h) => h.id)).toEqual(["agentic-1"]); +}); diff --git a/frontend/src/v1/lib/pdf/highlights.ts b/frontend/src/v1/lib/pdf/highlights.ts new file mode 100644 index 0000000000..b0b78ee92d --- /dev/null +++ b/frontend/src/v1/lib/pdf/highlights.ts @@ -0,0 +1,321 @@ +/* + * Highlights in one shape, whatever produced them. Every position is a fraction + * of the page box (0 to 1, from the top-left), so a highlight stays on its text + * at any zoom without being recomputed. + * + * Input shapes: + * + * - Line entry, LLMWhisperer `line_metadata[*].raw` (Prompt Studio, HITL): + * `[page, base_y, height, page_height, line_confidence?]`. `base_y` is the + * line's baseline, so the box runs from `base_y - height` down to `base_y`, + * across the full width. `line_confidence` is a number when sent. + * - Line entry with a word box in place of the confidence: + * `[page, y, height, page_height, {offset, width, page_width?}]`. The agentic + * table worker sends this today, with `page_width`; v0 HITL used to build it + * from `confidence_metadata`, without one. + * - Word boxes, LLMWhisperer `confidence_metadata`: `{text, confidence, offset, + * width}`, only for words below 0.9 confidence. They carry no y: they sit on + * their line, so they are passed with it (`normaliseWords`). + * - Agentic Prompt Studio: `[page, left%, top%, width%, height%, hasMetadata]`, + * already in percentages of the page. + * + * Pages are 0-based everywhere, as the producers send them. + */ +import type { PageSize } from "./types"; + +export type HighlightVariant = "line" | "box"; +export type HighlightTone = "default" | "active" | "low-confidence"; + +export interface Highlight { + id: string; + /** 0-based page index. */ + page: number; + x: number; + y: number; + w: number; + h: number; + variant: HighlightVariant; + tone: HighlightTone; + /** 0 to 1, when the producer sent one. */ + confidence?: number; +} + +/** A word's horizontal extent, in the same page-image units as its line. */ +export interface WordBox { + offset: number; + width: number; + /** The page width in those units. Absent from LLMWhisperer's own output. */ + page_width?: number; + text?: string; + confidence?: number; +} + +export interface NormaliseOptions { + /** PDF page sizes, used to find a page's width when a word box lacks one. */ + pageSizes?: readonly PageSize[]; + /** Prefix for generated ids; the entry's position is appended. */ + idPrefix?: string; + /** Confidence below this is shown as low. LLMWhisperer reports words below 0.9. */ + lowConfidenceBelow?: number; +} + +export const LOW_CONFIDENCE_BELOW = 0.9; + +function clamp01(value: number): number { + return Math.min(1, Math.max(0, value)); +} + +function isFiniteNumber(value: unknown): value is number { + return typeof value === "number" && Number.isFinite(value); +} + +function isWordBox(value: unknown): value is WordBox { + return ( + typeof value === "object" && + value !== null && + isFiniteNumber((value as WordBox).offset) && + isFiniteNumber((value as WordBox).width) + ); +} + +function toneFor( + confidence: number | undefined, + options: NormaliseOptions, +): HighlightTone { + const threshold = options.lowConfidenceBelow ?? LOW_CONFIDENCE_BELOW; + return confidence !== undefined && confidence < threshold + ? "low-confidence" + : "default"; +} + +/** A box with its edges clamped to the page; null when nothing is left of it. */ +function boxOnPage( + x: number, + y: number, + w: number, + h: number, +): { x: number; y: number; w: number; h: number } | null { + const left = clamp01(x); + const top = clamp01(y); + const right = clamp01(x + w); + const bottom = clamp01(y + h); + if (right <= left || bottom <= top) { + return null; + } + return { x: left, y: top, w: right - left, h: bottom - top }; +} + +/** A box spanning the page's width reads as a line, whatever produced it. */ +function variantOf(box: { x: number; w: number }): HighlightVariant { + return box.x <= 0.001 && box.w >= 0.999 ? "line" : "box"; +} + +interface LineGeometry { + page: number; + baseY: number; + height: number; + pageHeight: number; + extra: unknown; +} + +/** The four leading numbers of a line entry; null for padding and junk. */ +function readLine(entry: unknown): LineGeometry | null { + if (!Array.isArray(entry) || entry.length < 4) { + return null; + } + const [page, baseY, height, pageHeight, extra] = entry as unknown[]; + if ( + !isFiniteNumber(page) || + !isFiniteNumber(baseY) || + !isFiniteNumber(height) || + !isFiniteNumber(pageHeight) || + page < 0 || + pageHeight <= 0 || + height <= 0 + ) { + // Producers pad with [0, 0, 0, 0] (and zero-height separators) for lines + // that have no place on the page. + return null; + } + return { page: Math.trunc(page), baseY, height, pageHeight, extra }; +} + +/** + * The page's width in the line's units: the producer's when sent, otherwise + * from the PDF page's aspect ratio. Dividing by the page height instead, as v0 + * did, puts every word box too far left on a portrait page. + */ +function pageWidthUnits( + line: LineGeometry, + word: WordBox, + options: NormaliseOptions, +): number | null { + if (isFiniteNumber(word.page_width) && word.page_width > 0) { + return word.page_width; + } + const size = options.pageSizes?.[line.page]; + if (!size || size.height <= 0) { + return null; + } + return line.pageHeight * (size.width / size.height); +} + +function wordHighlight( + line: LineGeometry, + word: WordBox, + id: string, + options: NormaliseOptions, +): Highlight | null { + const pageWidth = pageWidthUnits(line, word, options); + if (pageWidth === null) { + return null; + } + const box = boxOnPage( + word.offset / pageWidth, + (line.baseY - line.height) / line.pageHeight, + word.width / pageWidth, + line.height / line.pageHeight, + ); + if (box === null) { + return null; + } + const confidence = isFiniteNumber(word.confidence) + ? word.confidence + : undefined; + return { + id, + page: line.page, + ...box, + variant: variantOf(box), + tone: toneFor(confidence, options), + confidence, + }; +} + +/** One line entry, as a full-width line or, with a word box, as that box. */ +export function normaliseLine( + entry: unknown, + id: string, + options: NormaliseOptions = {}, +): Highlight | null { + const line = readLine(entry); + if (line === null) { + return null; + } + if (isWordBox(line.extra)) { + return wordHighlight(line, line.extra, id, options); + } + const box = boxOnPage( + 0, + (line.baseY - line.height) / line.pageHeight, + 1, + line.height / line.pageHeight, + ); + if (box === null) { + return null; + } + const confidence = isFiniteNumber(line.extra) ? line.extra : undefined; + return { + id, + page: line.page, + ...box, + variant: "line", + tone: toneFor(confidence, options), + confidence, + }; +} + +/** Line entries, as sent for one field or prompt. Padding entries are dropped. */ +export function normaliseLines( + entries: readonly unknown[], + options: NormaliseOptions = {}, +): Highlight[] { + const prefix = options.idPrefix ?? "line-"; + const highlights: Highlight[] = []; + entries.forEach((entry, index) => { + const highlight = normaliseLine(entry, `${prefix}${index}`, options); + if (highlight !== null) { + highlights.push(highlight); + } + }); + return highlights; +} + +/** LLMWhisperer word boxes on one line, as boxes. Needs page sizes or `page_width`. */ +export function normaliseWords( + lineEntry: unknown, + words: readonly unknown[], + options: NormaliseOptions = {}, +): Highlight[] { + const line = readLine(lineEntry); + if (line === null) { + return []; + } + const prefix = options.idPrefix ?? "word-"; + const highlights: Highlight[] = []; + words.forEach((word, index) => { + if (!isWordBox(word)) { + return; + } + const highlight = wordHighlight(line, word, `${prefix}${index}`, options); + if (highlight !== null) { + highlights.push(highlight); + } + }); + return highlights; +} + +/** Agentic Prompt Studio entries, `[page, left%, top%, width%, height%, hasMetadata]`. */ +export function normaliseAgentic( + entries: readonly unknown[], + options: Pick = {}, +): Highlight[] { + const prefix = options.idPrefix ?? "agentic-"; + const highlights: Highlight[] = []; + entries.forEach((entry, index) => { + if (!Array.isArray(entry) || entry.length < 5) { + return; + } + // The 6th element, hasMetadata, says only that a word box was sent; the + // geometry already shows whether it spans the line. + const [page, left, top, width, height] = entry as unknown[]; + if ( + !isFiniteNumber(page) || + page < 0 || + !isFiniteNumber(left) || + !isFiniteNumber(top) || + !isFiniteNumber(width) || + !isFiniteNumber(height) + ) { + return; + } + const box = boxOnPage(left / 100, top / 100, width / 100, height / 100); + if (box === null) { + return; + } + highlights.push({ + id: `${prefix}${index}`, + page: Math.trunc(page), + ...box, + variant: variantOf(box), + tone: "default", + }); + }); + return highlights; +} + +/** Highlights grouped by 0-based page, in their original order. */ +export function highlightsByPage( + highlights: readonly Highlight[], +): Map { + const pages = new Map(); + for (const highlight of highlights) { + const list = pages.get(highlight.page); + if (list) { + list.push(highlight); + } else { + pages.set(highlight.page, [highlight]); + } + } + return pages; +} From f65af2848b4337049d390ed0b86e484dbc9d0ed8 Mon Sep 17 00:00:00 2001 From: ali <117142933+muhammad-ali-e@users.noreply.github.com> Date: Thu, 29 Oct 2026 08:45:22 +0000 Subject: [PATCH 2/3] UN-4504 [FEAT] Place v1 highlights through style custom properties Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01R7trgcAzu6jxDnit3eeozm --- .../document-viewer/HighlightLayer.tsx | 36 ++++++++----------- 1 file changed, 14 insertions(+), 22 deletions(-) diff --git a/frontend/src/v1/components/patterns/document-viewer/HighlightLayer.tsx b/frontend/src/v1/components/patterns/document-viewer/HighlightLayer.tsx index 2a2d86128b..90ab8c558e 100644 --- a/frontend/src/v1/components/patterns/document-viewer/HighlightLayer.tsx +++ b/frontend/src/v1/components/patterns/document-viewer/HighlightLayer.tsx @@ -1,6 +1,5 @@ import { cn } from "@v1/lib/cn"; import type { Highlight, HighlightTone } from "@v1/lib/pdf/highlights"; -import { useCallback } from "react"; export interface HighlightLayerProps { /** This page's highlights. */ @@ -21,24 +20,6 @@ function percent(fraction: number): string { return `${fraction * 100}%`; } -/** - * Positions a highlight from its page fractions. They are percentages of the - * page box, so the box needs no update when the zoom changes. Set as custom - * properties because v1 bans the style attribute. - */ -function usePlacement(highlight: Highlight) { - const { x, y, w, h } = highlight; - return useCallback( - (element: HTMLElement | null) => { - element?.style.setProperty("--hl-x", percent(x)); - element?.style.setProperty("--hl-y", percent(y)); - element?.style.setProperty("--hl-w", percent(w)); - element?.style.setProperty("--hl-h", percent(h)); - }, - [x, y, w, h], - ); -} - function label(highlight: Highlight, pageNumber: number): string { const confidence = highlight.confidence === undefined @@ -56,7 +37,12 @@ function HighlightBox({ active: boolean; onClick?: (highlight: Highlight) => void; }) { - const place = usePlacement(highlight); + // Page fractions as percentages of the page box, so the box needs no update + // when the zoom changes. Runtime geometry: custom properties the classes read. + const x = percent(highlight.x); + const y = percent(highlight.y); + const w = percent(highlight.w); + const h = percent(highlight.h); const tone: HighlightTone = active ? "active" : highlight.tone; const className = cn( "ui:absolute ui:top-(--hl-y) ui:left-(--hl-x) ui:h-(--hl-h) ui:w-(--hl-w) ui:rounded-sm ui:mix-blend-multiply", @@ -68,12 +54,18 @@ function HighlightBox({ "data-tone": tone, }; if (onClick === undefined) { - return
; + return ( +
+ ); } return (