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