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 () => {
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(
<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',
});
expect(screen.getByRole('link', { name: 'link' })).toHaveAttribute(
@@ -145,7 +149,7 @@ describe('PageReader structured content', () => {
<PageReader
page={page}
loading={false}
loadResource={async () => {
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(
<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) {

View File

@@ -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<OneNoteResourcePayloadDto>;
previewResource?(resourceId: string): Promise<OneNoteResourcePayloadDto>;
downloadResource?(resourceId: string, filename?: string): Promise<void>;
renderLimits?: Partial<PageRenderLimits>;
}
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 (
<article className="page-reader" aria-labelledby="selected-page-title">
<header className="page-reader__header">
<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 ? (
<dl className="page-metadata">
{createdAt ? (
@@ -96,14 +117,21 @@ export function PageReader({
{page.blocks.length === 0 && page.text.length === 0 ? (
<p className="empty-message">No readable content was extracted.</p>
) : page.blocks.length === 0 ? (
<p className="extracted-text">{page.text}</p>
<p className="extracted-text">{fallbackText.value}</p>
) : (
<ContentBlocks
blocks={page.blocks}
loadResource={loadResource}
blocks={renderPlan.blocks}
previewResource={previewResource}
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>
</article>
);
@@ -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) => (
<ContentBlockView
key={block.id}
block={block}
loadResource={loadResource}
previewResource={previewResource}
downloadResource={downloadResource}
/>
));
@@ -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({
<section className="onenote-outline" style={layoutStyle(block.layout)}>
<ContentBlocks
blocks={block.children}
loadResource={loadResource}
previewResource={previewResource}
downloadResource={downloadResource}
/>
</section>
@@ -168,7 +196,7 @@ function ContentBlockView({
<div className="onenote-outline-group">
<ContentBlocks
blocks={block.children}
loadResource={loadResource}
previewResource={previewResource}
downloadResource={downloadResource}
/>
</div>
@@ -183,12 +211,12 @@ function ContentBlockView({
>
<ContentBlocks
blocks={block.contents}
loadResource={loadResource}
previewResource={previewResource}
downloadResource={downloadResource}
/>
<ContentBlocks
blocks={block.children}
loadResource={loadResource}
previewResource={previewResource}
downloadResource={downloadResource}
/>
</div>
@@ -223,7 +251,7 @@ function ContentBlockView({
>
<ContentBlocks
blocks={cell.blocks}
loadResource={loadResource}
previewResource={previewResource}
downloadResource={downloadResource}
/>
</td>
@@ -238,7 +266,7 @@ function ContentBlockView({
return (
<ImageView
image={block}
loadResource={loadResource}
previewResource={previewResource}
downloadResource={downloadResource}
/>
);
@@ -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 ? (
<a
className="onenote-link"
@@ -283,27 +311,17 @@ function TextRunView({ run }: { run: OneNoteTextRun }) {
);
}
function textWithBreaks(text: string): ReactNode[] {
return text
.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 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 ?? '',

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