aboutsummaryrefslogtreecommitdiffstats
path: root/src/epub.zig
diff options
context:
space:
mode:
Diffstat (limited to 'src/epub.zig')
-rw-r--r--src/epub.zig129
1 files changed, 129 insertions, 0 deletions
diff --git a/src/epub.zig b/src/epub.zig
new file mode 100644
index 0000000..0bdf563
--- /dev/null
+++ b/src/epub.zig
@@ -0,0 +1,129 @@
+//! 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.toXhtml).
+ xhtml: []const u8,
+};
+
+/// 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="index"/>
+ \\ </spine>
+ \\</package>
+ \\
+ , .{ opts.version, opts.title, opts.version, opts.lang, modified });
+}
+
+fn buildNav(allocator: std.mem.Allocator, opts: Options) ![]u8 {
+ 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>Contents</h1>
+ \\ <ol>
+ \\ <li><a href="index.xhtml">{s} ({s})</a></li>
+ \\ </ol>
+ \\ </nav>
+ \\ </body>
+ \\</html>
+ \\
+ , .{ opts.lang, opts.title, opts.version, opts.title, opts.version });
+}
+
+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>",
+ });
+ 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);
+}