aboutsummaryrefslogtreecommitdiffstats
path: root/src/epub.zig
blob: 0bdf5638b9b86c63b4bec9b562e509af7b5e6446 (plain)
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
//! 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);
}