From ece969e2699fa6a558441e21cf0b28dee68c1bba Mon Sep 17 00:00:00 2001 From: Albrecht Degering Date: Wed, 22 Jul 2026 19:36:20 +0200 Subject: [PATCH] feat: add safe OneNote export serializers --- src/onenote/export/export.test.ts | 453 ++++++++++++++++ src/onenote/export/index.ts | 46 ++ src/onenote/export/naming.ts | 158 ++++++ src/onenote/export/plan.ts | 450 ++++++++++++++++ src/onenote/export/serializers.ts | 858 ++++++++++++++++++++++++++++++ src/onenote/export/types.ts | 185 +++++++ src/onenote/export/zip.ts | 178 +++++++ 7 files changed, 2328 insertions(+) create mode 100644 src/onenote/export/export.test.ts create mode 100644 src/onenote/export/index.ts create mode 100644 src/onenote/export/naming.ts create mode 100644 src/onenote/export/plan.ts create mode 100644 src/onenote/export/serializers.ts create mode 100644 src/onenote/export/types.ts create mode 100644 src/onenote/export/zip.ts diff --git a/src/onenote/export/export.test.ts b/src/onenote/export/export.test.ts new file mode 100644 index 0000000..7a89415 --- /dev/null +++ b/src/onenote/export/export.test.ts @@ -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 = ''; + +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>; + }; + + expect(structured).toMatchObject({ + app: { version: '1.2.3' }, + parser: { version: '9.8.7' }, + supportLevel: 'substantial', + source: { name: '../../' + ); + + 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: '../../', + }, + ]; + + return createOneNoteExportSnapshot({ + sourceName: '../../', + text: SOURCE_MARKUP, + textPreview: SOURCE_MARKUP, + blocks, + diagnostics: [diagnostic('page-warning')], + }, + { + id: 'page-2', + title: 'Fallback', + text: 'Fallback page text ', + 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(''), + }, + { + 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'), + }, + ], + 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} `, + recoverable: true, + }; +} diff --git a/src/onenote/export/index.ts b/src/onenote/export/index.ts new file mode 100644 index 0000000..7763f1f --- /dev/null +++ b/src/onenote/export/index.ts @@ -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'; diff --git a/src/onenote/export/naming.ts b/src/onenote/export/naming.ts new file mode 100644 index 0000000..1111eaf --- /dev/null +++ b/src/onenote/export/naming.ts @@ -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(); + + 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 + ); +} diff --git a/src/onenote/export/plan.ts b/src/onenote/export/plan.ts new file mode 100644 index 0000000..0e4c384 --- /dev/null +++ b/src/onenote/export/plan.ts @@ -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; + 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(); + 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(); + const seenResourceKeys = new Set(); + const resourceAllocators = new Map(); + 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(); + 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, + 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, + 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 | 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}` + ); +} diff --git a/src/onenote/export/serializers.ts b/src/onenote/export/serializers.ts new file mode 100644 index 0000000..361db77 --- /dev/null +++ b/src/onenote/export/serializers.ts @@ -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; +} + +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) => `
  • ${escapeHtml(value)}
  • `) + .join(''); + const supportNotes = snapshot.supportNotes + .map((note) => `
  • ${escapeHtml(note)}
  • `) + .join(''); + const sections = snapshot.sections + .map((section) => htmlSection(section, context)) + .join('\n'); + const warnings = htmlWarnings(plan); + return ` + + + + + + + + + ${escapeHtml(snapshot.notebookName)} + + + +
    +

    ${escapeHtml(snapshot.notebookName)}

    +
    +
    Source
    ${escapeHtml(snapshot.source.name)}
    +
    Source format
    ${snapshot.source.format}
    +
    Support level
    ${snapshot.supportLevel}
    +
    Application
    ${escapeHtml(snapshot.app.name)} ${escapeHtml(snapshot.app.version)}
    +
    Parser
    ${escapeHtml(snapshot.parser.name)} ${escapeHtml(snapshot.parser.version)}
    + ${snapshot.parser.referenceRevision ? `
    Parser reference
    ${escapeHtml(snapshot.parser.referenceRevision)}
    ` : ''} +
    + ${variants ? `

    Parser variants

      ${variants}
    ` : ''} + ${supportNotes ? `

    Support notes

      ${supportNotes}
    ` : ''} +
    +
    +${sections} +
    +${warnings} + + +`; +} + +function htmlSection( + section: OneNoteExportSection, + context: RenderContext +): string { + const pages = section.pages + .map((page) => { + const metadata = [ + page.createdAt + ? `
    Created
    ${escapeHtml(page.createdAt)}
    ` + : '', + page.updatedAt + ? `
    Updated
    ${escapeHtml(page.updatedAt)}
    ` + : '', + page.level === undefined ? '' : `
    Level
    ${page.level}
    `, + ].join(''); + const rendered = htmlBlocks(page.blocks, context, section.id); + const body = + rendered || + (page.text + ? `

    ${escapeHtmlWithBreaks(page.text)}

    ` + : '

    Empty page

    '); + return `
    +

    ${escapeHtml(page.title)}

    + ${metadata ? `` : ''} +
    ${body}
    +
    `; + }) + .join('\n'); + return `
    +

    ${escapeHtml(section.name)}

    +${pages} +
    `; +} + +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, + 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 `

    ${contents}

    `; + } + case 'image': { + const label = block.altText ?? block.filename ?? 'OneNote image'; + const path = resourcePath(context, sectionId, block.resourceId); + if (!path) { + return `
    Image: ${escapeHtml(label)}
    `; + } + const image = `${escapeAttribute(label)}`; + const href = safeExternalHref(block.href); + return `
    ${href ? `${image}` : image}
    ${escapeHtml(label)}
    `; + } + case 'attachment': { + const path = resourcePath(context, sectionId, block.resourceId); + return `

    ${path ? `Attachment: ${escapeHtml(block.filename)}` : `Attachment: ${escapeHtml(block.filename)} (unavailable)`}

    `; + } + case 'table': + return `${block.rows + .map( + (row) => + `${row.cells + .map( + (cell) => + `` + ) + .join('')}` + ) + .join('')}
    ${htmlBlocks(cell.blocks, context, sectionId)}
    `; + case 'outline': + return `
    ${htmlBlocks(block.children, context, sectionId)}
    `; + case 'outline-group': + return `
    ${htmlBlocks(block.children, context, sectionId)}
    `; + case 'outline-element': + return `
    ${htmlBlocks(block.contents, context, sectionId)}${block.children.length > 0 ? `
    ${htmlBlocks(block.children, context, sectionId)}
    ` : ''}
    `; + case 'ink': + return htmlInk(block, context, sectionId); + case 'unsupported': + return ``; + } +} + +function htmlRun(run: OneNoteTextRun): string { + let value = escapeHtmlWithBreaks(run.text); + if (!value) return ''; + if (run.style.bold) value = `${value}`; + if (run.style.italic) value = `${value}`; + if (run.style.underline) value = `${value}`; + if (run.style.strikethrough) value = `${value}`; + if (run.style.superscript) value = `${value}`; + if (run.style.subscript) value = `${value}`; + 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 = `${value}`; + const href = safeExternalHref(run.href); + return href + ? `${value}` + : 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 = `${strokes}`; + } + const children = block.children + .map((child) => htmlInk(child, context, sectionId)) + .join(''); + return `
    ${drawing || '
    Ink drawing unavailable
    '}
    Ink drawing: ${stats.strokes} stroke${stats.strokes === 1 ? '' : 's'}, ${stats.points} point${stats.points === 1 ? '' : 's'}
    ${children}
    `; +} + +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 ``; + } + const path = points + .map((point) => `${formatNumber(point.x)},${formatNumber(point.y)}`) + .join(' '); + return ``; +} + +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) => + `
  • ${escapeHtml(diagnostic.severity)} ${escapeHtml(diagnostic.code)}: ${escapeHtml(diagnostic.message)}
  • ` + ), + ...plan.warnings.map( + (warning) => + `
  • export warning ${escapeHtml(warning.code)}: ${escapeHtml(warning.message)}
  • ` + ), + ...plan.unsupportedContent.map( + (item) => + `
  • unsupported JCID 0x${item.sourceJcid.toString(16)}: ${escapeHtml(item.description)}
  • ` + ), + ]; + return ``; +} + +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, '\\$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, '''); +} + +function escapeAttribute(value: string): string { + return escapeHtml(value).replace(/\r|\n/gu, ' '); +} + +function escapeHtmlWithBreaks(value: string): string { + return escapeHtml(value) + .split('\u000b') + .join('
    ') + .replace(/\r\n|\r|\n/gu, '
    '); +} + +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 | 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 | 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} +`; diff --git a/src/onenote/export/types.ts b/src/onenote/export/types.ts new file mode 100644 index 0000000..21d6903 --- /dev/null +++ b/src/onenote/export/types.ts @@ -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; + 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 = { + 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; +} + +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(); + 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; +} diff --git a/src/onenote/export/zip.ts b/src/onenote/export/zip.ts new file mode 100644 index 0000000..8fa0fbc --- /dev/null +++ b/src/onenote/export/zip.ts @@ -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`); +}