src / htmlToOneNote.ts
/**
* Convert plain text, rich HTML (including tables), and Markdown into OneNote
* page XML fragments. Faithful TypeScript port of local-onenote-mcp's
* xml_utils.py content pipeline. HTML tokenizing uses htmlparser2 to mirror
* Python's html.parser event model.
*/
import { Parser } from "htmlparser2";
import { markdownToHtml } from "./markdown";
export type ContentFormat = "plain" | "html" | "markdown" | "md";
const BLOCK_TAGS = new Set([
"address", "article", "aside", "blockquote", "div", "figcaption", "figure", "footer",
"h1", "h2", "h3", "h4", "h5", "h6", "header", "li", "main", "nav", "p", "pre", "section",
"td", "th", "tr",
]);
const INLINE_TAGS = new Set([
"a", "b", "br", "code", "del", "em", "i", "mark", "span", "strike", "strong", "sub", "sup", "s", "u",
]);
const SAFE_ATTRS: Record<string, Set<string>> = {
a: new Set(["href", "title"]),
span: new Set(["style"]),
};
const INLINE_STYLE_TAGS: Record<string, string> = {
code: "font-family:Consolas,'Courier New',monospace",
del: "text-decoration:line-through",
mark: "background:#FFF2CC",
s: "text-decoration:line-through",
strike: "text-decoration:line-through",
};
const HEADING_STYLES: Record<string, string> = {
h1: "font-size:20.0pt;font-weight:bold",
h2: "font-size:16.0pt;font-weight:bold",
h3: "font-size:14.0pt;font-weight:bold",
h4: "font-size:12.0pt;font-weight:bold",
h5: "font-size:11.0pt;font-weight:bold",
h6: "font-size:10.0pt;font-weight:bold",
};
/** html.escape(quote=False) */
export function escapeText(value: string): string {
return value.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">");
}
/** html.escape(quote=True) */
export function escapeAttr(value: string): string {
return escapeText(value).replace(/"/g, """).replace(/'/g, "'");
}
export function cdata(value: string): string {
return "<![CDATA[" + value.replace(/]]>/g, "]]]]><![CDATA[>") + "]]>";
}
export function oneT(fragmentHtml: string): string {
return `<one:T>${cdata(fragmentHtml)}</one:T>`;
}
export function oeChildren(fragmentHtml: string): string {
const parts = fragmentHtml.split(/<br\s*\/?>/);
const list = parts.length ? parts : [""];
return list.map((part) => `<one:OE>${oneT(part)}</one:OE>`).join("");
}
/** Convert plain text to a OneNote inline HTML fragment (escaped, newlines -> <br/>). */
export function plainToInlineHtml(content: string): string {
return escapeText(content)
.replace(/\r\n/g, "\n")
.replace(/\r/g, "\n")
.replace(/\n/g, "<br/>");
}
function normalizePlain(content: string): string {
return plainToInlineHtml(content);
}
// Inline character-formatting tags that OneNote stores inside a one:T fragment
// (bold/italic/underline/strikethrough/etc. — usually as styled <span>s).
const PRESERVE_FORMAT_TAGS = new Set([
"span", "b", "strong", "i", "em", "u", "s", "strike", "del", "mark", "sub", "sup", "code", "a", "font",
]);
/**
* Re-apply the character formatting of a template one:T fragment to new text.
*
* OneNote encodes bold/italic/strikethrough/etc. as inline tags inside the
* one:T CDATA (e.g. <span style='font-weight:bold'>). When we set new cell
* text we must keep those wrappers, or formatting is lost. This captures the
* inline formatting tags active at the template's first text run and wraps the
* new text in the same tags.
*/
export function reformatLikeTemplate(templateFragment: string, newText: string): string {
const body = plainToInlineHtml(newText);
if (!templateFragment) return body;
type FmtTag = { name: string; attribs: Record<string, string> };
const stack: FmtTag[] = [];
let captured: FmtTag[] | null = null;
const parser = new Parser(
{
onopentag: (name, attribs) => {
if (captured) return;
const n = name.toLowerCase();
if (PRESERVE_FORMAT_TAGS.has(n)) stack.push({ name: n, attribs: attribs as Record<string, string> });
},
onclosetag: (name) => {
if (captured) return;
const n = name.toLowerCase();
if (!PRESERVE_FORMAT_TAGS.has(n)) return;
for (let i = stack.length - 1; i >= 0; i--) {
if (stack[i].name === n) {
stack.splice(i, 1);
break;
}
}
},
ontext: (text) => {
if (captured) return;
if (text && text.trim() !== "") captured = stack.slice();
},
},
{ decodeEntities: true },
);
parser.write(templateFragment);
parser.end();
const tags = captured as FmtTag[] | null;
if (!tags || tags.length === 0) return body;
const open = tags
.map((t) => {
const attrs = Object.entries(t.attribs)
.map(([k, v]) => ` ${k}="${escapeAttr(v ?? "")}"`)
.join("");
return `<${t.name}${attrs}>`;
})
.join("");
const close = tags
.slice()
.reverse()
.map((t) => `</${t.name}>`)
.join("");
return open + body + close;
}
interface StartTag {
name: string;
attribs: Record<string, string>;
}
/** Convert arbitrary HTML-ish input into a OneNote-friendly inline fragment. */
class InlineHTMLSanitizer {
private parts: string[] = [];
private dropStack: string[] = [];
startTag(tagRaw: string, attribs: Record<string, string>): void {
const tag = tagRaw.toLowerCase();
if (tag === "script" || tag === "style") {
this.dropStack.push(tag);
return;
}
if (this.dropStack.length) return;
if (tag in HEADING_STYLES) {
this.appendBreak();
this.parts.push(`<span style="${HEADING_STYLES[tag]}">`);
return;
}
if (BLOCK_TAGS.has(tag)) {
this.appendBreak();
return;
}
if (!INLINE_TAGS.has(tag)) return;
if (tag === "br") {
this.appendBreak();
return;
}
if (tag in INLINE_STYLE_TAGS) {
this.parts.push(`<span style="${INLINE_STYLE_TAGS[tag]}">`);
return;
}
const allowed = SAFE_ATTRS[tag] ?? new Set<string>();
const rendered: string[] = [];
for (const [rawName, value] of Object.entries(attribs)) {
if (value === undefined || value === null) continue;
const name = rawName.toLowerCase();
if (!allowed.has(name)) continue;
if (name === "href" && !/^(http:\/\/|https:\/\/|onenote:|mailto:)/i.test(value)) continue;
rendered.push(`${name}="${escapeAttr(value)}"`);
}
const attrText = rendered.length ? " " + rendered.join(" ") : "";
this.parts.push(`<${tag}${attrText}>`);
}
endTag(tagRaw: string): void {
const tag = tagRaw.toLowerCase();
if (this.dropStack.length) {
if (tag === this.dropStack[this.dropStack.length - 1]) this.dropStack.pop();
return;
}
if (tag in HEADING_STYLES) {
this.parts.push("</span>");
this.appendBreak();
return;
}
if (BLOCK_TAGS.has(tag)) {
this.appendBreak();
return;
}
if (tag in INLINE_STYLE_TAGS) {
this.parts.push("</span>");
return;
}
if (INLINE_TAGS.has(tag) && tag !== "br") this.parts.push(`</${tag}>`);
}
data(text: string): void {
if (!this.dropStack.length) this.parts.push(escapeText(text));
}
getHtml(): string {
let text = this.parts.join("");
text = text.replace(/(?:<br\/>){3,}/g, "<br/><br/>");
text = text.replace(/^(?:<br\/>)+|(?:<br\/>)+$/g, "");
return text.trim();
}
private appendBreak(): void {
if (!this.parts.length || this.parts[this.parts.length - 1] !== "<br/>") this.parts.push("<br/>");
}
}
interface TableCell {
html: string;
header: boolean;
}
type ContentBlock = { kind: "text"; html: string } | { kind: "table"; rows: TableCell[][] };
/** Convert simple HTML into ordered text/table blocks for OneNote XML. */
class OneNoteHTMLBlockParser {
blocks: ContentBlock[] = [];
private text = new InlineHTMLSanitizer();
private tableDepth = 0;
private rows: TableCell[][] = [];
private currentRow: TableCell[] | null = null;
private currentCell: InlineHTMLSanitizer | null = null;
private currentCellHeader = false;
private dropStack: string[] = [];
startTag(tagRaw: string, attribs: Record<string, string>): void {
const tag = tagRaw.toLowerCase();
if (tag === "table") {
if (this.tableDepth === 0) {
this.flushText();
this.rows = [];
this.currentRow = null;
this.currentCell = null;
}
this.tableDepth += 1;
return;
}
if (this.tableDepth) {
this.handleTableStart(tag, attribs);
return;
}
this.text.startTag(tag, attribs);
}
endTag(tagRaw: string): void {
const tag = tagRaw.toLowerCase();
if (tag === "table" && this.tableDepth) {
this.closeCell();
this.closeRow();
this.tableDepth -= 1;
if (this.tableDepth === 0) {
const rows = this.rows.filter((r) => r.length);
if (rows.length) this.blocks.push({ kind: "table", rows });
}
return;
}
if (this.tableDepth) {
this.handleTableEnd(tag);
return;
}
this.text.endTag(tag);
}
data(text: string): void {
if (this.tableDepth) {
if (!this.dropStack.length && this.currentCell) this.currentCell.data(text);
return;
}
this.text.data(text);
}
getBlocks(): ContentBlock[] {
this.flushText();
return this.blocks;
}
private handleTableStart(tag: string, attribs: Record<string, string>): void {
if (tag === "script" || tag === "style") {
this.dropStack.push(tag);
return;
}
if (this.dropStack.length) return;
if (tag === "tr") {
this.closeCell();
this.closeRow();
this.currentRow = [];
return;
}
if (tag === "td" || tag === "th") {
if (this.currentRow === null) this.currentRow = [];
this.closeCell();
this.currentCell = new InlineHTMLSanitizer();
this.currentCellHeader = tag === "th";
return;
}
if (this.currentCell) this.currentCell.startTag(tag, attribs);
}
private handleTableEnd(tag: string): void {
if (this.dropStack.length) {
if (tag === this.dropStack[this.dropStack.length - 1]) this.dropStack.pop();
return;
}
if (tag === "td" || tag === "th") {
this.closeCell();
return;
}
if (tag === "tr") {
this.closeCell();
this.closeRow();
return;
}
if (this.currentCell) this.currentCell.endTag(tag);
}
private flushText(): void {
const text = this.text.getHtml();
if (text) this.blocks.push({ kind: "text", html: text });
this.text = new InlineHTMLSanitizer();
}
private closeRow(): void {
if (this.currentRow && this.currentRow.length) this.rows.push(this.currentRow);
this.currentRow = null;
}
private closeCell(): void {
if (this.currentCell === null) return;
const cellHtml = this.currentCell.getHtml();
if (cellHtml || this.currentRow !== null) {
if (this.currentRow === null) this.currentRow = [];
this.currentRow.push({ html: cellHtml, header: this.currentCellHeader });
}
this.currentCell = null;
this.currentCellHeader = false;
}
}
function feed(input: string, handler: { startTag: (t: string, a: Record<string, string>) => void; endTag: (t: string) => void; data: (d: string) => void }): void {
const parser = new Parser(
{
onopentag: (name, attribs) => handler.startTag(name, attribs as Record<string, string>),
onclosetag: (name) => handler.endTag(name),
ontext: (text) => handler.data(text),
},
{ decodeEntities: true },
);
parser.write(input || "");
parser.end();
}
function oneTableCell(cell: TableCell): string {
const shading = cell.header ? ' shadingColor="#D9EAF7"' : "";
const fontSize = cell.header ? "10.5pt" : "10.0pt";
const styleAttr = ` style="font-family:'Microsoft YaHei';font-size:${fontSize}"`;
let cellHtml = cell.html;
if (cell.header) cellHtml = `<span style='font-weight:bold'>${cellHtml}</span>`;
return (
`<one:Cell${shading}>` +
"<one:OEChildren>" +
`<one:OE alignment="left" quickStyleIndex="0"${styleAttr}>${oneT(cellHtml)}</one:OE>` +
"</one:OEChildren>" +
"</one:Cell>"
);
}
function tableColumnWidths(rows: TableCell[][]): number[] {
const columnCount = rows.reduce((m, row) => Math.max(m, row.length), 0);
if (columnCount <= 0) return [];
const totalWidth = 960.0;
const width = Math.max(90.0, Math.min(220.0, totalWidth / columnCount));
return new Array(columnCount).fill(width);
}
function oneTable(rows: TableCell[][]): string {
const widths = tableColumnWidths(rows);
if (!widths.length) return "";
const columnXml = widths
.map((width, index) => `<one:Column index="${index}" width="${width.toFixed(1)}" isLocked="true"/>`)
.join("");
const columnCount = widths.length;
const rowXml = rows.map((row) => {
const padded = row.concat(new Array(Math.max(0, columnCount - row.length)).fill({ html: "", header: false }));
return "<one:Row>" + padded.slice(0, columnCount).map(oneTableCell).join("") + "</one:Row>";
});
return (
'<one:OE alignment="left"><one:Table bordersVisible="true" hasHeaderRow="false">' +
`<one:Columns>${columnXml}</one:Columns>` +
rowXml.join("") +
"</one:Table></one:OE>"
);
}
function htmlContentBlocks(content: string): ContentBlock[] {
const parser = new OneNoteHTMLBlockParser();
feed(content, parser);
return parser.getBlocks();
}
/**
* Convert content of the given format into a SINGLE OneNote inline HTML fragment
* suitable for the CDATA inside one one:T (e.g. a to-do item's text). Unlike
* contentToOeXml this never emits block/table structure: block boundaries and
* headings collapse to <br/>, hyperlinks and character formatting are kept.
*/
export async function contentToInlineHtml(content: string, format: ContentFormat = "plain", mdTimeout = 30): Promise<string> {
if (format === "plain") return normalizePlain(content);
if (format === "html") {
const sanitizer = new InlineHTMLSanitizer();
feed(content, sanitizer);
return sanitizer.getHtml();
}
if (format === "markdown" || format === "md") {
const html = await markdownToHtml(content, mdTimeout);
return contentToInlineHtml(html, "html", mdTimeout);
}
throw new Error("content_format must be 'plain', 'html', or 'markdown'.");
}
/** Convert content of the given format into one:OE / one:Table XML. */
export async function contentToOeXml(content: string, format: ContentFormat = "plain", mdTimeout = 30): Promise<string> {
if (format === "plain") return oeChildren(normalizePlain(content));
if (format === "html") {
const blocks = htmlContentBlocks(content);
if (!blocks.length) return "";
return blocks.map((block) => (block.kind === "text" ? oeChildren(block.html) : oneTable(block.rows))).join("");
}
if (format === "markdown" || format === "md") {
const html = await markdownToHtml(content, mdTimeout);
return contentToOeXml(html, "html", mdTimeout);
}
throw new Error("content_format must be 'plain', 'html', or 'markdown'.");
}