summaryrefslogtreecommitdiffhomepage
path: root/dlang
diff options
context:
space:
mode:
Diffstat (limited to 'dlang')
-rw-r--r--dlang/COPYRIGHT45
-rw-r--r--dlang/README.md85
-rw-r--r--dlang/dub.json15
-rw-r--r--dlang/flake.lock44
-rw-r--r--dlang/flake.nix62
-rw-r--r--dlang/src/COPYRIGHT45
-rw-r--r--dlang/src/main.d98
-rw-r--r--dlang/src/write/epub.d444
-rw-r--r--dlang/src/write/html.d781
-rw-r--r--dlang/src/write/markdown.d579
-rw-r--r--dlang/src/write/rgx.d104
-rw-r--r--dlang/src/write/text.d397
-rw-r--r--dlang/src/write/typst.d766
-rw-r--r--dlang/src/write/zip.d125
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, "&amp;")
+ .replaceAll(rgx_h.less_than, "&lt;")
+ .replaceAll(rgx_h.greater_than, "&gt;")
+ .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, "&amp;")
+ .replaceAll(rgx_h.quotation, "&quot;")
+ .replaceAll(rgx_h.less_than, "&lt;")
+ .replaceAll(rgx_h.greater_than, "&gt;");
+}
+
+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 &nbsp; 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_ ~= "&amp;"; break;
+ case '<': out_ ~= "&lt;"; break;
+ case '>': out_ ~= "&gt;"; 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("![" ~ inert(alt) ~ "](" ~ image_dir_mark ~ "/" ~ m["img"].to!string ~ ")")
+ ~ 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("&amp;", "&").replace("&lt;", "<").replace("&gt;", ">");
+}
+
+/+ ↓ 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)];
+}