fix: bound rich page rendering
This commit is contained in:
@@ -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) {
|
||||||
|
|||||||
@@ -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 ?? '',
|
||||||
|
|||||||
99
src/components/page-render-budget.test.ts
Normal file
99
src/components/page-render-budget.test.ts
Normal 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
291
src/components/page-render-budget.ts
Normal file
291
src/components/page-render-budget.ts
Normal 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;
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user