fix: bound OneNote export output
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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'),
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user