diff options
| author | Ralph Amissah <ralph.amissah@gmail.com> | 2026-09-07 11:18:52 -0400 |
|---|---|---|
| committer | Ralph Amissah <ralph.amissah@gmail.com> | 2026-09-09 17:30:06 -0400 |
| commit | 28b27ec45b9a592be3187d995b4355f483e53cc1 (patch) | |
| tree | 501b7fe115f36ae4c2f5634f4b1271cd5c19e1c3 /src/sisudoc | |
| parent | ssp: 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.d | 1 | ||||
| -rw-r--r-- | src/sisudoc/ocda/abstraction/ssp_in.d | 335 | ||||
| -rw-r--r-- | src/sisudoc/spine.d | 45 |
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() { |
