aboutsummaryrefslogtreecommitdiffhomepage
path: root/src/sisudoc
diff options
context:
space:
mode:
authorRalph Amissah <ralph.amissah@gmail.com>2026-09-07 11:18:52 -0400
committerRalph Amissah <ralph.amissah@gmail.com>2026-09-09 17:30:06 -0400
commit28b27ec45b9a592be3187d995b4355f483e53cc1 (patch)
tree501b7fe115f36ae4c2f5634f4b1271cd5c19e1c3 /src/sisudoc
parentssp: definition of an object record, for readers (diff)
ocda: a reader for .ssp, and a round trip check
sisudoc.ocda.abstraction.ssp_in reads a .ssp file back into ObjGenericComposite[][string], the same value the parser produces, so anything that consumes the abstraction can be fed from a .ssp instead of from markup. The three header blocks come back as key/value with their order preserved. --ssp-round-trip=<file.ssp> loads a file and emits it again on stdout, using sspObjectRecord, the writer's own definition of a record. So the check is against the writer, not against a second description of the format: ./bin/spine-ldc --ssp-round-trip=test/reference/abstraction/<doc>.ssp \ | diff test/reference/abstraction/<doc>.ssp - BUG as yet to FIX 27 of the 35 reference documents round trip byte identically. The other 8 fail on two defects in the *writer* that the round trip found, and which are left for a decision: 1. .heading_ancestors_text and .lev4_subtoc can carry a raw newline, because a heading's text may contain a line break. The value then spans two physical lines and the format's rule that a value runs to the end of the line is broken. 194 and 22 occurrences, in the seven live-manual translations. 2. .heading_ancestors_text joins its eight slots with "|" while the text in them may itself contain "|". 22 occurrences in revisiting_the_autonomous_contract. Both need an escape (or normalisation at source) and both change the .ssp, so require a decision and another reference regeneration. ocda: export the .ssp reader from the abstraction package package.d is the re-export surface for consumers that want to reach the abstraction without depending on the directory layout; the reader belongs there beside the writer. (assisted by Claude-Code)
Diffstat (limited to 'src/sisudoc')
-rw-r--r--src/sisudoc/ocda/abstraction/package.d1
-rw-r--r--src/sisudoc/ocda/abstraction/ssp_in.d335
-rw-r--r--src/sisudoc/spine.d45
3 files changed, 381 insertions, 0 deletions
diff --git a/src/sisudoc/ocda/abstraction/package.d b/src/sisudoc/ocda/abstraction/package.d
index 4b34b3a..6281f6e 100644
--- a/src/sisudoc/ocda/abstraction/package.d
+++ b/src/sisudoc/ocda/abstraction/package.d
@@ -84,3 +84,4 @@ module sisudoc.ocda.abstraction;
public import sisudoc.ocda.meta.metadoc; // spineAbstraction (A-layer)
public import sisudoc.ocda.meta.metadoc_from_src; // docAbstraction (B-layer)
public import sisudoc.ocda.abstraction.ssp; // spineAbstractionTxt (.ssp)
+public import sisudoc.ocda.abstraction.ssp_in; // spineAbstractionRead (.ssp back in)
diff --git a/src/sisudoc/ocda/abstraction/ssp_in.d b/src/sisudoc/ocda/abstraction/ssp_in.d
new file mode 100644
index 0000000..b393ac0
--- /dev/null
+++ b/src/sisudoc/ocda/abstraction/ssp_in.d
@@ -0,0 +1,335 @@
+/+
+- Name: SisuDoc Spine, Doc Reform [a part of]
+ - Description: documents, structuring, processing, publishing, search
+ - static content generator
+
+ - Author: Ralph Amissah
+ [ralph.amissah@gmail.com]
+
+ - Copyright: (C) 2015 (continuously updated, current 2026) Ralph Amissah, All Rights Reserved.
+
+ - License: AGPL 3 or later:
+
+ Spine (SiSU), a framework for document structuring, 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 AFERO General Public License 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 General Public License for
+ more details.
+
+ You should have received a copy of the GNU 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 these locations:
+ [https://www.fsf.org/licensing/licenses/agpl.html]
+ [https://www.gnu.org/licenses/agpl.html]
+
+ - Spine (by Doc Reform, related to SiSU) uses standard:
+ - docReform markup syntax
+ - standard SiSU markup syntax with modified headers and minor modifications
+ - docReform object numbering
+ - standard SiSU object citation numbering & system
+
+ - Homepages:
+ [https://www.sisudoc.org]
+ [https://www.doc-reform.org]
+
+ - Git
+ [https://git.sisudoc.org/]
+
++/
+module sisudoc.ocda.abstraction.ssp_in;
+@safe:
+/+ ↓ read a .ssp file back into the document abstraction
+
+ the reverse of sisudoc.ocda.abstraction.ssp. what it returns is the same
+ value the parser produces, ObjGenericComposite[][string], so anything
+ that consumes the abstraction can be fed from a .ssp instead of from
+ markup.
+
+ the check that it is faithful is a round trip: load a .ssp, emit each
+ object again with sspObjectRecord (the writer's own definition of a
+ record) and require the result to be byte identical to the file read.
++/
+template spineAbstractionRead() {
+ import std.algorithm : startsWith;
+ import std.array;
+ import std.conv : to;
+ import std.file;
+ import std.json : JSONValue;
+ import std.stdio;
+ import std.string;
+ import sisudoc.ocda.meta.metadoc_object_setter;
+ mixin ObjectSetter;
+ /+ ↓ what a .ssp file holds: the three header blocks as key/value, and the
+ object sections in the order the file gives them +/
+ struct SSPdocument {
+ string format; // "% SiSU Document Abstraction v0.1"
+ string source; // "% Source: ..."
+ string[string] meta;
+ string[] meta_order;
+ string[string] make;
+ string[] make_order;
+ string[string] doc_has;
+ string[] doc_has_order;
+ string[] section_order; // as found in the file
+ ObjGenericComposite[][string] abstraction;
+ }
+ private int _levOfMarkedUp(string lev) {
+ switch (lev) {
+ case "A": return 0;
+ case "B": return 1;
+ case "C": return 2;
+ case "D": return 3;
+ case "1": return 4;
+ case "2": return 5;
+ case "3": return 6;
+ case "4": return 7;
+ default: return 9;
+ }
+ }
+ private int[] _ints(string val) {
+ int[] _out;
+ foreach (f; val.split(" ")) {
+ if (f.length > 0) { _out ~= f.to!int; }
+ }
+ return _out;
+ }
+ /+ ↓ the eight slot arrays are int[] with a literal default, so every
+ default constructed object shares one array; write into a fresh one +/
+ private int[] _eight(string val) {
+ int[] _out = [ 0, 0, 0, 0, 0, 0, 0, 0, ];
+ foreach (i, v; _ints(val)) {
+ if (i < _out.length) { _out[i] = v; }
+ }
+ return _out;
+ }
+ private ubyte[32] _hexToBytes(string hex) {
+ ubyte[32] _out;
+ if (hex.length < 64) { return _out; }
+ foreach (i; 0..32) {
+ _out[i] = hex[i*2 .. i*2+2].to!ubyte(16);
+ }
+ return _out;
+ }
+ SSPdocument sspRead(string[] lines) {
+ SSPdocument doc;
+ string section; // the @section block currently open
+ string block; // "meta", "make", "doc_has", or "" for objects
+ bool in_object;
+ string[] text_lines;
+ ObjGenericComposite obj;
+ void _closeObject() {
+ if (!in_object) { return; }
+ obj.text = text_lines.join("\n");
+ doc.abstraction[section] ~= obj;
+ obj = ObjGenericComposite();
+ text_lines = [];
+ in_object = false;
+ }
+ foreach (line; lines) {
+ if (line.length == 0) { continue; }
+ if (line.startsWith("% SiSU Document Abstraction")) {
+ doc.format = line; continue;
+ }
+ if (line.startsWith("% Source: ")) {
+ doc.source = line["% Source: ".length .. $]; continue;
+ }
+ if (line.startsWith("@") && line.endsWith(" {")) {
+ _closeObject();
+ string _name = line[1 .. $-2];
+ switch (_name) {
+ case "meta": case "make": case "doc_has":
+ block = _name; section = ""; break;
+ default:
+ block = ""; section = _name;
+ doc.section_order ~= _name;
+ doc.abstraction[_name] = [];
+ break;
+ }
+ continue;
+ }
+ if (line == "}") { _closeObject(); block = ""; section = ""; continue; }
+ if (block.length > 0) { // a header block key: value line
+ auto _kv = line.stripLeft.findSplit(": "); // leading only: a value may end in a space
+ if (_kv[1].length > 0) {
+ final switch (block) {
+ case "meta":
+ doc.meta[_kv[0].to!string] = _kv[2].to!string;
+ doc.meta_order ~= _kv[0].to!string;
+ break;
+ case "make":
+ doc.make[_kv[0].to!string] = _kv[2].to!string;
+ doc.make_order ~= _kv[0].to!string;
+ break;
+ case "doc_has":
+ doc.doc_has[_kv[0].to!string] = _kv[2].to!string;
+ doc.doc_has_order ~= _kv[0].to!string;
+ break;
+ }
+ }
+ continue;
+ }
+ if (line.startsWith("[")) { // object declaration line
+ _closeObject();
+ in_object = true;
+ auto _close = line.indexOf("] ");
+ obj.metainfo.ocn = line[1 .. _close].to!int;
+ string _rest = line[_close + 2 .. $];
+ if (_rest.startsWith("heading :")) {
+ obj.metainfo.is_a = "heading";
+ string _tail = _rest["heading :".length .. $];
+ auto _sp = _tail.indexOf(" ");
+ if (_sp > 0) {
+ obj.metainfo.heading_lev_markup = _levOfMarkedUp(_tail[0 .. _sp]);
+ obj.metainfo.identifier = _tail[_sp + 1 .. $];
+ } else {
+ obj.metainfo.heading_lev_markup = _levOfMarkedUp(_tail);
+ obj.metainfo.identifier = obj.metainfo.ocn.to!string;
+ }
+ } else {
+ obj.metainfo.is_a = _rest;
+ obj.metainfo.identifier = obj.metainfo.ocn.to!string;
+ }
+ obj.metainfo.is_of_section = section;
+ continue;
+ }
+ if (line.startsWith("| ")) { text_lines ~= line[2 .. $]; continue; }
+ if (!line.startsWith(".")) { continue; }
+ auto _p = line[1 .. $].findSplit(": ");
+ string _key = (_p[1].length > 0) ? _p[0].to!string : line[1 .. $].to!string;
+ string _val = (_p[1].length > 0) ? _p[2].to!string : "";
+ switch (_key) {
+ case "identifier": obj.metainfo.identifier = _val; break;
+ case "part": obj.metainfo.is_of_part = _val; break;
+ case "section": obj.metainfo.is_of_section = _val; break;
+ case "parent": obj.metainfo.parent_ocn = _val.to!int; break;
+ case "last_descendant": obj.metainfo.last_descendant_ocn = _val.to!int; break;
+ case "children": obj.metainfo.children_headings = _ints(_val); break;
+ case "ancestors":
+ obj.metainfo.markedup_ancestors = _eight(_val);
+ break;
+ case "ancestors_collapsed":
+ obj.metainfo.collapsed_ancestors = _eight(_val);
+ break;
+ case "dom_status":
+ obj.metainfo.dom_structure_markedup_tags_status = _eight(_val);
+ break;
+ case "dom_status_collapsed":
+ obj.metainfo.dom_structure_collapsed_tags_status = _eight(_val);
+ break;
+ case "heading_lev_collapsed": obj.metainfo.heading_lev_collapsed = _val.to!int; break;
+ case "parent_lev": obj.metainfo.parent_lev_markup = _val.to!int; break;
+ case "dummy": obj.metainfo.dummy_heading = true; break;
+ case "ocn_off": obj.metainfo.object_number_off = true; break;
+ case "is_of_type": obj.metainfo.is_of_type = _val; break;
+ case "attrib": obj.metainfo.attrib = _val; break;
+ case "meta_lang": obj.metainfo.lang = _val; break;
+ case "meta_syntax": obj.metainfo.syntax = _val; break;
+ case "sha256": obj.metainfo.sha256.text = _hexToBytes(_val); break;
+ case "image":
+ {
+ ST_file_name_hash_size_ _img;
+ auto _fields = _val.split(" ");
+ if (_fields.length > 0) { _img.fileName = _fields[0].to!string; }
+ foreach (f; _fields[1 .. $]) {
+ if (f == "missing:true") { _img.fileMissing = true; }
+ else if (f.startsWith("sha256:")) { _img.fileHash_sha256 = _hexToBytes(f[7 .. $].to!string); }
+ else if (f.startsWith("bytes:")) { _img.fileSize = f[6 .. $].to!ulong; }
+ else if (f.startsWith("px:")) {
+ auto _wh = f[3 .. $].findSplit("x");
+ if (_wh[1].length > 0) {
+ _img.imageWidth = _wh[0].to!int;
+ _img.imageHeight = _wh[2].to!int;
+ }
+ }
+ }
+ obj.metainfo.sha256.images ~= _img;
+ }
+ break;
+ case "indent":
+ {
+ auto _i = _ints(_val);
+ if (_i.length > 0) { obj.attrib.indent_base = _i[0]; }
+ if (_i.length > 1) { obj.attrib.indent_hang = _i[1]; }
+ }
+ break;
+ case "bullet": obj.attrib.bullet = true; break;
+ case "lang": obj.attrib.language = _val; break;
+ case "has":
+ foreach (f; _val.split(" ")) {
+ switch (f) {
+ case "links": obj.has.inline_links = true; break;
+ case "notes_reg": obj.has.inline_notes_reg = true; break;
+ case "notes_star": obj.has.inline_notes_star = true; break;
+ case "images": obj.has.images = true; break;
+ case "images_no_dim": obj.has.image_without_dimensions = true; break;
+ default: break;
+ }
+ }
+ break;
+ case "table_cols": obj.table.number_of_columns = _val.to!int; break;
+ case "table_widths":
+ foreach (w; _val.split(" ")) { obj.table.column_widths ~= w.to!double; }
+ break;
+ case "table_aligns":
+ foreach (a; _val.split(" ")) { obj.table.column_aligns ~= a.to!string; }
+ break;
+ case "table_header": obj.table.heading = true; break;
+ case "code_linenumbers": obj.code_block.linenumbers = true; break;
+ case "stow_link": obj.stow.link ~= _val; break;
+ case "segment": obj.tags.in_segment_html = _val; break;
+ case "anchor": obj.tags.anchor_tag_html = _val; break;
+ case "segment_prev": obj.tags.segname_prev = _val; break;
+ case "segment_next": obj.tags.segname_next = _val; break;
+ case "heading_lev_anchor": obj.tags.heading_lev_anchor_tag = _val; break;
+ case "segment_epub": obj.tags.segment_anchor_tag_epub = _val; break;
+ case "segment_html_is": obj.tags.html_segment_anchor_tag_is = _val; break;
+ case "segment_epub_is": obj.tags.epub_segment_anchor_tag_is = _val; break;
+ case "heading_ancestors_text":
+ {
+ string[] _h = [ "", "", "", "", "", "", "", "", ];
+ foreach (i, h; _val.split("|")) {
+ if (i < _h.length) { _h[i] = h.to!string; }
+ }
+ obj.tags.heading_ancestors_text = _h;
+ }
+ break;
+ case "lev4_subtoc": obj.tags.lev4_subtoc ~= _val; break;
+ case "anchor_tag": obj.tags.anchor_tags ~= _val; break;
+ default: break;
+ }
+ }
+ _closeObject();
+ /+ ↓ .anchor is written only when it differs from .segment, and .section
+ only when it differs from the enclosing block; restore both +/
+ foreach (sect; doc.section_order) {
+ foreach (ref o; doc.abstraction[sect]) {
+ if (o.tags.anchor_tag_html.length == 0
+ && o.tags.in_segment_html.length > 0) {
+ o.tags.anchor_tag_html = o.tags.in_segment_html;
+ }
+ }
+ }
+ return doc;
+ }
+ SSPdocument sspReadFile(string file_path) {
+ string[] _lines;
+ try {
+ _lines = (cast(char[]) file_path.read).to!string.split("\n");
+ } catch (Exception ex) {
+ writeln("ERROR: could not read ", file_path, ": ", ex.msg);
+ return SSPdocument();
+ }
+ return sspRead(_lines);
+ }
+}
diff --git a/src/sisudoc/spine.d b/src/sisudoc/spine.d
index 17950d0..9ceb069 100644
--- a/src/sisudoc/spine.d
+++ b/src/sisudoc/spine.d
@@ -213,6 +213,7 @@ string program_name = "spine";
];
string[string] settings = [
"output" : "",
+ "ssp-round-trip" : "",
"www-http" : "",
"www-host" : "",
"www-host-doc-root" : "",
@@ -273,6 +274,7 @@ string program_name = "spine";
"odf", "open document format text (--odt)", &opts["odf"],
"odt", "open document format text", &opts["odt"],
"output", "=/path/to/output/dir specify where to place output", &settings["output"],
+ "ssp-round-trip", "=/path/to/file.ssp read a .ssp back and re-emit it on stdout", &settings["ssp-round-trip"],
"parallel", "parallelisation", &opts["parallel"],
"parallel-subprocesses", "nested parallelisation", &opts["parallel-subprocesses"],
"pdf", "latex output for pdfs", &opts["pdf"],
@@ -352,6 +354,49 @@ string program_name = "spine";
if (helpInfo.helpWanted) {
defaultGetoptPrinter("Some information about the program.", helpInfo.options);
}
+ /+ ↓ read a .ssp back into the abstraction and emit it again, on stdout.
+ the two should be byte identical: that is the check that the reader is
+ faithful, and it is made against the writer's own record definition,
+ not against a second description of the format
+ +/
+ if (settings["ssp-round-trip"].length > 0) {
+ import sisudoc.ocda.abstraction.ssp;
+ import sisudoc.ocda.abstraction.ssp_in;
+ mixin spineAbstractionRead;
+ mixin sspObjectRecord;
+ auto _doc = sspReadFile(settings["ssp-round-trip"]);
+ string[] _out;
+ if (_doc.format.length > 0) { _out ~= _doc.format; }
+ if (_doc.source.length > 0) { _out ~= "% Source: " ~ _doc.source; }
+ _out ~= "";
+ void _block(string _name, string[] _keys, string[string] _kv) {
+ _out ~= "@" ~ _name ~ " {";
+ foreach (k; _keys) {
+ if (k in _kv) { _out ~= " " ~ k ~ ": " ~ _kv[k]; }
+ }
+ _out ~= "}";
+ _out ~= "";
+ }
+ _block("meta", _doc.meta_order, _doc.meta);
+ _block("make", _doc.make_order, _doc.make);
+ _block("doc_has", _doc.doc_has_order, _doc.doc_has);
+ foreach (section; _doc.section_order) {
+ _out ~= "@" ~ section ~ " {";
+ _out ~= "";
+ foreach (obj; _doc.abstraction[section]) {
+ _out ~= sspObjectRecord(obj, section);
+ }
+ _out ~= "}";
+ _out ~= "";
+ }
+ foreach (line; _out) { writeln(line); }
+ /+ ↓ the document goes to stdout, so the run-complete banner that
+ scope(success) prints would be part of it; leave without it
+ +/
+ import core.stdc.stdlib : exit;
+ stdout.flush;
+ exit(0);
+ }
enum outTask { source_or_pod, sqlite, sqlite_multi, latex, odt, epub, html_scroll, html_seg, html_stuff, text, skel }
struct OptActions {
@trusted bool allow_downloads() {