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 () => {
|
||||
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) {
|
||||
|
||||
@@ -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 ?? '',
|
||||
|
||||
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