diff options
Diffstat (limited to 'gleam/src/write/typst.gleam')
| -rw-r--r-- | gleam/src/write/typst.gleam | 909 |
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))) + }) + } +} |
