summaryrefslogtreecommitdiffhomepage
path: root/gleam/src/write/typst.gleam
diff options
context:
space:
mode:
Diffstat (limited to 'gleam/src/write/typst.gleam')
-rw-r--r--gleam/src/write/typst.gleam909
1 files changed, 909 insertions, 0 deletions
diff --git a/gleam/src/write/typst.gleam b/gleam/src/write/typst.gleam
new file mode 100644
index 0000000..ada1bdb
--- /dev/null
+++ b/gleam/src/write/typst.gleam
@@ -0,0 +1,909 @@
+//// 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. A writer's
+//// job is model -> bytes; making a pdf is a typesetter's.
+////
+//// 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.
+////
+//// 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.
+////
+//// Byte for byte the same as the D writer. Where this looks like D rather
+//// than like Gleam - the codepoint walk in `escape`, the set threaded through
+//// every function in place of D's module variable - that is why.
+
+import gleam/dict
+import gleam/int
+import gleam/list
+import gleam/regexp
+import gleam/set.{type Set}
+import gleam/string
+import ocda/model.{type Document, type Object}
+import write/rgx
+
+const generator_name = "spine"
+
+/// The labels this document defines, and the ones already placed.
+///
+/// D keeps the second in a module variable; here it is threaded, because the
+/// order in which labels are claimed decides the bytes and a thread makes
+/// that order visible.
+pub type Labels {
+ Labels(defined: Set(String), placed: Set(String))
+}
+
+pub fn write(doc: Document) -> String {
+ let defined = defined_targets(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.
+ let placed =
+ sections(doc)
+ |> list.flat_map(fn(name) { model.section(doc, name) })
+ |> list.filter(fn(obj) { model.renders(obj) && obj.ocn != 0 })
+ |> list.map(fn(obj) { label_of(int.to_string(obj.ocn)) })
+ |> set.from_list
+ let labels = Labels(defined, placed)
+ let #(body, _) =
+ sections(doc)
+ |> list.flat_map(fn(name) { model.section(doc, name) })
+ |> list.filter(model.renders)
+ |> list.fold(#("", labels), fn(state, obj) {
+ let #(out, lab) = state
+ let #(s, lab) = object(obj, doc, lab)
+ #(out <> guard_block_starts(s), lab)
+ })
+ preamble(doc) <> body
+}
+
+/// 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.
+pub fn sections(doc: Document) -> List(String) {
+ let trailing =
+ ["glossary", "bibliography", "bookindex", "blurb"]
+ |> list.filter(fn(name) { list.length(model.section(doc, name)) > 1 })
+ list.append(["head", "toc", "body"], trailing)
+}
+
+/// 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.
+pub fn defined_targets(doc: Document) -> Set(String) {
+ sections(doc)
+ |> list.flat_map(fn(name) { model.section(doc, name) })
+ |> list.filter(model.renders)
+ |> list.fold(set.new(), fn(acc, obj) {
+ let acc = case obj.ocn {
+ 0 -> acc
+ n -> set.insert(acc, label_of(int.to_string(n)))
+ }
+ let acc = case obj.identifier {
+ "" -> acc
+ id -> set.insert(acc, label_of(id))
+ }
+ let acc = case obj.heading_lev_anchor {
+ "" -> acc
+ a -> set.insert(acc, label_of(a))
+ }
+ let acc =
+ list.fold(obj.anchor_tags, acc, fn(a, tag) { set.insert(a, label_of(tag)) })
+ regexp.scan(rgx.inline_link_anchor(), obj.text)
+ |> list.fold(acc, fn(a, m) {
+ set.insert(a, label_of(rgx.group(m.submatches, 0)))
+ })
+ })
+}
+
+/// 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 graphemes, so that the two writers cannot part company
+/// over a combining mark.
+pub fn label_of(name: String) -> String {
+ "t"
+ <> {
+ string.to_utf_codepoints(name)
+ |> list.map(fn(c) {
+ case is_label_code(string.utf_codepoint_to_int(c)) {
+ True -> string.from_utf_codepoints([c])
+ False -> "_"
+ }
+ })
+ |> string.concat
+ }
+}
+
+fn is_label_code(n: Int) -> Bool {
+ n == 0x5f
+ || n == 0x2d
+ || n == 0x2e
+ || { n >= 97 && n <= 122 }
+ || { n >= 65 && n <= 90 }
+ || { n >= 48 && n <= 57 }
+}
+
+fn preamble(doc: Document) -> String {
+ let language = case dict.get(doc.source, "language") {
+ Ok(l) -> l
+ Error(_) -> "en"
+ }
+ let parts = string.split(string.replace(language, "_", "-"), "-")
+ let #(lang, region) = case parts {
+ [l, r, ..] -> #(l, r)
+ [l] -> #(l, "")
+ [] -> #("en", "")
+ }
+ let title = case model.meta(doc, "title.full") {
+ "" -> model.meta(doc, "title.main")
+ t -> t
+ }
+ "// "
+ <> generator_name
+ <> ": the object-centric document abstraction, written as typst\n"
+ <> "// Generated from the abstraction. Compile with: typst compile <this file>\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
+ <> "#set document(\n title: "
+ <> str(title)
+ <> ",\n"
+ <> case model.meta(doc, "creator.author") {
+ "" -> ""
+ a -> " author: " <> str(a) <> ",\n"
+ }
+ <> " date: none,\n)\n"
+ // the left margin is wide because the object numbers live in it
+ <> "#set page(\n paper: \"a4\",\n"
+ <> " margin: (left: 3.4cm, right: 2cm, top: 2.2cm, bottom: 2.2cm),\n"
+ <> " numbering: \"1\",\n)\n"
+ <> "#set text(\n font: (\"DejaVu Serif\", \"Noto Sans CJK JP\"),\n"
+ <> " size: 10pt,\n lang: "
+ <> str(lang)
+ <> case region {
+ "" -> ""
+ r -> ",\n region: " <> str(r)
+ }
+ <> ",\n)\n"
+ <> "#set par(justify: true, leading: 0.65em)\n"
+ // the author's quotation marks and dashes, not typst's
+ <> "#set smartquote(enabled: false)\n"
+ <> "#set heading(numbering: none)\n"
+ <> "#show heading: it => block(above: 1.4em, below: 0.7em, it)\n"
+ <> "#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. One `place`
+ // with a negative offset, where spine's latex uses \marginpar and loses it
+ // inside a float or a footnote.
+ <> "#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"
+ <> "#let obj(body) = block(breakable: true, width: 100%, body)\n"
+ // monospace as a font rather than as `raw`: raw() takes a *string*, so it
+ // cannot carry the nested markup an object may have inside a monospace run
+ <> "#let mono(body) = text(font: (\"DejaVu Sans Mono\",), body)\n"
+ <> "#let anchor(name) = [#box(width: 0pt)#label(name)]\n\n"
+}
+
+fn object(obj: Object, doc: Document, lab: Labels) -> #(String, Labels) {
+ case obj.is_a {
+ "heading" -> heading(obj, doc, lab)
+ "toc" -> toc_entry(obj, doc, lab)
+ "para" | "blurb" | "glossary" | "bibliography" | "bookindex" ->
+ para(obj, doc, lab)
+ "quote" -> quote(obj, doc, lab)
+ "group" | "block" -> group(obj, doc, lab)
+ "verse" | "poem" -> verse(obj, doc, lab)
+ "code" -> #(code(obj), lab)
+ "table" -> table(obj, doc, lab)
+ _ -> #("", lab)
+ }
+}
+
+/// 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.
+fn heading_level(level: String) -> Int {
+ case level {
+ "A" -> 1
+ "B" -> 2
+ "C" -> 3
+ "D" -> 4
+ "1" -> 5
+ "2" -> 6
+ "3" -> 7
+ "4" -> 8
+ _ -> 1
+ }
+}
+
+fn heading(obj: Object, doc: Document, lab: Labels) -> #(String, Labels) {
+ case obj.dummy_heading {
+ // a dummy heading is generated furniture whose text repeats the heading
+ // above it; its anchors are what the table of contents wants
+ True -> {
+ let #(a, lab) = anchors(obj, lab)
+ #(case a {
+ "" -> ""
+ _ -> a <> "\n\n"
+ }, lab)
+ }
+ False -> {
+ let #(body, lab) = inline(obj, doc, lab)
+ case string.trim(body) {
+ "" -> #("", lab)
+ _ -> {
+ let #(a, lab) = anchors(obj, lab)
+ #(
+ wrap(
+ obj,
+ "#heading(level: "
+ <> int.to_string(heading_level(obj.heading_level))
+ <> ")["
+ <> a
+ <> body
+ <> "]",
+ )
+ <> "\n\n",
+ lab,
+ )
+ }
+ }
+ }
+ }
+}
+
+/// 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 a citation uses.
+///
+/// The text and the number are both links to the object.
+fn toc_entry(obj: Object, doc: Document, lab: Labels) -> #(String, Labels) {
+ case obj.dummy_heading {
+ True -> #("", lab)
+ False -> {
+ let #(body, lab) = inline(obj, doc, lab)
+ case string.trim(body) != "" && points_at_something(obj, lab) {
+ False -> #("", lab)
+ True -> {
+ let indent = case obj.indent_hang > 1 {
+ True -> obj.indent_hang - 1
+ False -> 0
+ }
+ let locator = case toc_target(obj, lab) {
+ "" -> ""
+ n ->
+ " #box(width: 1fr, inset: (x: 3pt), repeat[.]) #link(label("
+ <> str(label_of(n))
+ <> "))["
+ <> n
+ <> "]"
+ }
+ #(
+ "#block(inset: (left: "
+ <> int.to_string(indent * 8)
+ <> "pt))["
+ <> body
+ <> locator
+ <> "]\n\n",
+ lab,
+ )
+ }
+ }
+ }
+ }
+}
+
+/// 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.
+fn toc_target(obj: Object, lab: Labels) -> String {
+ regexp.scan(rgx.any_internal_target(), obj.text)
+ |> list.fold("", fn(found, m) {
+ case found {
+ "" -> {
+ let frag = rgx.group(m.submatches, 0)
+ case int.parse(frag), set.contains(lab.defined, label_of(frag)) {
+ Ok(_), True -> frag
+ _, _ -> ""
+ }
+ }
+ _ -> found
+ }
+ })
+}
+
+fn para(obj: Object, doc: Document, lab: Labels) -> #(String, Labels) {
+ case obj.dummy_heading {
+ True -> #("", lab)
+ False -> {
+ let #(body, lab) = inline(obj, doc, lab)
+ case string.trim(body) {
+ "" -> #("", lab)
+ _ -> {
+ let #(a, lab) = anchors(obj, lab)
+ let bullet = case obj.bullet {
+ True -> "\u{25cf}~"
+ False -> ""
+ }
+ let content = case obj.indent_base > 0 {
+ True ->
+ "#block(inset: (left: "
+ <> int.to_string(obj.indent_base * 12)
+ <> "pt))["
+ <> a
+ <> bullet
+ <> body
+ <> "]"
+ False -> a <> bullet <> body
+ }
+ #(wrap(obj, content) <> "\n\n", lab)
+ }
+ }
+ }
+ }
+}
+
+fn quote(obj: Object, doc: Document, lab: Labels) -> #(String, Labels) {
+ let #(body, lab) = inline(obj, doc, lab)
+ case string.trim(body) {
+ "" -> #("", lab)
+ _ -> {
+ let #(a, lab) = anchors(obj, lab)
+ #(
+ wrap(obj, "#block(inset: (left: 18pt, right: 18pt))[" <> a <> body <> "]")
+ <> "\n\n",
+ lab,
+ )
+ }
+ }
+}
+
+/// group and block: one object whose text holds its own lines.
+fn group(obj: Object, doc: Document, lab: Labels) -> #(String, Labels) {
+ let #(body, lab) = inline(obj, doc, lab)
+ case string.trim(body) {
+ "" -> #("", lab)
+ _ -> {
+ let #(a, lab) = anchors(obj, lab)
+ #(wrap(obj, "#block[" <> a <> body <> "]") <> "\n\n", lab)
+ }
+ }
+}
+
+/// verse: every line break kept, and no justification, because a line of
+/// verse ends where the poet ended it.
+fn verse(obj: Object, doc: Document, lab: Labels) -> #(String, Labels) {
+ let #(body, lab) = inline(obj, doc, lab)
+ case string.trim(body) {
+ "" -> #("", lab)
+ _ -> {
+ let #(a, lab) = anchors(obj, lab)
+ #(
+ wrap(
+ obj,
+ "#block(inset: (left: 12pt))[#set par(justify: false);" <> a <> body <> "]",
+ )
+ <> "\n\n",
+ lab,
+ )
+ }
+ }
+}
+
+/// 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.
+fn code(obj: Object) -> String {
+ let lang = case obj.code_syntax {
+ "" -> ""
+ s -> "lang: " <> str(s) <> ", "
+ }
+ let txt = regexp.replace(rgx.nbsp_char(), obj.text, " ")
+ 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.
+fn table(obj: Object, doc: Document, lab: Labels) -> #(String, Labels) {
+ let rows =
+ regexp.split(rgx.table_delimiter_row(), obj.text)
+ |> list.filter(fn(r) { string.trim(r) != "" })
+ let widths = string.split(obj.table_widths, on: " ")
+ let aligns = string.split(obj.table_aligns, on: " ")
+ let cols = int.max(obj.table_cols, 1)
+ let columns = case widths {
+ [first, ..] if first != "" ->
+ widths |> list.map(fn(w) { w <> "fr" }) |> string.join(", ")
+ _ -> list.repeat("1fr", cols) |> string.join(", ")
+ }
+ let align =
+ list.repeat("", cols)
+ |> list.index_map(fn(_, i) { align_name(at(aligns, i, "l")) })
+ |> string.join(", ")
+ let #(body, lab) =
+ rows
+ |> list.index_map(fn(row, row_idx) { #(row, row_idx) })
+ |> list.fold(#("", lab), fn(state, pair) {
+ let #(out, lab) = state
+ let #(row, row_idx) = pair
+ let header = row_idx == 0 && obj.table_header
+ let #(cells, lab) =
+ regexp.split(rgx.table_delimiter_col(), row)
+ |> list.fold(#("", lab), fn(cs, cell) {
+ let #(so_far, lab) = cs
+ let #(c, lab) = inline_text(cell, obj, doc, lab)
+ #(
+ so_far
+ <> case header {
+ True -> " table.cell(fill: luma(240))[#strong[" <> c <> "]],\n"
+ False -> " [" <> c <> "],\n"
+ },
+ lab,
+ )
+ })
+ #(out <> cells, lab)
+ })
+ #(
+ wrap(
+ obj,
+ "#table(\n columns: ("
+ <> columns
+ <> "),\n align: ("
+ <> align
+ <> "),\n stroke: 0.4pt + luma(180),\n inset: 4pt,\n"
+ <> body
+ <> ")",
+ )
+ <> "\n\n",
+ lab,
+ )
+}
+
+fn align_name(a: String) -> String {
+ case a {
+ "c" -> "center"
+ "r" -> "right"
+ _ -> "left"
+ }
+}
+
+fn at(items: List(String), index: Int, fallback: String) -> String {
+ case items, index {
+ [], _ -> fallback
+ [first, ..], 0 -> first
+ [_, ..rest], n -> at(rest, n - 1, fallback)
+ }
+}
+
+/// An object in its block, with its number in the margin when it has one.
+fn wrap(obj: Object, content: String) -> String {
+ case obj.ocn {
+ 0 -> "#obj[" <> content <> "]"
+ n -> "#ocn(" <> int.to_string(n) <> ")[" <> content <> "]"
+ }
+}
+
+/// The named targets an object carries, as labels with no output.
+fn anchors(obj: Object, lab: Labels) -> #(String, Labels) {
+ let own = case obj.ocn {
+ 0 -> ""
+ n -> label_of(int.to_string(n))
+ }
+ list.append([obj.identifier, obj.heading_lev_anchor], obj.anchor_tags)
+ |> list.fold(#("", lab), fn(state, name) {
+ let #(out, lab) = state
+ case name {
+ "" -> state
+ _ -> {
+ let l = label_of(name)
+ // the ocn's own label is placed on the number in the margin, so it is
+ // not repeated here
+ case l == own || set.contains(lab.placed, l) {
+ True -> state
+ False -> #(
+ out <> "#anchor(" <> str(l) <> ")",
+ Labels(..lab, placed: set.insert(lab.placed, l)),
+ )
+ }
+ }
+ }
+ })
+}
+
+fn inline(obj: Object, doc: Document, lab: Labels) -> #(String, Labels) {
+ inline_text(obj.text, obj, doc, lab)
+}
+
+fn inline_text(
+ txt: String,
+ obj: Object,
+ doc: Document,
+ lab: Labels,
+) -> #(String, Labels) {
+ let txt = notes(txt, obj, lab)
+ let txt = images(txt)
+ let txt = links(txt, obj, lab)
+ let txt = font_face(txt)
+ let #(txt, lab) = inline_anchors(txt, lab)
+ let txt = group_indents(txt, obj)
+ let txt = breaks(txt, obj)
+ let _ = doc
+ #(unprotect(escape(txt)), lab)
+}
+
+// The markers that protect a replacement from the escaping that follows. A
+// private-use pair, because they must not occur in a document.
+const keep_open = 0xe000
+
+const keep_close = 0xe001
+
+fn keep(typst_source: String) -> String {
+ cp(keep_open) <> typst_source <> cp(keep_close)
+}
+
+fn unprotect(txt: String) -> String {
+ txt |> string.replace(cp(keep_open), "") |> string.replace(cp(keep_close), "")
+}
+
+fn cp(n: Int) -> String {
+ let assert Ok(c) = string.utf_codepoint(n)
+ string.from_utf_codepoints([c])
+}
+
+/// 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.
+///
+/// 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`.
+///
+/// Codepoints rather than graphemes, because the D writer walks codepoints
+/// and the two must agree byte for byte.
+fn escape(txt: String) -> String {
+ let #(out, _, _) =
+ string.to_utf_codepoints(txt)
+ |> list.fold(#([], 0, 0), fn(state, c) {
+ let #(acc, depth, previous) = state
+ let n = string.utf_codepoint_to_int(c)
+ case n == keep_open, n == keep_close, depth > 0 {
+ True, _, _ -> #(acc, depth + 1, previous)
+ _, True, _ -> #(acc, int.max(depth - 1, 0), 0)
+ _, _, True -> #([c, ..acc], depth, previous)
+ _, _, False ->
+ case is_syntax(n), n == 0x2d && previous == 0x2d {
+ True, _ -> #([c, backslash(), ..acc], depth, n)
+ // a second dash would be an en dash, so it is escaped
+ _, True -> #([c, backslash(), ..acc], depth, n)
+ _, _ -> #([c, ..acc], depth, n)
+ }
+ }
+ })
+ string.from_utf_codepoints(list.reverse(out))
+}
+
+/// "\", "#", "[", "]", "*", "_", "$", "@", "<", ">", "`", "~", "(", ")",
+/// ".", "/" and the quotation mark. A "." also begins an ellipsis and a "/" a
+/// comment, both of which this covers. 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, which is what `guard_block_starts`
+/// reads.
+fn is_syntax(n: Int) -> Bool {
+ case n {
+ 0x5c | 0x23 | 0x5b | 0x5d | 0x2a | 0x5f | 0x24 | 0x40 -> True
+ 0x3c | 0x3e | 0x60 | 0x7e | 0x28 | 0x29 | 0x2e | 0x2f -> True
+ 0x22 -> True
+ _ -> False
+ }
+}
+
+fn backslash() -> UtfCodepoint {
+ let assert Ok(c) = string.utf_codepoint(0x5c)
+ c
+}
+
+/// A typst string literal.
+fn str(s: String) -> String {
+ "\""
+ <> {
+ s
+ |> string.replace("\\", "\\\\")
+ |> string.replace("\"", "\\\"")
+ |> string.replace("\n", "\\n")
+ }
+ <> "\""
+}
+
+/// 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.
+fn notes(txt: String, obj: Object, lab: Labels) -> String {
+ let back = case obj.ocn {
+ 0 -> ""
+ n -> {
+ let l = label_of(int.to_string(n))
+ case set.contains(lab.defined, l) {
+ True -> " #link(label(" <> str(l) <> "))[#sym.arrow.l.hook]"
+ False -> ""
+ }
+ }
+ }
+ rgx.replace_with(rgx.inline_notes_any_mark(), txt, fn(g) {
+ // the note's own text is content in its own right, so it goes through the
+ // same pipeline - links, faces, escaping - and nothing else
+ let body =
+ escape(breaks(font_face(links(images(g(1)), obj, lab)), obj))
+ keep(
+ "#footnote(numbering: _ => "
+ <> str(g(0))
+ <> ")["
+ <> unprotect(body)
+ <> back
+ <> "]",
+ )
+ })
+}
+
+/// 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.
+fn links(txt: String, obj: Object, lab: Labels) -> String {
+ let txt =
+ rgx.replace_with(rgx.inline_link_number_only(), txt, fn(g) {
+ let url = case int.parse(g(1)) {
+ Ok(n) -> at(obj.stow_link, n, "")
+ Error(_) -> ""
+ }
+ g(0) <> "┤" <> url <> "├"
+ })
+ let txt = rgx.replace_with(rgx.inline_link_empty(), txt, fn(g) { g(0) })
+ let txt =
+ rgx.replace_with(rgx.inline_link(), txt, fn(g) {
+ let text = escape(font_face(g(0)))
+ // an internal link to another segment: this is one document, so the
+ // segment is this document and only the fragment matters
+ let target = case string.starts_with(g(1), "¤") {
+ False -> g(1)
+ True ->
+ case regexp.scan(rgx.internal_target(), g(1)) {
+ [m, ..] -> rgx.group(m.submatches, 1)
+ [] -> g(1)
+ }
+ }
+ case string.starts_with(target, "#"), target {
+ True, _ -> {
+ let l = label_of(string.drop_start(target, 1))
+ keep(case set.contains(lab.defined, l) {
+ True -> "#link(label(" <> str(l) <> "))[" <> unprotect(text) <> "]"
+ False -> unprotect(text)
+ })
+ }
+ False, "" -> keep(unprotect(text))
+ False, _ ->
+ keep("#link(" <> str(target) <> ")[" <> unprotect(text) <> "]")
+ }
+ })
+ regexp.replace(rgx.mark_internal_site_lnk(), txt, "")
+}
+
+/// 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.
+fn images(txt: String) -> String {
+ rgx.replace_with(rgx.inline_image(), txt, fn(g) {
+ let width = case int.parse(g(3)) {
+ Ok(w) if w > 0 -> "width: " <> int.to_string(int.min(w, 440)) <> "pt"
+ _ -> "width: 100%"
+ }
+ let post = g(5)
+ let #(alt, rest) = case regexp.scan(rgx.inline_image_alt(), post) {
+ [m, ..] -> #(rgx.group(m.submatches, 0), case
+ string.split_once(post, m.content)
+ {
+ Ok(#(_, after)) -> after
+ Error(_) -> post
+ })
+ [] -> #("", post)
+ }
+ let img =
+ "#image("
+ <> str("image/" <> g(2))
+ <> ", "
+ <> width
+ <> case alt {
+ "" -> ""
+ a -> ", alt: " <> str(a)
+ }
+ <> ")"
+ // 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.
+ g(0) <> keep(img) <> rest
+ })
+}
+
+/// 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.
+fn font_face(txt: String) -> String {
+ [
+ #(rgx.inline_emphasis(), "emph"),
+ #(rgx.inline_bold(), "strong"),
+ #(rgx.inline_italics(), "emph"),
+ #(rgx.inline_underscore(), "underline"),
+ #(rgx.inline_superscript(), "super"),
+ #(rgx.inline_subscript(), "sub"),
+ #(rgx.inline_mono(), "mono"),
+ #(rgx.inline_strike(), "strike"),
+ #(rgx.inline_insert(), "underline"),
+ #(rgx.inline_cite(), "emph"),
+ ]
+ |> list.fold(txt, fn(acc, face) {
+ let #(pattern, fname) = face
+ rgx.replace_with(pattern, acc, fn(g) {
+ keep("#" <> fname <> "[") <> g(0) <> keep("]")
+ })
+ })
+}
+
+/// An anchor the markup placed in the text.
+fn inline_anchors(txt: String, lab: Labels) -> #(String, Labels) {
+ case regexp.scan(rgx.inline_link_anchor(), txt) {
+ [] -> #(txt, lab)
+ matches -> {
+ let #(out, rest, lab) =
+ list.fold(matches, #("", txt, lab), fn(state, m) {
+ let #(out, rest, lab) = state
+ case string.split_once(rest, m.content) {
+ Error(_) -> state
+ Ok(#(before, after)) -> {
+ let l = label_of(rgx.group(m.submatches, 0))
+ case
+ set.contains(lab.defined, l) && !set.contains(lab.placed, l)
+ {
+ False -> #(out <> before, after, lab)
+ True -> #(
+ out <> before <> keep("#anchor(" <> str(l) <> ")"),
+ after,
+ Labels(..lab, placed: set.insert(lab.placed, l)),
+ )
+ }
+ }
+ }
+ })
+ #(out <> rest, lab)
+ }
+ }
+}
+
+/// The line breaks the markup asked for. Not inside code, whose text is
+/// preserved as it stands.
+fn breaks(txt: String, obj: Object) -> String {
+ case obj.is_a {
+ "code" -> txt
+ _ -> {
+ let br = keep("#linebreak()")
+ txt
+ |> regexp.replace(rgx.br_line(), _, br)
+ |> regexp.replace(rgx.br_line_inline(), _, br)
+ |> regexp.replace(rgx.br_line_spaced(), _, br <> br)
+ |> regexp.replace(rgx.line_break(), _, br)
+ |> regexp.replace(rgx.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.
+fn group_indents(txt: String, obj: Object) -> String {
+ case obj.is_a == "group" || obj.is_a == "block" {
+ False -> txt
+ True ->
+ txt
+ |> rgx.replace_with(rgx.grouped_para_indent_hang(), _, fn(g) {
+ pad(g(1))
+ })
+ |> rgx.replace_with(rgx.grouped_para_bullet_at(), _, fn(g) {
+ pad(g(0)) <> keep("\u{25cf}") <> " "
+ })
+ |> rgx.replace_with(rgx.grouped_para_bullet(), _, fn(_) {
+ keep("\u{25cf}") <> " "
+ })
+ |> rgx.replace_with(rgx.grouped_para_indent(), _, fn(g) { pad(g(0)) })
+ }
+}
+
+fn pad(n: String) -> String {
+ keep("#h(" <> n <> " * 12pt)")
+}
+
+/// 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.
+fn guard_block_starts(s: String) -> String {
+ walk_guard(string.to_graphemes(s), [], False)
+}
+
+// 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.
+fn walk_guard(
+ rest: List(String),
+ acc: List(String),
+ in_string: Bool,
+) -> String {
+ case rest, in_string {
+ [], _ -> string.concat(list.reverse(acc))
+ ["\\", next, ..more], True -> walk_guard(more, [next, "\\", ..acc], True)
+ ["\"", ..more], True -> walk_guard(more, ["\"", ..acc], False)
+ [c, ..more], True -> walk_guard(more, [c, ..acc], True)
+ ["\"", ..more], False -> walk_guard(more, ["\"", ..acc], True)
+ [c, m, " ", ..more], False ->
+ case { c == "[" || c == "\n" } && { m == "-" || m == "+" || m == "=" } {
+ True -> walk_guard(more, [" ", m, "\\", c, ..acc], False)
+ False -> walk_guard([m, " ", ..more], [c, ..acc], False)
+ }
+ [c, ..more], False -> walk_guard(more, [c, ..acc], False)
+ }
+}
+
+/// 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.
+fn points_at_something(obj: Object, lab: Labels) -> Bool {
+ case regexp.scan(rgx.any_internal_target(), obj.text) {
+ [] -> True
+ matches ->
+ list.any(matches, fn(m) {
+ set.contains(lab.defined, label_of(rgx.group(m.submatches, 0)))
+ })
+ }
+}