module write.typst; /+ ↓ the typst writer: a document as typst markup, for a pdf. . The fourth format and the first that is not itself the output: what this writes is a .typ file, and `typst compile` turns it into a pdf. That is deliberate. A writer's job is model -> bytes; making a pdf is a typesetter's, and typst is a better typesetter than this file would be. . The judge is *typst itself*, and it is a strict one for the reason that matters here: **a link to a label that does not exist is a compile error.** So "it compiles" means "every internal link resolves", which in the html writer took a checker of its own. . What this demonstrates, and why it was worth doing last: the pdf is the format spine leans hardest on an external toolchain for, and the one whose hard parts are the ones an object-centric document cares about - *the object number in the margin*, and *a link from an object to its note and back*. Both are a few lines here. . Set against spine's xelatex path, measured rather than asserted: no preamble, no .sty files, no second pass, a toolchain of 108 MB where texlive-combined-full is 6.6 GB (nix closures), a tagged pdf where spine's says "Tagged: no", and **56,990 CJK characters on the page where spine's Japanese pdf has none at all** - 92 pages of Latin fragments and white space. The pdf is byte identical run to run. +/ import std.algorithm : canFind, endsWith, max, min, startsWith; import std.array : appender, array, join, replace; import std.array : asplit = split; import std.conv : to; import std.format : format; import std.regex : matchAll, matchFirst, replaceAll, split; import std.string : strip, toLower; import ocda.model; import write.rgx; static Rgx rgx_t; /+ ↓ the labels already placed in this document. . A name may be claimed twice: a book index sub-entry ("existing information") occurs under more than one heading, and each object carries the same anchor. A label defined twice is ambiguous, so the first placement keeps it and the rest are dropped - which is what a link to it would have resolved to anyway. +/ private bool[string] placed_labels; enum generator_name = "spine"; /+ ↓ the document as typst markup +/ string writeTypst(ref OcdaDocument doc) { placed_labels = null; auto targets = definedTargets(doc); /+ ↓ an OCN label is placed by `ocn` on the number in the margin, always and for every numbered object, so it is claimed before anything else. An anchor or an identifier that happens to carry the same name is then dropped rather than defining the label twice, which typst rejects: free_culture has a heading whose identifier is "2" and an object whose OCN is 2. +/ foreach (name; typstSections(doc)) { auto sec = doc.sectionByName(name); if (sec is null) { continue; } foreach (obj; *sec) { if (obj.renders && obj.ocn != 0) { placed_labels[labelOf(obj.ocn.to!string)] = true; } } } auto out_ = appender!string; out_ ~= preamble(doc); foreach (name; typstSections(doc)) { foreach (obj; *doc.sectionByName(name)) { if (!obj.renders) { continue; } out_ ~= guardBlockStarts(objectOut(obj, doc, targets)); } } return out_.data; } /+ ↓ which sections, in which order. . head, toc, body, then glossary, bibliography, bookindex and blurb where each has more than one object. *Not endnotes*: a note is a footnote here, at the foot of the page that refers to it, so a gathered endnotes section would put every note in the document twice. . Spine's latex makes the same choice and leaves the heading behind: its .tex ends with the word "Endnotes" as bare text, outside any sectioning command, with nothing under it and then \end{document}. Dropping the heading too is the same decision carried through. It costs the OCN that spine gives that generated heading, which is why the sweep names it rather than passing over it. +/ string[] typstSections(ref OcdaDocument doc) { string[] seq = ["head", "toc", "body"]; foreach (name; ["glossary", "bibliography", "bookindex", "blurb"]) { auto sec = doc.sectionByName(name); if (sec !is null && (*sec).length > 1) { seq ~= name; } } return seq; } /+ ↓ every label this document will define. . A link to an undefined label does not compile, so the writer has to know what it will define before it writes the first link. Same census the epub needed, for the same reason and with a harder consequence: there the reward was a valid publication, here it is a document at all. +/ bool[string] definedTargets(ref OcdaDocument doc) { bool[string] out_; foreach (name; typstSections(doc)) { auto sec = doc.sectionByName(name); if (sec is null) { continue; } foreach (obj; *sec) { if (!obj.renders) { continue; } if (obj.ocn != 0) { out_[labelOf(obj.ocn.to!string)] = true; } if (obj.identifier.length > 0) { out_[labelOf(obj.identifier)] = true; } if (obj.heading_lev_anchor.length > 0) { out_[labelOf(obj.heading_lev_anchor)] = true; } foreach (a; obj.anchor_tags) { out_[labelOf(a)] = true; } foreach (m; obj.text.matchAll(rgx_t.inline_link_anchor)) { out_[labelOf(m["anchor"].to!string)] = true; } } } return out_; } /+ ↓ a label name typst will accept, from a name the markup chose. Prefixed, because a label that begins with a digit is asking for trouble and every ocn does. . Codepoints, not bytes: a book index entry named ‟CC as movement and “machine”, and” has three-byte quotation marks in it, and walking bytes turned each into three underscores where the Gleam writer made one. The two writers must agree, and one underscore per character is the sensible of the two. +/ string labelOf(string name) { string out_ = "t"; foreach (dchar c; name) { bool ok = (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') || (c >= '0' && c <= '9') || c == '_' || c == '-' || c == '.'; if (ok) { out_ ~= c; } else { out_ ~= '_'; } } return out_; } /+ ↓ the preamble: page, text, and the two functions the body leans on +/ private string preamble(ref OcdaDocument doc) { string lang = ("language" in doc.source) ? doc.source["language"] : "en"; string region; auto parts = lang.replace("_", "-").asplit("-"); lang = parts[0]; if (parts.length > 1) { region = parts[1]; } string title = doc.meta("title.full"); if (title.length == 0) { title = doc.meta("title.main"); } auto out_ = appender!string; out_ ~= "// " ~ generator_name ~ ": the object-centric document abstraction," ~ " written as typst\n"; out_ ~= "// Generated from the abstraction. Compile with: typst compile \n\n"; /+ ↓ the pdf's own metadata, from the document's own metadata. date is the document's, never the clock's: a document typeset twice is one document +/ out_ ~= "#set document(\n title: " ~ str(title) ~ ",\n"; if (doc.meta("creator.author").length > 0) { out_ ~= " author: " ~ str(doc.meta("creator.author")) ~ ",\n"; } out_ ~= " date: none,\n)\n"; /+ ↓ the left margin is wide because the object numbers live in it +/ out_ ~= "#set page(\n paper: \"a4\",\n" ~ " margin: (left: 3.4cm, right: 2cm, top: 2.2cm, bottom: 2.2cm),\n" ~ " numbering: \"1\",\n)\n"; out_ ~= "#set text(\n font: (\"DejaVu Serif\", \"Noto Sans CJK JP\"),\n" ~ " size: 10pt,\n lang: " ~ str(lang); if (region.length > 0) { out_ ~= ",\n region: " ~ str(region); } out_ ~= ",\n)\n"; out_ ~= "#set par(justify: true, leading: 0.65em)\n"; /+ ↓ the author's quotation marks and dashes, not typst's. A document is reproduced, not restyled +/ out_ ~= "#set smartquote(enabled: false)\n"; out_ ~= "#set heading(numbering: none)\n"; out_ ~= "#show heading: it => block(above: 1.4em, below: 0.7em, it)\n"; out_ ~= "#show raw.where(block: true): it => block(\n" ~ " fill: luma(245), inset: 6pt, radius: 2pt, width: 100%, it)\n\n"; /+ ↓ the object number, in the margin, beside the object's first line, and carrying the label every citation of the object points at. . This is the thing a pdf of an object-centric document has to get right, and in typst it is one `place` with a negative offset. Spine's latex uses \marginpar, which fails inside a float or a footnote and migrates to the next page when it will not fit; `place` is a coordinate inside a block and does neither. +/ out_ ~= "#let ocn(n, body) = block(breakable: true, width: 100%, {\n" ~ " place(left, dx: -1.8cm, dy: 0.2em,\n" ~ " text(size: 7pt, fill: luma(130))[#n#label(\"t\" + str(n))])\n" ~ " body\n})\n"; /+ ↓ an object with no number of its own still needs its block +/ out_ ~= "#let obj(body) = block(breakable: true, width: 100%, body)\n"; /+ ↓ a named target with no output of its own +/ /+ ↓ monospace as a font rather than as `raw`: typst`s raw() takes a *string*, so it cannot carry the nested markup an object may have inside a monospace run, and hands back "expected string, found content". A code block is a different thing and does use raw. +/ out_ ~= "#let mono(body) = text(font: (\"DejaVu Sans Mono\",), body)\n"; out_ ~= "#let anchor(name) = [#box(width: 0pt)#label(name)]\n\n"; return out_.data; } /+ ↓ one object +/ private string objectOut(ref const OcdaObject obj, ref OcdaDocument doc, bool[string] targets) { switch (obj.is_a) { case "heading": return heading(obj, doc, targets); case "toc": return tocEntry(obj, doc, targets); case "para": case "blurb": return para(obj, doc, targets); case "glossary": case "bibliography": return para(obj, doc, targets); case "bookindex": return para(obj, doc, targets); case "quote": return quote(obj, doc, targets); case "group": case "block": return group(obj, doc, targets); case "verse": case "poem": return verse(obj, doc, targets); case "code": return code(obj); case "table": return table(obj, doc, targets); default: return ""; } } /+ ↓ eight markup levels, eight heading levels. typst has no six-level ceiling, so the document's own depth goes through unchanged - which the html writer could not do and had to say so with aria-level. +/ private int headingLevel(string level) { switch (level) { case "A": return 1; case "B": return 2; case "C": return 3; case "D": return 4; case "1": return 5; case "2": return 6; case "3": return 7; case "4": return 8; default: return 1; } } private string heading(ref const OcdaObject obj, ref OcdaDocument doc, bool[string] targets) { /+ ↓ a dummy heading is generated furniture whose text repeats the heading above it; its anchors are what the table of contents wants +/ if (obj.dummy_heading) { string a = anchors(obj, targets); return (a.length > 0) ? a ~ "\n\n" : ""; } string body_ = inline(obj, doc, targets); if (body_.strip.length == 0) { return ""; } string h = "#heading(level: " ~ headingLevel(obj.heading_level).to!string ~ ")[" ~ anchors(obj, targets) ~ body_ ~ "]"; return wrap(obj, h) ~ "\n\n"; } /+ ↓ a table of contents entry: its indent, the heading as a link, a dotted leader, and **the object number as the locator**. . Not a page number, and not typst's #outline(), which would make one. A page number is a fact about this typesetting: change the paper, the orientation, the type size, the leading or the margins and every number in the table moves. The object number does not. So the reader is given the reference that holds across every output of this document and across every setting of this one, and it is the same number the margin carries and the same number a citation uses. . The text and the number are both links to the object. +/ private string tocEntry(ref const OcdaObject obj, ref OcdaDocument doc, bool[string] targets) { if (obj.dummy_heading) { return ""; } string body_ = inline(obj, doc, targets); if (body_.strip.length == 0) { return ""; } if (!pointsAtSomething(obj, targets)) { return ""; } int indent = (obj.indent_hang > 1) ? (obj.indent_hang - 1) : 0; string locator; string n = tocTarget(obj, targets); if (n.length > 0) { locator = " #box(width: 1fr, inset: (x: 3pt), repeat[.]) " ~ "#link(label(" ~ str(labelOf(n)) ~ "))[" ~ n ~ "]"; } return "#block(inset: (left: " ~ (indent * 8).to!string ~ "pt))[" ~ body_ ~ locator ~ "]\n\n"; } /+ ↓ the object number a table of contents entry points at, where it points at one. An entry may name a section by its anchor instead, and then there is no number to show. +/ private string tocTarget(ref const OcdaObject obj, bool[string] targets) { foreach (m; obj.text.matchAll(rgx_t.any_internal_target)) { string frag = m["frag"].to!string; if (frag.length == 0) { continue; } bool digits = true; foreach (c; frag) { if (c < '0' || c > '9') { digits = false; break; } } if (digits && labelOf(frag) in targets) { return frag; } } return ""; } /+ ↓ whether a table of contents entry points at anything this writer renders. . A table of contents names what is in the document, so an entry whose only target is gone is not an entry: the endnotes are footnotes here, so the section they had is gone and so is the line that pointed at it. . Decided from the *target*, not from the rendered text. An earlier version dropped any entry that came out without a link in it, and so dropped gpl3`s copyright line, whose markup nests one link inside another - which the flat markers cannot express, so no writer resolves it and the entry has no link to show. +/ private bool pointsAtSomething(ref const OcdaObject obj, bool[string] targets) { bool saw = false; foreach (m; obj.text.matchAll(rgx_t.any_internal_target)) { saw = true; if (labelOf(m["frag"].to!string) in targets) { return true; } } return !saw; } private string para(ref const OcdaObject obj, ref OcdaDocument doc, bool[string] targets) { if (obj.dummy_heading) { return ""; } string body_ = inline(obj, doc, targets); if (body_.strip.length == 0) { return ""; } string bullet = obj.bullet ? "●~" : ""; string inset; if (obj.indent_base > 0) { inset = "#block(inset: (left: " ~ (obj.indent_base * 12).to!string ~ "pt))["; } string content = inset.length > 0 ? inset ~ anchors(obj, targets) ~ bullet ~ body_ ~ "]" : anchors(obj, targets) ~ bullet ~ body_; return wrap(obj, content) ~ "\n\n"; } private string quote(ref const OcdaObject obj, ref OcdaDocument doc, bool[string] targets) { string body_ = inline(obj, doc, targets); if (body_.strip.length == 0) { return ""; } return wrap(obj, "#block(inset: (left: 18pt, right: 18pt))[" ~ anchors(obj, targets) ~ body_ ~ "]") ~ "\n\n"; } /+ ↓ group and block: one object whose text holds its own lines +/ private string group(ref const OcdaObject obj, ref OcdaDocument doc, bool[string] targets) { string body_ = inline(obj, doc, targets); if (body_.strip.length == 0) { return ""; } return wrap(obj, "#block[" ~ anchors(obj, targets) ~ body_ ~ "]") ~ "\n\n"; } /+ ↓ verse: every line break kept, and no justification, because a line of verse ends where the poet ended it +/ private string verse(ref const OcdaObject obj, ref OcdaDocument doc, bool[string] targets) { string body_ = inline(obj, doc, targets); if (body_.strip.length == 0) { return ""; } return wrap(obj, "#block(inset: (left: 12pt))[#set par(justify: false);" ~ anchors(obj, targets) ~ body_ ~ "]") ~ "\n\n"; } /+ ↓ code: nothing inside it interpreted, which is what a raw block is for. . The one substitution is the non-breaking space marker spine uses inside code to hold a leading indent. The html writer turns it into a space, so this does too; left as it stands it is a lozenge in the middle of a program. +/ private string code(ref const OcdaObject obj) { string lang = obj.code_syntax.length > 0 ? ("lang: " ~ str(obj.code_syntax) ~ ", ") : ""; string txt = obj.text.replaceAll(rgx_t.nbsp_char, " "); return wrap(obj, "#raw(block: true, " ~ lang ~ str(txt) ~ ")") ~ "\n\n"; } /+ ↓ a table. The declared column widths become fractions, which is what a width relative to the others means, and a header row becomes one. +/ private string table(ref const OcdaObject obj, ref OcdaDocument doc, bool[string] targets) { auto rows = obj.text.split(rgx_t.table_delimiter_row); auto widths = obj.table_widths.asplit(" "); auto aligns = obj.table_aligns.asplit(" "); int cols = obj.table_cols; auto out_ = appender!string; out_ ~= "#table(\n columns: ("; if (widths.length > 0 && widths[0].length > 0) { foreach (i, w; widths) { out_ ~= (i > 0 ? ", " : "") ~ w ~ "fr"; } } else { foreach (i; 0 .. max(cols, 1)) { out_ ~= (i > 0 ? ", " : "") ~ "1fr"; } } out_ ~= "),\n align: ("; foreach (i; 0 .. max(cols, 1)) { string a = (i < aligns.length) ? aligns[i] : "l"; out_ ~= (i > 0 ? ", " : "") ~ alignName(a); } out_ ~= "),\n stroke: 0.4pt + luma(180),\n inset: 4pt,\n"; bool first = true; foreach (row; rows) { if (row.strip.length == 0) { continue; } auto cells = row.split(rgx_t.table_delimiter_col); foreach (cell; cells) { string c = inlineText(cell, obj, doc, targets); out_ ~= (first && obj.table_header) ? " table.cell(fill: luma(240))[#strong[" ~ c ~ "]],\n" : " [" ~ c ~ "],\n"; } first = false; } out_ ~= ")"; return wrap(obj, out_.data) ~ "\n\n"; } private string alignName(string a) { switch (a) { case "c": return "center"; case "r": return "right"; default: return "left"; } } /+ ↓ an object in its block, with its number in the margin when it has one +/ private string wrap(ref const OcdaObject obj, string content) { return (obj.ocn == 0) ? "#obj[" ~ content ~ "]" : "#ocn(" ~ obj.ocn.to!string ~ ")[" ~ content ~ "]"; } /+ ↓ the named targets an object carries, as labels with no output +/ private string anchors(ref const OcdaObject obj, bool[string] targets) { auto out_ = appender!string; bool[string] done; void put(string name) { if (name.length == 0) { return; } string l = labelOf(name); /+ ↓ the ocn's own label is placed on the number in the margin, so it is not repeated here +/ if (obj.ocn != 0 && l == labelOf(obj.ocn.to!string)) { return; } if (l in done || l in placed_labels) { return; } done[l] = true; placed_labels[l] = true; out_ ~= "#anchor(" ~ str(l) ~ ")"; } put(obj.identifier); put(obj.heading_lev_anchor); foreach (a; obj.anchor_tags) { put(a); } return out_.data; } /+ ↓ the object's text, as typst content +/ private string inline(ref const OcdaObject obj, ref OcdaDocument doc, bool[string] targets) { return inlineText(obj.text, obj, doc, targets); } private string inlineText(string txt, ref const OcdaObject obj, ref OcdaDocument doc, bool[string] targets) { txt = notes(txt, obj, doc, targets); txt = images(txt, obj); txt = links(txt, obj, targets); txt = fontFace(txt); txt = inlineAnchors(txt, targets); txt = groupIndents(txt, obj); txt = breaks(txt, obj); txt = escape(txt); txt = unprotect(txt); return txt; } /+ ↓ the markers this writer replaces with typst, protected from the escaping that follows. . The order is the awkward part of writing for a language whose markup has its own special characters: the replacements *are* typst syntax and must not be escaped, and the text around them must be. So each replacement is wrapped in two characters that cannot occur in a document - the same private-use pair throughout - and the escaping skips what lies between them. +/ private enum keep_open = ""; private enum keep_close = ""; private string keep(string typst_source) { return keep_open ~ typst_source ~ keep_close; } /+ ↓ ... and at the end the protection comes off +/ private string unprotect(string txt) { return txt.replace(keep_open, "").replace(keep_close, ""); } /+ ↓ everything outside a protected run is escaped. . The rule is deliberately blunt: **every character typst can read as syntax is escaped wherever it occurs**, not only where it would be read that way. A narrower rule kept failing, because typst continues a function call across whatever follows it: `#emph[a](b)` reads "(b)" as a second argument list and `#emph[a].b` as a field access, so a "(" or a "." of the author's own, landing after any call this writer emits, is a compile error. Escaping them everywhere makes the set closed, and a closed set is checkable. . So: "\", "#", "[", "]", "*", "_", "$", "@", "<", ">", "`", "~", "(", ")", ".", "/" and the quotation mark. A "." also begins an ellipsis and a "/" a comment, both of which this covers; "--" is an en dash and is caught by the doubled-dash case below. A document that says "--" means "--". . The quotation mark is not typst syntax in markup, and is escaped anyway, so that a bare one in the written source always delimits a string literal. guardBlockStarts relies on that to leave a code object's text alone; an odd number of quotation marks in a paragraph would otherwise put it out by one for the rest of the object. . The protection nests, so it is counted and not a flag. A link built by `links` is protected, and a line break inside its text is then protected again by `breaks`; with a flag the inner close read as the end of the outer run and the rest of a link got escaped, which cost the closing "]" of every image caption that carried a line break. +/ private string escape(string txt) { auto out_ = appender!string; int depth = 0; dchar previous = '\0'; foreach (dchar c; txt) { if (c == keep_open.to!dchar) { depth++; continue; } if (c == keep_close.to!dchar) { if (depth > 0) { depth--; } previous = '\0'; continue; } if (depth > 0) { out_ ~= c; continue; } switch (c) { case '\\': case '#': case '[': case ']': case '*': case '_': case '$': case '@': case '<': case '>': case '`': case '~': case '(': case ')': case '.': case '/': case '"': out_ ~= '\\'; out_ ~= c; break; case '-': /+ ↓ a second dash would be an en dash, so it is escaped +/ if (previous == '-') { out_ ~= '\\'; } out_ ~= c; break; default: out_ ~= c; break; } previous = c; } return out_.data; } /+ ↓ a typst string literal +/ private string str(string s) { auto out_ = appender!string; out_ ~= '"'; foreach (dchar c; s) { if (c == '"' || c == '\\') { out_ ~= '\\'; out_ ~= c; } else if (c == '\n') { out_ ~= "\\n"; } else { out_ ~= c; } } out_ ~= '"'; return out_.data; } /+ ↓ a note becomes a footnote *at the point that refers to it*, carrying the document's own number rather than typst's count, and a way back to the object it came from. . That is the whole of what 0.4 asked about: object to note is the footnote mark, note to object is one link, and typst puts the note at the foot of the page the reference is on. +/ private string notes(string txt, ref const OcdaObject obj, ref OcdaDocument doc, bool[string] targets) { string back; if (obj.ocn != 0) { string l = labelOf(obj.ocn.to!string); if (l in targets) { back = " #link(label(" ~ str(l) ~ "))[#sym.arrow.l.hook]"; } } return replaceAll!((m) { string mark = m["num"].to!string; string note = m["note"].to!string; /+ ↓ the note's own text is content in its own right, so it goes through the same pipeline - links, faces, escaping - and nothing else +/ string body_ = escape(breaks(fontFace( links(images(note, obj), obj, targets)), obj)); return keep("#footnote(numbering: _ => " ~ str(mark) ~ ")[" ~ unprotect(body_) ~ back ~ "]"); })(txt, rgx_t.inline_notes_al_all_note); } /+ ↓ a link. An internal target becomes a link to a label, and only when the label will exist: typst refuses to compile a link to one that will not, which is the check and so also the constraint. +/ private string links(string txt, ref const OcdaObject obj, bool[string] targets) { auto stow = obj.stow_link; txt = replaceAll!((m) { size_t num = m["num"].to!size_t; string url = (num < stow.length) ? stow[num] : ""; return m["linked_text"].to!string ~ "┤" ~ url ~ "├"; })(txt, rgx_t.inline_link_number_only); txt = txt.replaceAll(rgx_t.inline_link_empty, "$1"); txt = replaceAll!((m) { string text_ = escape(fontFace(m.captures[1].to!string)); string target = m.captures[2].to!string; /+ ↓ an internal link to another segment: this is one document, so the segment is this document and only the fragment matters +/ if (target.startsWith("¤")) { auto im = target.matchFirst(rgx_t.internal_target); if (!im.empty) { target = im["frag"].to!string; } } if (target.startsWith("#")) { string l = labelOf(target[1 .. $]); return keep((l in targets) ? "#link(label(" ~ str(l) ~ "))[" ~ unprotect(text_) ~ "]" : unprotect(text_)); } if (target.length == 0) { return keep(unprotect(text_)); } return keep("#link(" ~ str(target) ~ ")[" ~ unprotect(text_) ~ "]"); })(txt, rgx_t.inline_link); /+ what marker is left where a link was not one +/ return txt.replaceAll(rgx_t.mark_internal_site_lnk, ""); } /+ ↓ an image, with its own width where the document declared one. . Capped at the text width: the declared width is in pixels and a 600 pixel figure read as 600 points is wider than the page. Uncapped it overflows and typst says so; capped it fits and the aspect ratio is typst's business. +/ private string images(string txt, ref const OcdaObject obj) { return replaceAll!((m) { string file = m["img"].to!string; int w = 0; try { w = m["width"].to!int; } catch (Exception) {} string width = (w > 0) ? ("width: " ~ min(w, 440).to!string ~ "pt") : "width: 100%"; string rest = m["post"].to!string; string alt; if (auto a = rest.matchFirst(rgx_t.inline_image_alt)) { alt = a["alt"].to!string; rest = a.post.to!string; } string img = "#image(" ~ str("image/" ~ file) ~ ", " ~ width; if (alt.length > 0) { img ~= ", alt: " ~ str(alt); } img ~= ")"; /+ ↓ the opening link marker is kept, exactly as the html writer keeps it: an image in the markup sits inside a link whose target may be empty or may be a url, and the link handling that follows needs the pair. +/ return m["pre"].to!string ~ keep(img) ~ rest; })(txt, rgx_t.inline_image); } /+ ↓ the font faces, as typst functions rather than typst markup: a function call cannot be broken by the character beside it, and the markup form can +/ private string fontFace(string txt) { string wrapWith(string fn) { return keep("#" ~ fn ~ "[") ~ "$1" ~ keep("]"); } return txt .replaceAll(rgx_t.inline_emphasis, wrapWith("emph")) .replaceAll(rgx_t.inline_bold, wrapWith("strong")) .replaceAll(rgx_t.inline_italics, wrapWith("emph")) .replaceAll(rgx_t.inline_underscore, wrapWith("underline")) .replaceAll(rgx_t.inline_superscript, wrapWith("super")) .replaceAll(rgx_t.inline_subscript, wrapWith("sub")) .replaceAll(rgx_t.inline_mono, wrapWith("mono")) .replaceAll(rgx_t.inline_strike, wrapWith("strike")) .replaceAll(rgx_t.inline_insert, wrapWith("underline")) .replaceAll(rgx_t.inline_cite, wrapWith("emph")); } /+ ↓ an anchor the markup placed in the text +/ private string inlineAnchors(string txt, bool[string] targets) { return replaceAll!((m) { string l = labelOf(m["anchor"].to!string); if (l !in targets || l in placed_labels) { return ""; } placed_labels[l] = true; return keep("#anchor(" ~ str(l) ~ ")"); })(txt, rgx_t.inline_link_anchor); } /+ ↓ the line breaks the markup asked for. Not inside code, whose text is preserved as it stands. +/ private string breaks(string txt, ref const OcdaObject obj) { if (obj.is_a == "code") { return txt; } string br = keep("#linebreak()"); return txt .replaceAll(rgx_t.br_line, br) .replaceAll(rgx_t.br_line_inline, br) .replaceAll(rgx_t.br_line_spaced, br ~ br) .replaceAll(rgx_t.line_break, br) .replaceAll(rgx_t.nbsp_char, " "); } /+ ↓ the line-leading markers of a grouped object: an indent as a measurement, a hanging indent's inner level, a bullet. . Spine writes runs of non-breaking spaces here, up to eighteen of them for one line. A measurement says the same intent in a way a typesetter can act on, and the author's text keeps its own spaces. +/ private string groupIndents(string txt, ref const OcdaObject obj) { if (obj.is_a != "group" && obj.is_a != "block") { return txt; } string pad(string n) { return keep("#h(" ~ n ~ " * 12pt)"); } return txt .replaceAll(rgx_t.grouped_para_indent_hang, pad("$2")) .replaceAll(rgx_t.grouped_para_bullet_at, pad("$1") ~ keep("●") ~ " ") .replaceAll(rgx_t.grouped_para_bullet, keep("●") ~ " ") .replaceAll(rgx_t.grouped_para_indent, pad("$1")); } /+ ↓ a "- ", "+ " or "= " that begins a line or a content block. . typst reads those three as a list item, an enumeration item and a heading, but only in that position, so they are guarded there rather than escaped in every hyphen of the document. An enumeration also starts at "1. ", which needs nothing: the escaper takes every "." already. +/ private string guardBlockStarts(string s) { /+ ↓ and not inside a string literal. A code object is written as #raw(block: true, "..."), and a guard inserted in there is a backslash in the middle of somebody's program rather than an escape: one sample has "[+ " in a shell snippet, and the html writer shows no backslash. +/ auto out_ = appender!string; bool in_string = false; for (size_t i = 0; i < s.length; i++) { char c = s[i]; out_ ~= c; if (in_string) { if (c == '\\' && i + 1 < s.length) { out_ ~= s[++i]; } else if (c == '"') { in_string = false; } continue; } if (c == '"') { in_string = true; continue; } if ((c == '[' || c == '\n') && i + 2 < s.length && (s[i + 1] == '-' || s[i + 1] == '+' || s[i + 1] == '=') && s[i + 2] == ' ') { out_ ~= '\\'; } } return out_.data; }