feat: add safe OneNote export serializers

This commit is contained in:
2026-07-22 19:36:20 +02:00
parent f0f6e48188
commit ece969e269
7 changed files with 2328 additions and 0 deletions

View File

@@ -0,0 +1,453 @@
import { strFromU8, unzipSync } from 'fflate';
import { describe, expect, it } from 'vitest';
import type { ParserDiagnostic } from '../model/diagnostics.js';
import type {
OneNoteContentBlock,
OneNoteTextBlock,
OneNoteTextStyle,
} from '../one/content-model.js';
import {
createOneNoteExportSnapshot,
createOneNoteStaticNotebookZip,
OneNoteExportError,
SafeExportNameAllocator,
sanitizeExportFilename,
serializeOneNoteMarkdown,
serializeOneNotePlainText,
serializeOneNoteSemanticHtml,
serializeOneNoteStructuredJson,
type OneNoteExportSnapshot,
} from './index.js';
const PNG = Uint8Array.from([
0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0x00,
]);
const JPEG = Uint8Array.from([0xff, 0xd8, 0xff, 0xd9]);
const SOURCE_MARKUP = '<img src=x onerror=alert(1)><script>bad()</script>';
describe('OneNote export serializers', () => {
it('retains provenance and rich content without emitting active source markup', () => {
const snapshot = fixtureSnapshot();
const structured = JSON.parse(serializeOneNoteStructuredJson(snapshot)) as {
app: { version: string };
parser: { version: string };
supportLevel: string;
source: { name: string };
diagnostics: ParserDiagnostic[];
warnings: Array<{ code: string }>;
unsupportedContent: Array<{ sourceJcid: number }>;
resources: Array<Record<string, unknown>>;
};
expect(structured).toMatchObject({
app: { version: '1.2.3' },
parser: { version: '9.8.7' },
supportLevel: 'substantial',
source: { name: '../../<script>source.onepkg' },
});
expect(structured.diagnostics.map(({ code }) => code)).toEqual([
'package-warning',
'section-warning',
'page-warning',
]);
expect(
structured.resources.every((resource) => !('bytes' in resource))
).toBe(true);
expect(structured.unsupportedContent).toEqual([
expect.objectContaining({ sourceJcid: 0x1234 }),
]);
expect(structured.warnings.map(({ code }) => code)).toEqual(
expect.arrayContaining([
'UNSUPPORTED_IMAGE_RESOURCE',
'ACTIVE_ATTACHMENT_EXTENSION_NEUTRALIZED',
'BLOCK_RESOURCE_UNAVAILABLE',
])
);
const plain = serializeOneNotePlainText(snapshot);
expect(plain).toContain('Application: OneNote Tools 1.2.3');
expect(plain).toContain('Parser: TypeScript parser 9.8.7');
expect(plain).toContain(SOURCE_MARKUP);
expect(plain).toContain('Fallback page text');
expect(plain).toContain('Ink drawing: 1 stroke, 2 points');
const markdown = serializeOneNoteMarkdown(snapshot);
expect(markdown).not.toContain('<script>');
expect(markdown).not.toContain('<img src=x');
expect(markdown).not.toContain('javascript:');
expect(markdown).toContain(
String.raw`&lt;script&gt;bad\(\)&lt;/script&gt;`
);
expect(markdown).toContain('Fallback page text');
expect(markdown).toContain('| Column 1 | Column 2 |');
const html = serializeOneNoteSemanticHtml(snapshot);
const document = new DOMParser().parseFromString(html, 'text/html');
expect(
document.querySelectorAll('script,object,iframe,embed')
).toHaveLength(0);
expect(
document.querySelectorAll(
'[onload],[onerror],[onclick],foreignObject,use'
)
).toHaveLength(0);
expect(document.querySelectorAll('a[href^="javascript:"]')).toHaveLength(0);
expect(document.body.textContent).toContain(SOURCE_MARKUP);
expect(document.body.textContent).toContain('Fallback page text');
expect(document.querySelectorAll('table td')).toHaveLength(2);
expect(document.querySelector('.text strong')?.textContent).toContain(
SOURCE_MARKUP
);
expect(document.querySelectorAll('img')).toHaveLength(0);
expect(document.querySelectorAll('svg.ink')).toHaveLength(1);
expect(
document.querySelector('polyline')?.getAttribute('stroke-opacity')
).toBe('0.496');
expect(
document
.querySelector('meta[http-equiv="Content-Security-Policy"]')
?.getAttribute('content')
).toContain("default-src 'none'");
});
it('creates a deterministic, path-safe static notebook ZIP', () => {
const snapshot = fixtureSnapshot();
const first = createOneNoteStaticNotebookZip(snapshot);
const second = createOneNoteStaticNotebookZip(snapshot);
expect(first.filename).toBe('CON_script_.zip');
expect(first.mediaType).toBe('application/zip');
expect(first.bytes).toEqual(second.bytes);
expect(first.manifest.counts).toMatchObject({
sections: 1,
pages: 2,
resources: 3,
unsupportedContent: 1,
});
const files = unzipSync(first.bytes);
expect(Object.keys(files).sort()).toEqual([
'assets/_CON/CON_script_.html.bin',
'assets/_CON/photo.jpg',
'assets/_CON/photo.png',
'index.html',
'manifest.json',
'notebook.json',
'notebook.md',
'notebook.txt',
'warnings.json',
]);
for (const path of Object.keys(files)) {
expect(path.startsWith('/')).toBe(false);
expect(path).not.toContain('\\');
expect(path.split('/')).not.toContain('..');
}
expect(files['assets/_CON/photo.png']).toEqual(PNG);
expect(files['assets/_CON/photo.jpg']).toEqual(JPEG);
expect(strFromU8(files['assets/_CON/CON_script_.html.bin']!)).toBe(
'<script>alert(2)</script>'
);
const html = strFromU8(files['index.html']!);
const document = new DOMParser().parseFromString(html, 'text/html');
expect(document.querySelector('img')?.getAttribute('src')).toBe(
'assets/_CON/photo.png'
);
expect(document.querySelector('a[download]')?.getAttribute('href')).toBe(
'assets/_CON/CON_script_.html.bin'
);
expect(
document.querySelectorAll('script,object,iframe,embed')
).toHaveLength(0);
const manifest = JSON.parse(strFromU8(files['manifest.json']!)) as {
app: { version: string };
parser: { version: string };
supportLevel: string;
source: { name: string };
resources: Array<{ path: string; mediaType: string }>;
};
expect(manifest).toMatchObject({
app: { version: '1.2.3' },
parser: { version: '9.8.7' },
supportLevel: 'substantial',
source: { name: '../../<script>source.onepkg' },
});
expect(manifest.resources).toEqual(
expect.arrayContaining([
expect.objectContaining({
path: 'assets/_CON/photo.png',
mediaType: 'image/png',
}),
expect.objectContaining({
path: 'assets/_CON/photo.jpg',
mediaType: 'image/jpeg',
}),
expect.objectContaining({
path: 'assets/_CON/CON_script_.html.bin',
mediaType: 'application/octet-stream',
}),
])
);
const warnings = JSON.parse(strFromU8(files['warnings.json']!)) as {
diagnostics: ParserDiagnostic[];
exportWarnings: Array<{ code: string }>;
unsupportedContent: Array<{ sourceJcid: number }>;
};
expect(warnings.diagnostics).toHaveLength(3);
expect(warnings.exportWarnings.map(({ code }) => code)).toEqual(
expect.arrayContaining([
'UNSUPPORTED_IMAGE_RESOURCE',
'ACTIVE_ATTACHMENT_EXTENSION_NEUTRALIZED',
'BLOCK_RESOURCE_UNAVAILABLE',
])
);
expect(warnings.unsupportedContent).toEqual([
expect.objectContaining({ sourceJcid: 0x1234 }),
]);
});
it('bounds traversal and rejects cyclic content', () => {
const snapshot = fixtureSnapshot();
expect(() =>
serializeOneNotePlainText(snapshot, { limits: { maxPages: 1 } })
).toThrowError(
expect.objectContaining<Partial<OneNoteExportError>>({
code: 'limit-exceeded',
})
);
const cyclic: Extract<OneNoteContentBlock, { kind: 'outline' }> = {
kind: 'outline',
id: 'cycle',
indents: [],
layout: {},
children: [],
};
cyclic.children.push(cyclic);
snapshot.sections[0]!.pages[0]!.blocks = [cyclic];
expect(() => serializeOneNoteStructuredJson(snapshot)).toThrowError(
expect.objectContaining<Partial<OneNoteExportError>>({
code: 'invalid-input',
})
);
});
});
describe('OneNote export filenames', () => {
it('removes traversal, reserved names, controls and directional overrides', () => {
expect(sanitizeExportFilename('../../CON')).toBe('_CON');
expect(sanitizeExportFilename('..\\AUX.txt')).toBe('_AUX.txt');
expect(sanitizeExportFilename('safe\u202ename.txt')).toBe('safename.txt');
expect(sanitizeExportFilename('', '../../NUL')).toBe('_NUL');
expect(sanitizeExportFilename('x'.repeat(200))).toHaveLength(120);
expect(
sanitizeExportFilename(`name.${'x'.repeat(200)}`).length
).toBeLessThanOrEqual(120);
});
it('allocates case-insensitive unique names', () => {
const allocator = new SafeExportNameAllocator();
expect(allocator.allocate('Report.txt')).toBe('Report.txt');
expect(allocator.allocate('report.TXT')).toBe('report-2.txt');
});
});
function fixtureSnapshot(): OneNoteExportSnapshot {
const richText = textBlock('text-1', SOURCE_MARKUP, {
bold: true,
italic: true,
});
richText.runs[0]!.href = 'javascript:alert(1)';
const blocks: OneNoteContentBlock[] = [
richText,
{
kind: 'table',
id: 'table-1',
declaredColumnCount: 2,
rows: [
{
id: 'row-1',
cells: [
{ id: 'cell-1', blocks: [textBlock('cell-text', 'A | B')] },
{ id: 'cell-2', blocks: [textBlock('cell-text-2', 'C')] },
],
},
],
columnWidths: [100, 100],
lockedColumns: [false, false],
bordersVisible: true,
layout: {},
},
{
kind: 'image',
id: 'image-block',
resourceId: 'image-1',
filename: 'source.svg',
altText: '<svg onload=alert(3)>Preview</svg>',
href: 'javascript:alert(4)',
isBackground: false,
layout: {},
},
{
kind: 'image',
id: 'bad-image-block',
resourceId: 'bad-image',
filename: 'bad.svg',
isBackground: false,
layout: {},
},
{
kind: 'image',
id: 'jpeg-image-block',
resourceId: 'image-2',
filename: 'photo.jpeg',
isBackground: false,
layout: {},
},
{
kind: 'attachment',
id: 'attachment-block',
resourceId: 'attachment-1',
filename: '../../CON<script>.html',
layout: {},
},
{
kind: 'ink',
id: 'ink-1',
strokes: [
{
points: [
{ x: 0, y: 0 },
{ x: 100, y: 50 },
],
width: 8,
transparency: 128,
color: 0x332211,
},
],
children: [],
layout: {},
},
{
kind: 'unsupported',
id: 'unsupported-1',
sourceJcid: 0x1234,
description: '<script>Unknown object</script>',
},
];
return createOneNoteExportSnapshot({
sourceName: '../../<script>source.onepkg',
sourceFormat: 'onepkg',
notebookName: '../CON<script>.onepkg',
appName: 'OneNote Tools',
appVersion: '1.2.3',
parser: {
name: 'TypeScript parser',
version: '9.8.7',
implementation: 'typescript',
supportedVariants: ['desktop-store', 'fsshttpb-package-store'],
referenceRevision: 'MS-ONE 16.0',
},
supportLevel: 'substantial',
supportNotes: ['Unsupported objects remain explicit.'],
sections: [
{
id: 'section-1',
name: '../CON',
sourcePath: '../unsafe/Section.one',
pages: [
{
id: 'page-1',
title: '<script>Page</script>',
text: SOURCE_MARKUP,
textPreview: SOURCE_MARKUP,
blocks,
diagnostics: [diagnostic('page-warning')],
},
{
id: 'page-2',
title: 'Fallback',
text: 'Fallback page text <script>must stay text</script>',
textPreview: 'Fallback page text',
blocks: [],
diagnostics: [],
},
],
diagnostics: [diagnostic('section-warning')],
},
],
resources: [
{
sectionId: 'section-1',
id: 'image-1',
kind: 'image',
filename: 'photo.svg',
extension: 'svg',
mediaType: 'image/svg+xml',
browserRenderable: true,
size: PNG.byteLength,
bytes: PNG,
},
{
sectionId: 'section-1',
id: 'bad-image',
kind: 'image',
filename: 'bad.svg',
extension: 'svg',
mediaType: 'image/svg+xml',
browserRenderable: true,
bytes: new TextEncoder().encode('<svg><script>bad()</script></svg>'),
},
{
sectionId: 'section-1',
id: 'image-2',
kind: 'image',
filename: 'photo.png',
extension: 'png',
mediaType: 'image/png',
browserRenderable: true,
bytes: JPEG,
},
{
sectionId: 'section-1',
id: 'attachment-1',
kind: 'attachment',
filename: '../../CON<script>.html',
extension: 'html',
mediaType: 'text/html',
browserRenderable: false,
bytes: new TextEncoder().encode('<script>alert(2)</script>'),
},
],
diagnostics: [diagnostic('package-warning')],
});
}
function textBlock(
id: string,
text: string,
style: OneNoteTextStyle = {}
): OneNoteTextBlock {
return {
kind: 'text',
id,
sourceText: text,
text,
runs: [{ start: 0, end: text.length, text, style }],
paragraphStyle: {},
layout: {},
};
}
function diagnostic(code: string): ParserDiagnostic {
return {
severity: 'warning',
code,
message: `${code} <script>message</script>`,
recoverable: true,
};
}

