summaryrefslogtreecommitdiffhomepage
path: root/gleam/src/write/text.gleam
diff options
context:
space:
mode:
Diffstat (limited to 'gleam/src/write/text.gleam')
-rw-r--r--gleam/src/write/text.gleam399
1 files changed, 399 insertions, 0 deletions
diff --git a/gleam/src/write/text.gleam b/gleam/src/write/text.gleam
new file mode 100644
index 0000000..2e44e3a
--- /dev/null
+++ b/gleam/src/write/text.gleam
@@ -0,0 +1,399 @@
+//// The plain text writer: a function from the model to bytes.
+////
+//// Given an `ocda/model.Document` and nothing else. It does not know where
+//// the document came from, which is the point: the same call renders one
+//// loaded from a `.ssp` and one loaded from a `.ocda.db`, and cannot tell
+//// them apart.
+////
+//// **This no longer matches spine's `--text` byte for byte, by decision.**
+//// Byte identity was the first oracle and earned its place while there was
+//// nothing else to check against; holding to it meant reproducing a dozen
+//// things no one would choose. What replaced it is stricter where it matters:
+//// the D and the Gleam writer produce the same bytes, the words of the output
+//// equal the words of the html writer's objects as a multiset, every object
+//// number appears exactly once, and the same bytes come from every input
+//// form.
+////
+//// Byte for byte the same as the D writer. What each changed from spine, and
+//// why, is in that file's header and in the repository README.
+
+import gleam/dict.{type Dict}
+import gleam/int
+import gleam/list
+import gleam/regexp
+import gleam/string
+import ocda/model.{type Document, type Object}
+import write/rgx
+
+const newline = "\n"
+
+const newlines = "\n\n"
+
+/// Set to spine's own values so that a comparison is of the writer and not of
+/// the signature. A writer that is not spine should say so here.
+const generator_name = "spine"
+
+const generator_url = "https://sisudoc.org"
+
+pub fn write(doc: Document) -> String {
+ let depths = toc_depths(doc)
+ let body =
+ text_sections(doc)
+ |> list.flat_map(fn(name) { model.section(doc, name) })
+ |> list.filter(model.renders)
+ |> list.map(fn(obj) { object(obj, depths) })
+ |> string.concat
+ body <> newline <> tail(doc) <> newline
+}
+
+/// Which sections, in which order: head, toc, body, then each trailing
+/// section that has more than one object. One object is the section's own
+/// heading with nothing under it.
+pub fn text_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)
+}
+
+/// Rank the `indent_hang` values the table of contents uses.
+///
+/// spine's `indent_hang` is not a depth: it reserves 1 to 3 for levels above
+/// the body, so a document's toc uses 1 then jumps to 4, 5, 6. Indenting by
+/// the number itself leaves the first body level six spaces in. Ranking the
+/// values the document actually uses adapts to any document without a table
+/// of special cases.
+fn toc_depths(doc: Document) -> Dict(Int, Int) {
+ model.section(doc, "toc")
+ |> list.filter(model.renders)
+ |> list.map(fn(obj) { obj.indent_hang })
+ |> list.unique
+ |> list.sort(int.compare)
+ |> list.index_map(fn(level, i) { #(level, i) })
+ |> dict.from_list
+}
+
+/// One object, by kind.
+///
+/// The last case writes the object rather than dropping it: a kind this
+/// dispatch has not heard of is more likely to be new than to be unwanted,
+/// and an output that loses it silently cannot be checked.
+fn object(obj: Object, depths: Dict(Int, Int)) -> String {
+ // an object with no text of its own writes nothing. A poem is such an
+ // object: it is a container that closes a run of verses and carries the
+ // *same* object number as the last of them, so writing it would put that
+ // number in the file twice - 15 times over in one sample.
+ // and a dummy heading writes nothing: it is generated furniture whose text
+ // repeats the heading above it, and every other writer here drops it.
+ // Spine's text keeps it, so a trailing section's name is in the file twice.
+ case string.trim(obj.text) == "" || obj.dummy_heading {
+ True -> ""
+ False ->
+ case obj.is_a {
+ "toc" -> toc_entry(obj, depths)
+ "table" -> table(obj)
+ "bookindex" -> index_entry(obj)
+ "code" ->
+ block(obj, regexp.replace(rgx.nbsp_char(), obj.text, " "), "")
+ "heading" -> newline <> general(obj)
+ _ -> general(obj)
+ }
+ }
+}
+
+/// The usual case: the markers resolved, the notes gathered out of the
+/// resolved text, and the rest written as text.
+fn general(obj: Object) -> String {
+ let resolved = links(images(obj.text))
+ block(obj, inline(resolved, obj), notes_of(resolved))
+}
+
+/// The common shape: the text, the notes it carried, and its number.
+fn block(obj: Object, txt: String, notes: String) -> String {
+ txt <> notes <> newline <> ocn_mark(obj) <> newline
+}
+
+/// The object's number, on a line of its own.
+fn ocn_mark(obj: Object) -> String {
+ case obj.ocn {
+ 0 -> ""
+ n -> "「" <> int.to_string(n) <> "」" <> newline
+ }
+}
+
+/// A note is carried inside the object that refers to it, 【<n> text】. The
+/// reference becomes [n] where it stands and the note follows the object,
+/// which is what makes the text readable in one pass.
+fn notes_of(txt: String) -> String {
+ regexp.scan(rgx.inline_notes_any_mark(), txt)
+ |> list.map(fn(m) {
+ newlines
+ <> rgx.group(m.submatches, 0)
+ <> ". "
+ <> faces(breaks(rgx.group(m.submatches, 1)))
+ })
+ |> string.concat
+}
+
+/// A table of contents entry: two spaces per level, the text, and the object
+/// it names.
+fn toc_entry(obj: Object, depths: Dict(Int, Int)) -> String {
+ let indent = case dict.get(depths, obj.indent_hang) {
+ Ok(d) -> string.repeat(" ", d)
+ Error(_) -> ""
+ }
+ indent <> inline(links(images(obj.text)), obj) <> newline
+}
+
+/// A book index entry, whose links are to objects rather than to segments.
+fn index_entry(obj: Object) -> String {
+ let txt = regexp.replace(rgx.trailing_backslash(), obj.text, "")
+ inline(links(images(txt)), obj) <> newlines
+}
+
+/// A table, in columns as wide as their widest cell needs.
+///
+/// The object's text is the whole table: rows separated by newlines, columns
+/// by ┊. A row of one cell in a table of more than one column is a note under
+/// the table rather than a row of it.
+fn table(obj: Object) -> String {
+ let aligns = string.split(obj.table_aligns, on: " ")
+ let #(rows, note) =
+ regexp.split(rgx.table_delimiter_row(), obj.text)
+ |> list.filter(fn(r) { string.trim(r) != "" })
+ |> list.fold(#([], ""), fn(state, row) {
+ let #(rows, note) = state
+ let cells =
+ regexp.split(rgx.table_delimiter_col(), row)
+ |> list.map(fn(cell) { inline(links(images(cell)), obj) })
+ case cells, obj.table_cols > 1 {
+ [only], True -> #(rows, note <> only <> newline)
+ _, _ -> #([cells, ..rows], note)
+ }
+ })
+ let rows = list.reverse(rows)
+ let widths =
+ list.fold(rows, dict.new(), fn(acc, cells) {
+ list.index_fold(cells, acc, fn(a, cell, i) {
+ let w = display_width(cell)
+ case dict.get(a, i) {
+ Ok(had) if had >= w -> a
+ _ -> dict.insert(a, i, w)
+ }
+ })
+ })
+ let body =
+ rows
+ |> list.map(fn(cells) {
+ let n = list.length(cells)
+ cells
+ |> list.index_map(fn(cell, i) {
+ let w = case dict.get(widths, i) {
+ Ok(w) -> w
+ Error(_) -> display_width(cell)
+ }
+ let pad = string.repeat(" ", w - display_width(cell))
+ let padded = case at(aligns, i, "l") {
+ "l" -> cell <> pad
+ _ -> pad <> cell
+ }
+ case i + 1 < n {
+ True -> padded <> " ┊ "
+ False -> padded
+ }
+ })
+ |> string.concat
+ // stripped at the end of its own row, so a row that ends in an empty
+ // cell keeps the line it is on
+ |> strip_right
+ })
+ |> list.map(fn(line) { line <> newline })
+ |> string.concat
+ body <> note <> ocn_mark(obj) <> newline
+}
+
+fn strip_right(s: String) -> String {
+ case string.ends_with(s, " ") || string.ends_with(s, "\t") {
+ True -> strip_right(string.drop_end(s, 1))
+ False -> s
+ }
+}
+
+/// How many columns a string occupies.
+///
+/// One per character, and two for the ranges a terminal draws double width.
+/// Neither a byte count nor a codepoint count lines up a table holding
+/// Japanese, and this is the measurement the alignment means.
+fn display_width(s: String) -> Int {
+ string.to_utf_codepoints(s)
+ |> list.fold(0, fn(w, c) {
+ case wide(string.utf_codepoint_to_int(c)) {
+ True -> w + 2
+ False -> w + 1
+ }
+ })
+}
+
+fn wide(n: Int) -> Bool {
+ { n >= 0x1100 && n <= 0x115f }
+ || { n >= 0x2e80 && n <= 0x303e }
+ || { n >= 0x3041 && n <= 0x33ff }
+ || { n >= 0x3400 && n <= 0x4dbf }
+ || { n >= 0x4e00 && n <= 0x9fff }
+ || { n >= 0xa000 && n <= 0xa4cf }
+ || { n >= 0xac00 && n <= 0xd7a3 }
+ || { n >= 0xf900 && n <= 0xfaff }
+ || { n >= 0xfe30 && n <= 0xfe6f }
+ || { n >= 0xff00 && n <= 0xff60 }
+ || { n >= 0xffe0 && n <= 0xffe6 }
+ || { n >= 0x20000 && n <= 0x3fffd }
+}
+
+/// An object's text as plain text.
+///
+/// The order is the one every writer here uses: images before links, because
+/// an image sits inside a link; then the faces, then the breaks. A note
+/// reference becomes [n] and the note itself is gathered by `notes`.
+fn inline(txt: String, obj: Object) -> String {
+ txt
+ |> rgx.replace_with(rgx.inline_notes_any_mark(), _, fn(g) {
+ "[" <> g(0) <> "]"
+ })
+ // an anchor the markup placed in the text has no rendering of its own; in a
+ // text file there is nothing for it to be
+ |> regexp.replace(rgx.inline_link_anchor(), _, "")
+ |> faces
+ |> group_indents(obj)
+ |> breaks
+}
+
+/// The line-leading markers of a grouped object: an indent, a hanging indent's
+/// inner level, a bullet. Spine leaves them in the text as "_1 " and "_* ",
+/// which read as markup that was not translated.
+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) {
+ string.repeat(" ", 2) <> g(1)
+ })
+ |> rgx.replace_with(rgx.grouped_para_bullet_at(), _, fn(g) {
+ g(0) <> g(0) <> "\u{25cf} "
+ })
+ |> rgx.replace_with(rgx.grouped_para_bullet(), _, fn(_) { "\u{25cf} " })
+ |> rgx.replace_with(rgx.grouped_para_indent(), _, fn(g) { g(0) <> g(0) })
+ }
+}
+
+/// An image becomes its file name in braces, which is spine's own markup for
+/// one, and the words beside it stay where they are. Spine's text writes the
+/// file name alone and loses the caption.
+///
+/// The quotation marks around an alt string are the markup's delimiters and not
+/// the author's punctuation, so they come off here as they do in the html
+/// writer.
+fn images(txt: String) -> String {
+ rgx.replace_with(rgx.inline_image(), txt, fn(g) {
+ 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)
+ }
+ g(0) <> "{" <> g(2) <> "}" <> alt <> rest
+ })
+}
+
+/// A link becomes its text, and the object it points at after it.
+///
+/// Only when the target *is* an object: a name, a segment or a url means
+/// nothing to a reader of a text file, where "≫12" is a number they can
+/// search for. So the toc line for the endnotes reads "Endnotes" and not
+/// "Endnotes ≫endnotes".
+fn links(txt: String) -> String {
+ rgx.replace_with(rgx.inline_link(), txt, fn(g) {
+ case is_object_number(g(2)) {
+ True -> g(0) <> " ≫" <> g(2)
+ False -> g(0)
+ }
+ })
+ |> regexp.replace(rgx.mark_internal_site_lnk(), _, "")
+}
+
+/// A target that is an object number, and not a name or a url.
+fn is_object_number(s: String) -> Bool {
+ case s, int.parse(s) {
+ "", _ -> False
+ "0", _ -> False
+ _, Ok(_) -> True
+ _, Error(_) -> False
+ }
+}
+
+/// The font faces become the markup they were written as, which is what makes
+/// the text re-editable rather than stripped.
+fn faces(txt: String) -> String {
+ [
+ #(rgx.inline_strike(), "-{", "}-"),
+ #(rgx.inline_insert(), "+{", "}+"),
+ #(rgx.inline_cite(), "\"{", "}\""),
+ #(rgx.inline_emphasis(), "!{", "}!"),
+ #(rgx.inline_bold(), "*{", "}*"),
+ #(rgx.inline_italics(), "/{", "}/"),
+ #(rgx.inline_underscore(), "_{", "}_"),
+ #(rgx.inline_superscript(), "^{", "}^"),
+ #(rgx.inline_subscript(), ",{", "},"),
+ #(rgx.inline_mono(), "#{", "}#"),
+ ]
+ |> list.fold(txt, fn(acc, face) {
+ let #(pattern, open, close) = face
+ rgx.replace_with(pattern, acc, fn(g) { open <> g(0) <> close })
+ })
+}
+
+/// The line breaks the markup asked for, and the non-breaking space.
+fn breaks(txt: String) -> String {
+ txt
+ |> regexp.replace(rgx.nbsp_char(), _, " ")
+ |> regexp.replace(rgx.br_line(), _, newline)
+ |> regexp.replace(rgx.br_line_inline(), _, newline)
+ |> regexp.replace(rgx.br_line_spaced(), _, newlines)
+ |> regexp.replace(rgx.line_break(), _, newline)
+}
+
+fn at(items: List(String), index: Int, fallback: String) -> String {
+ case items, index {
+ [], _ -> fallback
+ [first, ..], 0 -> first
+ [_, ..rest], n -> at(rest, n - 1, fallback)
+ }
+}
+
+/// The metadata block at the foot of the file.
+///
+/// Two values are not in the abstraction: the generating program's name and
+/// its home page. They describe the program, not the document, so the model
+/// has no place for them and this writer names itself.
+fn tail(doc: Document) -> String {
+ [
+ #("Title", "title.full"),
+ #("Author", "creator.author"),
+ #("Published", "date.published"),
+ #("Copyright", "rights.copyright"),
+ #("License", "rights.license"),
+ ]
+ |> list.map(fn(pair) {
+ let #(label, key) = pair
+ case model.meta(doc, key) {
+ "" -> ""
+ value -> label <> ": " <> value <> newlines
+ }
+ })
+ |> string.concat
+ |> string.append(generator_name <> newline <> generator_url)
+}