. 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)
? " " : "")
~ 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_ ~= "";
out_ ~= ocnMark(obj);
out_ ~= " 0) {
out_ ~= " id=\"" ~ attr(obj.identifier) ~ "\"";
}
if (depth > 6) { out_ ~= " aria-level=\"" ~ depth.to!string ~ "\""; }
out_ ~= ">" ~ levelAnchor(obj, ids) ~ txt ~ " ";
out_ ~= "
\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)
? " "
: "";
}
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_ ~= "";
out_ ~= ocnMark(obj);
out_ ~= "
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_ ~= "● "; }
out_ ~= txt;
out_ ~= "
\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_ ~= "";
out_ ~= ocnMark(obj);
out_ ~= "<" ~ element ~ " class=\"" ~ cls ~ "\"";
if (obj.identifier.length > 0) {
out_ ~= " id=\"" ~ attr(obj.identifier) ~ "\"";
}
out_ ~= ">" ~ txt ~ "" ~ element ~ ">";
out_ ~= "
\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_ ~= "";
out_ ~= ocnMark(obj);
out_ ~= "
0) {
out_ ~= " id=\"" ~ attr(obj.identifier) ~ "\"";
}
out_ ~= "> 0) {
out_ ~= " class=\"language-" ~ attr(obj.code_syntax) ~ "\"";
}
out_ ~= ">" ~ escape(obj.text) ~ " ";
out_ ~= "
\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_ ~= "";
out_ ~= ocnMark(obj);
out_ ~= "
0) {
out_ ~= " id=\"" ~ attr(obj.identifier) ~ "\"";
}
out_ ~= ">";
if (widths.length > 0 && widths[0].length > 0) {
out_ ~= "";
foreach (w; widths) {
out_ ~= " ";
}
out_ ~= " ";
}
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 ? "" : " ";
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 ? " " : "";
first = false;
}
out_ ~= "
\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: 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 ""
~ n ~ " ";
}
/+ ↓ 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)
? " "
: " ";
}
return out_;
}
/+ ↓ the four characters html reserves, and nothing else.
Done first, over the whole text, so that a "<" the author wrote is a
"<" the reader sees and not the start of an element. The markup's own
markers are not html and survive it.
+/
string escape(string txt) {
return txt
.replaceAll(rgx_h.ampersand, "&")
.replaceAll(rgx_h.less_than, "<")
.replaceAll(rgx_h.greater_than, ">")
.replaceAll(rgx_h.nbsp_char, " ");
}
/+ ↓ the same, for a value that goes in an attribute +/
string attr(string txt) {
return txt
.replaceAll(rgx_h.ampersand, "&")
.replaceAll(rgx_h.quotation, """)
.replaceAll(rgx_h.less_than, "<")
.replaceAll(rgx_h.greater_than, ">");
}
private string fontFace(string txt) {
return txt
.replaceAll(rgx_h.inline_emphasis, "$1 ")
.replaceAll(rgx_h.inline_bold, "$1 ")
.replaceAll(rgx_h.inline_italics, "$1 ")
.replaceAll(rgx_h.inline_underscore, "$1 ")
.replaceAll(rgx_h.inline_superscript, "$1 ")
.replaceAll(rgx_h.inline_subscript, "$1 ")
.replaceAll(rgx_h.inline_mono, "$1")
.replaceAll(rgx_h.inline_strike, "$1")
.replaceAll(rgx_h.inline_insert, "$1 ")
.replaceAll(rgx_h.inline_cite, "$1 ");
}
/+ ↓ a note reference, 【 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 "" ~ mark ~ " ";
})(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
"#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: "¤.fnSuffix#", 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 ""
~ m.captures[1].to!string ~ " ";
})(txt, rgx_h.inline_link);
txt = txt.replaceAll(rgx_h.mark_internal_site_lnk, "");
return txt;
}
/+ ↓ an image, ┥☼,wh "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
~ " " ~ 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, " ");
}
/+ ↓ the line-leading markers of a grouped object.
.
The indent is a number, so it is written as a number, in a data
attribute the stylesheet reads. Spine writes runs of instead, up
to eighteen of them for one line, which puts presentation in the
content and cannot be undone by a reader that wants its own.
+/
private string groupIndents(string txt, ref const OcdaObject obj) {
if (obj.is_a != "group") { return txt; }
return txt
.replaceAll(rgx_h.grouped_para_indent_hang,
" ")
.replaceAll(rgx_h.grouped_para_bullet_at,
" "
~ "● ")
.replaceAll(rgx_h.grouped_para_bullet,
"● ")
.replaceAll(rgx_h.grouped_para_indent,
" ");
}
/+ ↓ 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, " \n")
.replaceAll(rgx_h.br_line_inline, " \n")
.replaceAll(rgx_h.br_line_spaced, " \n \n")
.replaceAll(rgx_h.line_break, " \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_ ~= "\n\n\n";
out_ ~= " \n";
out_ ~= " \n";
string title = m("title.full");
if (title.length == 0) { title = m("title.main"); }
string author = m("creator.author");
out_ ~= "" ~ escape(title)
~ (author.length > 0 ? ", " ~ escape(author) : "") ~ " \n";
void dc(string name, string value) {
if (value.length == 0) { return; }
out_ ~= " \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_ ~= " \n";
out_ ~= "\n";
out_ ~= "\n\n";
return out_.data;
}
private string tail(ref OcdaDocument doc) {
string m(string key) { return doc.meta(key); }
auto out_ = appender!string;
out_ ~= "\n\n\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; }
┃";