View File

@@ -0,0 +1,46 @@
// This Source Code Form is subject to the terms of the Mozilla Public
// License, v. 2.0. If a copy of the MPL was not distributed with this file,
// You can obtain one at https://mozilla.org/MPL/2.0/.
// SPDX-License-Identifier: MPL-2.0
export {
forceFilenameExtension,
neutralizeActiveExtension,
normalizeExportExtension,
SafeExportNameAllocator,
sanitizeExportFilename,
} from './naming.js';
export {
createOneNoteExportPlan,
exportResourceKey,
type OneNoteExportPlan,
type OneNoteExportWarning,
type OneNoteUnsupportedContentRecord,
type PlannedOneNoteResource,
} from './plan.js';
export {
serializeOneNoteMarkdown,
serializeOneNotePlainText,
serializeOneNoteSemanticHtml,
serializeOneNoteStructuredJson,
} from './serializers.js';
export {
createOneNoteExportSnapshot,
DEFAULT_ONENOTE_EXPORT_LIMITS,
ONENOTE_EXPORT_SCHEMA_VERSION,
OneNoteExportError,
type CreateOneNoteExportSnapshotOptions,
type OneNoteExportLimits,
type OneNoteExportOptions,
type OneNoteExportParserMetadata,
type OneNoteExportResource,
type OneNoteExportSection,
type OneNoteExportSnapshot,
type OneNoteExportSupportLevel,
} from './types.js';
export {
createOneNoteStaticNotebookZip,
type OneNoteExportWarningsManifest,
type OneNoteStaticNotebookManifest,
type OneNoteStaticNotebookZipArtifact,
} from './zip.js';

View File

@@ -0,0 +1,158 @@
// This Source Code Form is subject to the terms of the Mozilla Public
// License, v. 2.0. If a copy of the MPL was not distributed with this file,
// You can obtain one at https://mozilla.org/MPL/2.0/.
// SPDX-License-Identifier: MPL-2.0
const WINDOWS_RESERVED = /^(?:CON|PRN|AUX|NUL|COM[1-9]|LPT[1-9])$/iu;
const MAX_FILENAME_CODEPOINTS = 120;
const ACTIVE_EXTENSIONS = new Set([
'app',
'application',
'bat',
'cjs',
'cmd',
'com',
'desktop',
'exe',
'hta',
'htm',
'html',
'jar',
'js',
'lnk',
'mjs',
'ps1',
'scr',
'svg',
'svgz',
'url',
'vbs',
'wasm',
'xhtml',
'xml',
]);
/** Convert untrusted display metadata into one safe ZIP path segment. */
export function sanitizeExportFilename(
value: string | undefined,
fallback = 'untitled'
): string {
let result = cleanFilenameSegment(value);
if (!result || result === '.' || result === '..') {
result = cleanFilenameSegment(fallback);
}
if (!result || result === '.' || result === '..') result = 'untitled';
if (WINDOWS_RESERVED.test(result.split('.')[0] ?? result)) {
result = `_${result}`;
}
result = truncateFilename(result, MAX_FILENAME_CODEPOINTS);
return result || 'untitled';
}
function cleanFilenameSegment(value: string | undefined): string {
const basename = (value ?? '').split(/[\\/]/u).at(-1) ?? '';
let result = '';
for (const character of basename.normalize('NFC')) {
const code = character.codePointAt(0)!;
if (isControlOrDirectional(code)) continue;
result += '<>:"/\\|?*'.includes(character) ? '_' : character;
}
result = result
.replace(/\s+/gu, ' ')
.replace(/\.{2,}/gu, '.')
.replace(/^[ .]+|[ .]+$/gu, '');
return result;
}
export function normalizeExportExtension(
value: string | undefined
): string | undefined {
const extension = value
?.split('\0')
.join('')
.trim()
.replace(/^\.+/u, '')
.toLowerCase();
return extension && /^[a-z0-9][a-z0-9._+-]{0,15}$/u.test(extension)
? extension
: undefined;
}
/** Make browser/script/executable attachment suffixes download-inert. */
export function neutralizeActiveExtension(filename: string): {
filename: string;
neutralized: boolean;
} {
const extension = filenameExtension(filename);
if (!extension || !ACTIVE_EXTENSIONS.has(extension)) {
return { filename, neutralized: false };
}
return { filename: `${filename}.bin`, neutralized: true };
}
export function forceFilenameExtension(
filename: string,
extension: string
): string {
const safeExtension = normalizeExportExtension(extension) ?? 'bin';
const index = filename.lastIndexOf('.');
const stem = index > 0 ? filename.slice(0, index) : filename;
return truncateFilename(`${stem}.${safeExtension}`, MAX_FILENAME_CODEPOINTS);
}
export function filenameExtension(filename: string): string | undefined {
const index = filename.lastIndexOf('.');
return index > 0 && index < filename.length - 1
? filename.slice(index + 1).toLowerCase()
: undefined;
}
export class SafeExportNameAllocator {
private readonly used = new Set<string>();
allocate(value: string | undefined, fallback = 'untitled'): string {
const safe = sanitizeExportFilename(value, fallback);
if (this.reserve(safe)) return safe;
const extension = filenameExtension(safe);
const stem = extension ? safe.slice(0, -(extension.length + 1)) : safe;
for (let suffix = 2; suffix < 1_000_000; suffix += 1) {
const candidate = truncateFilename(
`${stem}-${suffix}${extension ? `.${extension}` : ''}`,
MAX_FILENAME_CODEPOINTS
);
if (this.reserve(candidate)) return candidate;
}
throw new Error('Could not allocate a unique export filename');
}
private reserve(value: string): boolean {
const key = value.toLocaleLowerCase('en-US');
if (this.used.has(key)) return false;
this.used.add(key);
return true;
}
}
function truncateFilename(value: string, maxCodepoints: number): string {
const characters = [...value];
if (characters.length <= maxCodepoints) return value;
const extension = filenameExtension(value);
const suffix =
extension && [...extension].length <= 16 ? `.${extension}` : '';
const suffixLength = [...suffix].length;
return `${characters.slice(0, Math.max(1, maxCodepoints - suffixLength)).join('')}${suffix}`.replace(
/[ .]+$/gu,
''
);
}
function isControlOrDirectional(code: number): boolean {
return (
code <= 0x1f ||
(code >= 0x7f && code <= 0x9f) ||
(code >= 0x202a && code <= 0x202e) ||
(code >= 0x2066 && code <= 0x2069) ||
code === 0xfeff
);
}

