feat: add safe OneNote export serializers
This commit is contained in:
453
src/onenote/export/export.test.ts
Normal file
453
src/onenote/export/export.test.ts
Normal 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`<script>bad\(\)</script>`
|
||||||
|
);
|
||||||
|
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,
|
||||||
|
};
|
||||||
|
}
|
||||||
46
src/onenote/export/index.ts
Normal file
46
src/onenote/export/index.ts
Normal 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';
|
||||||
158
src/onenote/export/naming.ts
Normal file
158
src/onenote/export/naming.ts
Normal 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
450
src/onenote/export/plan.ts
Normal 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}`
|
||||||
|
);
|
||||||
|
}
|
||||||
858
src/onenote/export/serializers.ts
Normal file
858
src/onenote/export/serializers.ts
Normal 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
|
||||||
|
? `})`
|
||||||
|
: `**[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, '&')
|
||||||
|
.replace(/</gu, '<')
|
||||||
|
.replace(/>/gu, '>')
|
||||||
|
.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, '&')
|
||||||
|
.replace(/</gu, '<')
|
||||||
|
.replace(/>/gu, '>')
|
||||||
|
.replace(/"/gu, '"')
|
||||||
|
.replace(/'/gu, ''');
|
||||||
|
}
|
||||||
|
|
||||||
|
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
185
src/onenote/export/types.ts
Normal 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
178
src/onenote/export/zip.ts
Normal 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`);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user