fix(export): correct list nesting, marks, line-start escaping, svg type

Four export-correctness fixes intermingled across the Typst and EPUB
renderers:

- H7: neutralize newlines in escaped text and apply leading-marker
  escaping (= - + / and N.) to the first text node of every block
  (paragraph, heading, list item, blockquote) and figure captions, even
  when that run carries a mark, so author text can never inject Typst
  markup or phantom headings/TOC entries.
- H9: render lists recursively so nested lists survive and multi-
  paragraph list items keep their paragraph breaks (Typst functional
  list()/enum(); EPUB nested <ul>/<ol> and multiple <p> in <li>).
- H10: render strike, underline and inline code marks in both exporters
  (Typst #strike/#underline/#raw; EPUB <s>/<u>/<code> with css).
- H8b: normalize +-bearing image subtypes so an imported svg+xml cover
  becomes cover.svg with the correct media type.

Validated the generated Typst constructs compile with typst 0.15.

Claude-Session: https://claude.ai/code/session_01RzTWrLiy4zGuw2hZSQGamk
This commit is contained in:
pj committed 2026-06-22 13:24:19 -04:00
1 parent 6e2ca9c4bc
commit 5c0040955e
2 files changed
+40 -27

No files matched your search

+25 -20
View File
@@ -10,27 +10,32 @@ const TRIM: Record<TrimSize, { w: string; h: string }> = {
};
const INLINE_SPECIAL = /[\\#$*_`<>@~[\]]/g;
const LINE_SEPARATORS = /[\n\r\v\f\u2028\u2029]+/g;
function esc(text: string): string {
return text.replace(INLINE_SPECIAL, (m) => "\\" + m);
return text.replace(LINE_SEPARATORS, " ").replace(INLINE_SPECIAL, (m) => "\\" + m);
}
function guardLineStart(line: string): string {
if (/^[=\-+/]/.test(line)) return "\\" + line;
if (/^\d+\./.test(line)) return line.replace(".", "\\.");
return line;
function guardLineStart(text: string): string {
if (/^[=\-+/]/.test(text)) return "\\" + text;
if (/^\d+\./.test(text)) return text.replace(".", "\\.");
return text;
}
function str(value: string): string {
return JSON.stringify(value);
}
function inline(node: JSONContent): string {
function inline(node: JSONContent, atLineStart: boolean): string {
if (node.type === "text") {
let text = esc(node.text ?? "");
const marks = node.marks ?? [];
if (marks.some((m) => m.type === "code")) return `#raw(${str(node.text ?? "")})`;
let text = esc(node.text ?? "");
if (atLineStart) text = guardLineStart(text);
if (marks.some((m) => m.type === "italic")) text = `#emph[${text}]`;
if (marks.some((m) => m.type === "bold")) text = `#strong[${text}]`;
if (marks.some((m) => m.type === "strike")) text = `#strike[${text}]`;
if (marks.some((m) => m.type === "underline")) text = `#underline[${text}]`;
const href = marks.find((m) => m.type === "link")?.attrs?.href;
if (href) text = `#link(${str(href)})[${text}]`;
return text;
@@ -39,15 +44,15 @@ function inline(node: JSONContent): string {
return "";
}
function inlines(content: JSONContent[] = []): string {
return content.map(inline).join("");
function inlines(content: JSONContent[] = [], atLineStart = false): string {
return content.map((node, i) => inline(node, atLineStart && i === 0)).join("");
}
function listItem(item: JSONContent): string {
return (item.content ?? [])
.filter((c) => c.type === "paragraph")
.map((p) => inlines(p.content))
.join(" ");
function listItem(item: JSONContent, paths: Map<string, string>): string {
const parts = (item.content ?? [])
.map((child) => block(child, paths))
.filter((s) => s.length > 0);
return `[${parts.join("\n\n")}]`;
}
function figure(node: JSONContent, paths: Map<string, string>): string {
@@ -59,7 +64,7 @@ function figure(node: JSONContent, paths: Map<string, string>): string {
}
const placed = placement as FigurePlacement;
const width = (isResizablePlacement(placed) ? node.attrs?.width : null) ?? FIGURE_WIDTH[placed] ?? 100;
const caption = node.attrs?.caption ? `, caption: [${esc(node.attrs.caption)}]` : "";
const caption = node.attrs?.caption ? `, caption: [${guardLineStart(esc(node.attrs.caption))}]` : "";
const float = placement === "float-top" ? ", placement: top" : "";
return `#figure(image(${str(path)}, width: ${width}%)${caption}${float})`;
}
@@ -67,18 +72,18 @@ function figure(node: JSONContent, paths: Map<string, string>): string {
function block(node: JSONContent, paths: Map<string, string>): string {
switch (node.type) {
case "paragraph": {
const body = guardLineStart(inlines(node.content));
const body = inlines(node.content, true);
if (!body.trim()) return "~";
return node.attrs?.indent ? `#h(1.3em)${body}` : body;
}
case "heading":
return `#heading(level: ${node.attrs?.level ?? 2})[${inlines(node.content)}]`;
return `#heading(level: ${node.attrs?.level ?? 2})[${inlines(node.content, true)}]`;
case "blockquote":
return `#blockquote[${(node.content ?? []).map((n) => block(n, paths)).join("\n\n")}]`;
case "bulletList":
return (node.content ?? []).map((li) => `- ${listItem(li)}`).join("\n");
return `#list(${(node.content ?? []).map((li) => listItem(li, paths)).join(", ")})`;
case "orderedList":
return (node.content ?? []).map((li) => `+ ${listItem(li)}`).join("\n");
return `#enum(${(node.content ?? []).map((li) => listItem(li, paths)).join(", ")})`;
case "horizontalRule":
return "#scenebreak";
case "figure":
@@ -188,7 +193,7 @@ function preamble(book: Book): string {
function imageExtension(dataUrl: string): string {
const match = /^data:image\/([a-z0-9.+-]+)/i.exec(dataUrl);
const kind = (match?.[1] ?? "png").toLowerCase();
const kind = (match?.[1] ?? "png").toLowerCase().split("+")[0];
return kind === "jpeg" ? "jpg" : kind;
}