fix: bound rich page rendering

This commit is contained in:
2026-07-22 20:05:39 +02:00
parent f14d5dd68d
commit 54740cd554
4 changed files with 500 additions and 41 deletions

View File

@@ -100,7 +100,7 @@ describe('PageReader structured content', () => {
}); });
it('renders semantic formatting, links, tables, and a signed image resource', async () => { 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', id: 'resource-image',
kind: 'image' as const, kind: 'image' as const,
mediaType: 'image/png', mediaType: 'image/png',
@@ -110,10 +110,14 @@ describe('PageReader structured content', () => {
})); }));
render( render(
<PageReader page={page} loading={false} loadResource={loadResource} /> <PageReader
page={page}
loading={false}
previewResource={previewResource}
/>
); );
expect(screen.getByText('Styled').parentElement).toHaveStyle({ expect(screen.getByText('Styled')).toHaveStyle({
fontWeight: '700', fontWeight: '700',
}); });
expect(screen.getByRole('link', { name: 'link' })).toHaveAttribute( expect(screen.getByRole('link', { name: 'link' })).toHaveAttribute(
@@ -145,7 +149,7 @@ describe('PageReader structured content', () => {
<PageReader <PageReader
page={page} page={page}
loading={false} loading={false}
loadResource={async () => { previewResource={async () => {
throw new Error('not renderable'); throw new Error('not renderable');
}} }}
downloadResource={downloadResource} downloadResource={downloadResource}
@@ -157,6 +161,53 @@ describe('PageReader structured content', () => {
); );
expect(downloadResource).toHaveBeenCalledWith('resource-file', 'notes.txt'); 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(
<PageReader
page={page}
loading={false}
previewResource={previewResource}
downloadResource={downloadResource}
/>
);
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(
<PageReader
page={{
...page,
blocks: [textBlock('First', 'first'), textBlock('Second', 'second')],
}}
loading={false}
renderLimits={{ maxBlocks: 1 }}
/>
);
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) { function textBlock(text: string, id: string) {

View File

@@ -1,4 +1,4 @@
import { useEffect, useState, type CSSProperties, type ReactNode } from 'react'; import { useEffect, useState, type CSSProperties } from 'react';
import type { import type {
OneNotePageDto, OneNotePageDto,
@@ -14,13 +14,20 @@ import type {
OneNoteTextRun, OneNoteTextRun,
OneNoteTextStyle, OneNoteTextStyle,
} from '../onenote/one/content-model.js'; } from '../onenote/one/content-model.js';
import {
DEFAULT_PAGE_RENDER_LIMITS,
limitDisplayString,
prepareBlocksForRendering,
type PageRenderLimits,
} from './page-render-budget.js';
interface PageReaderProps { interface PageReaderProps {
page?: OneNotePageDto; page?: OneNotePageDto;
loading: boolean; loading: boolean;
error?: string; error?: string;
loadResource?(resourceId: string): Promise<OneNoteResourcePayloadDto>; previewResource?(resourceId: string): Promise<OneNoteResourcePayloadDto>;
downloadResource?(resourceId: string, filename?: string): Promise<void>; downloadResource?(resourceId: string, filename?: string): Promise<void>;
renderLimits?: Partial<PageRenderLimits>;
} }
function formatDate(value: string | undefined): string | null { function formatDate(value: string | undefined): string | null {
@@ -37,8 +44,9 @@ export function PageReader({
page, page,
loading, loading,
error, error,
loadResource, previewResource,
downloadResource, downloadResource,
renderLimits,
}: PageReaderProps) { }: PageReaderProps) {
if (loading) { if (loading) {
return ( return (
@@ -68,12 +76,25 @@ export function PageReader({
const createdAt = formatDate(page.createdAt); const createdAt = formatDate(page.createdAt);
const updatedAt = formatDate(page.updatedAt); 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 ( return (
<article className="page-reader" aria-labelledby="selected-page-title"> <article className="page-reader" aria-labelledby="selected-page-title">
<header className="page-reader__header"> <header className="page-reader__header">
<p className="eyebrow">Selected page</p> <p className="eyebrow">Selected page</p>
<h2 id="selected-page-title">{page.title || 'Untitled page'}</h2> <h2 id="selected-page-title">{title.value}</h2>
{createdAt || updatedAt ? ( {createdAt || updatedAt ? (
<dl className="page-metadata"> <dl className="page-metadata">
{createdAt ? ( {createdAt ? (
@@ -96,14 +117,21 @@ export function PageReader({
{page.blocks.length === 0 && page.text.length === 0 ? ( {page.blocks.length === 0 && page.text.length === 0 ? (
<p className="empty-message">No readable content was extracted.</p> <p className="empty-message">No readable content was extracted.</p>
) : page.blocks.length === 0 ? ( ) : page.blocks.length === 0 ? (
<p className="extracted-text">{page.text}</p> <p className="extracted-text">{fallbackText.value}</p>
) : ( ) : (
<ContentBlocks <ContentBlocks
blocks={page.blocks} blocks={renderPlan.blocks}
loadResource={loadResource} previewResource={previewResource}
downloadResource={downloadResource} downloadResource={downloadResource}
/> />
)} )}
{displayTruncated ? (
<aside className="render-limit-notice" role="status">
This page exceeds the safe browser display budget. The visible
preview was truncated; local export may retain additional
parser-supported content.
</aside>
) : null}
</div> </div>
</article> </article>
); );
@@ -111,18 +139,18 @@ export function PageReader({
function ContentBlocks({ function ContentBlocks({
blocks, blocks,
loadResource, previewResource,
downloadResource, downloadResource,
}: { }: {
blocks: readonly OneNoteContentBlock[]; blocks: readonly OneNoteContentBlock[];
loadResource?: PageReaderProps['loadResource']; previewResource?: PageReaderProps['previewResource'];
downloadResource?: PageReaderProps['downloadResource']; downloadResource?: PageReaderProps['downloadResource'];
}) { }) {
return blocks.map((block) => ( return blocks.map((block) => (
<ContentBlockView <ContentBlockView
key={block.id} key={block.id}
block={block} block={block}
loadResource={loadResource} previewResource={previewResource}
downloadResource={downloadResource} downloadResource={downloadResource}
/> />
)); ));
@@ -130,11 +158,11 @@ function ContentBlocks({
function ContentBlockView({ function ContentBlockView({
block, block,
loadResource, previewResource,
downloadResource, downloadResource,
}: { }: {
block: OneNoteContentBlock; block: OneNoteContentBlock;
loadResource?: PageReaderProps['loadResource']; previewResource?: PageReaderProps['previewResource'];
downloadResource?: PageReaderProps['downloadResource']; downloadResource?: PageReaderProps['downloadResource'];
}) { }) {
switch (block.kind) { switch (block.kind) {
@@ -158,7 +186,7 @@ function ContentBlockView({
<section className="onenote-outline" style={layoutStyle(block.layout)}> <section className="onenote-outline" style={layoutStyle(block.layout)}>
<ContentBlocks <ContentBlocks
blocks={block.children} blocks={block.children}
loadResource={loadResource} previewResource={previewResource}
downloadResource={downloadResource} downloadResource={downloadResource}
/> />
</section> </section>
@@ -168,7 +196,7 @@ function ContentBlockView({
<div className="onenote-outline-group"> <div className="onenote-outline-group">
<ContentBlocks <ContentBlocks
blocks={block.children} blocks={block.children}
loadResource={loadResource} previewResource={previewResource}
downloadResource={downloadResource} downloadResource={downloadResource}
/> />
</div> </div>
@@ -183,12 +211,12 @@ function ContentBlockView({
> >
<ContentBlocks <ContentBlocks
blocks={block.contents} blocks={block.contents}
loadResource={loadResource} previewResource={previewResource}
downloadResource={downloadResource} downloadResource={downloadResource}
/> />
<ContentBlocks <ContentBlocks
blocks={block.children} blocks={block.children}
loadResource={loadResource} previewResource={previewResource}
downloadResource={downloadResource} downloadResource={downloadResource}
/> />
</div> </div>
@@ -223,7 +251,7 @@ function ContentBlockView({
> >
<ContentBlocks <ContentBlocks
blocks={cell.blocks} blocks={cell.blocks}
loadResource={loadResource} previewResource={previewResource}
downloadResource={downloadResource} downloadResource={downloadResource}
/> />
</td> </td>
@@ -238,7 +266,7 @@ function ContentBlockView({
return ( return (
<ImageView <ImageView
image={block} image={block}
loadResource={loadResource} previewResource={previewResource}
downloadResource={downloadResource} downloadResource={downloadResource}
/> />
); );
@@ -266,7 +294,7 @@ function ContentBlockView({
function TextRunView({ run }: { run: OneNoteTextRun }) { function TextRunView({ run }: { run: OneNoteTextRun }) {
if (run.style.hidden) return null; if (run.style.hidden) return null;
const content = textWithBreaks(run.text); const content = textWithBreaks(run.text);
const style = textStyle(run.style); const style = { ...textStyle(run.style), whiteSpace: 'pre-wrap' as const };
return run.href ? ( return run.href ? (
<a <a
className="onenote-link" className="onenote-link"
@@ -283,27 +311,17 @@ function TextRunView({ run }: { run: OneNoteTextRun }) {
); );
} }
function textWithBreaks(text: string): ReactNode[] { function textWithBreaks(text: string): string {
return text return text.replaceAll('\u000b', '\n');
.replaceAll('\u000b', '\n')
.split('\n')
.flatMap((part, index, values) =>
index + 1 < values.length
? [
<span key={`${index}-text`}>{part}</span>,
<br key={`${index}-br`} />,
]
: [<span key={`${index}-text`}>{part}</span>]
);
} }
function ImageView({ function ImageView({
image, image,
loadResource, previewResource,
downloadResource, downloadResource,
}: { }: {
image: OneNoteImageBlock; image: OneNoteImageBlock;
loadResource?: PageReaderProps['loadResource']; previewResource?: PageReaderProps['previewResource'];
downloadResource?: PageReaderProps['downloadResource']; downloadResource?: PageReaderProps['downloadResource'];
}) { }) {
const [state, setState] = useState< const [state, setState] = useState<
@@ -315,11 +333,11 @@ function ImageView({
useEffect(() => { useEffect(() => {
let disposed = false; let disposed = false;
let objectUrl: string | undefined; let objectUrl: string | undefined;
if (!image.resourceId || !loadResource) { if (!image.resourceId || !previewResource) {
return; return;
} }
const resourceId = image.resourceId; const resourceId = image.resourceId;
void loadResource(image.resourceId) void previewResource(image.resourceId)
.then((resource) => { .then((resource) => {
if ( if (
disposed || disposed ||
@@ -358,10 +376,10 @@ function ImageView({
disposed = true; disposed = true;
if (objectUrl) URL.revokeObjectURL(objectUrl); if (objectUrl) URL.revokeObjectURL(objectUrl);
}; };
}, [image.resourceId, loadResource]); }, [image.resourceId, previewResource]);
const visibleState = const visibleState =
!image.resourceId || !loadResource !image.resourceId || !previewResource
? { ? {
phase: 'unavailable' as const, phase: 'unavailable' as const,
resourceId: image.resourceId ?? '', resourceId: image.resourceId ?? '',

View File

@@ -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);
});
});

View File

@@ -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<PageRenderLimits> = {
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<OneNoteContentBlock>;
}
/** Project a parser DTO into a bounded tree before React creates any nodes. */
export function prepareBlocksForRendering(
blocks: readonly OneNoteContentBlock[],
overrides: Partial<PageRenderLimits> = {}
): 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>
): 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;
}