feat: add safe OneNote export serializers

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

View File

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