876 lines
30 KiB
TypeScript
876 lines
30 KiB
TypeScript
// 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,
|
|
enforceSerializedTextOutput,
|
|
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 enforceSerializedTextOutput(
|
|
`${JSON.stringify(value, null, 2)}\n`,
|
|
plan,
|
|
'Structured JSON export'
|
|
);
|
|
}
|
|
|
|
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 enforceSerializedTextOutput(
|
|
`${lines
|
|
.join('\n')
|
|
.replace(/\n{4,}/gu, '\n\n\n')
|
|
.trimEnd()}\n`,
|
|
plan,
|
|
'Plain-text export'
|
|
);
|
|
}
|
|
|
|
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 enforceSerializedTextOutput(
|
|
`${lines
|
|
.join('\n')
|
|
.replace(/\n{4,}/gu, '\n\n\n')
|
|
.trimEnd()}\n`,
|
|
plan,
|
|
'Markdown export'
|
|
);
|
|
}
|
|
|
|
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 enforceSerializedTextOutput(
|
|
`<!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>
|
|
`,
|
|
plan,
|
|
'Semantic HTML export'
|
|
);
|
|
}
|
|
|
|
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 : 1 - transparency / 255;
|
|
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}
|
|
`;
|