diff options
Diffstat (limited to 'dlang')
| -rw-r--r-- | dlang/COPYRIGHT | 45 | ||||
| -rw-r--r-- | dlang/README.md | 85 | ||||
| -rw-r--r-- | dlang/dub.json | 15 | ||||
| -rw-r--r-- | dlang/flake.lock | 44 | ||||
| -rw-r--r-- | dlang/flake.nix | 62 | ||||
| -rw-r--r-- | dlang/src/COPYRIGHT | 45 | ||||
| -rw-r--r-- | dlang/src/main.d | 98 | ||||
| -rw-r--r-- | dlang/src/write/epub.d | 444 | ||||
| -rw-r--r-- | dlang/src/write/html.d | 781 | ||||
| -rw-r--r-- | dlang/src/write/markdown.d | 579 | ||||
| -rw-r--r-- | dlang/src/write/rgx.d | 104 | ||||
| -rw-r--r-- | dlang/src/write/text.d | 397 | ||||
| -rw-r--r-- | dlang/src/write/typst.d | 766 | ||||
| -rw-r--r-- | dlang/src/write/zip.d | 125 |
14 files changed, 3590 insertions, 0 deletions
diff --git a/dlang/COPYRIGHT b/dlang/COPYRIGHT new file mode 100644 index 0000000..a8ee2b4 --- /dev/null +++ b/dlang/COPYRIGHT @@ -0,0 +1,45 @@ +- Name: sisudoc-ocda-writer + - Description: part of SiSU spine (Doc Reform): documents, structuring, + processing, publishing and search + - [https://www.sisudoc.org] + + - Author: Ralph Amissah + [ralph.amissah@gmail.com] + + - Copyright: (C) 2026 Ralph Amissah, All Rights Reserved. + + - License: AGPL 3 or later, for everything in this repository unless a file or + a directory specifies otherwise.: + + sisudoc-ocda-writer + + part of SiSU spine (Doc Reform): documents, structuring, + processing, publishing and search + + Copyright (C) Ralph Amissah + + This program is free software: you can redistribute it and/or modify it + under the terms of the GNU Affero General Public License (AGPLv3) as + published by the Free Software Foundation, either version 3 of the License, + or (at your option) any later version. + + This program is distributed in the hope that it will be useful, but + WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY + or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public + License for more details. + + You should have received a copy of the GNU Affero General Public License + along with this program. If not, see [https://www.gnu.org/licenses/]. + + If you have Internet connection, the latest version of the AGPL should be + available at: [https://www.gnu.org/licenses/agpl-3.0.html] + + - Spine, Doc Reform (related to SiSU) uses standard: + - docReform markup syntax (based on SiSU markup) + - standard SiSU markup syntax with modified headers and minor + modifications + - docReform object numbering (based on SiSU object citation numbering) + - standard SiSU document object numbering + + - Homepages: + [https://www.sisudoc.org] diff --git a/dlang/README.md b/dlang/README.md new file mode 100644 index 0000000..e3a41b6 --- /dev/null +++ b/dlang/README.md @@ -0,0 +1,85 @@ +# ocda-write, in D + +Sample writers over the abstraction: + + ocda-write --text|--md|--html|--epub|--typst <file> [lang] + ocda-write --md <file> [lang] [--image-prefix <path>] + +writes a document out, from any serialisation of its abstraction. + +## Modules + +| | | +|---|---| +| `write.text` | the model -> spine's plain text, byte for byte | +| `write.html` | the model -> one self-contained, valid html5 file | +| `write.epub` | the model -> an epub3, one file per segment | +| `write.markdown` | the model -> CommonMark, object numbers and all | +| `write.typst` | the model -> typst markup, for a pdf | +| `write.zip` | writing a zip, stored only, with no timestamps | +| `write.rgx` | the internal markup, as regular expressions | +| `main` | the program: which format, which file, which language | + +`write.text` takes an `OcdaDocument` and returns a string. It is given no +file, no path and no option: a writer is a function from the model to bytes, +and everything it needs is in the model. + +## Build and run + +```sh +nix build --override-input ocda-lib path:../../sisudoc-ocda-lib +./result/bin/ocda-write --text <doc>.ssp +./result/bin/ocda-write --html <doc>.ocda.db de +./result/bin/ocda-write --html <doc>.sisupod +./result/bin/ocda-write --md <doc>.ssp +./result/bin/ocda-write --epub <doc>.ocda.db en --out <doc>.epub +./result/bin/ocda-write --typst <doc>.ocda.db en --out out/<doc>.typ +typst compile --root out out/<doc>.typ out/<doc>.pdf +``` + +`--epub` writes a zip, so it takes `--out` rather than stdout. `--typst` goes +to stdout too, but with `--out` it also writes the `image/` directory the +`.typ` names. + +`nix build .#ocda-write-dmd` builds with dmd instead of ldc. + +The library's modules are compiled alongside these rather than linked as a +separate product; for five modules a second build step buys nothing. + +## The checks + +`--text` and `--md` are checked against the html writer's objects, word for +word, and against each other language's writer, byte for byte: **36 of 36 on +every count.** Byte identity with `spine --text` was the first oracle and is +no longer the target; see the repository README. + +`--html` is checked harder still: it parses, every `href="#x"` +resolves, no id appears twice, no heading falls outside `h1`..`h6`, and the +objects' visible text equals spine's scroll html exactly. **36 of 36 on each, +and byte identical to the Gleam writer.** See the repository README for what +it does differently from spine and why. + +`--epub` is judged by `epubcheck`, and `--typst` by typst: a link to a label +that does not exist is a compile error, so a silent compile means every +internal link in the document resolves. **36 of 36 on each, and byte +identical to the Gleam writer.** + +## Where it is faithful rather than sensible + +The *text* writer reproduces spine's output exactly, which means reproducing +what would not be chosen from scratch. The comments say so at each point. +The three worth knowing before reading the code: + +- **the table layout strips trailing whitespace from the whole accumulated + table after every cell**, not once per row, so a row ending in an empty + cell loses the newline before it; +- **a column's width is the widest cell in bytes and the padding is counted + in codepoints**, so a cell holding `©` is padded one place further than one + holding `c`; +- **the section sequence is spine's `scroll` sequence**, not its `text` one: + head, toc, body, then each trailing section *that has more than one + object*, and no `tail` unless an html or epub output was also asked for. + +The *html* writer takes none of these as given. What it takes from the model +and what it declines to take from spine is set out in its own comments, at +the point where each choice is made. diff --git a/dlang/dub.json b/dlang/dub.json new file mode 100644 index 0000000..dcc891c --- /dev/null +++ b/dlang/dub.json @@ -0,0 +1,15 @@ +{ + "authors": [ "Ralph Amissah" ], + "copyright": "Copyright (C) 2026 Ralph Amissah", + "name": "ocda-write", + "version": "2.0.0", + "description": "sample writers over the object-centric document abstraction", + "homepage": "https://sisudoc.org", + "license": "AGPL-3.0+", + "targetType": "executable", + "targetName": "ocda-write", + "sourcePaths": [ "./src" ], + "importPaths": [ "./src" ], + "dependencies": { "ocda-lib": { "path": "../../sisudoc-ocda-lib/dlang" } }, + "libs": [ "sqlite3", "zstd" ] +} diff --git a/dlang/flake.lock b/dlang/flake.lock new file mode 100644 index 0000000..ab89304 --- /dev/null +++ b/dlang/flake.lock @@ -0,0 +1,44 @@ +{ + "nodes": { + "nixpkgs": { + "locked": { + "lastModified": 1790185690, + "narHash": "sha256-xJ+X4hBtOcAFGBOe5nAMyMUeF9foJBmIOu3NjBqBycU=", + "owner": "NixOS", + "repo": "nixpkgs", + "rev": "4975466d324710c576dc11ad614684e6bd8cad8e", + "type": "github" + }, + "original": { + "owner": "NixOS", + "ref": "nixos-unstable", + "repo": "nixpkgs", + "type": "github" + } + }, + "ocda-lib": { + "flake": false, + "locked": { + "lastModified": 1790430427, + "narHash": "sha256-GSfZ1mJstp+rpvCvQJzPlXLPlrziV6uDa3Tvs/9b9p0=", + "ref": "refs/heads/main", + "rev": "fda48e417435e31a3122bfa7ca563333628aed99", + "revCount": 1, + "type": "git", + "url": "git://git.sisudoc.org/tools/sisudoc-ocda-lib" + }, + "original": { + "type": "git", + "url": "git://git.sisudoc.org/tools/sisudoc-ocda-lib" + } + }, + "root": { + "inputs": { + "nixpkgs": "nixpkgs", + "ocda-lib": "ocda-lib" + } + } + }, + "root": "root", + "version": 7 +} diff --git a/dlang/flake.nix b/dlang/flake.nix new file mode 100644 index 0000000..966425a --- /dev/null +++ b/dlang/flake.nix @@ -0,0 +1,62 @@ +{ + description = "ocda-write: sample writers over the object-centric document abstraction, in D"; + + inputs.nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable"; + + /* The library this is built on, as a source tree rather than a flake: the + build compiles its modules alongside these, which is what a dub path + dependency does and avoids a second build product for a library of five + modules. + + flake.lock pins the commit of the library this writer was built and + checked against. To build against a working copy of it instead: + + nix build --override-input ocda-lib path:../../sisudoc-ocda-lib + */ + inputs.ocda-lib = { + url = "git+git://git.sisudoc.org/tools/sisudoc-ocda-lib"; + flake = false; + }; + + outputs = { self, nixpkgs, ocda-lib }: + let + system = "x86_64-linux"; # adjust for aarch64-linux / darwin + pkgs = import nixpkgs { inherit system; }; + + mkWrite = { compiler, compilerBin }: pkgs.stdenv.mkDerivation { + pname = "ocda-write"; + version = "2.0.0"; + src = self; + nativeBuildInputs = [ compiler ]; + buildInputs = [ pkgs.sqlite pkgs.zstd ]; + buildPhase = '' + runHook preBuild + export HOME=$TMPDIR + ${compilerBin} -I=src -I=${ocda-lib}/dlang/src -of=ocda-write \ + ${ocda-lib}/dlang/src/ocda/*.d src/write/*.d src/main.d \ + -L-lsqlite3 -L-lzstd + runHook postBuild + ''; + installPhase = '' + runHook preInstall + mkdir -p $out/bin + install -m755 ocda-write $out/bin/ocda-write + runHook postInstall + ''; + meta = { + description = "writes a document out from either serialisation of its abstraction"; + mainProgram = "ocda-write"; + }; + }; + in { + packages.${system} = { + default = self.packages.${system}.ocda-write; + ocda-write = mkWrite { compiler = pkgs.ldc; compilerBin = "ldmd2"; }; + ocda-write-dmd = mkWrite { compiler = pkgs.dmd; compilerBin = "dmd"; }; + }; + + devShells.${system}.default = pkgs.mkShell { + packages = [ pkgs.ldc pkgs.dub pkgs.dtools pkgs.sqlite pkgs.zstd ]; + }; + }; +} diff --git a/dlang/src/COPYRIGHT b/dlang/src/COPYRIGHT new file mode 100644 index 0000000..a8ee2b4 --- /dev/null +++ b/dlang/src/COPYRIGHT @@ -0,0 +1,45 @@ +- Name: sisudoc-ocda-writer + - Description: part of SiSU spine (Doc Reform): documents, structuring, + processing, publishing and search + - [https://www.sisudoc.org] + + - Author: Ralph Amissah + [ralph.amissah@gmail.com] + + - Copyright: (C) 2026 Ralph Amissah, All Rights Reserved. + + - License: AGPL 3 or later, for everything in this repository unless a file or + a directory specifies otherwise.: + + sisudoc-ocda-writer + + part of SiSU spine (Doc Reform): documents, structuring, + processing, publishing and search + + Copyright (C) Ralph Amissah + + This program is free software: you can redistribute it and/or modify it + under the terms of the GNU Affero General Public License (AGPLv3) as + published by the Free Software Foundation, either version 3 of the License, + or (at your option) any later version. + + This program is distributed in the hope that it will be useful, but + WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY + or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public + License for more details. + + You should have received a copy of the GNU Affero General Public License + along with this program. If not, see [https://www.gnu.org/licenses/]. + + If you have Internet connection, the latest version of the AGPL should be + available at: [https://www.gnu.org/licenses/agpl-3.0.html] + + - Spine, Doc Reform (related to SiSU) uses standard: + - docReform markup syntax (based on SiSU markup) + - standard SiSU markup syntax with modified headers and minor + modifications + - docReform object numbering (based on SiSU object citation numbering) + - standard SiSU document object numbering + + - Homepages: + [https://www.sisudoc.org] diff --git a/dlang/src/main.d b/dlang/src/main.d new file mode 100644 index 0000000..fffbff0 --- /dev/null +++ b/dlang/src/main.d @@ -0,0 +1,98 @@ +/++ + ocda-write: a sample writer over the abstraction. + + Usage: ocda-write --text|--md|--html|--epub|--typst <file> [lang] + + --epub writes a zip, so it takes a path to write to rather than + stdout: ocda-write --epub <file> [lang] --out <file>.epub + + The input may be either serialisation, and the writer is not told which. + That is the claim being demonstrated: a writer is a function from the + model to bytes, so a document renders the same whichever of its two + abstraction files it is given. + + Output goes to stdout, so that the comparison against spine's own output + is a shell pipeline and not a directory of files to clean up. ++/ +import std.stdio; +import ocda.load; +import ocda.model; +import write.epub; +import write.html; +import write.markdown; +import write.text; +import write.typst; + +int main(string[] args) { + string format_; + string out_path; + /+ ↓ where the images are, relative to the file being written. Only markdown + takes it: a .md names an image by path, and the path depends on where the + .md is put, which is the caller's business and not the model's. +/ + string image_pth = "image"; + string[] rest; + bool want_out = false; + bool want_image_pth = false; + foreach (a; args[1 .. $]) { + if (want_out) { out_path = a; want_out = false; continue; } + if (want_image_pth) { image_pth = a; want_image_pth = false; continue; } + if (a == "--text") { format_ = "text"; } + else if (a == "--html") { format_ = "html"; } + else if (a == "--epub") { format_ = "epub"; } + else if (a == "--typst" || a == "--typ") { format_ = "typst"; } + else if (a == "--md" || a == "--markdown") { format_ = "markdown"; } + else if (a == "--out") { want_out = true; } + else if (a == "--image-prefix") { want_image_pth = true; } + else if (a.length > 2 && a[0 .. 2] == "--") { + stderr.writeln("ocda-write: unknown option ", a); + return 2; + } else { rest ~= a; } + } + if (format_.length == 0 || rest.length < 1) { + stderr.writeln("usage: ocda-write --text|--md|--html|--epub|--typst" + ~ " <file>.ssp|<file>.ocda.db|<file>.sisupod [lang]" + ~ "\n --md also takes --image-prefix <path> (default \"image\")"); + return 2; + } + string lang = (rest.length > 1) ? rest[1] : ""; + OcdaDocument doc; + try { + doc = load(rest[0], lang); + } catch (Exception e) { + stderr.writeln("error: ", e.msg); + return 2; + } + if (format_ == "epub") { + if (out_path.length == 0) { + stderr.writeln("ocda-write --epub: --out <file>.epub is needed," + ~ " because an epub is a zip and stdout is not a file"); + return 2; + } + import std.file : write; + write(out_path, writeEpub(doc, rest[0])); + return 0; + } + if (format_ == "typst") { + string typ = writeTypst(doc); + if (out_path.length == 0) { stdout.write(typ); return 0; } + /+ a .typ names its images by path, so they are written beside it +/ + import std.file : mkdirRecurse, write; + import std.path : buildPath, dirName; + import ocda.images : canGiveImages, imageBytes; + write(out_path, typ); + if (canGiveImages(rest[0])) { + string img_dir = buildPath(out_path.dirName, "image"); + bool made = false; + foreach (name; imagesNamed(doc)) { + auto bytes = imageBytes(rest[0], name); + if (bytes.length == 0) { continue; } + if (!made) { mkdirRecurse(img_dir); made = true; } + write(buildPath(img_dir, name), bytes); + } + } + return 0; + } + if (format_ == "markdown") { stdout.write(writeMarkdown(doc, image_pth)); return 0; } + stdout.write((format_ == "html") ? writeHtmlScroll(doc) : writeText(doc)); + return 0; +} diff --git a/dlang/src/write/epub.d b/dlang/src/write/epub.d new file mode 100644 index 0000000..3765267 --- /dev/null +++ b/dlang/src/write/epub.d @@ -0,0 +1,444 @@ +module write.epub; + +/+ ↓ the epub3 writer: a zip of xhtml, one file per segment. + . + This is the step the scroll html put off. A scroll is one file, so an + internal link is a fragment and a segment is a notion the writer can + ignore; an epub is *the segmented output plus a container*, so the writer + has to answer two questions the scroll writer did not: + . + which file does this object belong to, and + what is the link from one file to another. + . + The model answers both: .segment_epub says which segment a heading + opens, .segment_epub_is which segment an object sits in, and the target + of a link is an identifier or an anchor tag that some object carries. So + the writer builds one map from target to file and hands it to the same + object rendering the scroll writer uses. + . + The judge is *epubcheck*, which is better than a diff: it reads the + specification so that this writer does not have to be trusted to have + read it. And, as with the html, the two languages' writers must produce + the same bytes. + . + Not byte identical with spine's epub, and not trying to be: a spine epub + is a zip whose entry order and compression would have to match, and + matching them would prove nothing that epubcheck does not prove better. ++/ + +import std.algorithm : canFind, endsWith, sort, startsWith; +import std.array : appender, array, join, replace; +import std.conv : to; +import std.regex : ctRegex, matchAll; +import std.digest.sha : sha1Of, toHexString; +import std.string : representation, strip, toLower; +import ocda.images; +import ocda.model; +import write.html; +import write.zip; + +/+ ↓ a note's mark, a number or a run of stars or pluses +/ +private static note_mark = ctRegex!(`【(?P<num>\d+|(?:[*]|[+])+)\s`, "mg"); + +/+ ↓ an anchor the markup placed in the text +/ +private static inline_anchor = ctRegex!(`┃(?P<anchor>\S+?)┃`, "mg"); + +enum generator_name = "spine"; +enum generator_url = "https://sisudoc.org"; + +/+ ↓ the epub, as bytes. + source is the path the document was loaded from, which is how the images + are found: the model names them and does not hold them. ++/ +ubyte[] writeEpub(ref OcdaDocument doc, string source) { + auto segs = segments(doc); + auto targets = targetMap(doc, segs); + string[string] in_file; /+ target -> file, for the resolver +/ + foreach (k, v; targets) { in_file[k] = v; } + bool[string] files_written; + foreach (s; segs) { files_written[s.file] = true; } + string current; /+ the file being written, for a same-file link +/ + + /+ ↓ a target in this file is a fragment; one in another file is that file + and the fragment. A target no object carries is left as it stands, + which an epubcheck run then reports rather than this writer hiding. + +/ + useLinkResolver( + /+ ↓ a bare fragment: which file holds it +/ + (string target) { + if (!target.startsWith("#")) { return target; } + string name = target[1 .. $]; + if (auto p = name in in_file) { + return (*p == current) ? target : (*p ~ ".xhtml" ~ target); + } + return target; + }, + /+ ↓ and an internal link, which already names its segment. Trust the + abstraction's answer over the map: the markup said which segment, + and the map is only an index of where targets ended up. + +/ + (string seg, string frag) { + string file = safeName(seg); + if (!(file in files_written)) { + /+ ↓ a segment this writer did not write is one it cannot link to, + so the fragment alone is better than a file that is not there + +/ + return frag; + } + return (file == current) ? frag : (file ~ ".xhtml" ~ frag); + }); + + auto ids = AnchorIds(); + reserveIdentifiers(doc, ids); + + ZipEntryOut[] entries; + /+ ↓ "mimetype" first, stored, no extra field. That is the one ordering the + format insists on, so that a reading system can identify the file from + its first bytes. + +/ + entries ~= ZipEntryOut("mimetype", bytesOf("application/epub+zip")); + entries ~= ZipEntryOut("META-INF/container.xml", bytesOf(container)); + + string[] spine_files; + foreach (seg; segs) { + current = seg.file; + auto body_ = appender!string; + foreach (obj; seg.objects) { + body_ ~= writeObj(obj, doc, ids); + } + entries ~= ZipEntryOut("OEBPS/" ~ seg.file ~ ".xhtml", + bytesOf(xhtml(doc, seg, body_.data))); + spine_files ~= seg.file; + } + current = ""; + + /+ ↓ the images, and only the ones the document names. + A manifest item whose file is absent is an epubcheck error, and so is + an <img> whose file is not in the manifest, so the two lists are built + from the same walk. + +/ + string[] image_names; + if (canGiveImages(source)) { + foreach (name; imagesNamed(doc)) { + auto data = imageBytes(source, name); + if (data.length > 0) { + entries ~= ZipEntryOut("OEBPS/image/" ~ name, data); + image_names ~= name; + } + } + } + + entries ~= ZipEntryOut("OEBPS/style.css", bytesOf(stylesheet)); + entries ~= ZipEntryOut("OEBPS/nav.xhtml", bytesOf(nav(doc, segs))); + entries ~= ZipEntryOut("OEBPS/content.opf", + bytesOf(opf(doc, spine_files, image_names))); + return zipWrite(entries); +} + +/+ ↓ one segment: its file name and the objects in it, in document order +/ +struct Segment { + string file; + string title; + OcdaObject[] objects; +} + +/+ ↓ the segments, in document order. + . + An object says which segment it is in, and the first object of a segment + is where that segment begins. Objects with no segment at all - the + document title, the table of contents - go into a segment named for + themselves, because an epub has no place for an object outside the + reading order. ++/ +Segment[] segments(ref OcdaDocument doc) { + /+ ↓ grouped by name, in order of first appearance, *not* by run. + . + A segment's objects are not always contiguous: a document may put an + object with no segment of its own after a segment has begun, and the + fallback then names it for its section, so "body" recurs. Grouping by + run made two zip entries of one name, which is an epubcheck error + (OPF-060) and was one in nine of the collection. + +/ + string[] order; + Segment[string] by_name; + foreach (name; htmlSections(doc)) { + auto sec = doc.sectionByName(name); + if (sec is null) { continue; } + foreach (obj; *sec) { + if (!obj.renders) { continue; } + string seg = segmentOf(obj, name); + if (!(seg in by_name)) { + by_name[seg] = Segment(seg, "", []); + order ~= seg; + } + by_name[seg].objects ~= obj; + if (by_name[seg].title.length == 0) { + string title = headingTextOf(obj); + if (title.length > 0) { by_name[seg].title = title; } + } + } + } + Segment[] out_; + foreach (seg; order) { out_ ~= by_name[seg]; } + return out_; +} + +/+ ↓ which segment an object is in. + .segment_epub_is is the answer where there is one. Where there is none + the object is outside the segmented body - the title, the table of + contents - and the section it sits in is the segment. ++/ +private string segmentOf(ref const OcdaObject obj, string section) { + if (obj.segment_epub_is.length > 0) { return safeName(obj.segment_epub_is); } + if (obj.segment.length > 0) { return safeName(obj.segment); } + return safeName(section); +} + +/+ ↓ a file name a zip entry and an href can both carry. + A segment name comes from the markup, so it may hold anything; these are + file names inside the container and a reading system has to be able to + resolve them. ++/ +private string safeName(string s) { + string out_; + foreach (c; s) { + bool ok = (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') + || (c >= '0' && c <= '9') || c == '_' || c == '-' || c == '.'; + out_ ~= ok ? [c] : "_"; + } + return (out_.length > 0) ? out_ : "segment"; +} + +/+ ↓ the plain text of a heading, for a title and a navigation label +/ +private string headingTextOf(ref const OcdaObject obj) { + if (obj.is_a != "heading" && obj.is_a != "toc") { return ""; } + return plain(obj.text); +} + +/+ ↓ every target an object carries, and the file it is in. + This is what a cross-segment link needs and what only the whole document + can say. ++/ +string[string] targetMap(ref OcdaDocument doc, Segment[] segs) { + string[string] out_; + foreach (seg; segs) { + foreach (obj; seg.objects) { + void put(string key) { + if (key.length > 0 && !(key in out_)) { out_[key] = seg.file; } + } + if (obj.ocn != 0) { put(obj.ocn.to!string); } + put(obj.identifier); + put(obj.heading_lev_anchor); + foreach (a; obj.anchor_tags) { put(a); } + /+ ↓ and the note references this object's own text will create. + . + A note reference is an id the *writer* makes, not one the object + carries: the object's text holds 【<n> the note】 and the writer + turns the mark into an anchor named noteref_<n>. The gathered + endnote then links back to it, from another segment, so the map + has to know where it is. Without this every backlink in the + endnotes points at a fragment in a file that does not declare it: + 306 of them in one document, which is what epubcheck said. + +/ + foreach (m; obj.text.matchAll(note_mark)) { + put("noteref_" ~ m["num"].to!string); + } + /+ ↓ and an anchor the markup placed *in the text*, ┃name┃. + . + Those are ids too, and a link to one is written as a bare + fragment rather than as an internal link naming its segment, so + only this map can say which file it is in. Two links in the + reference collection point at one, and both dangled until the map + knew. + +/ + foreach (m; obj.text.matchAll(inline_anchor)) { + put(m["anchor"].to!string); + } + } + } + return out_; +} + +/+ ↓ the images the document names, each once, in the order met +/ +string[] imagesNamed(ref OcdaDocument doc) { + string[] out_; + bool[string] seen; + foreach (name; htmlSections(doc)) { + auto sec = doc.sectionByName(name); + if (sec is null) { continue; } + foreach (obj; *sec) { + foreach (img; obj.images) { + if (img.name.length > 0 && !(img.name in seen) && !img.missing) { + seen[img.name] = true; + out_ ~= img.name; + } + } + } + } + return out_; +} + +private enum container = `<?xml version="1.0" encoding="utf-8"?> +<container version="1.0" xmlns="urn:oasis:names:tc:opendocument:xmlns:container"> + <rootfiles> + <rootfile full-path="OEBPS/content.opf" media-type="application/oebps-package+xml" /> + </rootfiles> +</container> +`; + +/+ ↓ one segment as xhtml. + xhtml rather than html: an epub's content documents are xml, so the + namespace is declared and every element is closed. ++/ +private string xhtml(ref OcdaDocument doc, Segment seg, string body_) { + string lang = languageTag(doc); + string title = seg.title.length > 0 ? seg.title : doc.meta("title.full"); + auto out_ = appender!string; + out_ ~= `<?xml version="1.0" encoding="utf-8"?>` ~ "\n"; + out_ ~= `<html xmlns="http://www.w3.org/1999/xhtml"` + ~ ` xmlns:epub="http://www.idpf.org/2007/ops"` + ~ ` lang="` ~ attr(lang) ~ `" xml:lang="` ~ attr(lang) ~ `">` ~ "\n"; + out_ ~= "<head>\n<title>" ~ escape(title) ~ "</title>\n"; + out_ ~= `<link rel="stylesheet" type="text/css" href="style.css" />` ~ "\n"; + out_ ~= "</head>\n<body>\n"; + out_ ~= body_; + out_ ~= "</body>\n</html>\n"; + return out_.data; +} + +/+ ↓ the navigation document, which epub3 requires and which is the + document's own table of contents rather than a second one. + . + Built from the segments, not from the toc objects: a toc object's link + may point inside a segment, and a navigation entry has to name the file. + One level, because the segment list is already flat; a nested nav would + need the heading tree, which is a refinement and not a requirement. ++/ +private string nav(ref OcdaDocument doc, Segment[] segs) { + string lang = languageTag(doc); + auto out_ = appender!string; + out_ ~= `<?xml version="1.0" encoding="utf-8"?>` ~ "\n"; + out_ ~= `<html xmlns="http://www.w3.org/1999/xhtml"` + ~ ` xmlns:epub="http://www.idpf.org/2007/ops"` + ~ ` lang="` ~ attr(lang) ~ `" xml:lang="` ~ attr(lang) ~ `">` ~ "\n"; + out_ ~= "<head>\n<title>" ~ escape(doc.meta("title.full")) ~ "</title>\n"; + out_ ~= `<link rel="stylesheet" type="text/css" href="style.css" />` ~ "\n"; + out_ ~= "</head>\n<body>\n"; + out_ ~= `<nav epub:type="toc" id="toc" role="doc-toc">` ~ "\n"; + out_ ~= "<h1>" ~ escape(doc.meta("title.full")) ~ "</h1>\n<ol>\n"; + foreach (seg; segs) { + string label = seg.title.length > 0 ? seg.title : seg.file; + out_ ~= `<li><a href="` ~ attr(seg.file) ~ `.xhtml">` + ~ escape(label) ~ "</a></li>\n"; + } + out_ ~= "</ol>\n</nav>\n</body>\n</html>\n"; + return out_.data; +} + +/+ ↓ the package document. + . + dcterms:modified is required and must be to the second in UTC. It is + taken from the document's own publication date rather than from the + clock, so that the same document gives the same epub twice: a writer + that stamps the time of its run cannot be compared with itself. ++/ +private string opf(ref OcdaDocument doc, string[] files, string[] images) { + string lang = languageTag(doc); + string title = doc.meta("title.full"); + if (title.length == 0) { title = doc.meta("title.main"); } + auto out_ = appender!string; + out_ ~= `<?xml version="1.0" encoding="utf-8"?>` ~ "\n"; + out_ ~= `<package version="3.0" xmlns="http://www.idpf.org/2007/opf"` + ~ ` unique-identifier="bookid" xml:lang="` ~ attr(lang) ~ `">` ~ "\n"; + out_ ~= `<metadata xmlns:dc="http://purl.org/dc/elements/1.1/">` ~ "\n"; + out_ ~= `<dc:identifier id="bookid">urn:uuid:` ~ uuid(doc) ~ "</dc:identifier>\n"; + out_ ~= "<dc:title>" ~ escape(title) ~ "</dc:title>\n"; + if (doc.meta("creator.author").length > 0) { + out_ ~= "<dc:creator>" ~ escape(doc.meta("creator.author")) ~ "</dc:creator>\n"; + } + out_ ~= "<dc:language>" ~ escape(lang) ~ "</dc:language>\n"; + if (doc.meta("date.published").length > 0) { + out_ ~= "<dc:date>" ~ escape(doc.meta("date.published")) ~ "</dc:date>\n"; + } + if (doc.meta("rights.copyright").length > 0) { + out_ ~= "<dc:rights>" ~ escape(doc.meta("rights.copyright")) ~ "</dc:rights>\n"; + } + out_ ~= `<meta property="dcterms:modified">` ~ modified(doc) ~ "</meta>\n"; + out_ ~= "</metadata>\n<manifest>\n"; + out_ ~= `<item id="nav" href="nav.xhtml" media-type="application/xhtml+xml" properties="nav" />` ~ "\n"; + out_ ~= `<item id="css" href="style.css" media-type="text/css" />` ~ "\n"; + foreach (f; files) { + out_ ~= `<item id="s_` ~ attr(f) ~ `" href="` ~ attr(f) + ~ `.xhtml" media-type="application/xhtml+xml" />` ~ "\n"; + } + foreach (i, name; images) { + out_ ~= `<item id="i_` ~ i.to!string ~ `" href="image/` ~ attr(name) + ~ `" media-type="` ~ mediaType(name) ~ `" />` ~ "\n"; + } + out_ ~= "</manifest>\n<spine>\n"; + foreach (f; files) { + out_ ~= `<itemref idref="s_` ~ attr(f) ~ `" />` ~ "\n"; + } + out_ ~= "</spine>\n</package>\n"; + return out_.data; +} + +/+ ↓ a uuid from the document's own digest, so that the same document has the + same identifier every time it is written. A random one would make two + runs of the same input two different publications. ++/ +private string uuid(ref OcdaDocument doc) { + string seed = ("digest" in doc.source) ? doc.source["digest"] : ""; + if (seed.length == 0) { seed = doc.meta("title.full") ~ doc.source_filename; } + auto h = sha1Of(seed.representation).toHexString.idup.toLower; + /+ ↓ version 5 shape: the version nibble and the variant bits set, which is + what says "this uuid is a hash of a name" +/ + return h[0 .. 8] ~ "-" ~ h[8 .. 12] ~ "-5" ~ h[13 .. 16] + ~ "-a" ~ h[17 .. 20] ~ "-" ~ h[20 .. 32]; +} + +/+ ↓ the publication date as a timestamp to the second in UTC +/ +private string modified(ref OcdaDocument doc) { + string d = doc.meta("date.modified"); + if (d.length == 0) { d = doc.meta("date.published"); } + if (d.length < 10) { return "1970-01-01T00:00:00Z"; } + return d[0 .. 10] ~ "T00:00:00Z"; +} + +/+ ↓ the language, as a language tag rather than as a locale name. + . + BCP 47 separates a language from its region with a hyphen, and one + document of the reference collection declares "pt_BR", which epubcheck + refuses: "Invalid subtag: pt_BR". The underscore is the POSIX locale + spelling and the abstraction carries it as the markup gave it, so the + conversion belongs here, in the writer that has a specification to + satisfy. + . + A finding for the markup as much as for the writer: "pt-BR" is what the + document means, and every output that declares a language is affected. ++/ +private string languageTag(ref OcdaDocument doc) { + string lang = ("language" in doc.source) ? doc.source["language"] : "en"; + return lang.replace("_", "-"); +} + +private string mediaType(string name) { + string n = name.toLower; + if (n.endsWith(".png")) { return "image/png"; } + if (n.endsWith(".jpg") || n.endsWith(".jpeg")) { return "image/jpeg"; } + if (n.endsWith(".gif")) { return "image/gif"; } + if (n.endsWith(".svg")) { return "image/svg+xml"; } + return "application/octet-stream"; +} + +/+ ↓ an object's text with the internal markup taken out, for a title or a + navigation label, where no markup may go +/ +private string plain(string txt) { + import std.regex : regex, replaceAll; + static auto markers = regex(`⑆.┨|┣.|┥|┝|┤.*?├|【.+?】|░|┘|┙|┚|┃`, "g"); + return escape(txt.replaceAll(markers, "")).strip; +} + +private ubyte[] bytesOf(string s) { + return cast(ubyte[]) s.representation.dup; +} diff --git a/dlang/src/write/html.d b/dlang/src/write/html.d new file mode 100644 index 0000000..efbb40e --- /dev/null +++ b/dlang/src/write/html.d @@ -0,0 +1,781 @@ +module write.html; + +/+ ↓ the scroll html writer: one file, self-contained, valid html5. + . + Not a reproduction of spine's html. The text writer is spine's output + byte for byte because the format is plain and there was nothing to + improve; html is different, and reproducing it exactly would mean + carrying forward things that are wrong rather than merely unusual. So + this writer is held to a different standard, and a stricter one in the + places that matter: + . + - the D and the Gleam writer produce the *same bytes*, which is the + agreement that says the model is enough; + - the visible text equals spine's, normalised the same way on both + sides, so nothing is dropped, added or reordered; + - every internal link resolves to an id in the same file; + - it parses. + . + What is deliberately different from spine, each with the reason: + . + 1. *headings are h1..h6*. Spine writes <h0> for a level A heading, + which is not an html element: the level is its markup depth and 0 + is passed through. A document title is h1. + 2. *one charset declaration*. Spine writes + Content-Type: text/plain for an html document and then a second + Content-Type saying text/html. + 3. *no empty or placeholder metadata*. Spine writes + dc.publisher="Publisher" and dc.date.issued="year" literally where + it has no value, and generator="" when asked not to name itself. A + meta with nothing in it is left out. + 4. *span, not label, for the object number*. <label> is for form + controls. + 5. *section with a dpub-aria role*, rather than div with a class that + means something to one stylesheet. Spine's roles are right and are + kept; the element is the improvement. + 6. *self-contained*: a small embedded stylesheet, so the file renders + on its own. Spine links ../../css/html_scroll.css, which resolves + only inside spine's own output tree. + 7. *no site furniture*. The headband of sisudoc.org links is not in + the abstraction and is not the document. ++/ + +import std.algorithm : canFind, endsWith, 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, replaceFirst, split; +import std.string : strip; +import std.uri : encodeComponent; +import ocda.model; +import write.rgx; + +static Rgx rgx_h; + +/+ ↓ the whole document as one html file +/ +string writeHtmlScroll(ref OcdaDocument doc) { + auto out_ = appender!string; + useLinkResolver((string t) => t); + auto ids = AnchorIds(); + reserveIdentifiers(doc, ids); + out_ ~= head(doc); + string open_section = ""; + foreach (name; htmlSections(doc)) { + foreach (obj; *doc.sectionByName(name)) { + if (!obj.renders) { continue; } + if (name != open_section) { + if (open_section.length > 0) { out_ ~= "</section>\n"; } + out_ ~= sectionOpen(name); + open_section = name; + } + out_ ~= writeObj(obj, doc, ids); + } + } + if (open_section.length > 0) { out_ ~= "</section>\n"; } + out_ ~= tail(doc); + return out_.data; +} + +/+ ↓ an id must be unique, so for each anchor tag the *first* occurrence + becomes the id and the rest become markers. + . + An anchor tag is a named target the markup placed on an object, and a + book index entry puts the same marker on every object the entry names: + "loc" 296 times in one document. An id repeated 296 times is not a + target, it is a defect, and a link to it arrives nowhere in particular. + The first occurrence is a definite place and is where a link should land. + . + Spine decides by kind instead - an endnote's tag becomes an id and + nothing else does - which is right about endnotes and silent about the + rest, so a link to any other tag has nothing to arrive at. Its own + output has such links. + . + The state is carried rather than computed up front, because "first" + is a property of the traversal. ++/ +struct AnchorIds { + bool[string] taken; + bool claim(string tag) { + if (tag in taken) { return false; } + taken[tag] = true; + return true; + } +} + +/+ ↓ every object's identifier is an id, so those names are taken before any + anchor can ask for one. + . + They collide in practice: a heading's level anchor may be named "1", + and "1" is also the identifier of the object whose ocn is 1. Claiming + the identifiers first means the collision costs an anchor rather than + making the same id twice, and an id that appears twice is not a target. ++/ +void reserveIdentifiers(ref OcdaDocument doc, ref AnchorIds ids) { + foreach (name; htmlSections(doc)) { + auto sec = doc.sectionByName(name); + if (sec is null) { continue; } + foreach (obj; *sec) { + if (obj.identifier.length > 0) { ids.claim(obj.identifier); } + /+ ↓ and the ocn, which is the id of every numbered object +/ + if (obj.ocn != 0) { ids.claim(obj.ocn.to!string); } + } + } +} + +/+ ↓ the same sequence the text writer uses, and for the same reason: it is + the document's own order, and a trailing section holding only its own + heading is not a section +/ +string[] htmlSections(ref OcdaDocument doc) { + string[] seq = ["head", "toc", "body"]; + foreach (name; ["endnotes", "glossary", "bibliography", "bookindex", "blurb"]) { + auto sec = doc.sectionByName(name); + if (sec !is null && (*sec).length > 1) { seq ~= name; } + } + return seq; +} + +/+ ↓ the dpub-aria role of a section, where it has one. A section with no + role of its own gets none rather than an invented one. +/ +string sectionRole(string name) { + switch (name) { + case "toc": return "doc-toc"; + case "endnotes": return "doc-endnotes"; + case "bibliography": return "doc-bibliography"; + case "glossary": return "doc-glossary"; + case "bookindex": return "doc-index"; + default: return ""; + } +} + +/+ ↓ a section, with no id of its own. + . + It needs none: the document already carries the targets its table of + contents links to, as anchor tags on the section headings - "toc", + "endnotes", "section_endnotes" - and this writer emits those. Inventing + a second id for the same place would put the same name in the document + twice. + . + Spine's scroll output *does* have a link with nowhere to go, its toc + entry for the endnotes pointing at "#section_endnotes", and the reason + is not a missing anchor tag: the abstraction has it. Spine makes an id + out of an anchor tag only when the object is an endnote, so every other + tag the markup placed is written as a target that is not one. ++/ +private string sectionOpen(string name) { + string role = sectionRole(name); + return "<section class=\"" ~ name ~ "\"" + ~ ((role.length > 0) ? " role=\"" ~ role ~ "\"" : "") ~ ">\n"; +} + +/+ ↓ one object +/ +string writeObj(ref const OcdaObject obj, ref OcdaDocument doc, + ref AnchorIds ids) { + switch (obj.is_a) { + case "heading": return heading(obj, doc, ids); + case "toc": return para(obj, doc, ids, "toc"); + case "para": return para(obj, doc, ids, "para"); + case "endnote": return para(obj, doc, ids, "endnote"); + case "glossary": return para(obj, doc, ids, "glossary"); + case "bibliography": return para(obj, doc, ids, "bibliography"); + case "bookindex": return para(obj, doc, ids, "bookindex"); + case "blurb": return para(obj, doc, ids, "blurb"); + case "quote": return blockish(obj, doc, ids, "blockquote", "quote"); + case "group": return blockish(obj, doc, ids, "div", "group"); + case "block": return blockish(obj, doc, ids, "div", "block"); + case "verse": return blockish(obj, doc, ids, "div", "verse"); + case "code": return code(obj); + case "table": return table(obj, doc, ids); + default: return ""; + } +} + +/+ ↓ eight markup levels into six html ones. + . + A B C D are the title levels and 1 2 3 4 the body levels beneath them, + so the outline is eight deep and html offers six. The first six take an + element each; deeper headings take h6 and say their real depth in + aria-level, which is what a reading system uses and what h7 would have + meant if html had one. + . + Spine passes the markup level straight through, so a level A heading + becomes <h0>. There is no h0. ++/ +int headingDepth(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, + ref AnchorIds ids) { + /+ ↓ a dummy heading is furniture the abstraction generated so that a + section has a heading, and its text repeats the real heading above + it. Its text is not rendered - spine does the same - but its anchors + *are*, because they are what the table of contents links to. Spine + drops both, which is why its own toc entry for the endnotes points at + a target that is not in the file. + +/ + if (obj.dummy_heading) { + /+ ↓ its identifier is still a link target, and the table of contents + uses it, so it is kept even though the text is not +/ + return ((obj.identifier.length > 0) + ? "<a id=\"" ~ attr(obj.identifier) ~ "\"></a>" : "") + ~ anchorTags(obj, ids); + } + int depth = headingDepth(obj.heading_level); + int element = (depth > 6) ? 6 : depth; + string txt = inlineMarkup(obj, doc, ids); + if (txt.strip.length == 0) { return ""; } + auto out_ = appender!string; + out_ ~= "<div class=\"substance\">"; + out_ ~= ocnMark(obj); + out_ ~= "<h" ~ element.to!string; + if (obj.identifier.length > 0) { + out_ ~= " id=\"" ~ attr(obj.identifier) ~ "\""; + } + if (depth > 6) { out_ ~= " aria-level=\"" ~ depth.to!string ~ "\""; } + out_ ~= ">" ~ levelAnchor(obj, ids) ~ txt ~ "</h" ~ element.to!string ~ ">"; + out_ ~= "</div>\n"; + return out_.data; +} + +/+ ↓ the name the markup gave this heading, which is what a link written as + "#that-name" points at: 1,854 of them in the reference collection, and a + link to one is the ordinary way a document refers to its own sections. + It is a second name for the same place, so it is an empty anchor rather + than the heading's own id. ++/ +string levelAnchor(ref const OcdaObject obj, ref AnchorIds ids) { + if (obj.heading_lev_anchor.length == 0) { return ""; } + return ids.claim(obj.heading_lev_anchor) + ? "<a id=\"" ~ attr(obj.heading_lev_anchor) ~ "\"></a>" + : ""; +} + +private string para(ref const OcdaObject obj, ref OcdaDocument doc, + ref AnchorIds ids, string cls) { + /+ ↓ a dummy table of contents entry names a heading the abstraction + generated, "[Intro]" or "[Part III]", and it is furniture in the same + way a dummy heading is. Its anchors are kept and its text is not. + . + Spine drops it here and *keeps* it in the text output, which is an + inconsistency between its own two writers rather than a rule. + +/ + if (obj.dummy_heading) { return anchorTags(obj, ids); } + string txt = inlineMarkup(obj, doc, ids); + if (txt.strip.length == 0) { return ""; } + auto out_ = appender!string; + out_ ~= "<div class=\"substance\">"; + out_ ~= ocnMark(obj); + out_ ~= "<p class=\"" ~ cls ~ "\""; + if (obj.identifier.length > 0) { + out_ ~= " id=\"" ~ attr(obj.identifier) ~ "\""; + } + /+ ↓ the indent is the document's, so it is data rather than presentation; + the stylesheet reads it +/ + if (obj.indent_base > 0 || obj.indent_hang > 0) { + out_ ~= " data-indent=\"" ~ obj.indent_base.to!string + ~ "\" data-hang=\"" ~ obj.indent_hang.to!string ~ "\""; + } + /+ ↓ a gathered endnote is a note body whatever section it sits in, and + the reference to it carries doc-noteref at the other end +/ + if (obj.is_a == "endnote") { out_ ~= " role=\"doc-footnote\""; } + out_ ~= ">"; + if (obj.bullet) { out_ ~= "<span class=\"bullet\">●</span> "; } + out_ ~= txt; + out_ ~= "</p></div>\n"; + return out_.data; +} + +/+ ↓ quote, group, block and verse: one object whose text holds its own line + structure, so the lines are kept and not re-flowed +/ +private string blockish(ref const OcdaObject obj, ref OcdaDocument doc, + ref AnchorIds ids, string element, string cls) { + string txt = inlineMarkup(obj, doc, ids); + if (txt.strip.length == 0) { return ""; } + auto out_ = appender!string; + out_ ~= "<div class=\"substance\">"; + out_ ~= ocnMark(obj); + out_ ~= "<" ~ element ~ " class=\"" ~ cls ~ "\""; + if (obj.identifier.length > 0) { + out_ ~= " id=\"" ~ attr(obj.identifier) ~ "\""; + } + out_ ~= ">" ~ txt ~ "</" ~ element ~ ">"; + out_ ~= "</div>\n"; + return out_.data; +} + +/+ ↓ a code block: pre and code, and nothing inside it interpreted. + The text is escaped and otherwise untouched, which is the whole point + of a code block. ++/ +private string code(ref const OcdaObject obj) { + auto out_ = appender!string; + out_ ~= "<div class=\"substance\">"; + out_ ~= ocnMark(obj); + out_ ~= "<pre class=\"code\""; + if (obj.identifier.length > 0) { + out_ ~= " id=\"" ~ attr(obj.identifier) ~ "\""; + } + out_ ~= "><code"; + if (obj.code_syntax.length > 0) { + out_ ~= " class=\"language-" ~ attr(obj.code_syntax) ~ "\""; + } + out_ ~= ">" ~ escape(obj.text) ~ "</code></pre>"; + out_ ~= "</div>\n"; + return out_.data; +} + +/+ ↓ a table. The object's text is the whole table: rows separated by + newlines, columns by ┊. A header row becomes th, and the column widths + the markup declared become a colgroup, which is where a width belongs. ++/ +private string table(ref const OcdaObject obj, ref OcdaDocument doc, + ref AnchorIds ids) { + auto rows = obj.text.split(rgx_h.table_delimiter_row); + auto widths = obj.table_widths.asplit(" "); + auto aligns = obj.table_aligns.asplit(" "); + auto out_ = appender!string; + out_ ~= "<div class=\"substance\">"; + out_ ~= ocnMark(obj); + out_ ~= "<table class=\"table\""; + if (obj.identifier.length > 0) { + out_ ~= " id=\"" ~ attr(obj.identifier) ~ "\""; + } + out_ ~= ">"; + if (widths.length > 0 && widths[0].length > 0) { + out_ ~= "<colgroup>"; + foreach (w; widths) { + out_ ~= "<col style=\"width: " ~ attr(w) ~ "%\" />"; + } + out_ ~= "</colgroup>"; + } + bool first = true; + foreach (row; rows) { + if (row.strip.length == 0) { continue; } + bool header = first && obj.table_header; + string cell_element = header ? "th" : "td"; + out_ ~= header ? "<thead><tr>" : "<tr>"; + auto cols = row.split(rgx_h.table_delimiter_col); + foreach (i, cell; cols) { + string a = (i < aligns.length) ? aligns[i] : "l"; + /+ ↓ a cell carries the same inline markup a paragraph does, line + breaks among it: a table of features has cells of several lines +/ + out_ ~= "<" ~ cell_element ~ " class=\"align-" ~ alignName(a) ~ "\">" + ~ lineBreaks(fontFace(escape(cell)), obj) ~ "</" ~ cell_element ~ ">"; + } + out_ ~= header ? "</tr></thead>" : "</tr>"; + first = false; + } + out_ ~= "</table></div>\n"; + return out_.data; +} + +private string alignName(string a) { + switch (a) { + case "c": return "centre"; + case "r": return "right"; + default: return "left"; + } +} + +/+ ↓ the object number, which is also the anchor every citation of this + object points at. + . + A span rather than a label: <label> is for form controls, and an object + number labels nothing that can be focused. ++/ +/+ ↓ ... and the object's own .ocn_off property is deliberately *not* + consulted. + . + It looks as though it should be, and it is not what suppresses a number: + what suppresses one in spine is the *command line* option --ocn-off, + which is a request from the caller. The object property is set on + objects that do carry an ocn - every bibliography entry in one document + has both .ocn_off and an ocn of its own - and neither of spine's writers + reads it. Consulting it dropped the number from 298 bibliography entries + that spine numbers. ++/ +string ocnMark(ref const OcdaObject obj) { + if (obj.ocn == 0) { return ""; } + string n = obj.ocn.to!string; + return "<span class=\"ocn\"><a href=\"#" ~ n ~ "\" class=\"lnkocn\">" + ~ n ~ "</a></span>"; +} + +/+ ↓ everything inside an object's text, in the order the markers require: + images and links first, because their markers hold text that must not + be escaped as content, then notes, then font faces, then the line and + indent markers. ++/ +string inlineMarkup(ref const OcdaObject obj, ref OcdaDocument doc, + ref AnchorIds ids) { + string txt = escape(obj.text); + txt = images(txt); + txt = links(txt, obj); + txt = notes(txt); + txt = fontFace(txt); + txt = anchors(txt); + txt = groupIndents(txt, obj); + txt = lineBreaks(txt, obj); + return anchorTags(obj, ids) ~ txt; +} + +/+ ↓ the object's own named targets, ahead of its text. + A tag unique in the document becomes an id and so a link target; one + that repeats is recorded in a data attribute, which a script or a + stylesheet can find and a link cannot mistake for a destination. ++/ +string anchorTags(ref const OcdaObject obj, ref AnchorIds ids) { + string out_; + foreach (a; obj.anchor_tags) { + out_ ~= ids.claim(a) + ? "<a id=\"" ~ attr(a) ~ "\"></a>" + : "<span class=\"marker\" data-anchor=\"" ~ attr(a) ~ "\"></span>"; + } + return out_; +} + +/+ ↓ the four characters html reserves, and nothing else. + Done first, over the whole text, so that a "<" the author wrote is a + "<" the reader sees and not the start of an element. The markup's own + markers are not html and survive it. ++/ +string escape(string txt) { + return txt + .replaceAll(rgx_h.ampersand, "&") + .replaceAll(rgx_h.less_than, "<") + .replaceAll(rgx_h.greater_than, ">") + .replaceAll(rgx_h.nbsp_char, " "); +} + +/+ ↓ the same, for a value that goes in an attribute +/ +string attr(string txt) { + return txt + .replaceAll(rgx_h.ampersand, "&") + .replaceAll(rgx_h.quotation, """) + .replaceAll(rgx_h.less_than, "<") + .replaceAll(rgx_h.greater_than, ">"); +} + +private string fontFace(string txt) { + return txt + .replaceAll(rgx_h.inline_emphasis, "<em>$1</em>") + .replaceAll(rgx_h.inline_bold, "<strong>$1</strong>") + .replaceAll(rgx_h.inline_italics, "<i>$1</i>") + .replaceAll(rgx_h.inline_underscore, "<u>$1</u>") + .replaceAll(rgx_h.inline_superscript, "<sup>$1</sup>") + .replaceAll(rgx_h.inline_subscript, "<sub>$1</sub>") + .replaceAll(rgx_h.inline_mono, "<code>$1</code>") + .replaceAll(rgx_h.inline_strike, "<del>$1</del>") + .replaceAll(rgx_h.inline_insert, "<ins>$1</ins>") + .replaceAll(rgx_h.inline_cite, "<cite>$1</cite>"); +} + +/+ ↓ a note reference, 【<n> the note text】. + . + The reference becomes a superscript link to the gathered note, and the + note's own text is dropped here because the endnotes section carries + it: an object and the endnotes section both holding it would put it in + the document twice. ++/ +private string notes(string txt) { + /+ ↓ a note's mark may be a number or a run of stars or pluses, and both + are gathered into the endnotes section, so both need a reference. + . + The target goes through the resolver: in one file the note is in this + file, and in a segmented output it is in the endnotes file, which is + the same question a cross reference asks. + +/ + return replaceAll!((m) { + string mark = m["num"].to!string; + return "<sup class=\"noteref\"><a id=\"noteref_" ~ mark + ~ "\" href=\"" ~ resolveTarget("#note_" ~ mark) + ~ "\" role=\"doc-noteref\">" ~ mark ~ "</a></sup>"; + })(txt, rgx_h.inline_notes_al_all_note); +} + +/+ ↓ how a link to a target inside the document is written. + . + In one file a "#x" is "#x". In a set of files it is + "<the file holding x>#x", and which file that is is a fact about the + document that only the whole document knows. So the object rendering + takes the answer rather than working it out, and the same code serves + the scroll html and the epub. ++/ +alias LinkResolver = string delegate(string target); + +/+ ↓ and the answer for a link the markup marked as internal to the + collection: "¤<segment>.fnSuffix#<fragment>", where ".fnSuffix" stands + where an output's file suffix goes. + . + The abstraction *names the segment* in such a link, which is exactly what + a segmented output needs and what a single file has no use for. So the + two writers answer differently, and both answers are one line. ++/ +alias SegLinkResolver = string delegate(string segment, string fragment); + +private LinkResolver current_resolver; +private SegLinkResolver current_seg_resolver; + +/+ ↓ set for the duration of one document. + Module variables rather than parameters on nine functions: the rendering + is single threaded and one document at a time, and threading two + delegates through every shape to be read by one of them would be noise. ++/ +void useLinkResolver(LinkResolver r, SegLinkResolver s = null) { + current_resolver = r; + /+ ↓ the one-file answer: the segment is this file, so only the fragment is + left, which is what spine's scroll output does +/ + current_seg_resolver = (s is null) + ? (string seg, string frag) => frag + : s; +} + +private string resolveTarget(string target) { + /+ ↓ an internal link carries its segment; the resolver decides what to do + with it, and only it knows whether there is more than one file +/ + if (target.length > 0 && target.startsWith("¤")) { + auto m = target.matchFirst(rgx_h.internal_target); + if (!m.empty) { + return current_seg_resolver(m["seg"].to!string, m["frag"].to!string); + } + } + return (current_resolver is null) ? target : current_resolver(target); +} + +/+ ↓ a link, ┥text┝┤target├. + . + Before the writers see it the target may still be a number, which is an + index into the object's own .stow_link list rather than a url. That + indirection is the abstraction's, so undoing it is the writer's job. ++/ +private string links(string txt, ref const OcdaObject obj) { + auto stow = obj.stow_link; + txt = replaceAll!((m) { + size_t num = m["num"].to!size_t; + string url = (num < stow.length) ? stow[num] : ""; + /+ ↓ escaped here, because a url from the abstraction has not been + through the text escaping: the text was escaped before the writers + saw it and this comes from .stow_link, beside it. + . + An unescaped "&" in an href is a fatal xml parse error in an epub, + and one url in the collection has one: + "...boylesite/law&info.htm". epubcheck stopped reading the file at + it and then reported every id after that point as undefined - 378 + errors from one ampersand, none of them where the fault was. + +/ + return m["linked_text"] ~ "┤" ~ attr(url) ~ "├"; + })(txt, rgx_h.inline_link_number_only); + /+ ↓ a link the markup left with no target at all keeps its text +/ + txt = txt.replaceAll(rgx_h.inline_link_empty, "$1"); + txt = replaceAll!((m) { + return "<a href=\"" ~ resolveTarget(m.captures[2].to!string) ~ "\">" + ~ m.captures[1].to!string ~ "</a>"; + })(txt, rgx_h.inline_link); + txt = txt.replaceAll(rgx_h.mark_internal_site_lnk, ""); + return txt; +} + +/+ ↓ an image, ┥☼<file>,w<W>h<H> "alt"┝┤target├. + . + alt is required. An image with nothing to say for itself takes alt="", + which is how to say "decorative, skip me"; no alt at all is an + omission, and a reading system then announces the filename. + . + The dimensions are dropped when they are zero, because width="0" is a + valid html integer meaning zero wide. ++/ +private string images(string txt) { + return replaceAll!((m) { + string alt; + string rest = m["post"].to!string; + if (auto a = rest.matchFirst(rgx_h.inline_image_alt)) { + alt = a["alt"].to!string; + rest = a.post.to!string; + } + string dim = (m["width"] == "0" && m["height"] == "0") + ? "" + : " width=\"" ~ m["width"].to!string + ~ "\" height=\"" ~ m["height"].to!string ~ "\""; + /+ ↓ the opening link marker is kept, because an image in the markup sits + inside a link whose target may be empty or may be a url. Keeping it + lets the link handling that follows either drop the wrapper or make + the image a link, and dropping it here would leave the closing + marker behind as text. + +/ + return m["pre"].to!string + ~ "<img src=\"image/" ~ attr(m["img"].to!string) ~ "\" alt=\"" + ~ attr(alt) ~ "\"" ~ dim ~ " /> " ~ rest; + })(txt, rgx_h.inline_image); +} + +/+ ↓ an anchor the markup placed in the text, ┃name┃ +/ +private string anchors(string txt) { + return txt.replaceAll(rgx_h.inline_link_anchor, "<a id=\"$1\"></a>"); +} + +/+ ↓ the line-leading markers of a grouped object. + . + The indent is a number, so it is written as a number, in a data + attribute the stylesheet reads. Spine writes runs of instead, up + to eighteen of them for one line, which puts presentation in the + content and cannot be undone by a reader that wants its own. ++/ +private string groupIndents(string txt, ref const OcdaObject obj) { + if (obj.is_a != "group") { return txt; } + return txt + .replaceAll(rgx_h.grouped_para_indent_hang, + "<span class=\"indent\" data-hang=\"$1\" data-indent=\"$2\"></span>") + .replaceAll(rgx_h.grouped_para_bullet_at, + "<span class=\"indent\" data-indent=\"$1\"></span>" + ~ "<span class=\"bullet\">●</span> ") + .replaceAll(rgx_h.grouped_para_bullet, + "<span class=\"bullet\">●</span> ") + .replaceAll(rgx_h.grouped_para_indent, + "<span class=\"indent\" data-indent=\"$1\"></span>"); +} + +/+ ↓ the line breaks the markup asked for, and the ones a block preserves. + Not inside code, whose text is preserved as it stands. ++/ +private string lineBreaks(string txt, ref const OcdaObject obj) { + if (obj.is_a == "code") { return txt; } + return txt + .replaceAll(rgx_h.br_line, "<br />\n") + .replaceAll(rgx_h.br_line_inline, "<br />\n") + .replaceAll(rgx_h.br_line_spaced, "<br />\n<br />\n") + .replaceAll(rgx_h.line_break, "<br />\n"); +} + +/+ ↓ the head. + . + One charset declaration, and a Dublin Core meta only where there is a + value for it. Spine writes two conflicting Content-Type metas, the + first of them text/plain, and writes dc.publisher="Publisher" and + dc.date.issued="year" as literal strings when it has nothing. ++/ +private string head(ref OcdaDocument doc) { + string m(string key) { return doc.meta(key); } + string lang = ("language" in doc.source) ? doc.source["language"] : "en"; + auto out_ = appender!string; + out_ ~= "<!DOCTYPE html>\n<html lang=\"" ~ attr(lang) ~ "\">\n<head>\n"; + out_ ~= "<meta charset=\"utf-8\" />\n"; + out_ ~= "<meta name=\"viewport\" content=\"width=device-width, initial-scale=1\" />\n"; + string title = m("title.full"); + if (title.length == 0) { title = m("title.main"); } + string author = m("creator.author"); + out_ ~= "<title>" ~ escape(title) + ~ (author.length > 0 ? ", " ~ escape(author) : "") ~ "</title>\n"; + void dc(string name, string value) { + if (value.length == 0) { return; } + out_ ~= "<meta name=\"" ~ name ~ "\" content=\"" ~ attr(value) ~ "\" />\n"; + } + dc("dc.title", title); + dc("dc.creator", author); + dc("dc.date", m("date.published")); + dc("dc.date.created", m("date.created")); + dc("dc.date.issued", m("date.issued")); + dc("dc.date.available", m("date.available")); + dc("dc.date.valid", m("date.valid")); + dc("dc.date.modified", m("date.modified")); + dc("dc.language", lang); + dc("dc.rights", m("rights.copyright")); + dc("dc.publisher", m("publisher")); + dc("dc.identifier", m("identifier.oclc")); + out_ ~= "<meta name=\"generator\" content=\"" ~ attr(generator_name) ~ "\" />\n"; + out_ ~= "<style>\n" ~ stylesheet ~ "</style>\n"; + out_ ~= "</head>\n<body>\n"; + return out_.data; +} + +private string tail(ref OcdaDocument doc) { + string m(string key) { return doc.meta(key); } + auto out_ = appender!string; + out_ ~= "<footer>\n"; + void line(string label, string value) { + if (value.length == 0) { return; } + out_ ~= "<p class=\"meta\"><span class=\"label\">" ~ label + ~ "</span> " ~ escape(value) ~ "</p>\n"; + } + line("Title:", m("title.full")); + line("Author:", m("creator.author")); + line("Published:", m("date.published")); + line("Copyright:", m("rights.copyright")); + line("License:", m("rights.license")); + out_ ~= "<p class=\"generator\">" ~ escape(generator_name) ~ " <a href=\"" + ~ attr(generator_url) ~ "\">" ~ escape(generator_url) ~ "</a></p>\n"; + out_ ~= "</footer>\n</body>\n</html>\n"; + return out_.data; +} + +/+ ↓ set to spine's own values so that the comparison is of the writer and + not of the signature +/ +enum generator_name = "spine"; +enum generator_url = "https://sisudoc.org"; + +/+ ↓ a stylesheet small enough to embed, so that the file renders on its + own. It styles what this writer emits and nothing else: the object + number in the margin, the indents the document declared, and the + blocks. Not spine's 4,747 line sheet, which is a site's and not a + document's. ++/ +enum stylesheet = q"┃html { font-size: 100%; } +body { + margin: 0 auto; padding: 1em 1em 1em 4em; max-width: 46em; + font-family: Georgia, "Times New Roman", serif; line-height: 1.5; + color: #111; background: #fff; +} +@media (prefers-color-scheme: dark) { + body { color: #ddd; background: #121212; } + a { color: #8ab4f8; } +} +.substance { position: relative; } +.ocn { + position: absolute; left: -3.6em; width: 3em; text-align: right; + font-size: 0.7em; line-height: 2.2; color: #888; +} +.ocn a { color: inherit; text-decoration: none; } +h1, h2, h3, h4, h5, h6 { line-height: 1.25; font-weight: 600; } +h1 { font-size: 1.7em; } h2 { font-size: 1.4em; } h3 { font-size: 1.2em; } +h4, h5, h6 { font-size: 1.05em; } +p { margin: 0.6em 0; } +p[data-indent="1"] { margin-left: 1em; } p[data-indent="2"] { margin-left: 2em; } +p[data-indent="3"] { margin-left: 3em; } p[data-indent="4"] { margin-left: 4em; } +p[data-indent="5"] { margin-left: 5em; } p[data-indent="6"] { margin-left: 6em; } +p[data-indent="7"] { margin-left: 7em; } p[data-indent="8"] { margin-left: 8em; } +p[data-indent="9"] { margin-left: 9em; } +.indent { display: inline-block; } +.indent[data-indent="1"] { width: 1em; } .indent[data-indent="2"] { width: 2em; } +.indent[data-indent="3"] { width: 3em; } .indent[data-indent="4"] { width: 4em; } +.indent[data-indent="5"] { width: 5em; } .indent[data-indent="6"] { width: 6em; } +.indent[data-indent="7"] { width: 7em; } .indent[data-indent="8"] { width: 8em; } +.indent[data-indent="9"] { width: 9em; } +blockquote.quote { margin: 0.8em 2em; font-style: normal; } +div.group, div.block, div.verse { margin: 0.8em 0; } +div.verse { white-space: pre-wrap; } +pre.code { + overflow-x: auto; padding: 0.6em; background: #f4f4f4; + font-family: ui-monospace, "DejaVu Sans Mono", monospace; font-size: 0.9em; +} +@media (prefers-color-scheme: dark) { pre.code { background: #1e1e1e; } } +table.table { border-collapse: collapse; margin: 0.8em 0; width: 100%; } +table.table th, table.table td { + border: 1px solid #ccc; padding: 0.2em 0.4em; vertical-align: top; +} +.align-right { text-align: right; } .align-centre { text-align: center; } +sup.noteref a { text-decoration: none; } +section[role="doc-endnotes"] p { font-size: 0.9em; } +footer { margin-top: 3em; border-top: 1px solid #ccc; font-size: 0.85em; } +footer .label { color: #666; } +img { max-width: 100%; height: auto; } +┃"; diff --git a/dlang/src/write/markdown.d b/dlang/src/write/markdown.d new file mode 100644 index 0000000..d9f8f64 --- /dev/null +++ b/dlang/src/write/markdown.d @@ -0,0 +1,579 @@ +module write.markdown; + +/+ ↓ the markdown writer: a document as CommonMark, for reading anywhere. + . + The fifth format, and the one asked for because markdown is what a + document is passed around as. The question was how much of spine's spirit + survives the translation, and the answer is: the object numbers do, which + is the part that matters. + . + **The dialect is CommonMark**, plus pipe tables, which every renderer + worth using understands. Raw inline html is used in exactly two places, + both of them for the object number, because markdown has no anchor + syntax and nothing else will do. + . + What an object looks like: + . + <a id="12"></a>The text of the paragraph.<sup>[12](#12)</sup> + . + The anchor is at the head of the object so that a link to it lands on the + object and not past it; the number is at the end, as a link to itself, so + that a reader can take the citation out of the rendered page. That is + what spine's own html does with an object number, and it is the nearest + markdown comes to a number in the margin. + . + **Markdown's own footnote syntax is not used.** It is an extension rather + than CommonMark, and, more to the point, a renderer numbers footnotes + itself, in the order it meets them. The document's note numbers are part + of its citation and are not a renderer's to choose. So a note is written + where it is referred to, with the document's own number, linking back to + the object - the same decision the typst and the text writers made, for + the same reason. + . + Set against the plain text writer: everything the text file carries, and + in addition a reader that renders it gets working links for every + citation, every note, every index entry and every line of the table of + contents, and the headings become headings. The text file has the + numbers; the markdown has the numbers *and* the graph between them. ++/ + +import std.algorithm : canFind, max, min, startsWith; +import std.array : appender, array, join, replace; +import std.array : asplit = split; +import std.conv : to; +import std.regex : matchAll, matchFirst, replaceAll, split; +import std.string : strip; +import ocda.model; +import write.rgx; + +enum newline = "\n"; +enum newlines = "\n\n"; + +static Rgx rgx_m; + +/+ ↓ the table of contents depth of each indent_hang the document uses. + spine reserves 1 to 3 for levels above the body, so a toc jumps from 1 to + 4; ranking the values the document uses gives 0, 1, 2, 3, and a markdown + list nests by two spaces a level and cannot take a jump. +/ +private size_t[int] toc_depth; + +/+ ↓ the document as markdown. + . + image_pth is where the images are, relative to wherever this output is put. + Given rather than assumed: the model names an image and does not say where + the file naming it will sit. spine, whose markdown goes two directories down + under a language, passes "../../image". ++/ +string writeMarkdown(ref OcdaDocument doc, string image_pth = "image") { + toc_depth = null; + auto toc = doc.sectionByName("toc"); + if (toc !is null) { + import std.algorithm : sort; + bool[int] seen; + foreach (obj; *toc) { if (obj.renders) { seen[obj.indent_hang] = true; } } + foreach (i, level; seen.keys.sort.array) { toc_depth[level] = i; } + } + auto out_ = appender!string; + out_ ~= frontMatter(doc); + foreach (name; markdownSections(doc)) { + foreach (obj; *doc.sectionByName(name)) { + if (!obj.renders || obj.dummy_heading) { continue; } + if (obj.text.strip.length == 0) { continue; } + out_ ~= objectOut(obj); + } + } + return out_.data.replace(image_dir_mark, image_pth); +} + +/+ ↓ head, toc, body, then each trailing section with more than one object. + Not endnotes: a note is written where it is referred to. ++/ +string[] markdownSections(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; +} + +/+ ↓ the document's metadata as yaml front matter, which is how a markdown + file carries metadata and what every static site generator reads. ++/ +private string frontMatter(ref OcdaDocument doc) { + auto out_ = appender!string; + out_ ~= "---" ~ newline; + void put(string key, string value) { + if (value.length > 0) { out_ ~= key ~ ": " ~ yaml(value) ~ newline; } + } + put("title", doc.meta("title.full")); + put("author", doc.meta("creator.author")); + put("date", doc.meta("date.published")); + put("language", ("language" in doc.source) ? doc.source["language"] : ""); + put("copyright", doc.meta("rights.copyright")); + put("license", doc.meta("rights.license")); + out_ ~= "---" ~ newlines; + return out_.data; +} + +/+ ↓ a yaml scalar. Quoted always, so that a colon or a leading marker in a + title cannot change the shape of the document's own metadata. +/ +private string yaml(string s) { + return "\"" ~ s.replace("\\", "\\\\").replace("\"", "\\\"") + .replace("\n", " ") ~ "\""; +} + +/+ ↓ one object, and then the notes it referred to. + . + The notes are gathered here rather than inside each kind, because a + reference can be anywhere - the markup manual has one in a heading - and a + reference whose note was never written is a link to nothing. ++/ +private string objectOut(ref const OcdaObject obj) { + return objectBody(obj) ~ notes(obj); +} + +private string objectBody(ref const OcdaObject obj) { + switch (obj.is_a) { + case "heading": return heading(obj); + case "toc": return tocEntry(obj); + case "table": return table(obj); + case "code": return code(obj); + case "quote": return quote(obj); + case "verse": case "poem": case "group": case "block": + return lines(obj); + case "bookindex": return indexEntry(obj); + default: return para(obj); + } +} + +/+ ↓ six levels of heading, which is all markdown has. A document may be + eight deep; the two deepest take the sixth, and the object number, the + anchor and the table of contents carry the true depth. ++/ +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": case "3": case "4": return 6; + default: return 1; + } +} + +private string heading(ref const OcdaObject obj) { + string hashes; + foreach (_; 0 .. headingLevel(obj.heading_level)) { hashes ~= "#"; } + return hashes ~ " " ~ anchor(obj) ~ inline(obj) ~ ocnMark(obj) ~ newlines; +} + +/+ ↓ a table of contents entry: a list item at its depth, the heading as a + link, and the object number as the locator. + . + The number and not a page: a page is a fact about one typesetting, and + the object number is the same reference in every output of the document. ++/ +private string tocEntry(ref const OcdaObject obj) { + string indent; + if (auto d = obj.indent_hang in toc_depth) { + foreach (_; 0 .. *d) { indent ~= " "; } + } + string n = internalTarget(obj); + string locator = (n.length > 0) ? (" <sup>[" ~ n ~ "](#" ~ n ~ ")</sup>") : ""; + return indent ~ "- " ~ inline(obj) ~ locator ~ newline; +} + +private string para(ref const OcdaObject obj) { + string bullet = obj.bullet ? "- " : ""; + string indent; + foreach (_; 0 .. obj.indent_base) { indent ~= " "; } + return indent ~ bullet ~ anchor(obj) ~ inline(obj) ~ ocnMark(obj) ~ newlines; +} + +private string quote(ref const OcdaObject obj) { + return "> " ~ anchor(obj) ~ inline(obj) ~ ocnMark(obj) ~ newlines; +} + +/+ ↓ verse, a poem, a group or a block: every line kept, with CommonMark's + own hard line break - a backslash at the end of a line - rather than the + two trailing spaces an editor will strip. ++/ +private string lines(ref const OcdaObject obj) { + string txt = inline(obj); + auto out_ = appender!string; + out_ ~= anchor(obj); + auto rows = txt.asplit(newline); + foreach (i, row; rows) { + out_ ~= row; + if (i + 1 < rows.length) { out_ ~= "\\" ~ newline; } + } + return out_.data ~ ocnMark(obj) ~ newlines; +} + +/+ ↓ a book index entry: a list item, and every object it names a link +/ +private string indexEntry(ref const OcdaObject obj) { + string txt = obj.text.replaceAll(rgx_m.trailing_backslash, ""); + return "- " ~ anchor(obj) ~ inlineText(txt, obj) ~ ocnMark(obj) ~ newlines; +} + +/+ ↓ a fenced code block, with the language where the document named one. + Nothing inside a fence is interpreted, which is the whole point. ++/ +private string code(ref const OcdaObject obj) { + string txt = obj.text.replaceAll(rgx_m.nbsp_char, " "); + /+ ↓ a fence long enough that the code cannot close it +/ + string fence = "```"; + while (txt.canFind(fence)) { fence ~= "`"; } + return anchor(obj) ~ newline ~ fence ~ obj.code_syntax ~ newline + ~ txt ~ newline ~ fence ~ newline ~ ocnMark(obj) ~ newlines; +} + +/+ ↓ a pipe table, which needs a header row: markdown has no table without + one, so a table the document did not give a header gets an empty row and + keeps all of its own. ++/ +private string table(ref const OcdaObject obj) { + auto rows = obj.text.split(rgx_m.table_delimiter_row); + auto aligns = obj.table_aligns.asplit(" "); + int cols = max(obj.table_cols, 1); + string[][] body_; + foreach (row; rows) { + if (row.strip.length == 0) { continue; } + string[] cells; + foreach (cell; row.split(rgx_m.table_delimiter_col)) { + cells ~= inlineText(cell, obj).replace("|", "\\|"); + } + body_ ~= cells; + } + auto out_ = appender!string; + out_ ~= anchor(obj) ~ newline; + string rule() { + auto r = appender!string; + r ~= "|"; + foreach (i; 0 .. cols) { + string a = (i < aligns.length) ? aligns[i] : "l"; + r ~= (a == "c") ? " :---: |" : (a == "r") ? " ---: |" : " :--- |"; + } + return r.data ~ newline; + } + string line(string[] cells) { + auto l = appender!string; + l ~= "|"; + foreach (i; 0 .. cols) { + l ~= " " ~ ((i < cells.length) ? cells[i] : "") ~ " |"; + } + return l.data ~ newline; + } + if (body_.length > 0 && obj.table_header) { + out_ ~= line(body_[0]) ~ rule(); + foreach (row; body_[1 .. $]) { out_ ~= line(row); } + } else { + out_ ~= line([]) ~ rule(); + foreach (row; body_) { out_ ~= line(row); } + } + return out_.data ~ ocnMark(obj) ~ newlines; +} + +/+ ↓ the object's anchor, so that a citation can point at it +/ +private string anchor(ref const OcdaObject obj) { + return (obj.ocn == 0) ? "" : "<a id=\"" ~ obj.ocn.to!string ~ "\"></a>"; +} + +/+ ↓ the object's number, at the end of it, as a link to itself +/ +private string ocnMark(ref const OcdaObject obj) { + if (obj.ocn == 0) { return ""; } + string n = obj.ocn.to!string; + return "<sup>[" ~ n ~ "](#" ~ n ~ ")</sup>"; +} + +/+ ↓ the notes the object referred to, each where it was referred to, with the + document's own number and a link back to the object +/ +private string notes(ref const OcdaObject obj) { + auto out_ = appender!string; + foreach (m; obj.text.matchAll(rgx_m.inline_notes_al_all_note)) { + string mark = m["num"].to!string; + string back = (obj.ocn == 0) + ? "" : " [\u21a9](#" ~ obj.ocn.to!string ~ ")"; + out_ ~= newlines ~ "<a id=\"note-" ~ mark ~ "\"></a>" + ~ "<sup>" ~ inert(mark) ~ ".</sup> " + ~ inlineText(m["note"].to!string, obj) ~ back; + } + /+ ↓ and a blank line after the last of them, or the next object begins on + the same line and markdown reads the two as one paragraph +/ + return (out_.data.length > 0) ? out_.data ~ newlines : ""; +} + +private string inline(ref const OcdaObject obj) { + return inlineText(obj.text, obj); +} + +/+ ↓ an object's text as markdown. + . + The order is the one every writer here uses: images before links, + because an image sits inside a link; then the note references, the + faces, the anchors and the breaks. ++/ +private string inlineText(string txt, ref const OcdaObject obj) { + txt = images(txt); + txt = links(txt, obj); + txt = noteRefs(txt); + txt = faces(txt); + txt = txt.replaceAll(rgx_m.inline_link_anchor, ""); + txt = breaks(txt, obj); + return guardLineStarts(unprotect(escape(txt))); +} + +/+ ↓ a "- ", "+ ", "> " or "1. " at the start of a line is markdown's own: a + list item, an enumeration item, a blockquote. The writer uses the first of + them itself, for a list, and a line of the document's that begins the same + way would be read as one - which is what a verse or a group can do. + . + Guarded rather than escaped everywhere, because these are special only in + that position and a backslash in every hyphen would be absurd. ++/ +private string guardLineStarts(string s) { + auto out_ = appender!string; + bool at_start = true; + for (size_t i = 0; i < s.length; i++) { + if (at_start) { + size_t j = i; + while (j < s.length && s[j] == ' ') { j++; } + if (j < s.length) { + char c = s[j]; + bool marker = (c == '-' || c == '+' || c == '>') + && j + 1 < s.length && (s[j + 1] == ' ' || s[j + 1] == '\t'); + size_t k = j; + while (k < s.length && s[k] >= '0' && s[k] <= '9') { k++; } + bool enumerated = k > j && k + 1 < s.length + && (s[k] == '.' || s[k] == ')') && s[k + 1] == ' '; + if (marker || enumerated) { + out_ ~= s[i .. (enumerated ? k : j)]; + out_ ~= '\\'; + i = (enumerated ? k : j) - 1; + at_start = false; + continue; + } + } + at_start = false; + } + out_ ~= s[i]; + if (s[i] == '\n') { at_start = true; } + } + return out_.data; +} + +/+ ↓ the markers that protect the markdown this writer emits from the escaping + that follows. + . + The escaping has to come *last*, and this is why: the abstraction's own + markers are built from the characters markdown reserves - "⑆*┨" for + emphasis, "⑆_┨" for an underscore face - so escaping first turns them + into "⑆\*┨" and no face is ever recognised. It cost 70 stray hashes and + every face in one document before it was seen. + . + A private-use pair, because they must not occur in a document, and the + protection nests, so the escaper counts it rather than flagging it. ++/ +private enum keep_open = ""; +private enum keep_close = ""; + +/+ ↓ a third marker of the same kind, standing for the directory the images are + in. + . + An image reference is written while an object is written, and where the + file will be put is not something the model knows: it is told. So the + reference carries a marker and writeMarkdown substitutes it once, at the + end, out of the one argument that says. ++/ +private enum image_dir_mark = ""; + +private string keep(string markdown_source) { + return keep_open ~ markdown_source ~ keep_close; +} + +private string unprotect(string txt) { + return txt.replace(keep_open, "").replace(keep_close, ""); +} + +/+ ↓ the document's own text, escaped for a protected run: the same rule, run + early, and then flattened so that the result is inert +/ +private string inert(string txt) { + return unprotect(escape(txt)); +} + +/+ ↓ the characters markdown reads as syntax, escaped wherever they occur, + and only outside a protected run. + . + The same blunt rule the typst writer arrived at, and for the same reason: + a closed set is checkable and a rule about positions is not. The three + html characters become entities rather than backslash escapes, because + markdown passes raw html through and "<b" would open a tag. + . + Last in the pipeline, because the abstraction's markers are built from + the characters markdown reserves and escaping them first destroys every + one. The protection nests, so it is counted, not flagged. ++/ +private string escape(string txt) { + auto out_ = appender!string; + int depth = 0; + foreach (dchar c; txt) { + if (c == keep_open.to!dchar) { depth++; continue; } + if (c == keep_close.to!dchar) { if (depth > 0) { depth--; } continue; } + if (depth > 0) { out_ ~= c; continue; } + switch (c) { + case '&': out_ ~= "&"; break; + case '<': out_ ~= "<"; break; + case '>': out_ ~= ">"; break; + case '\\': case '`': case '*': case '_': case '[': case ']': + case '#': case '|': case '~': + out_ ~= '\\'; out_ ~= c; break; + default: out_ ~= c; break; + } + } + return out_.data; +} + +/+ ↓ an image, with its alt text where the document gave one +/ +private string images(string txt) { + return replaceAll!((m) { + string rest = m["post"].to!string; + string alt; + if (auto a = rest.matchFirst(rgx_m.inline_image_alt)) { + alt = a["alt"].to!string; + rest = a.post.to!string; + } + return m["pre"].to!string + ~ keep("") + ~ rest; + })(txt, rgx_m.inline_image); +} + +/+ ↓ a link. An internal target becomes a fragment, which is the object + number, and so resolves against the anchor every object carries. + . + And *only* an object number: those are the anchors this writer defines, so + a target that names something else - the endnotes section, an anchor tag + the markup placed - would be a link to nothing. The text stands on its + own instead, which is what a reader sees anyway. ++/ +private string links(string txt, ref const OcdaObject obj) { + 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_m.inline_link_number_only); + return replaceAll!((m) { + string text_ = m.captures[1].to!string; + string target = m.captures[2].to!string; + if (target.startsWith("¤")) { + auto im = target.matchFirst(rgx_m.internal_target); + if (!im.empty) { target = im["frag"].to!string; } + } + string shown = inert(faces(text_)); + if (target.length == 0) { return keep(shown); } + if (target.startsWith("#") && !isObjectNumber(target[1 .. $])) { + return keep(shown); + } + return keep("[" ~ shown ~ "](" ~ target ~ ")"); + })(txt, rgx_m.inline_link); +} + +/+ ↓ a link target is a url or a fragment and not prose, so the escaping the + text went through comes off it again. A "#" that stayed escaped made every + table of contents line point at nothing. +/ +private string unescapeTarget(string target) { + auto out_ = appender!string; + for (size_t i = 0; i < target.length; i++) { + if (target[i] == 0x5c && i + 1 < target.length) { out_ ~= target[++i]; continue; } + out_ ~= target[i]; + } + return out_.data.replace("&", "&").replace("<", "<").replace(">", ">"); +} + +/+ ↓ a fragment that names an object of this document +/ +private bool isObjectNumber(string s) { + if (s.length == 0 || s == "0") { return false; } + foreach (c; s) { if (c < '0' || c > '9') { return false; } } + return true; +} + +/+ ↓ a note reference: a superscript link to the note, which sits under the + object that refers to it +/ +private string noteRefs(string txt) { + return replaceAll!((m) { + string mark = m["num"].to!string; + /+ ↓ the mark is shown as well as named in the target, and a star note`s mark + is an asterisk, which markdown would pair with the next one +/ + return keep("<sup>[" ~ inert(mark) ~ "](#note-" ~ mark ~ ")</sup>"); + })(txt, rgx_m.inline_notes_al_all_note); +} + +/+ ↓ the font faces. Emphasis and strong are markdown's own; the rest have no + markdown and take the html element markdown itself would produce. + . + The spaces move *outside* the markers. The abstraction's markers include + the spaces that surrounded the run - "⑆/┨ lex mercatoria ┣/" - and + markdown's emphasis will not open on a space: "* lex *" is three words and + two asterisks, not an italic. ++/ +private string faces(string txt) { + static struct Face { typeof(Rgx.inline_bold) pattern; string open, close; } + string wrap(string open, string close, string inner) { + size_t a = 0, b = inner.length; + while (a < b && inner[a] == ' ') { a++; } + while (b > a && inner[b - 1] == ' ') { b--; } + /+ ↓ the spaces are dropped rather than moved: the abstraction`s marker + takes in the space that was beside the run, and the text before it + already ends in one +/ + return keep(open) ~ inner[a .. b] ~ keep(close); + } + string apply(alias pattern)(string s, string open, string close) { + return replaceAll!((m) => wrap(open, close, m.captures[1].to!string)) + (s, pattern); + } + txt = apply!(rgx_m.inline_emphasis)(txt, "**", "**"); + txt = apply!(rgx_m.inline_bold)(txt, "**", "**"); + txt = apply!(rgx_m.inline_italics)(txt, "*", "*"); + txt = apply!(rgx_m.inline_underscore)(txt, "<u>", "</u>"); + txt = apply!(rgx_m.inline_superscript)(txt, "<sup>", "</sup>"); + txt = apply!(rgx_m.inline_subscript)(txt, "<sub>", "</sub>"); + txt = apply!(rgx_m.inline_mono)(txt, "`", "`"); + txt = apply!(rgx_m.inline_strike)(txt, "~~", "~~"); + txt = apply!(rgx_m.inline_insert)(txt, "<ins>", "</ins>"); + txt = apply!(rgx_m.inline_cite)(txt, "<cite>", "</cite>"); + return txt; +} + +/+ ↓ the line breaks the markup asked for, and the grouped indents +/ +private string breaks(string txt, ref const OcdaObject obj) { + if (obj.is_a == "code") { return txt; } + if (obj.is_a == "group" || obj.is_a == "block") { + txt = txt + .replaceAll(rgx_m.grouped_para_indent_hang, "$2$2") + .replaceAll(rgx_m.grouped_para_bullet_at, "$1\u25cf ") + .replaceAll(rgx_m.grouped_para_bullet, "\u25cf ") + .replaceAll(rgx_m.grouped_para_indent, "$1$1"); + } + return txt + .replaceAll(rgx_m.nbsp_char, " ") + .replaceAll(rgx_m.br_line, newline) + .replaceAll(rgx_m.br_line_inline, newline) + .replaceAll(rgx_m.br_line_spaced, newlines) + .replaceAll(rgx_m.line_break, newline) + .replaceAll(rgx_m.mark_internal_site_lnk, ""); +} + +/+ ↓ the object a table of contents entry points at, where that is an object + number +/ +private string internalTarget(ref const OcdaObject obj) { + foreach (m; obj.text.matchAll(rgx_m.any_internal_target)) { + string frag = m["frag"].to!string; + bool digits = frag.length > 0; + foreach (c; frag) { if (c < '0' || c > '9') { digits = false; break; } } + if (digits) { return frag; } + } + return ""; +} diff --git a/dlang/src/write/rgx.d b/dlang/src/write/rgx.d new file mode 100644 index 0000000..23c848d --- /dev/null +++ b/dlang/src/write/rgx.d @@ -0,0 +1,104 @@ +module write.rgx; + +/+ ↓ the internal markup, as regular expressions. + . + An object's text is *spine's internal representation*, not the author's + markup and not any output: the markers are single Unicode characters + chosen not to occur in text. A writer's first job is to turn them into + something the target format understands, so every writer needs these + and they live in one place. + . + Taken from the specification's description of object text and matched + against spine's own outputs/io_out/rgx.d, which is normative. The + characters are load bearing: do not "tidy" them into ASCII. ++/ + +import std.regex; + +static struct Rgx { + /+ ↓ spacing and line breaks +/ + static nbsp_char = ctRegex!(`░`, "mg"); + static br_line = ctRegex!(`\s*┘\s*`, "mg"); + static br_line_inline = ctRegex!(`\s*┙\s*`, "mg"); + static br_line_spaced = ctRegex!(`\s*┚\s*`, "mg"); + + /+ ↓ font face, each ⑆<mark>┨ text ┣<mark> +/ + static inline_strike = ctRegex!(`⑆[-]┨(?P<text>.+?)┣[-]`, "mg"); + static inline_insert = ctRegex!(`⑆[+]┨(?P<text>.+?)┣[+]`, "mg"); + static inline_cite = ctRegex!(`⑆[‖]┨(?P<text>.+?)┣[‖]`, "mg"); + static inline_emphasis = ctRegex!(`⑆[*]┨(?P<text>.+?)┣[*]`, "mg"); + static inline_bold = ctRegex!(`⑆[!]┨(?P<text>.+?)┣[!]`, "mg"); + static inline_italics = ctRegex!(`⑆[/]┨(?P<text>.+?)┣[/]`, "mg"); + static inline_underscore = ctRegex!(`⑆[_]┨(?P<text>.+?)┣[_]`, "mg"); + static inline_superscript = ctRegex!(`⑆\^┨(?P<text>.+?)┣\^`, "mg"); + static inline_subscript = ctRegex!(`⑆[,]┨(?P<text>.+?)┣[,]`, "mg"); + static inline_mono = ctRegex!(`⑆[■]┨(?P<text>.+?)┣[■]`, "mg"); + + /+ ↓ links and images. Capture 1 is the text, 3 the target without its + leading "#" where it has one +/ + static inline_link = ctRegex!(`┥(?P<text>.+?)┝┤(?P<link>#?(\S+?))├`, "mg"); + static inline_image = ctRegex!(`(?P<pre>┥)☼(?P<imginf>(?P<img>[a-zA-Z0-9._-]+?\.(?:jpg|gif|png)),w(?P<width>\d+)h(?P<height>\d+))\s*(?P<post>.*?┝┤.*?├)`, "mg"); + + /+ ↓ notes, carried inline in the object that refers to them +/ + static inline_notes_al_gen = ctRegex!(`【.+?】`, "m"); + static inline_notes_al_regular_number_note = ctRegex!(`【(?P<num>\d+)\s+(?P<note>.+?)\s*】`, "mg"); + /+ ↓ and the same for a note marked with a run of stars or pluses + instead of a number, which the markup also allows +/ + static inline_notes_al_all_note = ctRegex!(`【(?P<num>\d+|(?:[*]|[+])+)\s+(?P<note>.+?)\s*】`, "mg"); + + /+ ↓ tables: the object's text is rows and columns in one string +/ + static table_delimiter_row = ctRegex!("[ ]*\n", "mg"); + static table_delimiter_col = ctRegex!("[ ]*[┊][ ]*", "mg"); + + /+ ↓ the book index, whose entries carry their own links +/ + static find_bookindex_ocn_link_and_comma = ctRegex!(`[, ]*┥.+?┝┤#?\S+?├`, "mg"); + + /+ ↓ end of *input*, not end of line: spine's table layout strips the + whole accumulated string after every cell +/ + static trailing_whitespace_all = ctRegex!(`\s*$`); + static trailing_backslash = ctRegex!(`\s*\\`, "mg"); + + /+ ↓ a link before its url has been substituted in: the number is an index + into the object's .stow_link list, not a target +/ + static inline_link_number_only = ctRegex!(`(?P<linked_text>┥.+?┝)┤(?P<num>[0-9]+)├`, "mg"); + static inline_link_empty = ctRegex!(`┥(?P<text>.+?)┝┤├`, "mg"); + + /+ ↓ an anchor the markup placed in the text, ┃name┃ +/ + static inline_link_anchor = ctRegex!(`┃(?P<anchor>\S+?)┃`, "mg"); + + /+ ↓ "¤" marks a link that is internal to the document collection, and + ".fnSuffix" stands where an output's file suffix goes +/ + static mark_internal_site_lnk = ctRegex!(`¤`, "mg"); + static inline_seg_link = ctRegex!(`(¤)(?:.+?)\.fnSuffix`, "mg"); + + /+ ↓ the same target taken apart: the segment it names and whatever + follows it, which is what a segmented output needs +/ + static internal_target = ctRegex!(`^¤(?P<seg>.+?)\.fnSuffix(?P<frag>.*)$`); + + /+ ↓ an image's alt text, which the markup carries as a quoted string + following the image +/ + static inline_image_alt = ctRegex!(`^\s*[“"](?P<alt>[^”"]*)[”"]`, "m"); + + /+ ↓ a line break the markup asked for, " \\" +/ + static line_break = ctRegex!(` [\\]{2}`, "m"); + + /+ ↓ the leading markers of a line inside a group: indent, hang, bullet. + "_2 " is indent two, "_2*" a bullet at indent two, "_2_1 " a hanging + indent. They are line-leading and so anchored, with "m". + +/ + static grouped_para_indent = ctRegex!(`^_(?P<indent>[1-9])[ ]`, "m"); + static grouped_para_bullet = ctRegex!(`^_[*] `, "m"); + static grouped_para_bullet_at = ctRegex!(`^_(?P<indent>[1-9])[*] `, "m"); + static grouped_para_indent_hang = ctRegex!(`^_(?P<hang>[0-9])_(?P<indent>[0-9])[ ]`, "m"); + + /+ ↓ an internal link target as the abstraction writes it, with or without + the segment prefix a segmented output would use. Read to decide whether + a table of contents entry points at anything this writer renders. +/ + static any_internal_target = ctRegex!(`┤(?:¤.+?\.fnSuffix)?#(?P<frag>[^├]+)├`, "mg"); + + /+ ↓ the four characters html reserves +/ + static ampersand = ctRegex!(`[&]`, "mg"); + static quotation = ctRegex!(`["]`, "mg"); + static less_than = ctRegex!(`[<]`, "mg"); + static greater_than = ctRegex!(`[>]`, "mg"); +} diff --git a/dlang/src/write/text.d b/dlang/src/write/text.d new file mode 100644 index 0000000..9853a7e --- /dev/null +++ b/dlang/src/write/text.d @@ -0,0 +1,397 @@ +module write.text; + +/+ ↓ the plain text writer: a function from the model to bytes. + . + It is given an OcdaDocument and knows nothing about where it came from, + which is the point: the same call renders a document 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 it earned its place while there + was nothing else to check against; holding to it meant reproducing a + dozen things no one would choose, and the code said so a dozen times. + What replaced it is stricter where it matters and says nothing about + what it does not care about: + . + 1. the D and the Gleam writer produce the same bytes; + 2. the words of the output equal the words of the html writer's + objects, as a multiset, so nothing is lost or doubled; + 3. every object number appears exactly once; + 4. the same bytes from every input form. + . + The presentation conventions are spine's and are kept, because they are + good: 「n」 for the object number on a line of its own, the markup + written back as markup so the text is re-editable, ≫n for a link to an + object, a note after the object that refers to it. + . + What changed, each because it is better and not merely different: + . + - *a note is written once, after the object that refers to it, and the + gathered endnotes section is not written.* Spine writes both, so every + note's text is in the text file twice - the same duplication the rules + record for spine's epub. In a file with no pages the note belongs + beside its reference, so that is the copy that is kept. It costs the + OCN spine gives the generated endnotes heading, which is the question + the typst writer raised and the log carries. + - *the author's markup comments are not written.* Spine's text output + leaks them, `%` and all - 36 of them in one sample, including a line + reading "% $$$$". The model says an object of type comment is carried + so that nothing is lost and is not put into output; every other + writer here honours that and this one now does too. + - *an image keeps its caption.* Spine's text writes the file name alone + and loses the words beside it. + - *a table column is padded to its display width*, counting a CJK + character as the two columns it occupies. Spine measures the width in + bytes and pads in codepoints, so any table with a non-ASCII cell is + misaligned, in opposite directions depending on which. + - *a table row is stripped at its own end*, not by stripping the whole + accumulated table after every cell, which in spine loses the newline + before a row that ends in an empty cell. + - *the table of contents indents two spaces per level.* Spine writes + ":" at level two and "∴" at level three, which read as content. + - *one code path for the object number*, where spine's quote, table and + general cases each built it again. + - *an unlisted object kind is written rather than dropped.* Spine's + dispatch is a series of ifs and an unknown kind falls through all of + them, so a new kind would vanish silently. ++/ + +import std.algorithm : max; +import std.array : appender, array; +import std.array : asplit = split; +import std.conv : to; +import std.regex : matchAll, matchFirst, replaceAll, split; +import std.string : strip; +import ocda.model; +import write.rgx; + +enum newline = "\n"; +enum newlines = "\n\n"; + +static Rgx rgx; + +/+ ↓ the table of contents depth of each indent_hang the document 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 gives 0, 1, 2, 3 and adapts to any + document without a table of special cases. ++/ +private size_t[int] toc_depth; + +/+ ↓ the whole document +/ +string writeText(ref OcdaDocument doc) { + toc_depth = tocDepths(doc); + auto out_ = appender!string; + foreach (name; textSections(doc)) { + foreach (obj; *doc.sectionByName(name)) { + if (obj.renders) { out_ ~= objectOut(obj); } + } + } + out_ ~= newline ~ tail(doc) ~ newline; + return out_.data; +} + +/+ ↓ rank the indent_hang values the table of contents uses +/ +private size_t[int] tocDepths(ref OcdaDocument doc) { + bool[int] seen; + auto toc = doc.sectionByName("toc"); + if (toc !is null) { + foreach (obj; *toc) { if (obj.renders) { seen[obj.indent_hang] = true; } } + } + import std.algorithm : sort; + auto levels = seen.keys.sort.array; + size_t[int] depth; + foreach (i, level; levels) { depth[level] = i; } + return depth; +} + +/+ ↓ 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. ++/ +string[] textSections(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; +} + +/+ ↓ one object, by kind. + . + The default writes the object rather than dropping it: a kind this + switch has not heard of is more likely to be new than to be unwanted, + and an output that loses it silently cannot be checked. ++/ +private string objectOut(ref const OcdaObject obj) { + /+ ↓ 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. +/ + if (obj.text.strip.length == 0) { return ""; } + /+ ↓ 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. +/ + if (obj.dummy_heading) { return ""; } + switch (obj.is_a) { + case "toc": return tocEntry(obj); + case "table": return table(obj); + case "bookindex": return indexEntry(obj); + case "code": return block(obj, obj.text.replaceAll(rgx.nbsp_char, " "), ""); + case "heading": return newline ~ general(obj); + default: return general(obj); + } +} + +/+ ↓ the usual case: the markers resolved, the notes gathered out of the + resolved text, and the rest written as text +/ +private string general(ref const OcdaObject obj) { + string resolved = links(images(obj.text)); + return block(obj, inline(resolved, obj), notesOf(resolved)); +} + +/+ ↓ the common shape: the text, the notes it carried, and its number +/ +private string block(ref const OcdaObject obj, string txt, string notes) { + return txt ~ notes ~ newline ~ ocnMark(obj) ~ newline; +} + +/+ ↓ the object's number, on a line of its own. An object without one gets a + blank line, which keeps the objects apart on the page. +/ +private string ocnMark(ref const OcdaObject obj) { + return (obj.ocn == 0) ? "" : "「" ~ obj.ocn.to!string ~ "」" ~ 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. ++/ +private string notesOf(string txt) { + auto out_ = appender!string; + foreach (m; txt.matchAll(rgx.inline_notes_al_all_note)) { + out_ ~= newlines ~ m["num"] ~ ". " ~ faces(breaks(m["note"].to!string)); + } + return out_.data; +} + +/+ ↓ a table of contents entry: two spaces per level, the text, and the + object it names +/ +private string tocEntry(ref const OcdaObject obj) { + string indent; + if (auto d = obj.indent_hang in toc_depth) { + foreach (_; 0 .. *d) { indent ~= " "; } + } + return indent ~ inline(links(images(obj.text)), obj) ~ newline; +} + +/+ ↓ a book index entry, whose links are to objects rather than to segments +/ +private string indexEntry(ref const OcdaObject obj) { + string txt = obj.text.replaceAll(rgx.trailing_backslash, ""); + return 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. ++/ +private string table(ref const OcdaObject obj) { + auto rows = obj.text.split(rgx.table_delimiter_row); + auto aligns = obj.table_aligns.asplit(" "); + auto widths = new size_t[](max(obj.table_cols, 1)); + string[][] body_; + string note; + foreach (row; rows) { + if (row.strip.length == 0) { continue; } + auto cells = row.split(rgx.table_delimiter_col); + if (cells.length == 1 && obj.table_cols > 1) { + note ~= inline(links(images(cells[0])), obj) ~ newline; + continue; + } + string[] out_row; + foreach (i, cell; cells) { + string c = inline(links(images(cell)), obj); + if (i < widths.length) { widths[i] = max(widths[i], displayWidth(c)); } + out_row ~= c; + } + body_ ~= out_row; + } + auto out_ = appender!string; + foreach (row; body_) { + auto line = appender!string; + foreach (i, cell; row) { + size_t w = (i < widths.length) ? widths[i] : displayWidth(cell); + string pad; + foreach (_; displayWidth(cell) .. w) { pad ~= " "; } + line ~= (i < aligns.length && aligns[i] != "l") ? pad ~ cell : cell ~ pad; + if (i + 1 < row.length) { line ~= " ┊ "; } + } + /+ ↓ stripped at the end of its own row, so a row that ends in an empty + cell keeps the line it is on +/ + out_ ~= line.data.stripRight ~ newline; + } + return out_.data ~ note ~ ocnMark(obj) ~ newline; +} + +/+ ↓ trailing spaces off one line +/ +private string stripRight(string s) { + size_t end = s.length; + while (end > 0 && (s[end - 1] == ' ' || s[end - 1] == '\t')) { end--; } + return s[0 .. end]; +} + +/+ ↓ 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. ++/ +private size_t displayWidth(string s) { + size_t w = 0; + foreach (dchar c; s) { w += wide(c) ? 2 : 1; } + return w; +} + +private bool wide(dchar c) { + return (c >= 0x1100 && c <= 0x115F) + || (c >= 0x2E80 && c <= 0x303E) + || (c >= 0x3041 && c <= 0x33FF) + || (c >= 0x3400 && c <= 0x4DBF) + || (c >= 0x4E00 && c <= 0x9FFF) + || (c >= 0xA000 && c <= 0xA4CF) + || (c >= 0xAC00 && c <= 0xD7A3) + || (c >= 0xF900 && c <= 0xFAFF) + || (c >= 0xFE30 && c <= 0xFE6F) + || (c >= 0xFF00 && c <= 0xFF60) + || (c >= 0xFFE0 && c <= 0xFFE6) + || (c >= 0x20000 && c <= 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 separately + by `notes`. ++/ +private string inline(string txt, ref const OcdaObject obj) { + txt = txt.replaceAll(rgx.inline_notes_al_all_note, "[$1]"); + /+ ↓ 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 +/ + txt = txt.replaceAll(rgx.inline_link_anchor, ""); + txt = faces(txt); + txt = groupIndents(txt, obj); + return breaks(txt); +} + +/+ ↓ 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. +/ +private string groupIndents(string txt, ref const OcdaObject obj) { + if (obj.is_a != "group" && obj.is_a != "block") { return txt; } + return txt + .replaceAll(rgx.grouped_para_indent_hang, "$2$2") + .replaceAll(rgx.grouped_para_bullet_at, "$1● ") + .replaceAll(rgx.grouped_para_bullet, "● ") + .replaceAll(rgx.grouped_para_indent, "$1$1"); +} + +/+ ↓ 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. ++/ +private string images(string txt) { + return replaceAll!((m) { + string rest = m["post"].to!string; + string alt; + if (auto a = rest.matchFirst(rgx.inline_image_alt)) { + alt = a["alt"].to!string; + rest = a.post.to!string; + } + return m["pre"].to!string ~ "{" ~ m["img"].to!string ~ "}" ~ alt ~ rest; + })(txt, rgx.inline_image); +} + +/+ ↓ 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". ++/ +private string links(string txt) { + txt = replaceAll!((m) { + string target = m.captures[3].to!string; + string text_ = m.captures[1].to!string; + return isObjectNumber(target) ? text_ ~ " ≫" ~ target : text_; + })(txt, rgx.inline_link); + return txt.replaceAll(rgx.mark_internal_site_lnk, ""); +} + +/+ ↓ a target that is an object number, and not a name or a url +/ +private bool isObjectNumber(string s) { + if (s.length == 0 || s == "0") { return false; } + foreach (c; s) { if (c < '0' || c > '9') { return false; } } + return true; +} + +/+ ↓ the font faces become the markup they were written as, which is what + makes the text re-editable rather than stripped +/ +private string faces(string txt) { + return txt + .replaceAll(rgx.inline_strike, "-{$1}-") + .replaceAll(rgx.inline_insert, "+{$1}+") + .replaceAll(rgx.inline_cite, "\"{$1}\"") + .replaceAll(rgx.inline_emphasis, "!{$1}!") + .replaceAll(rgx.inline_bold, "*{$1}*") + .replaceAll(rgx.inline_italics, "/{$1}/") + .replaceAll(rgx.inline_underscore, "_{$1}_") + .replaceAll(rgx.inline_superscript, "^{$1}^") + .replaceAll(rgx.inline_subscript, ",{$1},") + .replaceAll(rgx.inline_mono, "#{$1}#"); +} + +/+ ↓ the line breaks the markup asked for, and the non-breaking space +/ +private string breaks(string txt) { + return txt + .replaceAll(rgx.nbsp_char, " ") + .replaceAll(rgx.br_line, newline) + .replaceAll(rgx.br_line_inline, newline) + .replaceAll(rgx.br_line_spaced, newlines) + .replaceAll(rgx.line_break, newline); +} + +/+ ↓ 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. ++/ +private string tail(ref OcdaDocument doc) { + auto out_ = appender!string; + void put(string label, string key) { + if (doc.meta(key).length > 0) { + out_ ~= label ~ ": " ~ doc.meta(key) ~ newlines; + } + } + put("Title", "title.full"); + put("Author", "creator.author"); + put("Published", "date.published"); + put("Copyright", "rights.copyright"); + put("License", "rights.license"); + out_ ~= generator_name ~ newline ~ generator_url; + return out_.data; +} + +/+ ↓ 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. +/ +enum generator_name = "spine"; +enum generator_url = "https://sisudoc.org"; diff --git a/dlang/src/write/typst.d b/dlang/src/write/typst.d new file mode 100644 index 0000000..d240c6b --- /dev/null +++ b/dlang/src/write/typst.d @@ -0,0 +1,766 @@ +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 <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 + +/ + 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; +} diff --git a/dlang/src/write/zip.d b/dlang/src/write/zip.d new file mode 100644 index 0000000..cfd8419 --- /dev/null +++ b/dlang/src/write/zip.d @@ -0,0 +1,125 @@ +module write.zip; + +/+ ↓ writing a zip, stored only. + . + An epub is a zip with rules: the first entry must be "mimetype", stored, + with no extra field, so that a reading system can identify the file from + its first bytes without inflating anything. Everything else may be + deflated and does not have to be. + . + So: stored only. Deflate would need zlib and would buy compression on a + sample writer's output, which is not what is being demonstrated, and it + would put a second thing in the way of the two writers agreeing byte for + byte. What is written here is a valid zip that epubcheck accepts. + . + No timestamps. Every entry is dated 1980-01-01, the epoch of the format, + which is what makes the same document produce the same bytes twice. Spine + does the same. ++/ + +import std.array : appender; +import std.digest.crc : crc32Of; +import std.string : representation; + +struct ZipEntryOut { + string name; + ubyte[] data; +} + +/+ ↓ the archive, entries in the order given +/ +ubyte[] zipWrite(ZipEntryOut[] entries) { + auto out_ = appender!(ubyte[]); + uint[] offsets; + uint[] crcs; + foreach (e; entries) { + offsets ~= cast(uint) out_.data.length; + uint crc = crc32(e.data); + crcs ~= crc; + out_ ~= localHeader(e, crc); + out_ ~= e.data; + } + uint cd_offset = cast(uint) out_.data.length; + foreach (i, e; entries) { + out_ ~= centralEntry(e, crcs[i], offsets[i]); + } + uint cd_size = cast(uint) out_.data.length - cd_offset; + out_ ~= endRecord(cast(ushort) entries.length, cd_size, cd_offset); + return out_.data; +} + +private enum ushort method_stored = 0; +private enum ushort version_needed = 20; /+ 2.0, which stored requires +/ +private enum ushort dos_time = 0; /+ 00:00:00 +/ +private enum ushort dos_date = 0x0021; /+ 1980-01-01 +/ + +private ubyte[] localHeader(ZipEntryOut e, uint crc) { + auto b = appender!(ubyte[]); + b ~= u32(0x04034b50); + b ~= u16(version_needed); + b ~= u16(0); /+ flags: none; the name is ascii +/ + b ~= u16(method_stored); + b ~= u16(dos_time); + b ~= u16(dos_date); + b ~= u32(crc); + b ~= u32(cast(uint) e.data.length); + b ~= u32(cast(uint) e.data.length); + b ~= u16(cast(ushort) e.name.length); + b ~= u16(0); /+ no extra field, which "mimetype" requires +/ + b ~= cast(ubyte[]) e.name.dup.representation.dup; + return b.data; +} + +private ubyte[] centralEntry(ZipEntryOut e, uint crc, uint offset) { + auto b = appender!(ubyte[]); + b ~= u32(0x02014b50); + b ~= u16(version_needed); /+ made by +/ + b ~= u16(version_needed); + b ~= u16(0); + b ~= u16(method_stored); + b ~= u16(dos_time); + b ~= u16(dos_date); + b ~= u32(crc); + b ~= u32(cast(uint) e.data.length); + b ~= u32(cast(uint) e.data.length); + b ~= u16(cast(ushort) e.name.length); + b ~= u16(0); /+ extra +/ + b ~= u16(0); /+ comment +/ + b ~= u16(0); /+ disk +/ + b ~= u16(0); /+ internal attributes +/ + b ~= u32(0); /+ external attributes +/ + b ~= u32(offset); + b ~= cast(ubyte[]) e.name.dup.representation.dup; + return b.data; +} + +private ubyte[] endRecord(ushort count, uint cd_size, uint cd_offset) { + auto b = appender!(ubyte[]); + b ~= u32(0x06054b50); + b ~= u16(0); /+ this disk +/ + b ~= u16(0); /+ the disk the central directory starts on +/ + b ~= u16(count); + b ~= u16(count); + b ~= u32(cd_size); + b ~= u32(cd_offset); + b ~= u16(0); /+ no archive comment +/ + return b.data; +} + +private uint crc32(const(ubyte)[] data) { + auto d = crc32Of(data); + /+ ↓ crc32Of gives the four bytes little endian, which is the order the + header wants them in, so they are read back in that order +/ + return (cast(uint) d[0]) + | (cast(uint) d[1] << 8) + | (cast(uint) d[2] << 16) + | (cast(uint) d[3] << 24); +} + +private ubyte[] u16(ushort v) { + return [cast(ubyte)(v & 0xff), cast(ubyte)((v >> 8) & 0xff)]; +} + +private ubyte[] u32(uint v) { + return [cast(ubyte)(v & 0xff), cast(ubyte)((v >> 8) & 0xff), + cast(ubyte)((v >> 16) & 0xff), cast(ubyte)((v >> 24) & 0xff)]; +} |