450
src/onenote/export/plan.ts Normal file
View File

@@ -0,0 +1,450 @@
// This Source Code Form is subject to the terms of the Mozilla Public
// License, v. 2.0. If a copy of the MPL was not distributed with this file,
// You can obtain one at https://mozilla.org/MPL/2.0/.
// SPDX-License-Identifier: MPL-2.0
import type { ParserDiagnostic } from '../model/diagnostics.js';
import type { OneNoteContentBlock } from '../one/content-model.js';
import {
filenameExtension,
forceFilenameExtension,
neutralizeActiveExtension,
normalizeExportExtension,
SafeExportNameAllocator,
sanitizeExportFilename,
} from './naming.js';
import {
DEFAULT_ONENOTE_EXPORT_LIMITS,
OneNoteExportError,
type OneNoteExportLimits,
type OneNoteExportOptions,
type OneNoteExportResource,
type OneNoteExportSnapshot,
} from './types.js';
export interface OneNoteExportWarning {
code: string;
message: string;
sectionId?: string;
pageId?: string;
blockId?: string;
resourceId?: string;
}
export interface OneNoteUnsupportedContentRecord {
sectionId: string;
pageId: string;
blockId: string;
sourceJcid: number;
description: string;
}
export interface PlannedOneNoteResource {
sectionId: string;
id: string;
kind: OneNoteExportResource['kind'];
path: string;
filename: string;
extension: string;
mediaType: string;
browserRenderable: boolean;
size: number;
bytes: Uint8Array;
}
export interface OneNoteExportPlan {
limits: OneNoteExportLimits;
resources: PlannedOneNoteResource[];
resourcePaths: ReadonlyMap<string, string>;
warnings: OneNoteExportWarning[];
unsupportedContent: OneNoteUnsupportedContentRecord[];
diagnostics: ParserDiagnostic[];
blockCount: number;
pageCount: number;
}
export function exportResourceKey(
sectionId: string,
resourceId: string
): string {
return JSON.stringify([sectionId, resourceId]);
}
export function createOneNoteExportPlan(
snapshot: OneNoteExportSnapshot,
options: OneNoteExportOptions = {}
): OneNoteExportPlan {
const limits = resolveExportLimits(options.limits);
if (snapshot.sections.length > limits.maxSections) {
throw limitError('section', snapshot.sections.length, limits.maxSections);
}
const warnings: OneNoteExportWarning[] = [];
const unsupportedContent: OneNoteUnsupportedContentRecord[] = [];
const diagnostics = collectDiagnostics(snapshot, limits);
const sectionIds = new Set(snapshot.sections.map(({ id }) => id));
const sectionNames = new Map<string, string>();
const sectionAllocator = new SafeExportNameAllocator();
for (const [index, section] of snapshot.sections.entries()) {
sectionNames.set(
section.id,
sectionAllocator.allocate(section.name, `section-${index + 1}`)
);
}
const plannedResources: PlannedOneNoteResource[] = [];
const resourcePaths = new Map<string, string>();
const seenResourceKeys = new Set<string>();
const resourceAllocators = new Map<string, SafeExportNameAllocator>();
let totalResourceBytes = 0;
for (const [index, resource] of snapshot.resources.entries()) {
if (!sectionIds.has(resource.sectionId)) {
warnings.push({
code: 'RESOURCE_SECTION_MISSING',
message:
'Resource was omitted because its section is not in the export',
sectionId: resource.sectionId,
resourceId: resource.id,
});
continue;
}
const key = exportResourceKey(resource.sectionId, resource.id);
if (seenResourceKeys.has(key)) {
warnings.push({
code: 'DUPLICATE_RESOURCE',
message: 'A duplicate resource identifier was omitted',
sectionId: resource.sectionId,
resourceId: resource.id,
});
continue;
}
seenResourceKeys.add(key);
const bytes = resourceBytes(resource.bytes);
if (bytes.byteLength > limits.maxResourceBytes) {
warnings.push({
code: 'RESOURCE_LIMIT_EXCEEDED',
message: `Resource was omitted because it exceeds ${limits.maxResourceBytes} bytes`,
sectionId: resource.sectionId,
resourceId: resource.id,
});
continue;
}
if (totalResourceBytes + bytes.byteLength > limits.maxTotalResourceBytes) {
warnings.push({
code: 'TOTAL_RESOURCE_LIMIT_EXCEEDED',
message: `Resource was omitted because exported resources exceed ${limits.maxTotalResourceBytes} bytes`,
sectionId: resource.sectionId,
resourceId: resource.id,
});
continue;
}
const allocator =
resourceAllocators.get(resource.sectionId) ??
new SafeExportNameAllocator();
resourceAllocators.set(resource.sectionId, allocator);
const planned = planResource(resource, bytes, allocator, index, warnings);
if (!planned) continue;
const sectionDirectory = sectionNames.get(resource.sectionId)!;
planned.path = `assets/${sectionDirectory}/${planned.filename}`;
plannedResources.push(planned);
resourcePaths.set(key, planned.path);
totalResourceBytes += bytes.byteLength;
if (resource.size !== undefined && resource.size !== bytes.byteLength) {
warnings.push({
code: 'RESOURCE_SIZE_MISMATCH',
message: `Resource metadata declares ${resource.size} bytes; exported payload has ${bytes.byteLength}`,
sectionId: resource.sectionId,
resourceId: resource.id,
});
}
}
let pageCount = 0;
let blockCount = 0;
const active = new Set<OneNoteContentBlock>();
for (const section of snapshot.sections) {
pageCount += section.pages.length;
if (pageCount > limits.maxPages) {
throw limitError('page', pageCount, limits.maxPages);
}
for (const page of section.pages) {
walkBlocks(
page.blocks,
0,
active,
(block) => {
blockCount += 1;
if (blockCount > limits.maxBlocks) {
throw limitError('content block', blockCount, limits.maxBlocks);
}
inspectBlock(
block,
section.id,
page.id,
resourcePaths,
warnings,
unsupportedContent
);
},
limits
);
}
}
return {
limits,
resources: plannedResources,
resourcePaths,
warnings,
unsupportedContent,
diagnostics,
blockCount,
pageCount,
};
}
function planResource(
resource: OneNoteExportResource,
bytes: Uint8Array,
allocator: SafeExportNameAllocator,
index: number,
warnings: OneNoteExportWarning[]
): PlannedOneNoteResource | undefined {
if (resource.kind === 'image') {
const image = sniffBrowserImage(bytes);
if (!image) {
warnings.push({
code: 'UNSUPPORTED_IMAGE_RESOURCE',
message: 'Only signature-verified PNG and JPEG images are exported',
sectionId: resource.sectionId,
resourceId: resource.id,
});
return undefined;
}
const safe = sanitizeExportFilename(
resource.filename,
`image-${index + 1}.${image.extension}`
);
const filename = allocator.allocate(
forceFilenameExtension(safe, image.extension),
`image-${index + 1}.${image.extension}`
);
return {
sectionId: resource.sectionId,
id: resource.id,
kind: resource.kind,
path: '',
filename,
extension: image.extension,
mediaType: image.mediaType,
browserRenderable: true,
size: bytes.byteLength,
bytes,
};
}
const metadataExtension = normalizeExportExtension(resource.extension);
let filename = sanitizeExportFilename(
resource.filename,
`attachment-${index + 1}${metadataExtension ? `.${metadataExtension}` : '.bin'}`
);
if (!filenameExtension(filename) && metadataExtension) {
filename = `${filename}.${metadataExtension}`;
}
const neutralized = neutralizeActiveExtension(filename);
filename = allocator.allocate(
neutralized.filename,
`attachment-${index + 1}.bin`
);
if (neutralized.neutralized) {
warnings.push({
code: 'ACTIVE_ATTACHMENT_EXTENSION_NEUTRALIZED',
message:
'A potentially active attachment extension was suffixed with .bin',
sectionId: resource.sectionId,
resourceId: resource.id,
});
}
return {
sectionId: resource.sectionId,
id: resource.id,
kind: resource.kind,
path: '',
filename,
extension: filenameExtension(filename) ?? 'bin',
mediaType: neutralized.neutralized
? 'application/octet-stream'
: safeMediaType(resource.mediaType),
browserRenderable: false,
size: bytes.byteLength,
bytes,
};
}
function inspectBlock(
block: OneNoteContentBlock,
sectionId: string,
pageId: string,
resourcePaths: ReadonlyMap<string, string>,
warnings: OneNoteExportWarning[],
unsupported: OneNoteUnsupportedContentRecord[]
): void {
if (block.kind === 'unsupported') {
unsupported.push({
sectionId,
pageId,
blockId: block.id,
sourceJcid: block.sourceJcid,
description: block.description,
});
return;
}
if (block.kind === 'image' && block.resourceId) {
checkBlockResource(block.id, block.resourceId);
} else if (block.kind === 'attachment' && block.resourceId) {
checkBlockResource(block.id, block.resourceId);
}
function checkBlockResource(blockId: string, resourceId: string): void {
if (resourcePaths.has(exportResourceKey(sectionId, resourceId))) return;
warnings.push({
code: 'BLOCK_RESOURCE_UNAVAILABLE',
message: 'A content resource was unavailable or omitted from the export',
sectionId,
pageId,
blockId,
resourceId,
});
}
}
function walkBlocks(
blocks: readonly OneNoteContentBlock[],
depth: number,
active: Set<OneNoteContentBlock>,
visit: (block: OneNoteContentBlock) => void,
limits: OneNoteExportLimits
): void {
if (depth > limits.maxDepth) {
throw new OneNoteExportError(
'limit-exceeded',
`Export content depth exceeds ${limits.maxDepth}`
);
}
for (const block of blocks) {
if (active.has(block)) {
throw new OneNoteExportError(
'invalid-input',
'Export content contains an object cycle'
);
}
active.add(block);
visit(block);
if (block.kind === 'outline' || block.kind === 'outline-group') {
walkBlocks(block.children, depth + 1, active, visit, limits);
} else if (block.kind === 'outline-element') {
walkBlocks(block.contents, depth + 1, active, visit, limits);
walkBlocks(block.children, depth + 1, active, visit, limits);
} else if (block.kind === 'table') {
for (const row of block.rows) {
for (const cell of row.cells) {
walkBlocks(cell.blocks, depth + 1, active, visit, limits);
}
}
} else if (block.kind === 'ink') {
walkBlocks(block.children, depth + 1, active, visit, limits);
}
active.delete(block);
}
}
function collectDiagnostics(
snapshot: OneNoteExportSnapshot,
limits: OneNoteExportLimits
): ParserDiagnostic[] {
const result: ParserDiagnostic[] = [];
append(snapshot.diagnostics);
for (const section of snapshot.sections) {
append(section.diagnostics);
for (const page of section.pages) append(page.diagnostics);
}
return result;
function append(diagnostics: readonly ParserDiagnostic[]): void {
for (const diagnostic of diagnostics) {
result.push(diagnostic);
if (result.length > limits.maxDiagnostics) {
throw limitError('diagnostic', result.length, limits.maxDiagnostics);
}
}
}
}
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
)
? value.toLowerCase()
: 'application/octet-stream';
}
function resourceBytes(value: ArrayBuffer | Uint8Array): Uint8Array {
return value instanceof Uint8Array ? value : new Uint8Array(value);
}
function resolveExportLimits(
overrides: Partial<OneNoteExportLimits> | undefined
): OneNoteExportLimits {
const result = { ...DEFAULT_ONENOTE_EXPORT_LIMITS, ...overrides };
for (const [name, value] of Object.entries(result)) {
if (!Number.isSafeInteger(value) || value <= 0) {
throw new OneNoteExportError(
'invalid-input',
`${name} must be a positive safe integer`
);
}
}
return result;
}
function limitError(
subject: string,
actual: number,
limit: number
): OneNoteExportError {
return new OneNoteExportError(
'limit-exceeded',
`Export ${subject} count ${actual} exceeds ${limit}`
);
}

