1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
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+|[*+]+[0-9]*)\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;
}
|