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
|
//! Package transformed XHTML content into a valid EPUB3 file (a ZIP with a
//! specific structure). The `mimetype` entry must be first and stored uncompressed.
const std = @import("std");
const assert = std.debug.assert;
const zip = @import("zip.zig");
/// Fixed modification timestamp for reproducible output.
const modified = "2021-01-01T00:00:00Z";
pub const Options = struct {
title: []const u8,
/// Version name, used in the title and as the unique identifier basis.
version: []const u8,
lang: []const u8 = "en",
/// Well-formed XHTML5 body (output of html.transform).
xhtml: []const u8,
/// `<ol>...</ol>` of TOC entries extracted from the page, or null. When
/// present it becomes the navigation document and the book's opening page.
toc: ?[]const u8 = null,
};
/// Build an EPUB3 archive from `opts`. Caller owns the returned bytes.
pub fn build(allocator: std.mem.Allocator, opts: Options) ![]u8 {
assert(opts.version.len > 0);
assert(opts.xhtml.len > 0);
var w = zip.Writer.init(allocator);
defer w.deinit();
// 1. mimetype — MUST be the first entry and stored uncompressed.
try w.addStored("mimetype", "application/epub+zip");
// 2. container.xml — points the reader at the package document.
try w.addStored("META-INF/container.xml", container_xml);
// 3. content.opf — package metadata, manifest, spine.
const opf = try buildOpf(allocator, opts);
defer allocator.free(opf);
try w.addStored("OEBPS/content.opf", opf);
// 4. nav.xhtml — minimal EPUB3 navigation document.
const nav = try buildNav(allocator, opts);
defer allocator.free(nav);
try w.addStored("OEBPS/nav.xhtml", nav);
// 5. index.xhtml — the transformed reference content.
try w.addStored("OEBPS/index.xhtml", opts.xhtml);
const bytes = try w.finish();
assert(std.mem.startsWith(u8, bytes, "PK"));
return bytes;
}
const container_xml =
\\<?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>
\\
;
fn buildOpf(allocator: std.mem.Allocator, opts: Options) ![]u8 {
return std.fmt.allocPrint(allocator,
\\<?xml version="1.0" encoding="utf-8"?>
\\<package xmlns="http://www.idpf.org/2007/opf" version="3.0" unique-identifier="pub-id">
\\ <metadata xmlns:dc="http://purl.org/dc/elements/1.1/">
\\ <dc:identifier id="pub-id">urn:ziglang-docs:{s}</dc:identifier>
\\ <dc:title>{s} ({s})</dc:title>
\\ <dc:language>{s}</dc:language>
\\ <meta property="dcterms:modified">{s}</meta>
\\ </metadata>
\\ <manifest>
\\ <item id="nav" href="nav.xhtml" media-type="application/xhtml+xml" properties="nav"/>
\\ <item id="index" href="index.xhtml" media-type="application/xhtml+xml"/>
\\ </manifest>
\\ <spine>
\\ <itemref idref="nav"/>
\\ <itemref idref="index"/>
\\ </spine>
\\</package>
\\
, .{ opts.version, opts.title, opts.version, opts.lang, modified });
}
fn buildNav(allocator: std.mem.Allocator, opts: Options) ![]u8 {
// Use the page's own table of contents when we extracted one; otherwise fall
// back to a single link to the whole document.
const list = opts.toc orelse
"<ol>\n <li><a href=\"index.xhtml\">Contents</a></li>\n </ol>";
return std.fmt.allocPrint(allocator,
\\<?xml version="1.0" encoding="utf-8"?>
\\<html xmlns="http://www.w3.org/1999/xhtml" xmlns:epub="http://www.idpf.org/2007/ops" lang="{s}">
\\ <head><title>{s} ({s})</title></head>
\\ <body>
\\ <nav epub:type="toc" id="toc">
\\ <h1>{s} ({s})</h1>
\\ {s}
\\ </nav>
\\ </body>
\\</html>
\\
, .{ opts.lang, opts.title, opts.version, opts.title, opts.version, list });
}
test "epub starts with PK and mimetype, and round-trips" {
const gpa = std.testing.allocator;
const bytes = try build(gpa, .{
.title = "Zig Language Reference",
.version = "0.15.2",
.xhtml = "<?xml version=\"1.0\"?>\n<html xmlns=\"http://www.w3.org/1999/xhtml\"><body>hi</body></html>",
.toc = "<ol><li><a href=\"index.xhtml#Intro\">Intro</a></li></ol>",
});
defer gpa.free(bytes);
try std.testing.expect(std.mem.startsWith(u8, bytes, "PK"));
// mimetype must be the first entry, stored, with its content right after the
// local header + filename (offset 30 + len("mimetype") == 38).
try std.testing.expectEqualStrings("mimetype", bytes[30..38]);
try std.testing.expectEqualStrings("application/epub+zip", bytes[38..58]);
var tmp = std.testing.tmpDir(.{});
defer tmp.cleanup();
try tmp.dir.writeFile(.{ .sub_path = "b.epub", .data = bytes });
var file = try tmp.dir.openFile("b.epub", .{});
defer file.close();
var buf: [4096]u8 = undefined;
var fr = file.reader(&buf);
try std.zip.extract(tmp.dir, &fr, .{});
const opf = try tmp.dir.readFileAlloc(gpa, "OEBPS/content.opf", 1 << 16);
defer gpa.free(opf);
try std.testing.expect(std.mem.indexOf(u8, opf, "0.15.2") != null);
// nav must come before index in the spine (TOC is the opening page).
const nav_ref = std.mem.indexOf(u8, opf, "idref=\"nav\"").?;
const idx_ref = std.mem.indexOf(u8, opf, "idref=\"index\"").?;
try std.testing.expect(nav_ref < idx_ref);
const nav = try tmp.dir.readFileAlloc(gpa, "OEBPS/nav.xhtml", 1 << 16);
defer gpa.free(nav);
try std.testing.expect(std.mem.indexOf(u8, nav, "href=\"index.xhtml#Intro\"") != null);
}
|