View File

@@ -0,0 +1,858 @@
// This Source Code Form is subject to the terms of the Mozilla Public
// License, v. 2.0. If a copy of the MPL was not distributed with this file,
// You can obtain one at https://mozilla.org/MPL/2.0/.
// SPDX-License-Identifier: MPL-2.0
import type { ParserDiagnostic } from '../model/diagnostics.js';
import type {
OneNoteColor,
OneNoteContentBlock,
OneNoteInkBlock,
OneNoteTextBlock,
OneNoteTextRun,
} from '../one/content-model.js';
import {
createOneNoteExportPlan,
exportResourceKey,
type OneNoteExportPlan,
type PlannedOneNoteResource,
} from './plan.js';
import type {
OneNoteExportOptions,
OneNoteExportSection,
OneNoteExportSnapshot,
} from './types.js';
interface RenderContext {
linkResources: boolean;
resources: ReadonlyMap<string, PlannedOneNoteResource>;
}
export function serializeOneNoteStructuredJson(
snapshot: OneNoteExportSnapshot,
options: OneNoteExportOptions = {}
): string {
const plan = createOneNoteExportPlan(snapshot, options);
return serializeStructuredJsonWithPlan(snapshot, plan);
}
export function serializeOneNotePlainText(
snapshot: OneNoteExportSnapshot,
options: OneNoteExportOptions = {}
): string {
const plan = createOneNoteExportPlan(snapshot, options);
return serializePlainTextWithPlan(snapshot, plan);
}
export function serializeOneNoteMarkdown(
snapshot: OneNoteExportSnapshot,
options: OneNoteExportOptions = {}
): string {
const plan = createOneNoteExportPlan(snapshot, options);
return serializeMarkdownWithPlan(snapshot, plan, false);
}
export function serializeOneNoteSemanticHtml(
snapshot: OneNoteExportSnapshot,
options: OneNoteExportOptions = {}
): string {
const plan = createOneNoteExportPlan(snapshot, options);
return serializeHtmlWithPlan(snapshot, plan, false);
}
export function serializeStructuredJsonWithPlan(
snapshot: OneNoteExportSnapshot,
plan: OneNoteExportPlan
): string {
const resources = plan.resources.map((resource) => ({
sectionId: resource.sectionId,
id: resource.id,
kind: resource.kind,
path: resource.path,
filename: resource.filename,
extension: resource.extension,
mediaType: resource.mediaType,
browserRenderable: resource.browserRenderable,
size: resource.size,
}));
const value = {
schemaVersion: snapshot.schemaVersion,
exportKind: 'onenote-tools-structured-export',
app: snapshot.app,
parser: snapshot.parser,
supportLevel: snapshot.supportLevel,
supportNotes: snapshot.supportNotes,
source: snapshot.source,
notebookName: snapshot.notebookName,
...(snapshot.tree ? { tree: snapshot.tree } : {}),
sections: snapshot.sections,
resources,
diagnostics: plan.diagnostics,
warnings: plan.warnings,
unsupportedContent: plan.unsupportedContent,
};
return `${JSON.stringify(value, null, 2)}\n`;
}
export function serializePlainTextWithPlan(
snapshot: OneNoteExportSnapshot,
plan: OneNoteExportPlan
): string {
const lines = [
snapshot.notebookName,
'='.repeat(Math.max(1, snapshot.notebookName.length)),
'',
`Source: ${oneLine(snapshot.source.name)}`,
`Source format: ${snapshot.source.format}`,
`Support level: ${snapshot.supportLevel}`,
`Application: ${oneLine(snapshot.app.name)} ${oneLine(snapshot.app.version)}`,
`Parser: ${oneLine(snapshot.parser.name)} ${oneLine(snapshot.parser.version)}`,
];
if (snapshot.parser.supportedVariants.length > 0) {
lines.push(
`Parser variants: ${snapshot.parser.supportedVariants.map(oneLine).join(', ')}`
);
}
if (snapshot.parser.referenceRevision) {
lines.push(
`Parser reference: ${oneLine(snapshot.parser.referenceRevision)}`
);
}
if (snapshot.supportNotes.length > 0) {
lines.push(
...snapshot.supportNotes.map((note) => `Support note: ${oneLine(note)}`)
);
}
for (const [sectionIndex, section] of snapshot.sections.entries()) {
lines.push('', '', `SECTION ${sectionIndex + 1}: ${oneLine(section.name)}`);
lines.push('-'.repeat(Math.max(1, section.name.length + 11)));
for (const [pageIndex, page] of section.pages.entries()) {
lines.push('', `PAGE ${pageIndex + 1}: ${oneLine(page.title)}`);
if (page.createdAt) lines.push(`Created: ${oneLine(page.createdAt)}`);
if (page.updatedAt) lines.push(`Updated: ${oneLine(page.updatedAt)}`);
if (page.level !== undefined) lines.push(`Level: ${page.level}`);
const rendered = plainBlocks(page.blocks).trim();
const body = rendered || page.text.trim();
lines.push('', body || '[Empty page]');
}
}
appendPlainWarnings(lines, plan);
return `${lines
.join('\n')
.replace(/\n{4,}/gu, '\n\n\n')
.trimEnd()}\n`;
}
export function serializeMarkdownWithPlan(
snapshot: OneNoteExportSnapshot,
plan: OneNoteExportPlan,
linkResources: boolean
): string {
const context = createRenderContext(plan, linkResources);
const lines = [
`# ${markdownText(snapshot.notebookName)}`,
'',
`- Source: ${markdownText(snapshot.source.name)}`,
`- Source format: \`${snapshot.source.format}\``,
`- Support level: \`${snapshot.supportLevel}\``,
`- Application: ${markdownText(snapshot.app.name)} \`${markdownText(snapshot.app.version)}\``,
`- Parser: ${markdownText(snapshot.parser.name)} \`${markdownText(snapshot.parser.version)}\``,
];
if (snapshot.parser.supportedVariants.length > 0) {
lines.push(
`- Parser variants: ${snapshot.parser.supportedVariants.map((value) => `\`${markdownText(value)}\``).join(', ')}`
);
}
if (snapshot.parser.referenceRevision) {
lines.push(
`- Parser reference: \`${markdownText(snapshot.parser.referenceRevision)}\``
);
}
if (snapshot.supportNotes.length > 0) {
lines.push('', '## Support notes', '');
lines.push(
...snapshot.supportNotes.map((note) => `- ${markdownText(note)}`)
);
}
for (const section of snapshot.sections) {
lines.push('', `## ${markdownText(section.name)}`, '');
for (const page of section.pages) {
lines.push(`### ${markdownText(page.title)}`, '');
const metadata = [
page.createdAt ? `Created: ${markdownText(page.createdAt)}` : undefined,
page.updatedAt ? `Updated: ${markdownText(page.updatedAt)}` : undefined,
page.level === undefined ? undefined : `Level: ${page.level}`,
].filter((value): value is string => value !== undefined);
if (metadata.length > 0)
lines.push(...metadata.map((line) => `_${line}_`), '');
const rendered = markdownBlocks(page.blocks, context, section.id).trim();
const body = rendered || markdownText(page.text).trim();
lines.push(body || '*Empty page*', '');
}
}
appendMarkdownWarnings(lines, plan);
return `${lines
.join('\n')
.replace(/\n{4,}/gu, '\n\n\n')
.trimEnd()}\n`;
}
export function serializeHtmlWithPlan(
snapshot: OneNoteExportSnapshot,
plan: OneNoteExportPlan,
linkResources: boolean
): string {
const context = createRenderContext(plan, linkResources);
const variants = snapshot.parser.supportedVariants
.map((value) => `<li>${escapeHtml(value)}</li>`)
.join('');
const supportNotes = snapshot.supportNotes
.map((note) => `<li>${escapeHtml(note)}</li>`)
.join('');
const sections = snapshot.sections
.map((section) => htmlSection(section, context))
.join('\n');
const warnings = htmlWarnings(plan);
return `<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta http-equiv="Content-Security-Policy" content="default-src 'none'; img-src 'self'; style-src 'unsafe-inline'; object-src 'none'; frame-src 'none'; base-uri 'none'; form-action 'none'">
<meta name="generator" content="${escapeAttribute(`${snapshot.app.name} ${snapshot.app.version}`)}">
<meta name="onenote-parser-version" content="${escapeAttribute(snapshot.parser.version)}">
<meta name="onenote-support-level" content="${snapshot.supportLevel}">
<title>${escapeHtml(snapshot.notebookName)}</title>
<style>${STATIC_EXPORT_CSS}</style>
</head>
<body>
<header class="export-header">
<h1>${escapeHtml(snapshot.notebookName)}</h1>
<dl>
<dt>Source</dt><dd>${escapeHtml(snapshot.source.name)}</dd>
<dt>Source format</dt><dd>${snapshot.source.format}</dd>
<dt>Support level</dt><dd>${snapshot.supportLevel}</dd>
<dt>Application</dt><dd>${escapeHtml(snapshot.app.name)} ${escapeHtml(snapshot.app.version)}</dd>
<dt>Parser</dt><dd>${escapeHtml(snapshot.parser.name)} ${escapeHtml(snapshot.parser.version)}</dd>
${snapshot.parser.referenceRevision ? `<dt>Parser reference</dt><dd>${escapeHtml(snapshot.parser.referenceRevision)}</dd>` : ''}
</dl>
${variants ? `<h2>Parser variants</h2><ul>${variants}</ul>` : ''}
${supportNotes ? `<h2>Support notes</h2><ul>${supportNotes}</ul>` : ''}
</header>
<main>
${sections}
</main>
${warnings}
</body>
</html>
`;
}
function htmlSection(
section: OneNoteExportSection,
context: RenderContext
): string {
const pages = section.pages
.map((page) => {
const metadata = [
page.createdAt
? `<dt>Created</dt><dd>${escapeHtml(page.createdAt)}</dd>`
: '',
page.updatedAt
? `<dt>Updated</dt><dd>${escapeHtml(page.updatedAt)}</dd>`
: '',
page.level === undefined ? '' : `<dt>Level</dt><dd>${page.level}</dd>`,
].join('');
const rendered = htmlBlocks(page.blocks, context, section.id);
const body =
rendered ||
(page.text
? `<p class="text">${escapeHtmlWithBreaks(page.text)}</p>`
: '<p class="empty-page">Empty page</p>');
return ` <article class="page">
<h3>${escapeHtml(page.title)}</h3>
${metadata ? `<dl class="page-metadata">${metadata}</dl>` : ''}
<div class="page-content">${body}</div>
</article>`;
})
.join('\n');
return ` <section class="section">
<h2>${escapeHtml(section.name)}</h2>
${pages}
</section>`;
}
function plainBlocks(blocks: readonly OneNoteContentBlock[]): string {
return blocks.map(plainBlock).filter(Boolean).join('\n');
}
function plainBlock(block: OneNoteContentBlock): string {
switch (block.kind) {
case 'text':
return block.text.split('\u000b').join('\n');
case 'image':
return `[Image: ${oneLine(block.altText ?? block.filename ?? 'unnamed')}]`;
case 'attachment':
return `[Attachment: ${oneLine(block.filename)}]`;
case 'table':
return block.rows
.map((row) =>
row.cells
.map((cell) => compactPlain(plainBlocks(cell.blocks)))
.join('\t')
)
.join('\n');
case 'outline':
case 'outline-group':
return plainBlocks(block.children);
case 'outline-element':
return [plainBlocks(block.contents), plainBlocks(block.children)]
.filter(Boolean)
.join('\n');
case 'ink': {
const stats = inkStats(block);
const children = block.children
.map(plainBlock)
.filter(Boolean)
.join('\n');
return [
`[Ink drawing: ${stats.strokes} stroke${stats.strokes === 1 ? '' : 's'}, ${stats.points} point${stats.points === 1 ? '' : 's'}]`,
children,
]
.filter(Boolean)
.join('\n');
}
case 'unsupported':
return `[Unsupported content: ${oneLine(block.description)}]`;
}
}
function markdownBlocks(
blocks: readonly OneNoteContentBlock[],
context: RenderContext,
sectionId: string
): string {
return blocks
.map((block) => markdownBlock(block, context, sectionId))
.filter(Boolean)
.join('\n\n');
}
function markdownBlock(
block: OneNoteContentBlock,
context: RenderContext,
sectionId: string
): string {
switch (block.kind) {
case 'text':
return markdownTextBlock(block);
case 'image': {
const label = markdownText(block.altText ?? block.filename ?? 'Image');
const path = resourcePath(context, sectionId, block.resourceId);
const image = path
? `![${label}](${markdownUrl(path)})`
: `**[Image: ${label}]**`;
const href = safeExternalHref(block.href);
return href ? `[${image}](${markdownUrl(href)})` : image;
}
case 'attachment': {
const label = markdownText(block.filename);
const path = resourcePath(context, sectionId, block.resourceId);
return path
? `[Attachment: ${label}](${markdownUrl(path)})`
: `**[Attachment: ${label}]**`;
}
case 'table':
return markdownTable(block, context, sectionId);
case 'outline':
case 'outline-group':
return markdownBlocks(block.children, context, sectionId);
case 'outline-element':
return [
markdownBlocks(block.contents, context, sectionId),
markdownBlocks(block.children, context, sectionId),
]
.filter(Boolean)
.join('\n\n');
case 'ink': {
const stats = inkStats(block);
return `**[Ink drawing: ${stats.strokes} stroke${stats.strokes === 1 ? '' : 's'}, ${stats.points} point${stats.points === 1 ? '' : 's'}]**`;
}
case 'unsupported':
return `> Unsupported content: ${markdownText(block.description)}`;
}
}
function markdownTextBlock(block: OneNoteTextBlock): string {
const visible = block.runs.filter((run) => run.style.hidden !== true);
if (visible.length === 0) return markdownText(block.text);
return visible.map(markdownRun).join('').split('\u000b').join(' \n');
}
function markdownRun(run: OneNoteTextRun): string {
let value = markdownText(run.text);
if (!value) return '';
if (run.style.bold) value = `**${value}**`;
if (run.style.italic) value = `*${value}*`;
if (run.style.strikethrough) value = `~~${value}~~`;
const href = safeExternalHref(run.href);
return href ? `[${value}](${markdownUrl(href)})` : value;
}
function markdownTable(
block: Extract<OneNoteContentBlock, { kind: 'table' }>,
context: RenderContext,
sectionId: string
): string {
let columnCount = Math.max(0, block.declaredColumnCount ?? 0);
for (const row of block.rows) {
columnCount = Math.max(columnCount, row.cells.length);
}
if (columnCount === 0) return '**[Empty table]**';
const header = Array.from(
{ length: columnCount },
(_, index) => `Column ${index + 1}`
);
const rows = block.rows.map((row) =>
Array.from({ length: columnCount }, (_, index) =>
markdownCell(
row.cells[index]
? markdownBlocks(row.cells[index]!.blocks, context, sectionId)
: ''
)
)
);
return [
`| ${header.join(' | ')} |`,
`| ${header.map(() => '---').join(' | ')} |`,
...rows.map((row) => `| ${row.join(' | ')} |`),
].join('\n');
}
function htmlBlocks(
blocks: readonly OneNoteContentBlock[],
context: RenderContext,
sectionId: string
): string {
return blocks.map((block) => htmlBlock(block, context, sectionId)).join('');
}
function htmlBlock(
block: OneNoteContentBlock,
context: RenderContext,
sectionId: string
): string {
switch (block.kind) {
case 'text': {
const runs = block.runs.filter((run) => run.style.hidden !== true);
const contents =
runs.length > 0
? runs.map(htmlRun).join('')
: escapeHtmlWithBreaks(block.text);
const alignment =
block.paragraphAlignment === 'center' ||
block.paragraphAlignment === 'right'
? ` align-${block.paragraphAlignment}`
: '';
return `<p class="text${alignment}"${block.rightToLeft ? ' dir="rtl"' : ''}>${contents}</p>`;
}
case 'image': {
const label = block.altText ?? block.filename ?? 'OneNote image';
const path = resourcePath(context, sectionId, block.resourceId);
if (!path) {
return `<figure class="resource-placeholder"><figcaption>Image: ${escapeHtml(label)}</figcaption></figure>`;
}
const image = `<img src="${escapeAttribute(pathUrl(path))}" alt="${escapeAttribute(label)}" loading="lazy">`;
const href = safeExternalHref(block.href);
return `<figure>${href ? `<a href="${escapeAttribute(href)}" rel="noopener noreferrer">${image}</a>` : image}<figcaption>${escapeHtml(label)}</figcaption></figure>`;
}
case 'attachment': {
const path = resourcePath(context, sectionId, block.resourceId);
return `<p class="attachment">${path ? `<a href="${escapeAttribute(pathUrl(path))}" download>Attachment: ${escapeHtml(block.filename)}</a>` : `Attachment: ${escapeHtml(block.filename)} (unavailable)`}</p>`;
}
case 'table':
return `<table><tbody>${block.rows
.map(
(row) =>
`<tr>${row.cells
.map(
(cell) =>
`<td>${htmlBlocks(cell.blocks, context, sectionId)}</td>`
)
.join('')}</tr>`
)
.join('')}</tbody></table>`;
case 'outline':
return `<section class="outline">${htmlBlocks(block.children, context, sectionId)}</section>`;
case 'outline-group':
return `<section class="outline-group">${htmlBlocks(block.children, context, sectionId)}</section>`;
case 'outline-element':
return `<div class="outline-element">${htmlBlocks(block.contents, context, sectionId)}${block.children.length > 0 ? `<div class="outline-children">${htmlBlocks(block.children, context, sectionId)}</div>` : ''}</div>`;
case 'ink':
return htmlInk(block, context, sectionId);
case 'unsupported':
return `<aside class="unsupported" role="note">Unsupported content: ${escapeHtml(block.description)}</aside>`;
}
}
function htmlRun(run: OneNoteTextRun): string {
let value = escapeHtmlWithBreaks(run.text);
if (!value) return '';
if (run.style.bold) value = `<strong>${value}</strong>`;
if (run.style.italic) value = `<em>${value}</em>`;
if (run.style.underline) value = `<u>${value}</u>`;
if (run.style.strikethrough) value = `<del>${value}</del>`;
if (run.style.superscript) value = `<sup>${value}</sup>`;
if (run.style.subscript) value = `<sub>${value}</sub>`;
const styles: string[] = [];
const color = cssColor(run.style.fontColor);
const highlight = cssColor(run.style.highlight);
if (color) styles.push(`color:${color}`);
if (highlight) styles.push(`background-color:${highlight}`);
if (
run.style.fontSize !== undefined &&
Number.isFinite(run.style.fontSize) &&
run.style.fontSize >= 2 &&
run.style.fontSize <= 800
) {
styles.push(`font-size:${formatNumber(run.style.fontSize / 2)}pt`);
}
if (styles.length > 0)
value = `<span style="${styles.join(';')}">${value}</span>`;
const href = safeExternalHref(run.href);
return href
? `<a href="${escapeAttribute(href)}" rel="noopener noreferrer">${value}</a>`
: value;
}
function htmlInk(
block: OneNoteInkBlock,
context: RenderContext,
sectionId: string
): string {
const stats = inkStats(block);
const bounds =
validBounds(block.boundingBox) ?? boundsFromStrokes(block.strokes);
let drawing = '';
if (bounds) {
let maxWidth = 1;
for (const stroke of block.strokes) {
maxWidth = Math.max(
maxWidth,
Number.isFinite(stroke.width) && stroke.width! > 0 ? stroke.width! : 35
);
}
const padding = maxWidth / 2;
const viewBox = [
bounds.x - padding,
bounds.y - padding,
Math.max(1, bounds.width + padding * 2),
Math.max(1, bounds.height + padding * 2),
]
.map(formatNumber)
.join(' ');
const strokes = block.strokes.map(svgStroke).join('');
drawing = `<svg class="ink" viewBox="${viewBox}" role="img" aria-label="Ink drawing with ${stats.strokes} strokes">${strokes}</svg>`;
}
const children = block.children
.map((child) => htmlInk(child, context, sectionId))
.join('');
return `<figure class="ink-figure">${drawing || '<div class="resource-placeholder">Ink drawing unavailable</div>'}<figcaption>Ink drawing: ${stats.strokes} stroke${stats.strokes === 1 ? '' : 's'}, ${stats.points} point${stats.points === 1 ? '' : 's'}</figcaption>${children}</figure>`;
}
function svgStroke(stroke: OneNoteInkBlock['strokes'][number]): string {
const points = stroke.points.filter(
(point) => Number.isFinite(point.x) && Number.isFinite(point.y)
);
if (points.length === 0) return '';
const color = inkColor(stroke.color);
const width =
Number.isFinite(stroke.width) && stroke.width! > 0 ? stroke.width! : 35;
const transparency =
stroke.transparency !== undefined && Number.isFinite(stroke.transparency)
? Math.max(0, Math.min(255, stroke.transparency))
: undefined;
const opacity = transparency === undefined ? 1 : (255 - transparency) / 256;
if (points.length === 1) {
return `<circle cx="${formatNumber(points[0]!.x)}" cy="${formatNumber(points[0]!.y)}" r="${formatNumber(width / 2)}" fill="${color}" fill-opacity="${formatNumber(opacity)}"/>`;
}
const path = points
.map((point) => `${formatNumber(point.x)},${formatNumber(point.y)}`)
.join(' ');
return `<polyline points="${path}" fill="none" stroke="${color}" stroke-opacity="${formatNumber(opacity)}" stroke-width="${formatNumber(width)}" stroke-linecap="round" stroke-linejoin="round"/>`;
}
function appendPlainWarnings(lines: string[], plan: OneNoteExportPlan): void {
lines.push('', '', 'WARNINGS AND DIAGNOSTICS', '------------------------');
if (
plan.diagnostics.length === 0 &&
plan.warnings.length === 0 &&
plan.unsupportedContent.length === 0
) {
lines.push('None.');
return;
}
for (const diagnostic of plan.diagnostics) {
lines.push(diagnosticLine(diagnostic));
}
for (const warning of plan.warnings) {
lines.push(`[export warning] ${warning.code}: ${oneLine(warning.message)}`);
}
for (const item of plan.unsupportedContent) {
lines.push(
`[unsupported] JCID 0x${item.sourceJcid.toString(16)}: ${oneLine(item.description)}`
);
}
}
function appendMarkdownWarnings(
lines: string[],
plan: OneNoteExportPlan
): void {
lines.push('', '## Warnings and diagnostics', '');
if (
plan.diagnostics.length === 0 &&
plan.warnings.length === 0 &&
plan.unsupportedContent.length === 0
) {
lines.push('None.');
return;
}
for (const diagnostic of plan.diagnostics) {
lines.push(`- ${markdownText(diagnosticLine(diagnostic))}`);
}
for (const warning of plan.warnings) {
lines.push(
`- Export warning \`${markdownText(warning.code)}\`: ${markdownText(warning.message)}`
);
}
for (const item of plan.unsupportedContent) {
lines.push(
`- Unsupported JCID \`0x${item.sourceJcid.toString(16)}\`: ${markdownText(item.description)}`
);
}
}
function htmlWarnings(plan: OneNoteExportPlan): string {
const rows = [
...plan.diagnostics.map(
(diagnostic) =>
`<li><strong>${escapeHtml(diagnostic.severity)}</strong> <code>${escapeHtml(diagnostic.code)}</code>: ${escapeHtml(diagnostic.message)}</li>`
),
...plan.warnings.map(
(warning) =>
`<li><strong>export warning</strong> <code>${escapeHtml(warning.code)}</code>: ${escapeHtml(warning.message)}</li>`
),
...plan.unsupportedContent.map(
(item) =>
`<li><strong>unsupported</strong> <code>JCID 0x${item.sourceJcid.toString(16)}</code>: ${escapeHtml(item.description)}</li>`
),
];
return `<aside class="warnings" aria-labelledby="warnings-title"><h2 id="warnings-title">Warnings and diagnostics</h2>${rows.length > 0 ? `<ul>${rows.join('')}</ul>` : '<p>None.</p>'}</aside>`;
}
function createRenderContext(
plan: OneNoteExportPlan,
linkResources: boolean
): RenderContext {
return {
linkResources,
resources: new Map(
plan.resources.map((resource) => [
exportResourceKey(resource.sectionId, resource.id),
resource,
])
),
};
}
function resourcePath(
context: RenderContext,
sectionId: string,
resourceId: string | undefined
): string | undefined {
if (!context.linkResources || !resourceId) return undefined;
return context.resources.get(exportResourceKey(sectionId, resourceId))?.path;
}
function safeExternalHref(value: string | undefined): string | undefined {
if (!value || containsControl(value)) return undefined;
try {
const url = new URL(value);
return url.protocol === 'http:' ||
url.protocol === 'https:' ||
url.protocol === 'mailto:'
? value
: undefined;
} catch {
return undefined;
}
}
function containsControl(value: string): boolean {
for (let index = 0; index < value.length; index += 1) {
const code = value.charCodeAt(index);
if (code <= 0x1f || code === 0x7f) return true;
}
return false;
}
function markdownText(value: string): string {
return value
.split('\0')
.join('')
.replace(/&/gu, '&amp;')
.replace(/</gu, '&lt;')
.replace(/>/gu, '&gt;')
.replace(/([\\`*_{}[\]()#+.!|~-])/gu, '\\$1');
}
function markdownUrl(value: string): string {
if (/^[a-z][a-z0-9+.-]*:/iu.test(value)) {
return encodeURI(value).replace(/[()]/gu, (character) =>
encodeURIComponent(character)
);
}
return pathUrl(value);
}
function markdownCell(value: string): string {
return value.replace(/\s+/gu, ' ').replace(/\|/gu, '\\|').trim();
}
function pathUrl(path: string): string {
return path.split('/').map(encodeURIComponent).join('/');
}
function escapeHtml(value: string): string {
return value
.split('\0')
.join('')
.replace(/&/gu, '&amp;')
.replace(/</gu, '&lt;')
.replace(/>/gu, '&gt;')
.replace(/"/gu, '&quot;')
.replace(/'/gu, '&#39;');
}
function escapeAttribute(value: string): string {
return escapeHtml(value).replace(/\r|\n/gu, ' ');
}
function escapeHtmlWithBreaks(value: string): string {
return escapeHtml(value)
.split('\u000b')
.join('<br>')
.replace(/\r\n|\r|\n/gu, '<br>');
}
function cssColor(color: OneNoteColor | undefined): string | undefined {
if (!color || color.kind === 'auto') return undefined;
const red = colorByte(color.red);
const green = colorByte(color.green);
const blue = colorByte(color.blue);
if (red === undefined || green === undefined || blue === undefined) {
return undefined;
}
if (color.kind === 'rgba') {
const alpha = colorByte(color.alpha);
if (alpha === undefined) return undefined;
return `rgba(${red},${green},${blue},${formatNumber(alpha / 255)})`;
}
return `rgb(${red},${green},${blue})`;
}
function colorByte(value: number | undefined): number | undefined {
return value !== undefined &&
Number.isInteger(value) &&
value >= 0 &&
value <= 255
? value
: undefined;
}
function inkColor(value: number | undefined): string {
if (value === undefined || !Number.isSafeInteger(value)) return '#222222';
const red = value & 0xff;
const green = (value >>> 8) & 0xff;
const blue = (value >>> 16) & 0xff;
return `rgb(${red},${green},${blue})`;
}
function validBounds(
value: OneNoteInkBlock['boundingBox']
): NonNullable<OneNoteInkBlock['boundingBox']> | undefined {
return value &&
Number.isFinite(value.x) &&
Number.isFinite(value.y) &&
Number.isFinite(value.width) &&
Number.isFinite(value.height) &&
value.width >= 0 &&
value.height >= 0
? value
: undefined;
}
function boundsFromStrokes(
strokes: readonly OneNoteInkBlock['strokes'][number][]
): NonNullable<OneNoteInkBlock['boundingBox']> | undefined {
let x = Number.POSITIVE_INFINITY;
let y = Number.POSITIVE_INFINITY;
let x2 = Number.NEGATIVE_INFINITY;
let y2 = Number.NEGATIVE_INFINITY;
for (const stroke of strokes) {
for (const point of stroke.points) {
if (!Number.isFinite(point.x) || !Number.isFinite(point.y)) continue;
x = Math.min(x, point.x);
y = Math.min(y, point.y);
x2 = Math.max(x2, point.x);
y2 = Math.max(y2, point.y);
}
}
if (!Number.isFinite(x)) return undefined;
return { x, y, width: x2 - x, height: y2 - y };
}
function inkStats(block: OneNoteInkBlock): { strokes: number; points: number } {
let strokes = block.strokes.length;
let points = block.strokes.reduce(
(total, stroke) => total + stroke.points.length,
0
);
for (const child of block.children) {
const childStats = inkStats(child);
strokes += childStats.strokes;
points += childStats.points;
}
return { strokes, points };
}
function diagnosticLine(diagnostic: ParserDiagnostic): string {
return `[${diagnostic.severity}] ${oneLine(diagnostic.code)}: ${oneLine(diagnostic.message)}`;
}
function oneLine(value: string): string {
return value.split('\0').join('').replace(/\s+/gu, ' ').trim();
}
function compactPlain(value: string): string {
return value.replace(/\s+/gu, ' ').trim();
}
function formatNumber(value: number): string {
return String(Math.round(value * 1000) / 1000);
}
const STATIC_EXPORT_CSS = `
:root{color-scheme:light dark;font-family:system-ui,sans-serif;line-height:1.5}
body{max-width:72rem;margin:0 auto;padding:2rem}
.export-header,.section,.warnings{margin-block:2rem}
dl{display:grid;grid-template-columns:max-content 1fr;gap:.25rem 1rem}dt{font-weight:700}
.page{border-top:1px solid #8886;padding-block:1.5rem}.page-metadata{font-size:.875rem}
.page-content{overflow-wrap:anywhere}.text{white-space:normal}.align-center{text-align:center}.align-right{text-align:right}
.outline-children{margin-inline-start:2rem}table{border-collapse:collapse;max-width:100%}td{border:1px solid #888;padding:.4rem;vertical-align:top}
figure{margin:1rem 0}img{display:block;max-width:100%;height:auto}.ink{display:block;max-width:100%;height:auto;max-height:32rem}
.attachment,.resource-placeholder,.unsupported,.warnings{border:1px solid #8886;padding:.75rem}.unsupported,.warnings{background:#f3b33d22}
code{font-family:ui-monospace,monospace}
`;

185
src/onenote/export/types.ts Normal file
View File

@@ -0,0 +1,185 @@
// This Source Code Form is subject to the terms of the Mozilla Public
// License, v. 2.0. If a copy of the MPL was not distributed with this file,
// You can obtain one at https://mozilla.org/MPL/2.0/.
// SPDX-License-Identifier: MPL-2.0
import { APP_VERSION } from '../../version.js';
import type { ParserDiagnostic } from '../model/diagnostics.js';
import type { OneNoteNotebookTreeDto, OneNotePageDto } from '../model/dto.js';
export const ONENOTE_EXPORT_SCHEMA_VERSION = 1 as const;
export type OneNoteExportSupportLevel =
'metadata-only' | 'partial' | 'substantial' | 'complete';
export interface OneNoteExportParserMetadata {
name: string;
version: string;
implementation: string;
supportedVariants: string[];
referenceRevision?: string;
}
export interface OneNoteExportSection {
id: string;
name: string;
sourcePath?: string;
pages: OneNotePageDto[];
diagnostics: ParserDiagnostic[];
}
/** Resource payload fetched from the parser worker for a particular section. */
export interface OneNoteExportResource {
sectionId: string;
id: string;
kind: 'image' | 'attachment';
filename?: string;
extension?: string;
mediaType: string;
browserRenderable: boolean;
size?: number;
bytes: ArrayBuffer | Uint8Array;
}
export interface OneNoteExportSnapshot {
schemaVersion: typeof ONENOTE_EXPORT_SCHEMA_VERSION;
app: {
name: string;
version: string;
};
parser: OneNoteExportParserMetadata;
supportLevel: OneNoteExportSupportLevel;
supportNotes: string[];
source: {
name: string;
format: 'one' | 'onepkg';
};
notebookName: string;
sections: OneNoteExportSection[];
resources: OneNoteExportResource[];
diagnostics: ParserDiagnostic[];
tree?: OneNoteNotebookTreeDto;
}
export interface CreateOneNoteExportSnapshotOptions {
sourceName: string;
sourceFormat: 'one' | 'onepkg';
notebookName?: string;
appName?: string;
appVersion?: string;
parser?: Partial<OneNoteExportParserMetadata>;
supportLevel?: OneNoteExportSupportLevel;
supportNotes?: readonly string[];
sections: readonly OneNoteExportSection[];
resources?: readonly OneNoteExportResource[];
diagnostics?: readonly ParserDiagnostic[];
tree?: OneNoteNotebookTreeDto;
}
export interface OneNoteExportLimits {
maxSections: number;
maxPages: number;
maxBlocks: number;
maxDepth: number;
maxDiagnostics: number;
maxResourceBytes: number;
maxTotalResourceBytes: number;
}
export const DEFAULT_ONENOTE_EXPORT_LIMITS: Readonly<OneNoteExportLimits> = {
maxSections: 10_000,
maxPages: 100_000,
maxBlocks: 1_000_000,
maxDepth: 256,
maxDiagnostics: 100_000,
maxResourceBytes: 256 * 1024 * 1024,
maxTotalResourceBytes: 512 * 1024 * 1024,
};
export interface OneNoteExportOptions {
limits?: Partial<OneNoteExportLimits>;
}
export class OneNoteExportError extends Error {
constructor(
readonly code: 'invalid-input' | 'limit-exceeded',
message: string
) {
super(message);
this.name = 'OneNoteExportError';
}
}
export function createOneNoteExportSnapshot(
options: CreateOneNoteExportSnapshotOptions
): OneNoteExportSnapshot {
const sourceName = cleanLabel(options.sourceName, 'Untitled source');
const notebookName = cleanLabel(options.notebookName, sourceName);
const parserName = cleanLabel(
options.parser?.name,
'onenote-tools TypeScript parser'
);
const parserVersion = cleanLabel(options.parser?.version, APP_VERSION);
const implementation = cleanLabel(
options.parser?.implementation,
'typescript'
);
const sectionIds = new Set<string>();
const sections = options.sections.map((section, index) => {
const id = cleanLabel(section.id, `section-${index + 1}`);
if (sectionIds.has(id)) {
throw new OneNoteExportError(
'invalid-input',
`Duplicate export section ID ${id}`
);
}
sectionIds.add(id);
return {
...section,
id,
name: cleanLabel(section.name, `Section ${index + 1}`),
pages: [...section.pages],
diagnostics: [...section.diagnostics],
};
});
return {
schemaVersion: ONENOTE_EXPORT_SCHEMA_VERSION,
app: {
name: cleanLabel(options.appName, 'OneNote Tools'),
version: cleanLabel(options.appVersion, APP_VERSION),
},
parser: {
name: parserName,
version: parserVersion,
implementation,
supportedVariants: (options.parser?.supportedVariants ?? []).map(
(value) => cleanLabel(value, 'unknown')
),
...(options.parser?.referenceRevision
? {
referenceRevision: cleanLabel(
options.parser.referenceRevision,
'unknown'
),
}
: {}),
},
supportLevel: options.supportLevel ?? 'partial',
supportNotes: (options.supportNotes ?? []).map((value) =>
cleanLabel(value, 'Unspecified support note')
),
source: { name: sourceName, format: options.sourceFormat },
notebookName,
sections,
resources: [...(options.resources ?? [])],
diagnostics: [...(options.diagnostics ?? [])],
...(options.tree ? { tree: options.tree } : {}),
};
}
function cleanLabel(value: string | undefined, fallback: string): string {
const cleaned = value?.split('\0').join('').trim();
return cleaned || fallback;
}

178
src/onenote/export/zip.ts Normal file
View File

@@ -0,0 +1,178 @@
// This Source Code Form is subject to the terms of the Mozilla Public
// License, v. 2.0. If a copy of the MPL was not distributed with this file,
// You can obtain one at https://mozilla.org/MPL/2.0/.
// SPDX-License-Identifier: MPL-2.0
import { strToU8, zipSync, type Zippable } from 'fflate';
import { forceFilenameExtension, sanitizeExportFilename } from './naming.js';
import { createOneNoteExportPlan, type OneNoteExportPlan } from './plan.js';
import {
serializeHtmlWithPlan,
serializeMarkdownWithPlan,
serializePlainTextWithPlan,
serializeStructuredJsonWithPlan,
} from './serializers.js';
import type { OneNoteExportOptions, OneNoteExportSnapshot } from './types.js';
export interface OneNoteStaticNotebookManifest {
schemaVersion: number;
exportKind: 'onenote-tools-static-notebook';
app: OneNoteExportSnapshot['app'];
parser: OneNoteExportSnapshot['parser'];
supportLevel: OneNoteExportSnapshot['supportLevel'];
supportNotes: string[];
source: OneNoteExportSnapshot['source'];
notebookName: string;
files: {
viewer: 'index.html';
structuredData: 'notebook.json';
plainText: 'notebook.txt';
markdown: 'notebook.md';
warnings: 'warnings.json';
};
counts: {
sections: number;
pages: number;
blocks: number;
resources: number;
diagnostics: number;
exportWarnings: number;
unsupportedContent: number;
};
sections: Array<{
id: string;
name: string;
sourcePath?: string;
pageCount: number;
}>;
resources: Array<{
sectionId: string;
id: string;
kind: 'image' | 'attachment';
path: string;
filename: string;
mediaType: string;
size: number;
}>;
}
export interface OneNoteExportWarningsManifest {
schemaVersion: number;
app: OneNoteExportSnapshot['app'];
parser: OneNoteExportSnapshot['parser'];
supportLevel: OneNoteExportSnapshot['supportLevel'];
source: OneNoteExportSnapshot['source'];
diagnostics: OneNoteExportPlan['diagnostics'];
exportWarnings: OneNoteExportPlan['warnings'];
unsupportedContent: OneNoteExportPlan['unsupportedContent'];
}
export interface OneNoteStaticNotebookZipArtifact {
filename: string;
mediaType: 'application/zip';
bytes: Uint8Array;
manifest: OneNoteStaticNotebookManifest;
}
/**
* Create an application-owned static export. This never writes or claims to
* write a .one/.onepkg file, and it never interprets source HTML or SVG.
*/
export function createOneNoteStaticNotebookZip(
snapshot: OneNoteExportSnapshot,
options: OneNoteExportOptions = {}
): OneNoteStaticNotebookZipArtifact {
const plan = createOneNoteExportPlan(snapshot, options);
const manifest = createStaticManifest(snapshot, plan);
const warnings: OneNoteExportWarningsManifest = {
schemaVersion: snapshot.schemaVersion,
app: snapshot.app,
parser: snapshot.parser,
supportLevel: snapshot.supportLevel,
source: snapshot.source,
diagnostics: plan.diagnostics,
exportWarnings: plan.warnings,
unsupportedContent: plan.unsupportedContent,
};
const files: Zippable = Object.create(null) as Zippable;
files['index.html'] = strToU8(serializeHtmlWithPlan(snapshot, plan, true));
files['notebook.json'] = strToU8(
serializeStructuredJsonWithPlan(snapshot, plan)
);
files['notebook.txt'] = strToU8(serializePlainTextWithPlan(snapshot, plan));
files['notebook.md'] = strToU8(
serializeMarkdownWithPlan(snapshot, plan, true)
);
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,
mtime: new Date(1980, 0, 1, 0, 0, 0),
os: 3,
attrs: 0o644 << 16,
});
const safeName = sanitizeExportFilename(
snapshot.notebookName,
'onenote-export'
);
return {
filename: forceFilenameExtension(safeName, 'zip'),
mediaType: 'application/zip',
bytes,
manifest,
};
}
function createStaticManifest(
snapshot: OneNoteExportSnapshot,
plan: OneNoteExportPlan
): OneNoteStaticNotebookManifest {
return {
schemaVersion: snapshot.schemaVersion,
exportKind: 'onenote-tools-static-notebook',
app: snapshot.app,
parser: snapshot.parser,
supportLevel: snapshot.supportLevel,
supportNotes: snapshot.supportNotes,
source: snapshot.source,
notebookName: snapshot.notebookName,
files: {
viewer: 'index.html',
structuredData: 'notebook.json',
plainText: 'notebook.txt',
markdown: 'notebook.md',
warnings: 'warnings.json',
},
counts: {
sections: snapshot.sections.length,
pages: plan.pageCount,
blocks: plan.blockCount,
resources: plan.resources.length,
diagnostics: plan.diagnostics.length,
exportWarnings: plan.warnings.length,
unsupportedContent: plan.unsupportedContent.length,
},
sections: snapshot.sections.map((section) => ({
id: section.id,
name: section.name,
...(section.sourcePath ? { sourcePath: section.sourcePath } : {}),
pageCount: section.pages.length,
})),
resources: plan.resources.map((resource) => ({
sectionId: resource.sectionId,
id: resource.id,
kind: resource.kind,
path: resource.path,
filename: resource.filename,
mediaType: resource.mediaType,
size: resource.size,
})),
};
}
function jsonBytes(value: unknown): Uint8Array {
return strToU8(`${JSON.stringify(value, null, 2)}\n`);
}