diff --git a/src/components/PageReader.test.tsx b/src/components/PageReader.test.tsx index ce048a8..e101fc3 100644 --- a/src/components/PageReader.test.tsx +++ b/src/components/PageReader.test.tsx @@ -100,7 +100,7 @@ describe('PageReader structured content', () => { }); it('renders semantic formatting, links, tables, and a signed image resource', async () => { - const loadResource = vi.fn(async () => ({ + const previewResource = vi.fn(async () => ({ id: 'resource-image', kind: 'image' as const, mediaType: 'image/png', @@ -110,10 +110,14 @@ describe('PageReader structured content', () => { })); render( - + ); - expect(screen.getByText('Styled').parentElement).toHaveStyle({ + expect(screen.getByText('Styled')).toHaveStyle({ fontWeight: '700', }); expect(screen.getByRole('link', { name: 'link' })).toHaveAttribute( @@ -145,7 +149,7 @@ describe('PageReader structured content', () => { { + previewResource={async () => { throw new Error('not renderable'); }} downloadResource={downloadResource} @@ -157,6 +161,53 @@ describe('PageReader structured content', () => { ); expect(downloadResource).toHaveBeenCalledWith('resource-file', 'notes.txt'); }); + + it('does not turn a rejected image preview into an implicit download', async () => { + const previewResource = vi.fn(async () => { + throw new Error( + 'This image can be downloaded but is not approved for automatic preview.' + ); + }); + const downloadResource = vi.fn(async () => undefined); + render( + + ); + + expect( + await screen.findByText(/not approved for automatic preview/i) + ).toBeVisible(); + expect(previewResource).toHaveBeenCalledWith('resource-image'); + expect(downloadResource).not.toHaveBeenCalled(); + + await userEvent.click( + screen.getByRole('button', { name: 'Download original image' }) + ); + expect(downloadResource).toHaveBeenCalledWith('resource-image', undefined); + }); + + it('shows an explicit notice when the display projection is truncated', () => { + render( + + ); + + expect(screen.getByText('First')).toBeInTheDocument(); + expect(screen.queryByText('Second')).not.toBeInTheDocument(); + expect(screen.getByRole('status')).toHaveTextContent( + 'safe browser display budget' + ); + }); }); function textBlock(text: string, id: string) { diff --git a/src/components/PageReader.tsx b/src/components/PageReader.tsx index 005e56b..f75adc6 100644 --- a/src/components/PageReader.tsx +++ b/src/components/PageReader.tsx @@ -1,4 +1,4 @@ -import { useEffect, useState, type CSSProperties, type ReactNode } from 'react'; +import { useEffect, useState, type CSSProperties } from 'react'; import type { OneNotePageDto, @@ -14,13 +14,20 @@ import type { OneNoteTextRun, OneNoteTextStyle, } from '../onenote/one/content-model.js'; +import { + DEFAULT_PAGE_RENDER_LIMITS, + limitDisplayString, + prepareBlocksForRendering, + type PageRenderLimits, +} from './page-render-budget.js'; interface PageReaderProps { page?: OneNotePageDto; loading: boolean; error?: string; - loadResource?(resourceId: string): Promise; + previewResource?(resourceId: string): Promise; downloadResource?(resourceId: string, filename?: string): Promise; + renderLimits?: Partial; } function formatDate(value: string | undefined): string | null { @@ -37,8 +44,9 @@ export function PageReader({ page, loading, error, - loadResource, + previewResource, downloadResource, + renderLimits, }: PageReaderProps) { if (loading) { return ( @@ -68,12 +76,25 @@ export function PageReader({ const createdAt = formatDate(page.createdAt); const updatedAt = formatDate(page.updatedAt); + const renderPlan = prepareBlocksForRendering(page.blocks, renderLimits); + const title = limitDisplayString( + page.title || 'Untitled page', + renderLimits?.maxMetadataCharacters ?? + DEFAULT_PAGE_RENDER_LIMITS.maxMetadataCharacters + ); + const fallbackText = limitDisplayString( + page.text, + renderLimits?.maxTextCharacters ?? + DEFAULT_PAGE_RENDER_LIMITS.maxTextCharacters + ); + const displayTruncated = + renderPlan.truncated || title.truncated || fallbackText.truncated; return (

Selected page

-

{page.title || 'Untitled page'}

+

{title.value}

{createdAt || updatedAt ? (
{createdAt ? ( @@ -96,14 +117,21 @@ export function PageReader({ {page.blocks.length === 0 && page.text.length === 0 ? (

No readable content was extracted.

) : page.blocks.length === 0 ? ( -

{page.text}

+

{fallbackText.value}

) : ( )} + {displayTruncated ? ( + + ) : null}
); @@ -111,18 +139,18 @@ export function PageReader({ function ContentBlocks({ blocks, - loadResource, + previewResource, downloadResource, }: { blocks: readonly OneNoteContentBlock[]; - loadResource?: PageReaderProps['loadResource']; + previewResource?: PageReaderProps['previewResource']; downloadResource?: PageReaderProps['downloadResource']; }) { return blocks.map((block) => ( )); @@ -130,11 +158,11 @@ function ContentBlocks({ function ContentBlockView({ block, - loadResource, + previewResource, downloadResource, }: { block: OneNoteContentBlock; - loadResource?: PageReaderProps['loadResource']; + previewResource?: PageReaderProps['previewResource']; downloadResource?: PageReaderProps['downloadResource']; }) { switch (block.kind) { @@ -158,7 +186,7 @@ function ContentBlockView({
@@ -168,7 +196,7 @@ function ContentBlockView({
@@ -183,12 +211,12 @@ function ContentBlockView({ > @@ -223,7 +251,7 @@ function ContentBlockView({ > @@ -238,7 +266,7 @@ function ContentBlockView({ return ( ); @@ -266,7 +294,7 @@ function ContentBlockView({ function TextRunView({ run }: { run: OneNoteTextRun }) { if (run.style.hidden) return null; const content = textWithBreaks(run.text); - const style = textStyle(run.style); + const style = { ...textStyle(run.style), whiteSpace: 'pre-wrap' as const }; return run.href ? ( - index + 1 < values.length - ? [ - {part}, -
, - ] - : [{part}] - ); +function textWithBreaks(text: string): string { + return text.replaceAll('\u000b', '\n'); } function ImageView({ image, - loadResource, + previewResource, downloadResource, }: { image: OneNoteImageBlock; - loadResource?: PageReaderProps['loadResource']; + previewResource?: PageReaderProps['previewResource']; downloadResource?: PageReaderProps['downloadResource']; }) { const [state, setState] = useState< @@ -315,11 +333,11 @@ function ImageView({ useEffect(() => { let disposed = false; let objectUrl: string | undefined; - if (!image.resourceId || !loadResource) { + if (!image.resourceId || !previewResource) { return; } const resourceId = image.resourceId; - void loadResource(image.resourceId) + void previewResource(image.resourceId) .then((resource) => { if ( disposed || @@ -358,10 +376,10 @@ function ImageView({ disposed = true; if (objectUrl) URL.revokeObjectURL(objectUrl); }; - }, [image.resourceId, loadResource]); + }, [image.resourceId, previewResource]); const visibleState = - !image.resourceId || !loadResource + !image.resourceId || !previewResource ? { phase: 'unavailable' as const, resourceId: image.resourceId ?? '', diff --git a/src/components/page-render-budget.test.ts b/src/components/page-render-budget.test.ts new file mode 100644 index 0000000..ca6b013 --- /dev/null +++ b/src/components/page-render-budget.test.ts @@ -0,0 +1,99 @@ +import { describe, expect, it } from 'vitest'; + +import type { OneNoteContentBlock } from '../onenote/one/content-model.js'; +import { prepareBlocksForRendering } from './page-render-budget.js'; + +describe('page render budget', () => { + it('stops reading blocks at the configured limit', () => { + const blocks: OneNoteContentBlock[] = Array.from( + { length: 200_000 }, + (_, index) => ({ + kind: 'unsupported', + id: String(index), + sourceJcid: index, + description: 'unsupported', + }) + ); + + const plan = prepareBlocksForRendering(blocks, { maxBlocks: 3 }); + expect(plan.blocks).toHaveLength(3); + expect(plan.truncated).toBe(true); + }); + + it('caps rich-text runs and characters before React sees them', () => { + const block: OneNoteContentBlock = { + kind: 'text', + id: 'text', + sourceText: 'x'.repeat(1_000), + text: 'x'.repeat(1_000), + runs: Array.from({ length: 100 }, (_, index) => ({ + start: index * 10, + end: index * 10 + 10, + text: 'x'.repeat(10), + style: {}, + })), + paragraphStyle: {}, + layout: {}, + }; + + const plan = prepareBlocksForRendering([block], { + maxTextRuns: 3, + maxTextCharacters: 25, + }); + const projected = plan.blocks[0]; + expect(projected?.kind).toBe('text'); + if (projected?.kind !== 'text') throw new Error('Expected text'); + expect(projected.runs).toHaveLength(3); + expect(projected.runs.map((run) => run.text).join('')).toHaveLength(25); + expect(plan.truncated).toBe(true); + }); + + it('caps table cells and ink points independently', () => { + const table: OneNoteContentBlock = { + kind: 'table', + id: 'table', + rows: [ + { + id: 'row', + cells: Array.from({ length: 100 }, (_, index) => ({ + id: String(index), + blocks: [], + })), + }, + ], + columnWidths: [], + lockedColumns: [], + bordersVisible: true, + layout: {}, + }; + const ink: OneNoteContentBlock = { + kind: 'ink', + id: 'ink', + strokes: [ + { + points: Array.from({ length: 100 }, (_, index) => ({ + x: index, + y: index, + })), + }, + ], + children: [], + layout: {}, + }; + + const plan = prepareBlocksForRendering([table, ink], { + maxTableCells: 4, + maxInkPoints: 5, + }); + const projectedTable = plan.blocks[0]; + const projectedInk = plan.blocks[1]; + expect(projectedTable?.kind).toBe('table'); + expect(projectedInk?.kind).toBe('ink'); + if (projectedTable?.kind !== 'table' || projectedInk?.kind !== 'ink') { + throw new Error('Expected projected table and ink'); + } + expect(projectedTable.rows[0]?.cells).toHaveLength(4); + expect(projectedInk.strokes[0]?.points).toHaveLength(5); + expect(plan.truncated).toBe(true); + }); +}); diff --git a/src/components/page-render-budget.ts b/src/components/page-render-budget.ts new file mode 100644 index 0000000..f4fb904 --- /dev/null +++ b/src/components/page-render-budget.ts @@ -0,0 +1,291 @@ +import type { + OneNoteContentBlock, + OneNoteInkBlock, + OneNoteInkStroke, + OneNoteTableRow, + OneNoteTextRun, +} from '../onenote/one/content-model.js'; + +export interface PageRenderLimits { + maxBlocks: number; + maxDepth: number; + maxTextRuns: number; + maxTextCharacters: number; + maxTableRows: number; + maxTableCells: number; + maxTableColumns: number; + maxInkStrokes: number; + maxInkPoints: number; + maxMetadataCharacters: number; +} + +export const DEFAULT_PAGE_RENDER_LIMITS: Readonly = { + maxBlocks: 5_000, + maxDepth: 64, + maxTextRuns: 10_000, + maxTextCharacters: 500_000, + maxTableRows: 2_000, + maxTableCells: 5_000, + maxTableColumns: 256, + maxInkStrokes: 10_000, + maxInkPoints: 50_000, + maxMetadataCharacters: 4_096, +}; + +export interface PageRenderPlan { + blocks: OneNoteContentBlock[]; + truncated: boolean; +} + +interface RenderBudget { + limits: PageRenderLimits; + blocks: number; + textRuns: number; + textCharacters: number; + tableRows: number; + tableCells: number; + inkStrokes: number; + inkPoints: number; + truncated: boolean; + active: Set; +} + +/** Project a parser DTO into a bounded tree before React creates any nodes. */ +export function prepareBlocksForRendering( + blocks: readonly OneNoteContentBlock[], + overrides: Partial = {} +): PageRenderPlan { + const limits = resolveRenderLimits(overrides); + const budget: RenderBudget = { + limits, + blocks: 0, + textRuns: 0, + textCharacters: 0, + tableRows: 0, + tableCells: 0, + inkStrokes: 0, + inkPoints: 0, + truncated: false, + active: new Set(), + }; + return { + blocks: projectBlocks(blocks, 0, budget), + truncated: budget.truncated, + }; +} + +export function limitDisplayString( + value: string, + maxCharacters = DEFAULT_PAGE_RENDER_LIMITS.maxMetadataCharacters +): { value: string; truncated: boolean } { + if (value.length <= maxCharacters) return { value, truncated: false }; + return { + value: `${value.slice(0, Math.max(0, maxCharacters - 1))}…`, + truncated: true, + }; +} + +function projectBlocks( + blocks: readonly OneNoteContentBlock[], + depth: number, + budget: RenderBudget +): OneNoteContentBlock[] { + if (depth > budget.limits.maxDepth) { + if (blocks.length > 0) budget.truncated = true; + return []; + } + const result: OneNoteContentBlock[] = []; + for (const block of blocks) { + if (budget.blocks >= budget.limits.maxBlocks) { + budget.truncated = true; + break; + } + const projected = projectBlock(block, depth, budget); + if (projected) result.push(projected); + } + return result; +} + +function projectBlock( + block: OneNoteContentBlock, + depth: number, + budget: RenderBudget +): OneNoteContentBlock | undefined { + if (budget.active.has(block)) { + budget.truncated = true; + return undefined; + } + budget.blocks += 1; + budget.active.add(block); + try { + switch (block.kind) { + case 'text': { + const runs: OneNoteTextRun[] = []; + for (const run of block.runs) { + if (budget.textRuns >= budget.limits.maxTextRuns) { + budget.truncated = true; + break; + } + const remaining = + budget.limits.maxTextCharacters - budget.textCharacters; + if (remaining <= 0) { + budget.truncated = true; + break; + } + const text = run.text.slice(0, remaining); + if (text.length < run.text.length) budget.truncated = true; + runs.push({ ...run, text, end: run.start + text.length }); + budget.textRuns += 1; + budget.textCharacters += text.length; + if (text.length < run.text.length) break; + } + if (runs.length < block.runs.length) budget.truncated = true; + const sourceText = runs.map((run) => run.text).join(''); + return { + ...block, + sourceText, + text: runs + .filter((run) => run.style.hidden !== true) + .map((run) => run.text) + .join(''), + runs, + }; + } + case 'outline': + return { + ...block, + children: projectBlocks(block.children, depth + 1, budget), + }; + case 'outline-group': + return { + ...block, + children: projectBlocks(block.children, depth + 1, budget), + }; + case 'outline-element': + return { + ...block, + contents: projectBlocks(block.contents, depth + 1, budget), + children: projectBlocks(block.children, depth + 1, budget), + }; + case 'table': { + const rows: OneNoteTableRow[] = []; + for (const row of block.rows) { + if (budget.tableRows >= budget.limits.maxTableRows) { + budget.truncated = true; + break; + } + const cells: OneNoteTableRow['cells'] = []; + for (const cell of row.cells) { + if (budget.tableCells >= budget.limits.maxTableCells) { + budget.truncated = true; + break; + } + budget.tableCells += 1; + cells.push({ + ...cell, + blocks: projectBlocks(cell.blocks, depth + 1, budget), + }); + } + if (cells.length < row.cells.length) budget.truncated = true; + rows.push({ ...row, cells }); + budget.tableRows += 1; + if (budget.tableCells >= budget.limits.maxTableCells) break; + } + if (rows.length < block.rows.length) budget.truncated = true; + if ( + block.columnWidths.length > budget.limits.maxTableColumns || + block.lockedColumns.length > budget.limits.maxTableColumns + ) { + budget.truncated = true; + } + return { + ...block, + rows, + columnWidths: block.columnWidths.slice( + 0, + budget.limits.maxTableColumns + ), + lockedColumns: block.lockedColumns.slice( + 0, + budget.limits.maxTableColumns + ), + }; + } + case 'ink': { + const strokes: OneNoteInkStroke[] = []; + for (const stroke of block.strokes) { + if (budget.inkStrokes >= budget.limits.maxInkStrokes) { + budget.truncated = true; + break; + } + const remaining = budget.limits.maxInkPoints - budget.inkPoints; + if (remaining <= 0 && stroke.points.length > 0) { + budget.truncated = true; + break; + } + const points = stroke.points.slice(0, Math.max(0, remaining)); + if (points.length < stroke.points.length) budget.truncated = true; + strokes.push({ ...stroke, points }); + budget.inkStrokes += 1; + budget.inkPoints += points.length; + if (points.length < stroke.points.length) break; + } + if (strokes.length < block.strokes.length) budget.truncated = true; + return { + ...block, + strokes, + children: projectBlocks(block.children, depth + 1, budget).filter( + (child): child is OneNoteInkBlock => child.kind === 'ink' + ), + }; + } + case 'image': + return { + ...block, + filename: limitedMetadata(block.filename, budget), + altText: limitedMetadata(block.altText, budget), + ocrText: limitedMetadata(block.ocrText, budget), + href: limitedMetadata(block.href, budget), + }; + case 'attachment': + return { + ...block, + filename: + limitedMetadata(block.filename, budget) ?? 'Embedded attachment', + }; + case 'unsupported': + return { + ...block, + description: + limitedMetadata(block.description, budget) ?? 'Unsupported object', + }; + } + } finally { + budget.active.delete(block); + } +} + +function limitedMetadata( + value: string | undefined, + budget: RenderBudget +): string | undefined { + if (value === undefined) return undefined; + const limited = limitDisplayString( + value, + budget.limits.maxMetadataCharacters + ); + if (limited.truncated) budget.truncated = true; + return limited.value; +} + +function resolveRenderLimits( + overrides: Partial +): PageRenderLimits { + const result = { ...DEFAULT_PAGE_RENDER_LIMITS, ...overrides }; + for (const [name, value] of Object.entries(result)) { + if (!Number.isSafeInteger(value) || value <= 0) { + throw new TypeError(`${name} must be a positive safe integer`); + } + } + return result; +}