diff --git a/src/components/documents/DocumentSettings.tsx b/src/components/documents/DocumentSettings.tsx index bdb534c..ed6aa92 100644 --- a/src/components/documents/DocumentSettings.tsx +++ b/src/components/documents/DocumentSettings.tsx @@ -16,8 +16,8 @@ import { clampTtsSegmentMaxBlockLength, } from '@/types/config'; import { useFeatureFlag } from '@/contexts/RuntimeConfigContext'; -import { Section, ToggleRow, buttonClass, segmentedButtonClass, segmentedGroupClass } from '@/components/formPrimitives'; -import { RefreshIcon } from '@/components/icons/Icons'; +import { Section, ToggleRow, CheckItem, buttonClass, segmentedButtonClass, segmentedGroupClass } from '@/components/formPrimitives'; +import { RefreshIcon, SparkleIcon } from '@/components/icons/Icons'; import type { ParsedPdfBlockKind, PdfParseStatus } from '@/types/parsed-pdf'; const PDF_SKIP_KIND_OPTIONS: Array<{ kind: ParsedPdfBlockKind; label: string }> = [ @@ -422,19 +422,27 @@ export function DocumentSettings({ isOpen, setIsOpen, epub, html, pdf }: { title="PDF Structure" subtitle="Layout-aware parsing controls." variant="flat" + action={ +
+ + + PP-DocLayout-V3 + + + {pdf.parseStatus ?? 'pending'} + + +
+ } > -
- Parse status: {pdf.parseStatus ?? 'pending'} - -
-
-

Skip while reading aloud

- {PDF_SKIP_KIND_OPTIONS.map((option) => { - const checked = pdf.skipBlockKinds.includes(option.kind); - return ( - +

Skip while reading aloud

+
+ {PDF_SKIP_KIND_OPTIONS.map((option) => ( + pdf.onToggleSkipKind(option.kind, enabled)} - variant="flat" /> - ); - })} + ))} +
)} diff --git a/src/components/formPrimitives.tsx b/src/components/formPrimitives.tsx index 73ce83d..6095591 100644 --- a/src/components/formPrimitives.tsx +++ b/src/components/formPrimitives.tsx @@ -1,6 +1,6 @@ 'use client'; -import type { ReactNode } from 'react'; +import { useId, type ReactNode } from 'react'; /** * Shared compact form primitives used by settings-like surfaces across @@ -78,7 +78,7 @@ export function Section({ variant = 'panel', }: { title: string; - subtitle?: string; + subtitle?: ReactNode; children: ReactNode; action?: ReactNode; variant?: 'panel' | 'flat'; @@ -130,6 +130,65 @@ export function Card({ ); } +export type SwitchSize = 'sm' | 'md'; + +const SWITCH_SIZE: Record = { + sm: { + track: 'h-4 w-7', + thumb: 'h-3 w-3', + on: 'translate-x-3', + off: 'translate-x-0.5', + }, + md: { + track: 'h-5 w-9', + thumb: 'h-4 w-4', + on: 'translate-x-4', + off: 'translate-x-0.5', + }, +}; + +export function Switch({ + checked, + onChange, + disabled = false, + size = 'md', + ariaLabel, + ariaLabelledBy, + ariaDescribedBy, +}: { + checked: boolean; + onChange: (checked: boolean) => void; + disabled?: boolean; + size?: SwitchSize; + ariaLabel?: string; + ariaLabelledBy?: string; + ariaDescribedBy?: string; +}) { + const s = SWITCH_SIZE[size]; + return ( + + ); +} + export function ToggleRow({ label, description, @@ -147,32 +206,76 @@ export function ToggleRow({ right?: ReactNode; variant?: 'card' | 'flat'; }) { + const labelId = useId(); + const descId = useId(); const rowClass = variant === 'flat' ? 'px-0.5 pt-1 pb-2 border-b border-offbase last:border-b-0 transition-transform duration-200 ease-out hover:scale-[1.003]' : 'rounded-md border border-offbase bg-background px-2.5 py-1.5 transition-transform duration-200 ease-out hover:scale-[1.005]'; + const handleTextToggle = () => { + if (!disabled) onChange(!checked); + }; return (
- +
+ {label} + {description} +
{right ?
{right}
: null} +
); } +export function CheckItem({ + label, + checked, + onChange, + disabled = false, +}: { + label: string; + checked: boolean; + onChange: (checked: boolean) => void; + disabled?: boolean; +}) { + const labelId = useId(); + const handleTextToggle = () => { + if (!disabled) onChange(!checked); + }; + return ( +
+ + {label} + + +
+ ); +} + export function Field({ label, hint, diff --git a/src/components/icons/Icons.tsx b/src/components/icons/Icons.tsx index b23217d..9e0d10b 100644 --- a/src/components/icons/Icons.tsx +++ b/src/components/icons/Icons.tsx @@ -620,3 +620,19 @@ export function FileSettingsIcon(props: React.SVGProps) { ); } + +export function SparkleIcon(props: React.SVGProps) { + return ( + + + + ); +} diff --git a/src/components/views/PDFViewer.tsx b/src/components/views/PDFViewer.tsx index 483ec55..48c961b 100644 --- a/src/components/views/PDFViewer.tsx +++ b/src/components/views/PDFViewer.tsx @@ -360,6 +360,37 @@ export function PDFViewer({ zoomLevel, pdfState }: PDFViewerProps) { } for (const list of map.values()) { + const geoKey = (entry: { + block: ParsedPdfBlock; + fragment: ParsedPdfBlock['fragments'][number]; + isContinuation: boolean; + }): string => { + const [x0, y0, x1, y1] = entry.fragment.bbox; + const round = (value: number) => Math.round(value * 10) / 10; + return [ + entry.block.kind, + round(x0), + round(y0), + round(x1), + round(y1), + ].join(':'); + }; + + const continuationGeometry = new Set(); + for (const entry of list) { + if (entry.isContinuation) { + continuationGeometry.add(geoKey(entry)); + } + } + + const filtered = list.filter((entry) => { + if (entry.isContinuation) return true; + return !continuationGeometry.has(geoKey(entry)); + }); + + list.length = 0; + list.push(...filtered); + list.sort((a, b) => { if (a.fragment.readingOrder !== b.fragment.readingOrder) { return a.fragment.readingOrder - b.fragment.readingOrder; diff --git a/src/lib/server/pdf-layout/parsePdf.ts b/src/lib/server/pdf-layout/parsePdf.ts index 6939a0a..cbb8f29 100644 --- a/src/lib/server/pdf-layout/parsePdf.ts +++ b/src/lib/server/pdf-layout/parsePdf.ts @@ -15,9 +15,21 @@ interface ParsePdfInput { const LAYOUT_RENDER_SCALE = 1.5; -function normalizeTextItems(items: TextItem[], pageHeight: number): PdfTextItem[] { +export function normalizeTextItemsForLayout(items: TextItem[], pageHeight: number): PdfTextItem[] { return items - .filter((item) => typeof item.str === 'string' && item.str.trim().length > 0) + .filter((item) => { + if (!(typeof item.str === 'string' && item.str.trim().length > 0)) return false; + const transform = item.transform; + if (!Array.isArray(transform) || transform.length < 6) return false; + + // Reject heavily skewed/rotated text runs (e.g. vertical margin labels + // such as arXiv metadata) so they do not get merged into body blocks. + const skewX = Number(transform[1] ?? 0); + const skewY = Number(transform[2] ?? 0); + if (Math.abs(skewX) > 0.5 || Math.abs(skewY) > 0.5) return false; + + return true; + }) .map((item) => { const x = Number(item.transform[4] ?? 0); const width = Math.max(0, Number(item.width ?? 0)); @@ -71,7 +83,7 @@ export async function parsePdf(input: ParsePdfInput): Promise const page = await pdf.getPage(pageNumber); const viewport = page.getViewport({ scale: 1.0 }); const textContent = await page.getTextContent(); - const textItems = normalizeTextItems( + const textItems = normalizeTextItemsForLayout( textContent.items.filter((item): item is TextItem => 'str' in item && 'transform' in item), viewport.height, ); diff --git a/src/lib/server/pdf-layout/stitchCrossPageBlocks.ts b/src/lib/server/pdf-layout/stitchCrossPageBlocks.ts index 2cafdde..0508769 100644 --- a/src/lib/server/pdf-layout/stitchCrossPageBlocks.ts +++ b/src/lib/server/pdf-layout/stitchCrossPageBlocks.ts @@ -28,6 +28,39 @@ function canStitch(a: ParsedPdfBlock, b: ParsedPdfBlock): boolean { return true; } +function splitHeadContinuation(text: string): { continuation: string; remainder: string } { + const normalized = text.replace(/\s+/g, ' ').trim(); + if (!normalized) return { continuation: '', remainder: '' }; + + const CLOSERS = new Set(['"', "'", '”', '’', ')', ']', '}']); + const isTerminal = (ch: string): boolean => ch === '.' || ch === '!' || ch === '?'; + + for (let i = 0; i < normalized.length; i += 1) { + const ch = normalized[i]; + if (!isTerminal(ch)) continue; + + const prev = i > 0 ? normalized[i - 1] : ''; + const next = i + 1 < normalized.length ? normalized[i + 1] : ''; + if (ch === '.' && /\d/.test(prev) && /\d/.test(next)) continue; + + let cut = i + 1; + while (cut < normalized.length && CLOSERS.has(normalized[cut])) cut += 1; + + const after = cut < normalized.length ? normalized[cut] : ''; + if (!after || /\s/.test(after) || /[A-Z]/.test(after)) { + return { + continuation: normalized.slice(0, cut).trim(), + remainder: normalized.slice(cut).trim(), + }; + } + } + + return { + continuation: normalized, + remainder: '', + }; +} + const HARD_BOUNDARY_KINDS = new Set([ 'paragraph_title', 'doc_title', @@ -81,9 +114,27 @@ export function stitchCrossPageBlocks(doc: ParsedPdfDocument): ParsedPdfDocument if (!tail || !head) continue; if (!canStitch(tail, head)) continue; - tail.fragments.push(...head.fragments); - tail.text = `${tail.text} ${head.text}`.replace(/\s+/g, ' ').trim(); - next.blocks.splice(headIndex, 1); + const { continuation, remainder } = splitHeadContinuation(head.text); + if (!continuation) continue; + + const continuationFragment = head.fragments[0] + ? { ...head.fragments[0], text: continuation } + : null; + + if (continuationFragment) { + tail.fragments.push(continuationFragment); + } + tail.text = `${tail.text} ${continuation}`.replace(/\s+/g, ' ').trim(); + + if (!remainder) { + next.blocks.splice(headIndex, 1); + continue; + } + + head.text = remainder; + if (head.fragments[0]) { + head.fragments[0].text = remainder; + } } return { diff --git a/src/lib/shared/tts-segment-plan.ts b/src/lib/shared/tts-segment-plan.ts index de3fd0c..b33ca15 100644 --- a/src/lib/shared/tts-segment-plan.ts +++ b/src/lib/shared/tts-segment-plan.ts @@ -188,6 +188,7 @@ export function planCanonicalTtsSegments( const readerType = options.readerType ?? 'pdf'; const enforceSourceBoundaries = Boolean(options.enforceSourceBoundaries); const keyPrefix = options.keyPrefix ?? TTS_SEGMENT_PLAN_VERSION; + const sourceSeparator = enforceSourceBoundaries ? '\n\n' : ' '; const preparedSources: PreparedSourceUnit[] = []; const textParts: string[] = []; let combinedLength = 0; @@ -197,8 +198,8 @@ export function planCanonicalTtsSegments( if (!text) continue; if (textParts.length > 0) { - textParts.push(' '); - combinedLength += 1; + textParts.push(sourceSeparator); + combinedLength += sourceSeparator.length; } const startOffset = combinedLength; @@ -215,9 +216,73 @@ export function planCanonicalTtsSegments( const canonicalText = textParts.join(''); const splitOptions = { maxBlockLength: options.maxBlockLength }; - const blocks = readerType === 'epub' - ? splitTextToTtsBlocksEPUB(canonicalText, splitOptions) - : splitTextToTtsBlocks(canonicalText, splitOptions); + const splitIntoBlocks = (text: string): string[] => + readerType === 'epub' + ? splitTextToTtsBlocksEPUB(text, splitOptions) + : splitTextToTtsBlocks(text, splitOptions); + + if (enforceSourceBoundaries) { + const segments: CanonicalTtsSegment[] = []; + + for (const source of preparedSources) { + const localBlocks = splitIntoBlocks(source.text); + let localRawCursor = 0; + let localNormalizedCursor = 0; + + for (const block of localBlocks) { + const text = block.trim(); + if (!text) continue; + + const exactStart = source.text.indexOf(text, localRawCursor); + let localStart: number; + let localEnd: number; + + if (exactStart >= 0) { + localStart = exactStart; + localEnd = exactStart + text.length; + localRawCursor = localEnd; + localNormalizedCursor = normalizeWithRawMap(source.text.slice(0, localEnd)).text.length; + } else { + const flexible = findFlexibleOffset(source.text, text, localNormalizedCursor); + if (flexible) { + localStart = flexible.start; + localEnd = flexible.end; + localRawCursor = localEnd; + localNormalizedCursor = flexible.normalizedEnd; + } else { + // Never drop blocks in enforced boundary mode. + localStart = Math.max(0, Math.min(localRawCursor, source.text.length)); + localEnd = Math.max(localStart, Math.min(source.text.length, localStart + text.length)); + localRawCursor = localEnd; + localNormalizedCursor = normalizeWithRawMap(source.text.slice(0, localEnd)).text.length; + } + } + + const absoluteStart = source.startOffset + localStart; + const absoluteEnd = source.startOffset + localEnd; + const ordinal = segments.length; + segments.push({ + key: buildSegmentKey(keyPrefix, text), + ordinal, + text, + ownerSourceKey: source.sourceKey, + ownerLocator: source.locator, + startAnchor: anchorForOffset(source, absoluteStart), + endAnchor: anchorForOffset(source, absoluteEnd), + spansSourceBoundary: false, + }); + } + } + + return { + version: TTS_SEGMENT_PLAN_VERSION, + readerType, + text: canonicalText, + segments, + }; + } + + const blocks = splitIntoBlocks(canonicalText); let rawCursor = 0; let normalizedCursor = 0; @@ -238,11 +303,33 @@ export function planCanonicalTtsSegments( normalizedCursor = normalizeWithRawMap(canonicalText.slice(0, endOffset)).text.length; } else { const flexible = findFlexibleOffset(canonicalText, text, normalizedCursor); - if (!flexible) continue; - startOffset = flexible.start; - endOffset = flexible.end; - rawCursor = endOffset; - normalizedCursor = flexible.normalizedEnd; + if (!flexible) { + if (!enforceSourceBoundaries) continue; + + // In enforced-boundary mode (PDF block source units), never drop a + // split block just because canonical rematching failed. Prefer a + // best-effort anchor inside the source that the cursor currently sits + // in, then emit the segment text as-is. + const fallbackSource = findSourceForOffset(preparedSources, rawCursor, 'start') + ?? preparedSources[preparedSources.length - 1] + ?? null; + if (!fallbackSource) continue; + + const fallbackStart = Math.max(fallbackSource.startOffset, Math.min(rawCursor, fallbackSource.endOffset)); + const fallbackEnd = Math.max( + fallbackStart, + Math.min(fallbackSource.endOffset, fallbackStart + text.length), + ); + startOffset = fallbackStart; + endOffset = fallbackEnd; + rawCursor = fallbackEnd; + normalizedCursor = normalizeWithRawMap(canonicalText.slice(0, fallbackEnd)).text.length; + } else { + startOffset = flexible.start; + endOffset = flexible.end; + rawCursor = endOffset; + normalizedCursor = flexible.normalizedEnd; + } } const ownerSource = findSourceForOffset(preparedSources, startOffset, 'start'); diff --git a/tests/unit/pdf-parse-normalize-text-items.spec.ts b/tests/unit/pdf-parse-normalize-text-items.spec.ts new file mode 100644 index 0000000..d763a12 --- /dev/null +++ b/tests/unit/pdf-parse-normalize-text-items.spec.ts @@ -0,0 +1,40 @@ +import { expect, test } from '@playwright/test'; + +import { normalizeTextItemsForLayout } from '../../src/lib/server/pdf-layout/parsePdf'; +import type { TextItem } from 'pdfjs-dist/types/src/display/api'; + +function makeTextItem( + str: string, + transform: [number, number, number, number, number, number], + width = 100, +): TextItem { + return { + str, + transform, + width, + height: Math.abs(transform[3]), + dir: 'ltr', + fontName: 'test', + hasEOL: false, + } as unknown as TextItem; +} + +test.describe('normalizeTextItemsForLayout', () => { + test('keeps horizontal body text and drops rotated/skewed margin text', () => { + const horizontal = makeTextItem( + 'Powered by large language models', + [10, 0, 0, 10, 100, 600], + ); + + // Typical 90deg-ish rotated/skewed run (like side metadata labels). + const rotated = makeTextItem( + 'arXiv:2407.16741v3 [cs.SE] 18 Apr 2025', + [0, 10, -10, 0, 30, 400], + ); + + const normalized = normalizeTextItemsForLayout([horizontal, rotated], 800); + expect(normalized).toHaveLength(1); + expect(normalized[0]?.text).toBe('Powered by large language models'); + }); +}); + diff --git a/tests/unit/pdf-stitch-cross-page-blocks.spec.ts b/tests/unit/pdf-stitch-cross-page-blocks.spec.ts index cdc3ac2..34902a9 100644 --- a/tests/unit/pdf-stitch-cross-page-blocks.spec.ts +++ b/tests/unit/pdf-stitch-cross-page-blocks.spec.ts @@ -57,6 +57,27 @@ test.describe('stitchCrossPageBlocks', () => { expect(page2?.blocks.map((b) => b.id)).toEqual(['b3']); }); + test('moves only the continuation sentence and keeps remaining text on next page', () => { + const doc = makeDoc( + [ + makeBlock('b1', 'text', 'This sentence continues', 1, 0), + ], + [ + makeBlock('b2', 'text', 'into the next page. This should stay on page two.', 2, 0), + ], + ); + + const stitched = stitchCrossPageBlocks(doc); + const page1 = stitched.pages[0]; + const page2 = stitched.pages[1]; + + expect(page1?.blocks[0]?.text).toBe('This sentence continues into the next page.'); + expect(page1?.blocks[0]?.fragments).toHaveLength(2); + expect(page2?.blocks).toHaveLength(1); + expect(page2?.blocks[0]?.id).toBe('b2'); + expect(page2?.blocks[0]?.text).toBe('This should stay on page two.'); + }); + test('does not stitch across paragraph-title boundary', () => { const doc = makeDoc( [ diff --git a/tests/unit/tts-segment-plan.spec.ts b/tests/unit/tts-segment-plan.spec.ts index 4efbd21..8f03797 100644 --- a/tests/unit/tts-segment-plan.spec.ts +++ b/tests/unit/tts-segment-plan.spec.ts @@ -164,6 +164,63 @@ test.describe('planCanonicalTtsSegments', () => { expect(plan.segments).toHaveLength(1); expect(plan.segments[0].ownerSourceKey).toBe('page:1'); }); + + test('keeps paragraph-title boundaries when source boundaries are enforced', () => { + const plan = planCanonicalTtsSegments([ + { + sourceKey: 'abstract', + locator: { page: 1, readerType: 'pdf', blockId: 'a1' }, + text: 'Released under the permissive MIT license, OpenHands is a community project spanning academia and industry with more than 2.1K contributions.', + }, + { + sourceKey: 'intro-title', + locator: { page: 1, readerType: 'pdf', blockId: 't1' }, + text: '1 INTRODUCTION', + }, + { + sourceKey: 'intro-body', + locator: { page: 1, readerType: 'pdf', blockId: 'p1' }, + text: 'Powered by large language models (LLMs; OpenAI 2024b; Team et al. 2023), user-facing AI systems have become increasingly capable of performing complex tasks such as accurately responding to user queries, solving math problems, and generating code.', + }, + ], { + readerType: 'pdf', + maxBlockLength: 450, + keyPrefix: 'doc:v1', + enforceSourceBoundaries: true, + }); + + expect(plan.segments.some((segment) => segment.ownerSourceKey === 'intro-title' && segment.text === '1 INTRODUCTION')).toBeTruthy(); + expect(plan.segments.some((segment) => segment.ownerSourceKey === 'intro-body' && segment.text.startsWith('Powered by large language models'))).toBeTruthy(); + expect(plan.segments.some((segment) => segment.text.startsWith('1 INTRODUCTION Powered by'))).toBeFalsy(); + }); + + test('does not drop first sentence when canonical rematch fails in enforced boundary mode', () => { + const plan = planCanonicalTtsSegments([ + { + sourceKey: 'title', + locator: { page: 1, readerType: 'pdf', blockId: 't1' }, + text: '1 INTRODUCTION', + }, + { + sourceKey: 'intro', + locator: { page: 1, readerType: 'pdf', blockId: 'p1' }, + // Missing whitespace after sentence terminal is a common PDF extraction artifact. + text: 'Powered by large language models have become increasingly capable of generating code.In particular, AI agents have recently received ever-increasing research focus.', + }, + ], { + readerType: 'pdf', + maxBlockLength: 450, + keyPrefix: 'doc:v1', + enforceSourceBoundaries: true, + }); + + const introSegments = plan.segments + .filter((segment) => segment.ownerSourceKey === 'intro') + .map((segment) => segment.text); + const combinedIntro = introSegments.join(' '); + expect(combinedIntro.includes('Powered by large language models')).toBeTruthy(); + expect(combinedIntro.includes('In particular, AI agents')).toBeTruthy(); + }); }); test.describe('buildSegmentKeyPrefix / buildSegmentKey contract', () => {