fix: bound OneNote export output

This commit is contained in:
2026-07-22 20:03:55 +02:00
parent 45880fcf2b
commit 572a67148a
7 changed files with 1049 additions and 121 deletions

View File

@@ -8,6 +8,7 @@ import type {
OneNoteTextStyle,
} from '../one/content-model.js';
import {
createOneNoteExportPlan,
createOneNoteExportSnapshot,
createOneNoteStaticNotebookZip,
OneNoteExportError,
@@ -19,11 +20,17 @@ import {
serializeOneNoteStructuredJson,
type OneNoteExportSnapshot,
} from './index.js';
import { utf8ByteLength } from './plan.js';
const PNG = Uint8Array.from([
0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0x00,
0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0x00, 0x00, 0x00, 0x0d, 0x49,
0x48, 0x44, 0x52, 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x01, 0x08, 0x06,
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
]);
const JPEG = Uint8Array.from([
0xff, 0xd8, 0xff, 0xc0, 0x00, 0x0b, 0x08, 0x00, 0x01, 0x00, 0x01, 0x01, 0x01,
0x11, 0x00, 0xff, 0xd9,
]);
const JPEG = Uint8Array.from([0xff, 0xd8, 0xff, 0xd9]);
const SOURCE_MARKUP = '<img src=x onerror=alert(1)><script>bad()</script>';
describe('OneNote export serializers', () => {
@@ -260,6 +267,158 @@ describe('OneNote export serializers', () => {
});
});
describe('OneNote export aggregate budgets', () => {
it('counts repeated rich-text representations against one text budget', () => {
const baseline = minimalSnapshot([textBlock('text', '')]);
const baselineCharacters =
createOneNoteExportPlan(baseline).textCharacterCount;
const snapshot = minimalSnapshot([textBlock('text', '0123456789')]);
expect(() =>
serializeOneNoteStructuredJson(snapshot, {
limits: { maxTextCharacters: baselineCharacters + 29 },
})
).toThrowError(
expect.objectContaining<Partial<OneNoteExportError>>({
code: 'limit-exceeded',
message: expect.stringContaining('text character'),
})
);
});
it('counts ink points across blocks and strokes', () => {
const ink = (id: string): OneNoteContentBlock => ({
kind: 'ink',
id,
strokes: [
{
points: [
{ x: 0, y: 0 },
{ x: 1, y: 1 },
],
},
],
children: [],
layout: {},
});
const snapshot = minimalSnapshot([ink('ink-1'), ink('ink-2')]);
expect(() =>
serializeOneNoteSemanticHtml(snapshot, {
limits: { maxInkPoints: 3 },
})
).toThrowError(
expect.objectContaining<Partial<OneNoteExportError>>({
code: 'limit-exceeded',
message: expect.stringContaining('ink point'),
})
);
});
it('preflights standalone output and measures exact UTF-8 bytes', () => {
const encodedSample = 'ASCII · 😀 · \ud800';
expect(utf8ByteLength(encodedSample)).toBe(
new TextEncoder().encode(encodedSample).byteLength
);
expect(() =>
serializeOneNoteMarkdown(minimalSnapshot(), {
limits: { maxOutputBytes: 8 * 1024 },
})
).toThrowError(
expect.objectContaining<Partial<OneNoteExportError>>({
code: 'limit-exceeded',
message: expect.stringContaining('Projected standalone'),
})
);
});
it('includes resource members in the static ZIP output budget', () => {
const resourceBytes = new Uint8Array(64);
const snapshot = minimalSnapshot(
[],
[
{
sectionId: 's',
id: 'attachment',
kind: 'attachment',
filename: 'attachment.bin',
extension: 'bin',
mediaType: 'application/octet-stream',
browserRenderable: false,
bytes: resourceBytes,
},
]
);
const resourceLimits = {
maxResourceBytes: 128,
maxTotalResourceBytes: 128,
};
const plan = createOneNoteExportPlan(snapshot, {
limits: { ...resourceLimits, maxOutputBytes: 1_000_000 },
});
const justTooSmall =
plan.projectedOutputBytes * 6 + resourceBytes.byteLength - 1;
expect(() =>
createOneNoteStaticNotebookZip(snapshot, {
limits: { ...resourceLimits, maxOutputBytes: justTooSmall },
})
).toThrowError(
expect.objectContaining<Partial<OneNoteExportError>>({
code: 'limit-exceeded',
message: expect.stringContaining('Projected static notebook'),
})
);
});
it('never auto-embeds parser-disabled or oversized-pixel images', () => {
const imageResource = (
id: string,
bytes: Uint8Array,
browserRenderable = true
): OneNoteExportSnapshot['resources'][number] => ({
sectionId: 's',
id,
kind: 'image',
filename: `${id}.png`,
extension: 'png',
mediaType: 'image/png',
browserRenderable,
bytes,
});
const snapshot = minimalSnapshot(
[],
[
imageResource('parser-disabled', PNG, false),
imageResource('dimension-bomb', pngWithDimensions(16_385, 1)),
imageResource('pixel-bomb', pngWithDimensions(8_193, 8_193)),
]
);
const plan = createOneNoteExportPlan(snapshot);
expect(plan.resources).toHaveLength(0);
expect(plan.warnings).toEqual(
expect.arrayContaining([
expect.objectContaining({
code: 'IMAGE_RESOURCE_NOT_RENDERABLE',
resourceId: 'parser-disabled',
}),
expect.objectContaining({
code: 'UNSAFE_IMAGE_RESOURCE',
resourceId: 'dimension-bomb',
message: expect.stringContaining('dimension-limit'),
}),
expect.objectContaining({
code: 'UNSAFE_IMAGE_RESOURCE',
resourceId: 'pixel-bomb',
message: expect.stringContaining('pixel-limit'),
}),
])
);
});
});
describe('OneNote export filenames', () => {
it('removes traversal, reserved names, controls and directional overrides', () => {
expect(sanitizeExportFilename('../../CON')).toBe('_CON');
@@ -451,6 +610,51 @@ function fixtureSnapshot(): OneNoteExportSnapshot {
});
}
function minimalSnapshot(
blocks: OneNoteContentBlock[] = [],
resources: OneNoteExportSnapshot['resources'] = []
): OneNoteExportSnapshot {
return createOneNoteExportSnapshot({
sourceName: 's.one',
sourceFormat: 'one',
notebookName: 'n',
appName: 'a',
appVersion: '1',
parser: {
name: 'p',
version: '1',
implementation: 't',
supportedVariants: [],
},
sections: [
{
id: 's',
name: 's',
pages: [
{
id: 'p',
title: 'p',
text: '',
textPreview: '',
blocks,
diagnostics: [],
},
],
diagnostics: [],
},
],
resources,
});
}
function pngWithDimensions(width: number, height: number): Uint8Array {
const bytes = PNG.slice();
const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
view.setUint32(16, width);
view.setUint32(20, height);
return bytes;
}
function textBlock(
id: string,
text: string,

View File

@@ -4,7 +4,15 @@
// SPDX-License-Identifier: MPL-2.0
import type { ParserDiagnostic } from '../model/diagnostics.js';
import type { OneNoteContentBlock } from '../one/content-model.js';
import type { OneNoteNotebookNodeDto } from '../model/dto.js';
import {
DEFAULT_BROWSER_IMAGE_SAFETY_LIMITS,
inspectBrowserImage,
} from '../image-safety.js';
import type {
OneNoteContentBlock,
OneNoteTextStyle,
} from '../one/content-model.js';
import {
filenameExtension,
forceFilenameExtension,
@@ -61,6 +69,18 @@ export interface OneNoteExportPlan {
diagnostics: ParserDiagnostic[];
blockCount: number;
pageCount: number;
textCharacterCount: number;
inkPointCount: number;
totalResourceBytes: number;
projectedOutputBytes: number;
}
interface ExportBudget {
textCharacterCount: number;
inkPointCount: number;
modelEntryCount: number;
tableCellSlotCount: number;
numericArrayValueCount: number;
}
export function exportResourceKey(
@@ -82,6 +102,14 @@ export function createOneNoteExportPlan(
const warnings: OneNoteExportWarning[] = [];
const unsupportedContent: OneNoteUnsupportedContentRecord[] = [];
const diagnostics = collectDiagnostics(snapshot, limits);
const budget: ExportBudget = {
textCharacterCount: 0,
inkPointCount: 0,
modelEntryCount: 1,
tableCellSlotCount: 0,
numericArrayValueCount: 0,
};
countSnapshotMetadata(snapshot, budget, limits);
const sectionIds = new Set(snapshot.sections.map(({ id }) => id));
const sectionNames = new Map<string, string>();
const sectionAllocator = new SafeExportNameAllocator();
@@ -144,7 +172,14 @@ export function createOneNoteExportPlan(
resourceAllocators.get(resource.sectionId) ??
new SafeExportNameAllocator();
resourceAllocators.set(resource.sectionId, allocator);
const planned = planResource(resource, bytes, allocator, index, warnings);
const planned = planResource(
resource,
bytes,
allocator,
index,
warnings,
limits
);
if (!planned) continue;
const sectionDirectory = sectionNames.get(resource.sectionId)!;
planned.path = `assets/${sectionDirectory}/${planned.filename}`;
@@ -179,6 +214,7 @@ export function createOneNoteExportPlan(
if (blockCount > limits.maxBlocks) {
throw limitError('content block', blockCount, limits.maxBlocks);
}
countBlockMetadata(block, budget, limits);
inspectBlock(
block,
section.id,
@@ -202,22 +238,340 @@ export function createOneNoteExportPlan(
diagnostics,
blockCount,
pageCount,
textCharacterCount: budget.textCharacterCount,
inkPointCount: budget.inkPointCount,
totalResourceBytes,
projectedOutputBytes: projectedOutputBytes(budget, limits),
};
}
function countSnapshotMetadata(
snapshot: OneNoteExportSnapshot,
budget: ExportBudget,
limits: OneNoteExportLimits
): void {
addText(
budget,
limits,
snapshot.app.name,
snapshot.app.version,
snapshot.parser.name,
snapshot.parser.version,
snapshot.parser.implementation,
snapshot.parser.referenceRevision,
snapshot.supportLevel,
snapshot.source.name,
snapshot.source.format,
snapshot.notebookName
);
for (const variant of snapshot.parser.supportedVariants) {
addModelEntries(budget, 1);
addText(budget, limits, variant);
}
for (const note of snapshot.supportNotes) {
addModelEntries(budget, 1);
addText(budget, limits, note);
}
countDiagnosticsMetadata(snapshot.diagnostics, budget, limits);
for (const section of snapshot.sections) {
addModelEntries(budget, 1);
addText(budget, limits, section.id, section.name, section.sourcePath);
countDiagnosticsMetadata(section.diagnostics, budget, limits);
for (const page of section.pages) {
addModelEntries(budget, 1);
addText(
budget,
limits,
page.id,
page.title,
page.createdAt,
page.updatedAt,
page.text,
page.textPreview
);
countDiagnosticsMetadata(page.diagnostics, budget, limits);
}
}
for (const resource of snapshot.resources) {
addModelEntries(budget, 1);
addText(
budget,
limits,
resource.sectionId,
resource.id,
resource.kind,
resource.filename,
resource.extension,
resource.mediaType
);
}
if (snapshot.tree) {
addModelEntries(budget, 1);
addText(budget, limits, snapshot.tree.tocPath);
countTreeNodes(
snapshot.tree.nodes,
0,
new Set<OneNoteNotebookNodeDto>(),
budget,
limits
);
}
}
function countTreeNodes(
nodes: readonly OneNoteNotebookNodeDto[],
depth: number,
active: Set<OneNoteNotebookNodeDto>,
budget: ExportBudget,
limits: OneNoteExportLimits
): void {
if (depth > limits.maxDepth) {
throw new OneNoteExportError(
'limit-exceeded',
`Export notebook tree depth exceeds ${limits.maxDepth}`
);
}
for (const node of nodes) {
if (active.has(node)) {
throw new OneNoteExportError(
'invalid-input',
'Export notebook tree contains an object cycle'
);
}
active.add(node);
addModelEntries(budget, 1);
addText(
budget,
limits,
node.kind,
node.id,
node.displayName,
node.kind === 'section' ? node.sectionId : undefined
);
if (node.kind === 'section-group') {
countTreeNodes(node.children, depth + 1, active, budget, limits);
}
active.delete(node);
}
}
function countDiagnosticsMetadata(
diagnostics: readonly ParserDiagnostic[],
budget: ExportBudget,
limits: OneNoteExportLimits
): void {
for (const diagnostic of diagnostics) {
addModelEntries(budget, 1);
addText(
budget,
limits,
diagnostic.severity,
diagnostic.code,
diagnostic.message,
diagnostic.structure,
diagnostic.propertyId
);
}
}
function countBlockMetadata(
block: OneNoteContentBlock,
budget: ExportBudget,
limits: OneNoteExportLimits
): void {
addModelEntries(budget, 1);
addText(budget, limits, block.kind, block.id);
switch (block.kind) {
case 'text':
addText(
budget,
limits,
block.sourceText,
block.text,
block.paragraphAlignment
);
countTextStyle(block.paragraphStyle, budget, limits);
for (const run of block.runs) {
addModelEntries(budget, 1);
addText(budget, limits, run.text, run.href);
countTextStyle(run.style, budget, limits);
}
return;
case 'image':
addText(
budget,
limits,
block.resourceId,
block.filename,
block.altText,
block.ocrText,
block.href
);
return;
case 'attachment':
addText(
budget,
limits,
block.resourceId,
block.iconResourceId,
block.filename
);
return;
case 'table': {
addNumericArrayValues(
budget,
block.columnWidths.length + block.lockedColumns.length
);
let columnCount = 0;
if (block.declaredColumnCount !== undefined) {
if (
!Number.isSafeInteger(block.declaredColumnCount) ||
block.declaredColumnCount < 0
) {
throw new OneNoteExportError(
'invalid-input',
'Export table column count must be a non-negative safe integer'
);
}
columnCount = block.declaredColumnCount;
}
for (const row of block.rows) {
addModelEntries(budget, 1);
addText(budget, limits, row.id);
columnCount = Math.max(columnCount, row.cells.length);
for (const cell of row.cells) {
addModelEntries(budget, 1);
addText(budget, limits, cell.id, cell.backgroundColor?.kind);
}
}
addTableCellSlots(budget, columnCount, block.rows.length + 2);
return;
}
case 'outline':
addNumericArrayValues(budget, block.indents.length);
return;
case 'outline-element':
case 'outline-group':
return;
case 'ink':
for (const stroke of block.strokes) {
addModelEntries(budget, 1);
addText(budget, limits, stroke.bias);
addInkPoints(budget, limits, stroke.points.length);
}
return;
case 'unsupported':
addText(budget, limits, block.description);
}
}
function countTextStyle(
style: OneNoteTextStyle,
budget: ExportBudget,
limits: OneNoteExportLimits
): void {
addText(
budget,
limits,
style.font,
style.fontColor?.kind,
style.highlight?.kind
);
}
function addText(
budget: ExportBudget,
limits: OneNoteExportLimits,
...values: Array<string | undefined>
): void {
for (const value of values) {
if (value === undefined) continue;
if (value.length > limits.maxTextCharacters - budget.textCharacterCount) {
throw limitError(
'text character',
limits.maxTextCharacters + 1,
limits.maxTextCharacters
);
}
budget.textCharacterCount += value.length;
}
}
function addInkPoints(
budget: ExportBudget,
limits: OneNoteExportLimits,
count: number
): void {
if (count > limits.maxInkPoints - budget.inkPointCount) {
throw limitError('ink point', limits.maxInkPoints + 1, limits.maxInkPoints);
}
budget.inkPointCount += count;
}
function addModelEntries(budget: ExportBudget, count: number): void {
budget.modelEntryCount = saturatingAdd(budget.modelEntryCount, count);
}
function addNumericArrayValues(budget: ExportBudget, count: number): void {
budget.numericArrayValueCount = saturatingAdd(
budget.numericArrayValueCount,
count
);
}
function addTableCellSlots(
budget: ExportBudget,
columns: number,
rows: number
): void {
budget.tableCellSlotCount = saturatingAdd(
budget.tableCellSlotCount,
saturatingMultiply(columns, rows)
);
}
function planResource(
resource: OneNoteExportResource,
bytes: Uint8Array,
allocator: SafeExportNameAllocator,
index: number,
warnings: OneNoteExportWarning[]
warnings: OneNoteExportWarning[],
limits: OneNoteExportLimits
): PlannedOneNoteResource | undefined {
if (resource.kind === 'image') {
const image = sniffBrowserImage(bytes);
if (!image) {
if (!resource.browserRenderable) {
warnings.push({
code: 'UNSUPPORTED_IMAGE_RESOURCE',
message: 'Only signature-verified PNG and JPEG images are exported',
code: 'IMAGE_RESOURCE_NOT_RENDERABLE',
message:
'Image resource was omitted because the parser did not mark it safe for browser rendering',
sectionId: resource.sectionId,
resourceId: resource.id,
});
return undefined;
}
const inspected = inspectBrowserImage(bytes, {
maxBytes: Math.min(
limits.maxResourceBytes,
DEFAULT_BROWSER_IMAGE_SAFETY_LIMITS.maxBytes
),
maxDimension: DEFAULT_BROWSER_IMAGE_SAFETY_LIMITS.maxDimension,
maxPixels: DEFAULT_BROWSER_IMAGE_SAFETY_LIMITS.maxPixels,
});
if (
!inspected.browserRenderable ||
!inspected.extension ||
!inspected.mediaType
) {
const reason = inspected.rejectionReason ?? 'invalid-header';
warnings.push({
code:
reason === 'unsupported-format'
? 'UNSUPPORTED_IMAGE_RESOURCE'
: 'UNSAFE_IMAGE_RESOURCE',
message: `Image resource was omitted by bounded inspection (${reason})`,
sectionId: resource.sectionId,
resourceId: resource.id,
});
@@ -225,11 +579,11 @@ function planResource(
}
const safe = sanitizeExportFilename(
resource.filename,
`image-${index + 1}.${image.extension}`
`image-${index + 1}.${inspected.extension}`
);
const filename = allocator.allocate(
forceFilenameExtension(safe, image.extension),
`image-${index + 1}.${image.extension}`
forceFilenameExtension(safe, inspected.extension),
`image-${index + 1}.${inspected.extension}`
);
return {
sectionId: resource.sectionId,
@@ -237,8 +591,8 @@ function planResource(
kind: resource.kind,
path: '',
filename,
extension: image.extension,
mediaType: image.mediaType,
extension: inspected.extension,
mediaType: inspected.mediaType,
browserRenderable: true,
size: bytes.byteLength,
bytes,
@@ -382,35 +736,6 @@ function collectDiagnostics(
}
}
function sniffBrowserImage(
bytes: Uint8Array
):
| { extension: 'png' | 'jpg'; mediaType: 'image/png' | 'image/jpeg' }
| undefined {
if (
bytes.length >= 8 &&
bytes[0] === 0x89 &&
bytes[1] === 0x50 &&
bytes[2] === 0x4e &&
bytes[3] === 0x47 &&
bytes[4] === 0x0d &&
bytes[5] === 0x0a &&
bytes[6] === 0x1a &&
bytes[7] === 0x0a
) {
return { extension: 'png', mediaType: 'image/png' };
}
if (
bytes.length >= 3 &&
bytes[0] === 0xff &&
bytes[1] === 0xd8 &&
bytes[2] === 0xff
) {
return { extension: 'jpg', mediaType: 'image/jpeg' };
}
return undefined;
}
function safeMediaType(value: string): string {
return /^[a-z0-9][a-z0-9!#$&^_.+-]*\/[a-z0-9][a-z0-9!#$&^_.+-]*$/iu.test(
value
@@ -419,6 +744,55 @@ function safeMediaType(value: string): string {
: 'application/octet-stream';
}
/** Enforce the exact UTF-8 size of one completed standalone member. */
export function enforceSerializedTextOutput(
value: string,
plan: OneNoteExportPlan,
subject: string
): string {
enforceOutputByteLength(plan, utf8ByteLength(value), subject);
return value;
}
export function enforceOutputByteLength(
plan: OneNoteExportPlan,
byteLength: number,
subject: string
): void {
if (byteLength > plan.limits.maxOutputBytes) {
throw new OneNoteExportError(
'limit-exceeded',
`${subject} is ${byteLength} UTF-8 bytes; the export limit is ${plan.limits.maxOutputBytes}`
);
}
}
/** Exact TextEncoder-compatible byte length without allocating encoded bytes. */
export function utf8ByteLength(value: string): number {
let bytes = 0;
for (let index = 0; index < value.length; index += 1) {
const code = value.charCodeAt(index);
if (code <= 0x7f) {
bytes += 1;
} else if (code <= 0x7ff) {
bytes += 2;
} else if (
code >= 0xd800 &&
code <= 0xdbff &&
index + 1 < value.length &&
value.charCodeAt(index + 1) >= 0xdc00 &&
value.charCodeAt(index + 1) <= 0xdfff
) {
bytes += 4;
index += 1;
} else {
// TextEncoder replaces unpaired surrogates with U+FFFD (three bytes).
bytes += 3;
}
}
return bytes;
}
function resourceBytes(value: ArrayBuffer | Uint8Array): Uint8Array {
return value instanceof Uint8Array ? value : new Uint8Array(value);
}
@@ -438,6 +812,80 @@ function resolveExportLimits(
return result;
}
function projectedOutputBytes(
budget: ExportBudget,
limits: OneNoteExportLimits
): number {
let result = 8 * 1024;
result = addProjectedBytes(
result,
budget.textCharacterCount,
12,
limits.maxOutputBytes
);
result = addProjectedBytes(
result,
budget.inkPointCount,
96,
limits.maxOutputBytes
);
result = addProjectedBytes(
result,
budget.modelEntryCount,
512,
limits.maxOutputBytes
);
result = addProjectedBytes(
result,
budget.tableCellSlotCount,
64,
limits.maxOutputBytes
);
result = addProjectedBytes(
result,
budget.numericArrayValueCount,
32,
limits.maxOutputBytes
);
if (result > limits.maxOutputBytes) {
throw new OneNoteExportError(
'limit-exceeded',
`Projected standalone export output exceeds ${limits.maxOutputBytes} bytes`
);
}
return result;
}
function addProjectedBytes(
current: number,
count: number,
bytesPerItem: number,
limit: number
): number {
if (current > limit || count > Math.floor((limit - current) / bytesPerItem)) {
return Number.POSITIVE_INFINITY;
}
return current + count * bytesPerItem;
}
function saturatingAdd(left: number, right: number): number {
return !Number.isSafeInteger(right) ||
right < 0 ||
left > Number.MAX_SAFE_INTEGER - right
? Number.POSITIVE_INFINITY
: left + right;
}
function saturatingMultiply(left: number, right: number): number {
return !Number.isSafeInteger(left) ||
!Number.isSafeInteger(right) ||
left < 0 ||
right < 0 ||
(left !== 0 && right > Number.MAX_SAFE_INTEGER / left)
? Number.POSITIVE_INFINITY
: left * right;
}
function limitError(
subject: string,
actual: number,

View File

@@ -13,6 +13,7 @@ import type {
} from '../one/content-model.js';
import {
createOneNoteExportPlan,
enforceSerializedTextOutput,
exportResourceKey,
type OneNoteExportPlan,
type PlannedOneNoteResource,
@@ -91,7 +92,11 @@ export function serializeStructuredJsonWithPlan(
warnings: plan.warnings,
unsupportedContent: plan.unsupportedContent,
};
return `${JSON.stringify(value, null, 2)}\n`;
return enforceSerializedTextOutput(
`${JSON.stringify(value, null, 2)}\n`,
plan,
'Structured JSON export'
);
}
export function serializePlainTextWithPlan(
@@ -138,10 +143,14 @@ export function serializePlainTextWithPlan(
}
}
appendPlainWarnings(lines, plan);
return `${lines
return enforceSerializedTextOutput(
`${lines
.join('\n')
.replace(/\n{4,}/gu, '\n\n\n')
.trimEnd()}\n`;
.trimEnd()}\n`,
plan,
'Plain-text export'
);
}
export function serializeMarkdownWithPlan(
@@ -192,10 +201,14 @@ export function serializeMarkdownWithPlan(
}
}
appendMarkdownWarnings(lines, plan);
return `${lines
return enforceSerializedTextOutput(
`${lines
.join('\n')
.replace(/\n{4,}/gu, '\n\n\n')
.trimEnd()}\n`;
.trimEnd()}\n`,
plan,
'Markdown export'
);
}
export function serializeHtmlWithPlan(
@@ -214,7 +227,8 @@ export function serializeHtmlWithPlan(
.map((section) => htmlSection(section, context))
.join('\n');
const warnings = htmlWarnings(plan);
return `<!doctype html>
return enforceSerializedTextOutput(
`<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
@@ -246,7 +260,10 @@ ${sections}
${warnings}
</body>
</html>
`;
`,
plan,
'Semantic HTML export'
);
}
function htmlSection(

View File

@@ -82,8 +82,14 @@ export interface OneNoteExportLimits {
maxBlocks: number;
maxDepth: number;
maxDiagnostics: number;
/** Aggregate UTF-16 code units across all snapshot metadata and content. */
maxTextCharacters: number;
/** Aggregate point records across all ink strokes. */
maxInkPoints: number;
maxResourceBytes: number;
maxTotalResourceBytes: number;
/** UTF-8 bytes for one standalone export or all uncompressed ZIP members. */
maxOutputBytes: number;
}
export const DEFAULT_ONENOTE_EXPORT_LIMITS: Readonly<OneNoteExportLimits> = {
@@ -92,8 +98,11 @@ export const DEFAULT_ONENOTE_EXPORT_LIMITS: Readonly<OneNoteExportLimits> = {
maxBlocks: 1_000_000,
maxDepth: 256,
maxDiagnostics: 100_000,
maxResourceBytes: 256 * 1024 * 1024,
maxTotalResourceBytes: 512 * 1024 * 1024,
maxTextCharacters: 8_000_000,
maxInkPoints: 500_000,
maxResourceBytes: 64 * 1024 * 1024,
maxTotalResourceBytes: 64 * 1024 * 1024,
maxOutputBytes: 128 * 1024 * 1024,
};
export interface OneNoteExportOptions {

View File

@@ -5,14 +5,23 @@
import { strToU8, zipSync, type Zippable } from 'fflate';
import { forceFilenameExtension, sanitizeExportFilename } from './naming.js';
import { createOneNoteExportPlan, type OneNoteExportPlan } from './plan.js';
import {
createOneNoteExportPlan,
enforceOutputByteLength,
utf8ByteLength,
type OneNoteExportPlan,
} from './plan.js';
import {
serializeHtmlWithPlan,
serializeMarkdownWithPlan,
serializePlainTextWithPlan,
serializeStructuredJsonWithPlan,
} from './serializers.js';
import type { OneNoteExportOptions, OneNoteExportSnapshot } from './types.js';
import {
OneNoteExportError,
type OneNoteExportOptions,
type OneNoteExportSnapshot,
} from './types.js';
export interface OneNoteStaticNotebookManifest {
schemaVersion: number;
@@ -83,6 +92,7 @@ export function createOneNoteStaticNotebookZip(
options: OneNoteExportOptions = {}
): OneNoteStaticNotebookZipArtifact {
const plan = createOneNoteExportPlan(snapshot, options);
enforceProjectedStaticNotebookSize(plan);
const manifest = createStaticManifest(snapshot, plan);
const warnings: OneNoteExportWarningsManifest = {
schemaVersion: snapshot.schemaVersion,
@@ -96,17 +106,26 @@ export function createOneNoteStaticNotebookZip(
};
const files: Zippable = Object.create(null) as Zippable;
files['index.html'] = strToU8(serializeHtmlWithPlan(snapshot, plan, true));
files['notebook.json'] = strToU8(
const members: Array<{ path: string; byteLength: number }> = [];
let memberBytes = 0;
addTextMember('index.html', serializeHtmlWithPlan(snapshot, plan, true));
addTextMember(
'notebook.json',
serializeStructuredJsonWithPlan(snapshot, plan)
);
files['notebook.txt'] = strToU8(serializePlainTextWithPlan(snapshot, plan));
files['notebook.md'] = strToU8(
serializeMarkdownWithPlan(snapshot, plan, true)
addTextMember('notebook.txt', serializePlainTextWithPlan(snapshot, plan));
addTextMember('notebook.md', serializeMarkdownWithPlan(snapshot, plan, true));
addTextMember('manifest.json', jsonText(manifest));
addTextMember('warnings.json', jsonText(warnings));
for (const resource of plan.resources) {
addBinaryMember(resource.path, resource.bytes);
}
enforceOutputByteLength(
plan,
projectedZipByteLength(members),
'Projected static notebook ZIP'
);
files['manifest.json'] = jsonBytes(manifest);
files['warnings.json'] = jsonBytes(warnings);
for (const resource of plan.resources) files[resource.path] = resource.bytes;
const bytes = zipSync(files, {
level: 6,
@@ -114,6 +133,7 @@ export function createOneNoteStaticNotebookZip(
os: 3,
attrs: 0o644 << 16,
});
enforceOutputByteLength(plan, bytes.byteLength, 'Static notebook ZIP');
const safeName = sanitizeExportFilename(
snapshot.notebookName,
'onenote-export'
@@ -124,6 +144,37 @@ export function createOneNoteStaticNotebookZip(
bytes,
manifest,
};
function addTextMember(path: string, value: string): void {
ensureMemberCapacity(utf8ByteLength(value));
const bytes = strToU8(value);
addMember(path, bytes, bytes.byteLength);
}
function addBinaryMember(path: string, value: Uint8Array): void {
ensureMemberCapacity(value.byteLength);
addMember(path, value, value.byteLength);
}
function addMember(
path: string,
value: Uint8Array,
byteLength: number
): void {
ensureMemberCapacity(byteLength);
memberBytes += byteLength;
members.push({ path, byteLength });
files[path] = value;
}
function ensureMemberCapacity(byteLength: number): void {
if (byteLength > plan.limits.maxOutputBytes - memberBytes) {
throw new OneNoteExportError(
'limit-exceeded',
`Static notebook members exceed ${plan.limits.maxOutputBytes} bytes`
);
}
}
}
function createStaticManifest(
@@ -173,6 +224,48 @@ function createStaticManifest(
};
}
function jsonBytes(value: unknown): Uint8Array {
return strToU8(`${JSON.stringify(value, null, 2)}\n`);
function jsonText(value: unknown): string {
return `${JSON.stringify(value, null, 2)}\n`;
}
function enforceProjectedStaticNotebookSize(plan: OneNoteExportPlan): void {
const available = plan.limits.maxOutputBytes - plan.totalResourceBytes;
if (available < 0 || plan.projectedOutputBytes > Math.floor(available / 6)) {
throw new OneNoteExportError(
'limit-exceeded',
`Projected static notebook members exceed ${plan.limits.maxOutputBytes} bytes`
);
}
}
function projectedZipByteLength(
members: readonly { path: string; byteLength: number }[]
): number {
let result = 22;
for (const member of members) {
const pathBytes = utf8ByteLength(member.path);
const deflateOverhead = Math.ceil(member.byteLength / 16_384) * 16 + 128;
result = safeByteSum(
result,
member.byteLength,
deflateOverhead,
76,
pathBytes * 2
);
}
return result;
}
function safeByteSum(...values: number[]): number {
let total = 0;
for (const value of values) {
if (!Number.isSafeInteger(value) || value < 0) {
return Number.POSITIVE_INFINITY;
}
if (total > Number.MAX_SAFE_INTEGER - value) {
return Number.POSITIVE_INFINITY;
}
total += value;
}
return total;
}

View File

@@ -16,10 +16,44 @@ import {
} from './export-adapter.js';
import { workerPageKey, workerResourceKey } from './parser-adapter.js';
const PNG = Uint8Array.of(0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0x00);
const PNG = Uint8Array.of(
0x89,
0x50,
0x4e,
0x47,
0x0d,
0x0a,
0x1a,
0x0a,
0x00,
0x00,
0x00,
0x0d,
0x49,
0x48,
0x44,
0x52,
0x00,
0x00,
0x00,
0x01,
0x00,
0x00,
0x00,
0x01,
0x08,
0x06,
0x00,
0x00,
0x00,
0x00,
0x00,
0x00,
0x00
);
describe('worker export adapter', () => {
it('builds a partial section snapshot from full session pages and copied resources', () => {
it('builds a partial section snapshot with bounded zero-copy resources', () => {
const pageDiagnostic = diagnostic('page-warning', 'page page-1');
const sectionDiagnostic = diagnostic('section-warning');
const source = sectionDto('Unsafe/Section.one', 'Section name', [
@@ -80,21 +114,46 @@ describe('worker export adapter', () => {
mediaType: 'image/png',
size: PNG.length,
});
expect(new Uint8Array(snapshot.resources[0]!.bytes)).toEqual(PNG);
const imageBytes = snapshot.resources[0]!.bytes;
expect(imageBytes).toBeInstanceOf(Uint8Array);
if (!(imageBytes instanceof Uint8Array)) {
throw new Error('Expected a zero-copy image view');
}
expect(imageBytes).toEqual(PNG);
expect(imageBytes.buffer).toBe(resourceSource.buffer);
expect(snapshot.resources[1]).toMatchObject({
sectionId: 'section',
id: 'attachment-1',
kind: 'attachment',
size: 2,
});
expect(new Uint8Array(snapshot.resources[1]!.bytes)).toEqual(
Uint8Array.of(5, 4)
);
resourceSource.fill(0);
attachmentSource.fill(0);
expect(new Uint8Array(snapshot.resources[0]!.bytes)).toEqual(PNG);
expect(new Uint8Array(snapshot.resources[1]!.bytes)).toEqual(
Uint8Array.of(5, 4)
const attachmentBytes = snapshot.resources[1]!.bytes;
expect(attachmentBytes).toBeInstanceOf(Uint8Array);
if (!(attachmentBytes instanceof Uint8Array)) {
throw new Error('Expected a zero-copy attachment view');
}
expect(attachmentBytes).toEqual(Uint8Array.of(5, 4));
expect(attachmentBytes.buffer).toBe(attachmentSource.buffer);
attachmentSource[1] = 8;
expect(attachmentBytes[0]).toBe(8);
});
it('caps synthesized missing-page diagnostics without variadic appends', () => {
const source = sectionDto('Section.one', 'Section', []);
source.pages = [
{ id: 'missing-1', title: 'Missing 1', text: '', textPreview: '' },
{ id: 'missing-2', title: 'Missing 2', text: '', textPreview: '' },
];
expect(() =>
createWorkerExportSnapshot(source, new Map(), new Map(), {
limits: { maxDiagnostics: 1 },
})
).toThrowError(
expect.objectContaining({
code: 'limit-exceeded',
message: expect.stringContaining('diagnostic count'),
})
);
});

View File

@@ -6,7 +6,9 @@
import {
createOneNoteExportSnapshot,
createOneNoteStaticNotebookZip,
DEFAULT_ONENOTE_EXPORT_LIMITS,
forceFilenameExtension,
OneNoteExportError,
sanitizeExportFilename,
serializeOneNoteMarkdown,
serializeOneNotePlainText,
@@ -26,11 +28,7 @@ import type {
OneNoteSectionDto,
} from '../onenote/model/dto.js';
import type { OneNoteResource } from '../onenote/one/content-model.js';
import {
resourcePayload,
workerPageKey,
workerResourceKey,
} from './parser-adapter.js';
import { workerPageKey, workerResourceKey } from './parser-adapter.js';
export type WorkerExportFormat =
'static-zip' | 'json' | 'text' | 'markdown' | 'html';
@@ -57,11 +55,13 @@ interface CollectedPages {
export function createWorkerExportSnapshot(
source: OneNoteSectionDto | OneNotePackageDto,
pages: ReadonlyMap<string, OneNotePageDto>,
resources: ReadonlyMap<string, OneNoteResource>
resources: ReadonlyMap<string, OneNoteResource>,
options: OneNoteExportOptions = {}
): OneNoteExportSnapshot {
const maxDiagnostics = diagnosticLimit(options);
return source.format === 'one'
? sectionSnapshot(source, pages, resources)
: packageSnapshot(source, pages, resources);
? sectionSnapshot(source, pages, resources, maxDiagnostics)
: packageSnapshot(source, pages, resources, maxDiagnostics);
}
/** Serialize one parser session into a browser-downloadable export artifact. */
@@ -72,13 +72,18 @@ export function createWorkerExportArtifact(
format: WorkerExportFormat,
options: OneNoteExportOptions = {}
): WorkerExportArtifact {
const snapshot = createWorkerExportSnapshot(source, pages, resources);
const snapshot = createWorkerExportSnapshot(
source,
pages,
resources,
options
);
if (format === 'static-zip') {
const artifact = createOneNoteStaticNotebookZip(snapshot, options);
return {
filename: artifact.filename,
mediaType: artifact.mediaType,
bytes: copyToArrayBuffer(artifact.bytes),
bytes: transferableArrayBuffer(artifact.bytes),
};
}
@@ -90,7 +95,7 @@ export function createWorkerExportArtifact(
return {
filename,
mediaType: serialization.mediaType,
bytes: copyToArrayBuffer(
bytes: transferableArrayBuffer(
new TextEncoder().encode(serialization.serialize(snapshot, options))
),
};
@@ -132,9 +137,16 @@ const TEXT_EXPORTS: Record<
function sectionSnapshot(
source: OneNoteSectionDto,
sessionPages: ReadonlyMap<string, OneNotePageDto>,
sessionResources: ReadonlyMap<string, OneNoteResource>
sessionResources: ReadonlyMap<string, OneNoteResource>,
maxDiagnostics: number
): OneNoteExportSnapshot {
const collected = collectPages(source.pages, sessionPages);
const collected = collectPages(
source.pages,
sessionPages,
undefined,
undefined,
maxDiagnostics
);
const context: ExportedSectionContext = {
parserSection: source,
exportSection: {
@@ -170,13 +182,23 @@ function sectionSnapshot(
function packageSnapshot(
source: OneNotePackageDto,
sessionPages: ReadonlyMap<string, OneNotePageDto>,
sessionResources: ReadonlyMap<string, OneNoteResource>
sessionResources: ReadonlyMap<string, OneNoteResource>,
maxDiagnostics: number
): OneNoteExportSnapshot {
const contexts: ExportedSectionContext[] = [];
const globalDiagnostics = [
...source.diagnostics,
...source.sections.flatMap((section) => section.diagnostics),
];
const globalDiagnostics: ParserDiagnostic[] = [];
appendDiagnosticsBounded(
globalDiagnostics,
source.diagnostics,
maxDiagnostics
);
for (const section of source.sections) {
appendDiagnosticsBounded(
globalDiagnostics,
section.diagnostics,
maxDiagnostics
);
}
let missingPageCount = 0;
for (const packaged of source.sections) {
@@ -185,9 +207,14 @@ function packageSnapshot(
packaged.section.pages,
sessionPages,
packaged.id,
packaged.path
packaged.path,
maxDiagnostics
);
appendDiagnosticsBounded(
globalDiagnostics,
collected.diagnostics,
maxDiagnostics
);
globalDiagnostics.push(...collected.diagnostics);
missingPageCount += collected.missingCount;
contexts.push({
sessionSectionId: packaged.id,
@@ -261,7 +288,8 @@ function collectPages(
summaries: readonly OneNotePageSummaryDto[],
sessionPages: ReadonlyMap<string, OneNotePageDto>,
sessionSectionId?: string,
sectionPath?: string
sectionPath?: string,
maxDiagnostics = DEFAULT_ONENOTE_EXPORT_LIMITS.maxDiagnostics
): CollectedPages {
const fullPages: OneNotePageDto[] = [];
const diagnostics: ParserDiagnostic[] = [];
@@ -271,13 +299,17 @@ function collectPages(
fullPages.push(page);
continue;
}
diagnostics.push({
appendDiagnosticBounded(
diagnostics,
{
severity: 'warning',
code: 'export-page-unavailable',
message: `Page ${summary.title || summary.id} was omitted because its full parsed content is no longer available in this session.`,
structure: `${sectionPath ? `${sectionPath}/` : ''}page ${summary.id}`,
recoverable: true,
});
},
maxDiagnostics
);
}
return {
pages: fullPages,
@@ -291,10 +323,12 @@ function subtractNestedDiagnostics(
pages: readonly OneNotePageDto[]
): ParserDiagnostic[] {
const nested = new Map<string, number>();
for (const diagnostic of pages.flatMap((page) => page.diagnostics)) {
for (const page of pages) {
for (const diagnostic of page.diagnostics) {
const key = diagnosticKey(diagnostic);
nested.set(key, (nested.get(key) ?? 0) + 1);
}
}
return sectionDiagnostics.filter((diagnostic) => {
const key = diagnosticKey(diagnostic);
const remaining = nested.get(key) ?? 0;
@@ -304,6 +338,43 @@ function subtractNestedDiagnostics(
});
}
function diagnosticLimit(options: OneNoteExportOptions): number {
const value =
options.limits?.maxDiagnostics ??
DEFAULT_ONENOTE_EXPORT_LIMITS.maxDiagnostics;
if (!Number.isSafeInteger(value) || value <= 0) {
throw new OneNoteExportError(
'invalid-input',
'maxDiagnostics must be a positive safe integer'
);
}
return value;
}
function appendDiagnosticsBounded(
target: ParserDiagnostic[],
values: readonly ParserDiagnostic[],
limit: number
): void {
for (const diagnostic of values) {
appendDiagnosticBounded(target, diagnostic, limit);
}
}
function appendDiagnosticBounded(
target: ParserDiagnostic[],
diagnostic: ParserDiagnostic,
limit: number
): void {
if (target.length >= limit) {
throw new OneNoteExportError(
'limit-exceeded',
`Export diagnostic count exceeds ${limit}`
);
}
target.push(diagnostic);
}
function diagnosticKey(diagnostic: ParserDiagnostic): string {
return JSON.stringify([
diagnostic.severity,
@@ -329,23 +400,43 @@ function collectResources(
if (sessionSectionId === INVALID_RESOURCE_SECTION) continue;
const context = sectionBySessionId.get(sessionSectionId);
if (!context) continue;
const payload = resourcePayload(resource);
if (!payload) continue;
const blob = boundedResourceBlob(resource);
if (!blob) continue;
result.push({
sectionId: context.exportSection.id,
id: payload.id,
kind: payload.kind,
filename: payload.filename,
extension: payload.extension,
mediaType: payload.mediaType,
browserRenderable: payload.browserRenderable,
size: payload.size,
bytes: payload.bytes,
id: resource.id,
kind: resource.kind,
filename: resource.filename,
extension: resource.extension,
mediaType: resource.mediaType,
browserRenderable: resource.browserRenderable,
size: blob.byteLength,
// The export planner applies per-resource and aggregate limits before
// any serializer or ZIP compressor copies this bounded source view.
bytes: blob,
});
}
return result;
}
function boundedResourceBlob(
resource: OneNoteResource
): Uint8Array | undefined {
const blob = resource.blob;
if (
resource.availability !== 'available' ||
!blob ||
!Number.isSafeInteger(blob.offset) ||
!Number.isSafeInteger(blob.size) ||
blob.offset < 0 ||
blob.size < 0 ||
blob.offset > blob.source.byteLength - blob.size
) {
return undefined;
}
return blob.source.subarray(blob.offset, blob.offset + blob.size);
}
const INVALID_RESOURCE_SECTION = Symbol('invalid-resource-section');
function resourceSectionId(
@@ -390,7 +481,14 @@ function unique(values: readonly string[]): string[] {
return [...new Set(values.filter((value) => value.trim() !== ''))];
}
function copyToArrayBuffer(bytes: Uint8Array): ArrayBuffer {
function transferableArrayBuffer(bytes: Uint8Array): ArrayBuffer {
if (
bytes.buffer instanceof ArrayBuffer &&
bytes.byteOffset === 0 &&
bytes.byteLength === bytes.buffer.byteLength
) {
return bytes.buffer;
}
const result = new ArrayBuffer(bytes.byteLength);
new Uint8Array(result).set(bytes);
return result;