aboutsummaryrefslogtreecommitdiffhomepage
path: root/src/sisudoc
diff options
context:
space:
mode:
authorRalph Amissah <ralph.amissah@gmail.com>2026-09-07 22:30:03 -0400
committerRalph Amissah <ralph.amissah@gmail.com>2026-09-09 17:45:07 -0400
commitda575bf8a80cc2900bd5984718485cdacca0f527 (patch)
treecaf36b04621a87d6f063914bd2a62340a11d401c /src/sisudoc
parentocda: the eight slot arrays are fixed length (diff)
ocda: clean heading text used for navigation
heading text used for navigation is normalised, and | escaped (assisted by Claude-Code)
Diffstat (limited to 'src/sisudoc')
-rw-r--r--src/sisudoc/ocda/abstraction/ssp.d49
-rw-r--r--src/sisudoc/ocda/abstraction/ssp_in.d25
-rw-r--r--src/sisudoc/ocda/meta/metadoc_from_src_functions.d43
3 files changed, 97 insertions, 20 deletions
diff --git a/src/sisudoc/ocda/abstraction/ssp.d b/src/sisudoc/ocda/abstraction/ssp.d
index 181e785..6aa55c2 100644
--- a/src/sisudoc/ocda/abstraction/ssp.d
+++ b/src/sisudoc/ocda/abstraction/ssp.d
@@ -50,6 +50,48 @@
module sisudoc.ocda.abstraction.ssp;
@safe:
/+ ↓ write document abstraction as human-readable .ssp text file +/
+/+ ↓ escaping for values that are folded onto one line
+
+ a .ssp property value runs to the end of its line, and
+ .heading_ancestors_text joins its eight slots with "|", so a slot that
+ itself contains "|", a backslash or a line break has to be escaped or
+ the value cannot be read back. the pair here is the whole of the rule,
+ used by the writer and by the reader.
++/
+template sspEscape() {
+ import std.array : replace;
+ string sspEscapeValue(string _s) {
+ return _s
+ .replace("\\", "\\\\")
+ .replace("|", "\\|")
+ .replace("\n", "\\n")
+ .replace("\r", "\\r");
+ }
+ string[] sspUnescapeSplit(string _s) {
+ string[] _out;
+ string _cur;
+ bool _esc;
+ foreach (c; _s) {
+ if (_esc) {
+ switch (c) {
+ case 'n': _cur ~= '\n'; break;
+ case 'r': _cur ~= '\r'; break;
+ default: _cur ~= c; break; // covers \\ and \|
+ }
+ _esc = false;
+ } else if (c == '\\') {
+ _esc = true;
+ } else if (c == '|') {
+ _out ~= _cur;
+ _cur = "";
+ } else {
+ _cur ~= c;
+ }
+ }
+ _out ~= _cur;
+ return _out;
+ }
+}
/+ ↓ one object record: the single definition of what an object looks
like in a .ssp file. the writer emits with it, and the reader is
checked against it by round trip, so the two cannot drift
@@ -60,6 +102,7 @@ template sspObjectRecord() {
import std.stdio;
import std.string;
import std.array;
+ mixin sspEscape;
string[] sspObjectRecord(O)(O obj, string section, bool _vox = false) {
string[] output;
/+ ↓ object declaration line. a heading carries its identifier here,
@@ -328,7 +371,11 @@ template sspObjectRecord() {
if (h.length > 0) { has_hat = true; break; }
}
if (has_hat) {
- output ~= ".heading_ancestors_text: " ~ obj.tags.heading_ancestors_text.join("|");
+ string[] _hat;
+ foreach (h; obj.tags.heading_ancestors_text) {
+ _hat ~= sspEscapeValue(h.to!string);
+ }
+ output ~= ".heading_ancestors_text: " ~ _hat.join("|");
}
}
/+ ↓ lev4 subtoc +/
diff --git a/src/sisudoc/ocda/abstraction/ssp_in.d b/src/sisudoc/ocda/abstraction/ssp_in.d
index b393ac0..f0fb963 100644
--- a/src/sisudoc/ocda/abstraction/ssp_in.d
+++ b/src/sisudoc/ocda/abstraction/ssp_in.d
@@ -50,12 +50,12 @@
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.
@@ -69,9 +69,12 @@ template spineAbstractionRead() {
import std.stdio;
import std.string;
import sisudoc.ocda.meta.metadoc_object_setter;
+ import sisudoc.ocda.abstraction.ssp : sspEscape;
+ mixin sspEscape;
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 +/
+ object sections in the order the file gives them
+ +/
struct SSPdocument {
string format; // "% SiSU Document Abstraction v0.1"
string source; // "% Source: ..."
@@ -105,9 +108,10 @@ template spineAbstractionRead() {
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, ];
+ default constructed object shares one array; write into a fresh one
+ +/
+ private int[8] _eight(string val) {
+ int[8] _out;
foreach (i, v; _ints(val)) {
if (i < _out.length) { _out[i] = v; }
}
@@ -297,9 +301,9 @@ template spineAbstractionRead() {
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; }
+ string[8] _h;
+ foreach (i, h; sspUnescapeSplit(_val)) {
+ if (i < _h.length) { _h[i] = h; }
}
obj.tags.heading_ancestors_text = _h;
}
@@ -311,7 +315,8 @@ template spineAbstractionRead() {
}
_closeObject();
/+ ↓ .anchor is written only when it differs from .segment, and .section
- only when it differs from the enclosing block; restore both +/
+ 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
diff --git a/src/sisudoc/ocda/meta/metadoc_from_src_functions.d b/src/sisudoc/ocda/meta/metadoc_from_src_functions.d
index 9e9f8ba..f51f6fe 100644
--- a/src/sisudoc/ocda/meta/metadoc_from_src_functions.d
+++ b/src/sisudoc/ocda/meta/metadoc_from_src_functions.d
@@ -144,41 +144,65 @@ template docAbstractionFunctions() {
invariant() {
}
}
- pure ObjGenericComposite obj_heading_ancestors()(
+ /+ ↓ a heading's text as it is used for navigation, in
+ tags.heading_ancestors_text and in the lev4 subtoc entries.
+ .
+ these are one line labels, so what is dropped is what cannot be shown
+ on one line and means nothing there: the note itself (the reference
+ marker goes with it), the line break markers, and a line break that
+ came from a heading wrapped over two lines in the source. what is
+ kept is the text and its inline markup.
+ +/
+ string heading_text_for_navigation()(string _txt) {
+ mixin InternalMarkup;
+ static auto mkup = InlineMarkup();
+ static auto rgx = RgxI();
+ string _out = _txt
+ .replaceAll(rgx.inline_notes_al, "") // 【...】 and its marker
+ .replaceAll(rgx.inline_notes_curly_gen, "") // ~{...}~ if any remains
+ .replace(mkup.br_line, " ")
+ .replace(mkup.br_line_inline, " ")
+ .replace(mkup.br_line_spaced, " ")
+ .replace("\n", " ")
+ .replace("\r", " ");
+ while (_out.canFind(" ")) { _out = _out.replace(" ", " "); }
+ return _out.strip;
+ }
+ ObjGenericComposite obj_heading_ancestors()(
ObjGenericComposite obj,
string[] lv_ancestors_txt,
) {
switch (obj.metainfo.heading_lev_markup) {
case 0:
- lv_ancestors_txt[0] = obj.text.to!string;
+ lv_ancestors_txt[0] = heading_text_for_navigation(obj.text.to!string);
foreach(k; 1..8) { lv_ancestors_txt[k] = ""; }
goto default;
case 1:
- lv_ancestors_txt[1] = obj.text.to!string;
+ lv_ancestors_txt[1] = heading_text_for_navigation(obj.text.to!string);
foreach(k; 2..8) { lv_ancestors_txt[k] = ""; }
goto default;
case 2:
- lv_ancestors_txt[2] = obj.text.to!string;
+ lv_ancestors_txt[2] = heading_text_for_navigation(obj.text.to!string);
foreach(k; 3..8) { lv_ancestors_txt[k] = ""; }
goto default;
case 3:
- lv_ancestors_txt[3] = obj.text.to!string;
+ lv_ancestors_txt[3] = heading_text_for_navigation(obj.text.to!string);
foreach(k; 4..8) { lv_ancestors_txt[k] = ""; }
goto default;
case 4:
- lv_ancestors_txt[4] = obj.text.to!string;
+ lv_ancestors_txt[4] = heading_text_for_navigation(obj.text.to!string);
foreach(k; 5..8) { lv_ancestors_txt[k] = ""; }
goto default;
case 5:
- lv_ancestors_txt[5] = obj.text.to!string;
+ lv_ancestors_txt[5] = heading_text_for_navigation(obj.text.to!string);
foreach(k; 6..8) { lv_ancestors_txt[k] = ""; }
goto default;
case 6:
- lv_ancestors_txt[6] = obj.text.to!string;
+ lv_ancestors_txt[6] = heading_text_for_navigation(obj.text.to!string);
lv_ancestors_txt[7] = "";
goto default;
case 7:
- lv_ancestors_txt[7] = obj.text.to!string;
+ lv_ancestors_txt[7] = heading_text_for_navigation(obj.text.to!string);
goto default;
default:
obj.tags.heading_ancestors_text = lv_ancestors_txt.dup;
@@ -2671,6 +2695,7 @@ template docAbstractionFunctions() {
char[] heading_toc_ = (obj_["substantive"].dup.strip.to!(char[]))
.replaceAll(rgx.inline_notes_al, "");
heading_toc_ = _clean_heading_toc_(heading_toc_);
+ heading_toc_ = heading_text_for_navigation(heading_toc_.to!string).to!(char[]);
auto attrib = "";
string toc_txt_, subtoc_txt_;
int[string] indent;