mirror of
https://github.com/priyanshujain/margin.git
synced 2026-10-02 11:07:04 +00:00
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:
1 parent
6e2ca9c4bc
commit
5c0040955e
2 files changed
+40
-27
No files matched your search
+15
-7
@@ -25,7 +25,7 @@ function slug(text: string): 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;
|
||||
}
|
||||
|
||||
@@ -42,8 +42,11 @@ function inline(node: JSONContent): string {
|
||||
if (node.type === "text") {
|
||||
let text = esc(node.text ?? "");
|
||||
const marks = node.marks ?? [];
|
||||
if (marks.some((m) => m.type === "code")) text = `<code>${text}</code>`;
|
||||
if (marks.some((m) => m.type === "bold")) text = `<strong>${text}</strong>`;
|
||||
if (marks.some((m) => m.type === "italic")) text = `<em>${text}</em>`;
|
||||
if (marks.some((m) => m.type === "strike")) text = `<s>${text}</s>`;
|
||||
if (marks.some((m) => m.type === "underline")) text = `<u>${text}</u>`;
|
||||
const href = marks.find((m) => m.type === "link")?.attrs?.href;
|
||||
if (href) text = `<a href="${attr(href)}">${text}</a>`;
|
||||
return text;
|
||||
@@ -56,11 +59,11 @@ function inlines(content: JSONContent[] = []): string {
|
||||
return content.map(inline).join("");
|
||||
}
|
||||
|
||||
function listItem(item: JSONContent): string {
|
||||
function listItem(item: JSONContent, paths: Map<string, string>): string {
|
||||
const inner = (item.content ?? [])
|
||||
.filter((c) => c.type === "paragraph")
|
||||
.map((p) => inlines(p.content))
|
||||
.join(" ");
|
||||
.map((child) => block(child, paths))
|
||||
.filter((s) => s.length > 0)
|
||||
.join("");
|
||||
return `<li>${inner}</li>`;
|
||||
}
|
||||
|
||||
@@ -92,9 +95,9 @@ function block(node: JSONContent, paths: Map<string, string>): string {
|
||||
case "blockquote":
|
||||
return `<blockquote>${(node.content ?? []).map((n) => block(n, paths)).join("")}</blockquote>`;
|
||||
case "bulletList":
|
||||
return `<ul>${(node.content ?? []).map(listItem).join("")}</ul>`;
|
||||
return `<ul>${(node.content ?? []).map((li) => listItem(li, paths)).join("")}</ul>`;
|
||||
case "orderedList":
|
||||
return `<ol>${(node.content ?? []).map(listItem).join("")}</ol>`;
|
||||
return `<ol>${(node.content ?? []).map((li) => listItem(li, paths)).join("")}</ol>`;
|
||||
case "horizontalRule":
|
||||
return `<hr class="scene-break"/>`;
|
||||
case "figure":
|
||||
@@ -406,6 +409,11 @@ h2, h3 {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
code {
|
||||
font-family: "SF Mono", Menlo, Consolas, monospace;
|
||||
font-size: 0.9em;
|
||||
}
|
||||
|
||||
blockquote {
|
||||
margin: 1.2em 1.6em;
|
||||
font-style: italic;
|
||||
|
||||
+25
-20
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
Reference in new issue
Block a user