//! 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, /// `
    ...
` 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 = \\ \\ \\ \\ \\ \\ \\ ; fn buildOpf(allocator: std.mem.Allocator, opts: Options) ![]u8 { return std.fmt.allocPrint(allocator, \\ \\ \\ \\ urn:ziglang-docs:{s} \\ {s} ({s}) \\ {s} \\ {s} \\ \\ \\ \\ \\ \\ \\ \\ \\ \\ \\ , .{ 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 "
    \n
  1. Contents
  2. \n
"; return std.fmt.allocPrint(allocator, \\ \\ \\ {s} ({s}) \\ \\ \\ \\ \\ , .{ 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 = "\nhi", .toc = "
  1. Intro
", }); 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); }