Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 20 additions & 0 deletions src/formats/docx.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,26 @@ async function renderAndReadDocumentXml(markdown: string): Promise<string> {
}

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("<w:i/>");
});

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 |",
Expand Down
50 changes: 40 additions & 10 deletions src/formats/docx.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -358,23 +359,35 @@ function buildTable(
}

function parseInline(text: string, imageMap: Map<string, DocxImage>): 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]);
Expand All @@ -385,19 +398,19 @@ function parseInline(text: string, imageMap: Map<string, DocxImage>): 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]),
}));
}

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 {
Expand Down Expand Up @@ -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(
Expand Down
18 changes: 18 additions & 0 deletions src/formats/epub.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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("<td>x *</td>");
expect(chapter).toContain("<td>z *</td>");
const opens = (chapter.match(/<em>/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 = {
Expand Down
63 changes: 63 additions & 0 deletions src/formats/html-document.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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("<p>a * b</p>");
});

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

expect(html).toBe("<p>a * b * c</p>");
expect(html).not.toContain("<em>");
});

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("<td>a *</td>");
expect(html).toContain("<td>c *</td>");
expect(html).not.toContain("<em>");
});

it("keeps an escaped pipe inside a table cell", () => {
const html = markdownToBasicHtml("| a \\| b | c |\n|---|---|\n| 1 | 2 |");

expect(html).toContain("<th>a | b</th>");
expect(html).toContain("<th>c</th>");
});

it("does not turn escaped brackets into a link", () => {
const html = markdownToBasicHtml("\\[not a link\\](x)");

expect(html).toBe("<p>[not a link](x)</p>");
expect(html).not.toContain("<a ");
});

it("renders an escaped backslash as a single backslash", () => {
const html = markdownToBasicHtml("a \\\\ b");

expect(html).toBe("<p>a \\ b</p>");
});

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

expect(html).toBe("<p>a &lt; b</p>");
expect(html).not.toContain("a <");
});

it("keeps escaped characters untouched inside inline code", () => {
const html = markdownToBasicHtml("`a \\* b`");

expect(html).toBe("<p><code>a \\* b</code></p>");
});
});

describe("XSS prevention", () => {
it("escapes HTML in markdown body content", async () => {
const { html } = await renderTestHtml([
Expand Down Expand Up @@ -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("<p>3 * 4 * 5</p>");
expect(html).not.toContain("<em>");
});
});
});

Expand Down
63 changes: 47 additions & 16 deletions src/formats/html-document.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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[] = [];
Expand All @@ -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 => `<th>${c.trim()}</th>`).join("");
const rows = body.trim().split("\n").map(row => {
Expand All @@ -136,13 +158,13 @@ export function markdownToBasicHtml(md: string): string {
return `<table><thead><tr>${ths}</tr></thead><tbody>${rows}</tbody></table>`;
});

// 6. Blockquotes
// 7. Blockquotes
html = html.replace(/^(&gt; .+(?:\n&gt; .+)*)/gm, (match) => {
const content = match.replace(/^&gt; /gm, "");
return `<blockquote>${content}</blockquote>`;
});

// 7. Task lists — consecutive task lines form one <ul>; a bare <li> is
// 8. Task lists — consecutive task lines form one <ul>; a bare <li> is
// invalid XHTML in EPUB chapters (element not allowed in body) and
// malformed in standalone HTML.
html = html.replace(/^(?:- \[[x ]\] .+(?:\n- \[[x ]\] .+)*)$/gm, (match) => {
Expand All @@ -156,41 +178,43 @@ export function markdownToBasicHtml(md: string): string {
return `<ul class="task-list">${items}</ul>`;
});

// 8. Unordered lists
// 9. Unordered lists
html = html.replace(/^(?:[*-] .+(?:\n[*-] .+)*)/gm, (match) => {
const items = match.split("\n").map(line => `<li>${line.replace(/^[*-] /, "")}</li>`).join("");
return `<ul>${items}</ul>`;
});

// 9. Ordered lists
// 10. Ordered lists
html = html.replace(/^(?:\d+\. .+(?:\n\d+\. .+)*)/gm, (match) => {
const items = match.split("\n").map(line => `<li>${line.replace(/^\d+\. /, "")}</li>`).join("");
return `<ol>${items}</ol>`;
});

// 10. Horizontal rules
// 11. Horizontal rules
html = html.replace(/^[-*_]{3,}\s*$/gm, "<hr>");

// 11. Headers
// 12. Headers
html = html.replace(/^######\s+(.+)$/gm, "<h6>$1</h6>");
html = html.replace(/^#####\s+(.+)$/gm, "<h5>$1</h5>");
html = html.replace(/^####\s+(.+)$/gm, "<h4>$1</h4>");
html = html.replace(/^###\s+(.+)$/gm, "<h3>$1</h3>");
html = html.replace(/^##\s+(.+)$/gm, "<h2>$1</h2>");
html = html.replace(/^#\s+(.+)$/gm, "<h1>$1</h1>");

// 12. Inline formatting (strikethrough, bold, italic)
// 13. Inline formatting (strikethrough, bold, italic). Emphasis content
// must start and end with non-whitespace, matching CommonMark delimiter
// rules, so bare asterisks (`3 * 4 * 5`) stay literal.
html = html.replace(/~~(.+?)~~/g, "<del>$1</del>");
html = html.replace(/\*\*(.+?)\*\*/g, "<strong>$1</strong>");
html = html.replace(/\*(.+?)\*/g, "<em>$1</em>");
html = html.replace(/\*\*(\S(?:.*?\S)?)\*\*/g, "<strong>$1</strong>");
html = html.replace(/\*(\S(?:.*?\S)?)\*/g, "<em>$1</em>");

// 13. Images and links
// 14. Images and links
html = html.replace(/!\[([^\]]*)\]\(([^)]+)\)/g, (_match: string, alt: string, src: string) => {
return renderEmbeddedImage(src, alt);
});
html = html.replace(/\[([^\]]+)\]\(([^)]+)\)/g, '<a href="$2">$1</a>');

// 14. Paragraphs
// 15. Paragraphs
html = html
.split("\n\n")
.map((block) => {
Expand All @@ -206,13 +230,20 @@ export function markdownToBasicHtml(md: string): string {
})
.join("\n");

// 15. Restore inline code
// 16. Restore escapes as literal characters, HTML-escaped like the
// surrounding text (`\<` must come back as `&lt;`, not raw `<`).
html = html.replace(new RegExp(`${ESCAPE_PLACEHOLDER}(\\d+)\\uE001`, "g"), (match, idx: string) => {
const ch = escapes[parseInt(idx)];
return ch !== undefined ? escapeHtml(ch) : match;
});

// 17. Restore inline code
html = html.replace(/IC(\d+)/g, (_match: string, idx: string) => inlineCode[parseInt(idx)]);

// 16. Restore safe media blocks
// 18. Restore safe media blocks
html = html.replace(/MB(\d+)/g, (_match: string, idx: string) => mediaBlocks[parseInt(idx)]);

// 17. Restore code blocks
// 19. Restore code blocks
html = html.replace(/CB(\d+)/g, (_match: string, idx: string) => codeBlocks[parseInt(idx)]);

return html;
Expand Down
Loading