From 5cf2d55d2963d503eee762202b6819dd2969e48b Mon Sep 17 00:00:00 2001 From: Roger Deng <13251150+rogerdigital@users.noreply.github.com> Date: Tue, 22 Sep 2026 00:11:19 +0800 Subject: [PATCH] fix: handle markdown backslash escapes in EPUB, HTML, PDF, and DOCX exports MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Escaped punctuation (\*, \|, \[, …) was not recognized by the basic markdown converter or the DOCX inline parser, so an escaped asterisk paired as an emphasis delimiter: inside EPUB tables it produced an that crossed boundaries, yielding invalid XHTML that Apple Books refuses to open. Outside tables, escapes rendered with a strailing backslash. - Extract backslash escapes into placeholders before any syntax rule runs and restore them as literal characters afterwards (basic HTML converter and DOCX parseInline, sharing MARKDOWN_ESCAPE_RE) - Split table rows on unescaped pipes only so \| keeps a literal pipe inside the cell (basic converter and DOCX parseTableRow) - Require non-whitespace content edges for bold/italic per CommonMark delimiter rules, so bare multiplication asterisks (3 * 4 * 5) stay literal Fixes #91 --- src/formats/docx.test.ts | 20 ++++++++++ src/formats/docx.ts | 50 +++++++++++++++++++----- src/formats/epub.test.ts | 18 +++++++++ src/formats/html-document.test.ts | 63 +++++++++++++++++++++++++++++++ src/formats/html-document.ts | 63 +++++++++++++++++++++++-------- 5 files changed, 188 insertions(+), 26 deletions(-) diff --git a/src/formats/docx.test.ts b/src/formats/docx.test.ts index f84614d..23976d8 100644 --- a/src/formats/docx.test.ts +++ b/src/formats/docx.test.ts @@ -66,6 +66,26 @@ async function renderAndReadDocumentXml(markdown: string): Promise { } describe("DOCX rendering", () => { + it("renders escaped asterisks as literal text without italics", async () => { + const xml = await renderAndReadDocumentXml("a \\* b \\* c"); + + expect(xml).toContain(">a * b * c<"); + expect(xml).not.toContain(""); + }); + + it("keeps an escaped pipe inside a table cell", async () => { + const markdown = [ + "| a \\| b | c |", + "| --- | --- |", + "| 1 | 2 |", + ].join("\n"); + + const xml = await renderAndReadDocumentXml(markdown); + + expect(xml).toContain(">a | b<"); + expect(xml).toContain(">c<"); + }); + it("writes valid table rows and preserves every cell value", async () => { const markdown = [ "| Name | Value |", diff --git a/src/formats/docx.ts b/src/formats/docx.ts index 52ecdda..a993537 100644 --- a/src/formats/docx.ts +++ b/src/formats/docx.ts @@ -2,6 +2,7 @@ import { App, TFile } from "obsidian"; import { AssembledDocument, AttachmentCopy, ExportPlan } from "@/types"; import { OutputWriter } from "@/export/OutputWriter"; import { createZip } from "@/formats/zip"; +import { MARKDOWN_ESCAPE_RE } from "@/formats/html-document"; type DocxRun = { text: string; @@ -358,23 +359,35 @@ function buildTable( } function parseInline(text: string, imageMap: Map): DocxRun[] { + // Mask backslash escapes (`\*`, `\[`, …) before syntax matching so the + // escaped punctuation can never pair as emphasis or open a link; the + // literal characters are restored into the runs afterwards. Private-use + // sentinels cannot collide with real note text. + const escapes: string[] = []; + const marked = text.replace(MARKDOWN_ESCAPE_RE, (_match: string, ch: string) => { + escapes.push(ch); + return `\uE000${escapes.length - 1}\uE001`; + }); + const unmark = (value: string): string => + value.replace(/\uE000(\d+)\uE001/g, (match, idx: string) => escapes[parseInt(idx)] ?? match); + const runs: DocxRun[] = []; const regex = /(\*\*(.+?)\*\*)|(\*(.+?)\*)|(`([^`]+)`)|(\[([^\]]+)\]\(\s*(<[^>]+>|[^)\s]+)(?:\s+(?:"[^"]*"|'[^']*'|\([^)]*\)))?\s*\))|(!\[([^\]]*)\]\(\s*(<[^>]+>|[^)\s]+)(?:\s+(?:"[^"]*"|'[^']*'|\([^)]*\)))?\s*\))/g; let lastIndex = 0; let match: RegExpExecArray | null; - while ((match = regex.exec(text)) !== null) { + while ((match = regex.exec(marked)) !== null) { if (match.index > lastIndex) { - runs.push(createTextRun(text.slice(lastIndex, match.index))); + runs.push(createTextRun(unmark(marked.slice(lastIndex, match.index)))); } if (match[1]) { - runs.push(createTextRun(match[2], { bold: true })); + runs.push(createTextRun(unmark(match[2]), { bold: true })); } else if (match[3]) { - runs.push(createTextRun(match[4], { italics: true })); + runs.push(createTextRun(unmark(match[4]), { italics: true })); } else if (match[5]) { - runs.push(createTextRun(match[6], { code: true })); + runs.push(createTextRun(unmark(match[6]), { code: true })); } else if (match[10]) { const altText = match[11] || "image"; const imgRef = unwrapMarkdownDestination(match[12]); @@ -385,7 +398,7 @@ function parseInline(text: string, imageMap: Map): DocxRun[] runs.push(createTextRun(`[Image: ${altText}]`, { italics: true })); } } else if (match[7]) { - runs.push(createTextRun(match[8], { + runs.push(createTextRun(unmark(match[8]), { hyperlink: unwrapMarkdownDestination(match[9]), })); } @@ -393,11 +406,11 @@ function parseInline(text: string, imageMap: Map): DocxRun[] lastIndex = match.index + match[0].length; } - if (lastIndex < text.length) { - runs.push(createTextRun(text.slice(lastIndex))); + if (lastIndex < marked.length) { + runs.push(createTextRun(unmark(marked.slice(lastIndex)))); } - return runs.length > 0 ? runs : [createTextRun(text)]; + return runs.length > 0 ? runs : [createTextRun(unmark(marked))]; } function unwrapMarkdownDestination(value: string): string { @@ -454,7 +467,24 @@ function buildDrawingXml(img: DocxImage, altText: string): string { } function parseTableRow(line: string): string[] { - return line.split("|").slice(1, -1).map((cell) => cell.trim()); + // Split on unescaped pipes only: `\|` keeps a literal pipe inside the + // cell, left as-is so parseInline's escape handling restores it. + const cells: string[] = []; + let current = ""; + for (let i = 0; i < line.length; i++) { + const ch = line[i]; + if (ch === "\\" && i + 1 < line.length) { + current += ch + line[i + 1]; + i++; + } else if (ch === "|") { + cells.push(current); + current = ""; + } else { + current += ch; + } + } + cells.push(current); + return cells.slice(1, -1).map((cell) => cell.trim()); } function assignHyperlinkRelationships( diff --git a/src/formats/epub.test.ts b/src/formats/epub.test.ts index 4f444e7..f2e5740 100644 --- a/src/formats/epub.test.ts +++ b/src/formats/epub.test.ts @@ -147,6 +147,24 @@ describe("renderEpub", () => { expect(readStoredZipEntry(data, "OEBPS/styles.css")).toContain("body"); }); + it("produces balanced XHTML tags for escaped asterisks in tables", async () => { + const w = makeWriter(); + await renderEpub( + makeDoc("| a \\* | b | c \\* |\n|---|---|---|\n| x \\* | y | z \\* |"), + PLAN, + w.writer as never, + null, + ); + + const chapter = readStoredZipEntry(w.written!, "OEBPS/chapter-1.xhtml"); + expect(chapter).toContain("x *"); + expect(chapter).toContain("z *"); + const opens = (chapter.match(//g) ?? []).length; + const closes = (chapter.match(/<\/em>/g) ?? []).length; + expect(opens).toBe(closes); + expect(opens).toBe(0); + }); + it("embeds images under generated names and rewrites references", async () => { const pngBytes = new Uint8Array([0x89, 0x50, 0x4e, 0x47]); const app = { diff --git a/src/formats/html-document.test.ts b/src/formats/html-document.test.ts index c6933a4..ab785a9 100644 --- a/src/formats/html-document.test.ts +++ b/src/formats/html-document.test.ts @@ -93,6 +93,62 @@ describe("HTML Document rendering", () => { }); }); + describe("markdown escapes", () => { + it("renders an escaped asterisk as a literal character", () => { + const html = markdownToBasicHtml("a \\* b"); + + expect(html).toBe("

a * b

"); + }); + + it("renders two escaped asterisks without italics or backslashes", () => { + const html = markdownToBasicHtml("a \\* b \\* c"); + + expect(html).toBe("

a * b * c

"); + expect(html).not.toContain(""); + }); + + it("keeps escaped asterisks in table cells from pairing across cells", () => { + const html = markdownToBasicHtml("| h1 | h2 | h3 |\n|---|---|---|\n| a \\* | b | c \\* |"); + + expect(html).toContain("a *"); + expect(html).toContain("c *"); + expect(html).not.toContain(""); + }); + + it("keeps an escaped pipe inside a table cell", () => { + const html = markdownToBasicHtml("| a \\| b | c |\n|---|---|\n| 1 | 2 |"); + + expect(html).toContain("a | b"); + expect(html).toContain("c"); + }); + + it("does not turn escaped brackets into a link", () => { + const html = markdownToBasicHtml("\\[not a link\\](x)"); + + expect(html).toBe("

[not a link](x)

"); + expect(html).not.toContain(" { + const html = markdownToBasicHtml("a \\\\ b"); + + expect(html).toBe("

a \\ b

"); + }); + + it("escapes HTML in restored escaped characters", () => { + const html = markdownToBasicHtml("a \\< b"); + + expect(html).toBe("

a < b

"); + expect(html).not.toContain("a <"); + }); + + it("keeps escaped characters untouched inside inline code", () => { + const html = markdownToBasicHtml("`a \\* b`"); + + expect(html).toBe("

a \\* b

"); + }); + }); + describe("XSS prevention", () => { it("escapes HTML in markdown body content", async () => { const { html } = await renderTestHtml([ @@ -224,6 +280,13 @@ describe("HTML Document rendering", () => { expect(html).toContain("checked"); expect(html).toContain("task-done"); }); + + it("does not italicize bare multiplication asterisks", () => { + const html = markdownToBasicHtml("3 * 4 * 5"); + + expect(html).toBe("

3 * 4 * 5

"); + expect(html).not.toContain(""); + }); }); }); diff --git a/src/formats/html-document.ts b/src/formats/html-document.ts index 2c86cd6..a8e0f5a 100644 --- a/src/formats/html-document.ts +++ b/src/formats/html-document.ts @@ -101,6 +101,18 @@ async function renderSections( return { html: parts.join("\n"), warnings: allWarnings }; } +/** + * CommonMark backslash escapes: `\` followed by ASCII punctuation stands for + * the literal punctuation character. Shared by the basic HTML converter and + * the DOCX inline parser so both treat escapes identically. + */ +export const MARKDOWN_ESCAPE_RE = /\\([!"#$%&'()*+,\-./:;<=>?@[\\\]^_`{|}~])/g; + +// Placeholder sentinels for extracted escapes: private-use characters never +// occur in real notes, so they cannot collide with literal text the way an +// alphanumeric token (e.g. "ES3") could. +const ESCAPE_PLACEHOLDER = "\uE000"; + export function markdownToBasicHtml(md: string): string { // 1. Extract fenced code blocks const codeBlocks: string[] = []; @@ -123,10 +135,20 @@ export function markdownToBasicHtml(md: string): string { return `IC${inlineCode.length - 1}`; }); - // 4. Escape remaining HTML + // 4. Extract backslash escapes (`\*`, `\|`, `\[`, …) so the escaped + // punctuation participates in no syntax rule: no emphasis pairing, no + // table-cell splitting, no link/image detection. Restored as literal + // characters after all conversion steps. + const escapes: string[] = []; + html = html.replace(MARKDOWN_ESCAPE_RE, (_match: string, ch: string) => { + escapes.push(ch); + return `${ESCAPE_PLACEHOLDER}${escapes.length - 1}\uE001`; + }); + + // 5. Escape remaining HTML html = escapeHtml(html); - // 5. Tables + // 6. Tables html = html.replace(/^(\|.+\|)\n(\|[-:| ]+\|)\n((?:\|.+\|\n?)+)/gm, (_, header: string, _align: string, body: string) => { const ths = header.split("|").slice(1, -1).map(c => `${c.trim()}`).join(""); const rows = body.trim().split("\n").map(row => { @@ -136,13 +158,13 @@ export function markdownToBasicHtml(md: string): string { return `${ths}${rows}
`; }); - // 6. Blockquotes + // 7. Blockquotes html = html.replace(/^(> .+(?:\n> .+)*)/gm, (match) => { const content = match.replace(/^> /gm, ""); return `
${content}
`; }); - // 7. Task lists — consecutive task lines form one