aboutsummaryrefslogtreecommitdiffstats
path: root/src
diff options
context:
space:
mode:
Diffstat (limited to 'src')
-rw-r--r--src/epub.zig27
-rw-r--r--src/html.zig305
-rw-r--r--src/main.zig15
3 files changed, 325 insertions, 22 deletions
diff --git a/src/epub.zig b/src/epub.zig
index 0bdf563..7e35089 100644
--- a/src/epub.zig
+++ b/src/epub.zig
@@ -13,8 +13,11 @@ pub const Options = struct {
/// 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).
+ /// 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.
@@ -74,6 +77,7 @@ fn buildOpf(allocator: std.mem.Allocator, opts: Options) ![]u8 {
\\ <item id="index" href="index.xhtml" media-type="application/xhtml+xml"/>
\\ </manifest>
\\ <spine>
+ \\ <itemref idref="nav"/>
\\ <itemref idref="index"/>
\\ </spine>
\\</package>
@@ -82,21 +86,23 @@ fn buildOpf(allocator: std.mem.Allocator, opts: Options) ![]u8 {
}
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>Contents</h1>
- \\ <ol>
- \\ <li><a href="index.xhtml">{s} ({s})</a></li>
- \\ </ol>
+ \\ <h1>{s} ({s})</h1>
+ \\ {s}
\\ </nav>
\\ </body>
\\</html>
\\
- , .{ opts.lang, opts.title, opts.version, opts.title, opts.version });
+ , .{ opts.lang, opts.title, opts.version, opts.title, opts.version, list });
}
test "epub starts with PK and mimetype, and round-trips" {
@@ -105,6 +111,7 @@ test "epub starts with PK and mimetype, and round-trips" {
.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);
@@ -126,4 +133,12 @@ test "epub starts with PK and mimetype, and round-trips" {
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);
}
diff --git a/src/html.zig b/src/html.zig
index 56c5d91..ec9ecfd 100644
--- a/src/html.zig
+++ b/src/html.zig
@@ -1,13 +1,17 @@
//! Transform the Zig docs single-page HTML5 into well-formed XHTML5 for EPUB.
//!
//! The page is self-contained (embedded <style>, inline base64 icons, in-page TOC,
-//! entity-escaped code). The only well-formedness work needed is:
-//! 1. replace the leading `<!DOCTYPE html>` with an XML declaration,
-//! 2. ensure the <html> element carries the XHTML namespace,
-//! 3. strip <script>...</script> blocks (non-functional in EPUB, aids validation),
-//! 4. normalize HTML named entities (e.g. `&mdash;`) to numeric references,
+//! entity-escaped code). The transform:
+//! 1. replaces the leading `<!DOCTYPE html>` with an XML declaration,
+//! 2. ensures the <html> element carries the XHTML namespace,
+//! 3. strips <script>...</script> blocks (non-functional in EPUB, aids validation),
+//! 4. normalizes HTML named entities (e.g. `&mdash;`) to numeric references,
//! since XML predefines only `&amp; &lt; &gt; &quot; &apos;`,
-//! 5. self-close void elements (`<meta>` -> `<meta/>`, etc.).
+//! 5. removes invalid control characters,
+//! 6. extracts the in-page table of contents for use as the EPUB navigation,
+//! 7. removes the `#navigation` sidebar (it is `position: fixed`, so it bleeds
+//! onto every page, and the version-switcher dropdown is dead in an EPUB),
+//! 8. self-closes void elements (`<meta>` -> `<meta/>`, etc.).
const std = @import("std");
const assert = std.debug.assert;
@@ -15,30 +19,155 @@ const assert = std.debug.assert;
const xml_decl = "<?xml version=\"1.0\" encoding=\"utf-8\"?>\n";
const xhtml_ns = "http://www.w3.org/1999/xhtml";
+/// File name of the content document the TOC links point into.
+const content_href = "index.xhtml";
+
/// HTML void elements: never have children, must be self-closed in XHTML.
const void_elements = [_][]const u8{
"meta", "link", "br", "hr", "img", "col", "input", "source", "area", "base", "wbr", "track", "param",
};
-/// Transform `page` (HTML5) into well-formed XHTML5. Caller owns the result.
-pub fn toXhtml(allocator: std.mem.Allocator, page: []const u8) ![]u8 {
+/// Result of transforming a docs page: the content document plus, when present,
+/// the table-of-contents list extracted for the EPUB navigation document.
+pub const Document = struct {
+ allocator: std.mem.Allocator,
+ /// Well-formed XHTML5 content, with the fixed sidebar removed.
+ content: []u8,
+ /// `<ol>...</ol>` of TOC entries (links rewritten to `index.xhtml#...`), or
+ /// null if the page had no recognizable table of contents.
+ toc: ?[]u8,
+
+ pub fn deinit(self: *Document) void {
+ self.allocator.free(self.content);
+ if (self.toc) |t| self.allocator.free(t);
+ }
+};
+
+/// Transform `page` (HTML5) into an EPUB-ready `Document`. Caller owns it.
+pub fn transform(allocator: std.mem.Allocator, page: []const u8) !Document {
assert(page.len > 0);
const no_doctype = stripDoctype(page);
const no_scripts = try stripScripts(allocator, no_doctype);
defer allocator.free(no_scripts);
- const escaped = try escapeEntities(allocator, no_scripts);
+ const clean = try stripControlChars(allocator, no_scripts);
+ defer allocator.free(clean);
+
+ const escaped = try escapeEntities(allocator, clean);
defer allocator.free(escaped);
+ const toc = try extractToc(allocator, escaped);
+ errdefer if (toc) |t| allocator.free(t);
+
+ const no_sidebar = try removeNavigation(allocator, escaped);
+ defer allocator.free(no_sidebar);
+
var out: std.Io.Writer.Allocating = .init(allocator);
defer out.deinit();
try out.writer.writeAll(xml_decl);
- try writeWithFixups(&out.writer, escaped);
+ try writeWithFixups(&out.writer, no_sidebar);
+
+ const content = try out.toOwnedSlice();
+ assert(std.mem.startsWith(u8, content, "<?xml"));
+ return .{ .allocator = allocator, .content = content, .toc = toc };
+}
+
+/// Convenience wrapper returning only the content document. Caller owns it.
+pub fn toXhtml(allocator: std.mem.Allocator, page: []const u8) ![]u8 {
+ const doc = try transform(allocator, page);
+ defer if (doc.toc) |t| allocator.free(t);
+ return doc.content;
+}
+
+/// Extract the in-page table of contents (the `<ul>` inside the
+/// `table-of-contents` nav) as an EPUB `<ol>` with links rewritten to point into
+/// the content document. Returns null if no such list is found. Caller owns it.
+fn extractToc(allocator: std.mem.Allocator, html: []const u8) !?[]u8 {
+ const marker = "aria-labelledby=\"table-of-contents\"";
+ const m = std.mem.indexOf(u8, html, marker) orelse return null;
+ const ul_open = findTagOpen(html, m, "ul") orelse return null;
+ const ul_end = matchingClose(html, ul_open, "ul") orelse return null;
+ return try rewriteToc(allocator, html[ul_open..ul_end]);
+}
+
+/// Rewrite an extracted TOC list: `<ul>` → `<ol>` (EPUB nav requires ordered
+/// lists) and `href="#X"` → `href="index.xhtml#X"`. Caller owns the result.
+fn rewriteToc(allocator: std.mem.Allocator, list: []const u8) ![]u8 {
+ var out: std.Io.Writer.Allocating = .init(allocator);
+ defer out.deinit();
+
+ var i: usize = 0;
+ while (i < list.len) {
+ const lt = std.mem.indexOfScalarPos(u8, list, i, '<') orelse {
+ try out.writer.writeAll(list[i..]);
+ break;
+ };
+ try out.writer.writeAll(list[i..lt]);
+ const gt = tagEnd(list, lt) orelse return error.MalformedToc;
+ const tag = list[lt .. gt + 1];
+ try writeTocTag(&out.writer, tag);
+ i = gt + 1;
+ }
+ return out.toOwnedSlice();
+}
+
+fn writeTocTag(w: *std.Io.Writer, tag: []const u8) !void {
+ const closing = tag[1] == '/';
+ const name = tagName(tag);
+
+ if (std.ascii.eqlIgnoreCase(name, "ul")) {
+ try w.writeAll(if (closing) "</ol" else "<ol");
+ try w.writeAll(tag[1 + @as(usize, if (closing) 1 else 0) + name.len ..]);
+ return;
+ }
+ if (!closing and std.ascii.eqlIgnoreCase(name, "a")) {
+ if (std.mem.indexOf(u8, tag, "href=\"#")) |p| {
+ try w.writeAll(tag[0 .. p + 6]); // through the opening quote
+ try w.writeAll(content_href);
+ try w.writeAll(tag[p + 6 ..]); // from '#' onward
+ return;
+ }
+ }
+ try w.writeAll(tag);
+}
+
+/// Remove the `<div id="navigation">…</div>` sidebar. If absent, returns an
+/// unmodified copy. Caller owns the result.
+fn removeNavigation(allocator: std.mem.Allocator, html: []const u8) ![]u8 {
+ const m = std.mem.indexOf(u8, html, "id=\"navigation\"") orelse
+ return allocator.dupe(u8, html);
+ const div_open = std.mem.lastIndexOfScalar(u8, html[0..m], '<') orelse
+ return allocator.dupe(u8, html);
+ const div_end = matchingClose(html, div_open, "div") orelse
+ return allocator.dupe(u8, html);
- const result = try out.toOwnedSlice();
- assert(std.mem.startsWith(u8, result, "<?xml"));
- return result;
+ var out = try allocator.alloc(u8, html.len - (div_end - div_open));
+ @memcpy(out[0..div_open], html[0..div_open]);
+ @memcpy(out[div_open..], html[div_end..]);
+ return out;
+}
+
+/// Index just past the `</name>` that closes the element opened at `open_lt`,
+/// accounting for nested elements of the same name. Null if unbalanced.
+fn matchingClose(html: []const u8, open_lt: usize, name: []const u8) ?usize {
+ var i = open_lt;
+ var depth: usize = 0;
+ while (std.mem.indexOfScalarPos(u8, html, i, '<')) |lt| {
+ const gt = tagEnd(html, lt) orelse return null;
+ const tag = html[lt .. gt + 1];
+ if (std.ascii.eqlIgnoreCase(tagName(tag), name)) {
+ if (tag[1] == '/') {
+ if (depth == 0) return null;
+ depth -= 1;
+ if (depth == 0) return gt + 1;
+ } else if (!std.mem.endsWith(u8, tag, "/>")) {
+ depth += 1;
+ }
+ }
+ i = gt + 1;
+ }
+ return null;
}
/// Drop a leading `<!doctype html>` (case-insensitive), preserving the rest.
@@ -78,6 +207,93 @@ fn stripScripts(allocator: std.mem.Allocator, html: []const u8) ![]u8 {
return out.toOwnedSlice();
}
+/// Maximum element nesting depth we will validate (NASA Power-of-10: bounded).
+const max_depth = 256;
+
+/// Lightweight check that `xhtml` is well-formed: every element tag is balanced
+/// and properly nested. This is not a full XML parser, but it catches the
+/// structural breakage (unclosed/mis-nested tags) present in some older Zig docs
+/// pages, letting the caller skip versions that would not be valid EPUB3.
+///
+/// Relies on our transform guarantees: scripts stripped, void elements
+/// self-closed, code samples entity-escaped (so no stray `<` in text).
+pub fn isWellFormed(xhtml: []const u8) bool {
+ var stack: [max_depth][]const u8 = undefined;
+ var depth: usize = 0;
+
+ var i: usize = 0;
+ while (std.mem.indexOfScalarPos(u8, xhtml, i, '<')) |lt| {
+ // Markup we skip wholesale: processing instructions, comments, declarations.
+ if (startsAt(xhtml, lt, "<?")) {
+ i = (std.mem.indexOfPos(u8, xhtml, lt, "?>") orelse return false) + 2;
+ continue;
+ }
+ if (startsAt(xhtml, lt, "<!--")) {
+ i = (std.mem.indexOfPos(u8, xhtml, lt, "-->") orelse return false) + 3;
+ continue;
+ }
+ if (startsAt(xhtml, lt, "<!")) {
+ i = (tagEnd(xhtml, lt) orelse return false) + 1;
+ continue;
+ }
+
+ const gt = tagEnd(xhtml, lt) orelse return false;
+ const tag = xhtml[lt .. gt + 1];
+ i = gt + 1;
+
+ const name = tagName(tag);
+ if (name.len == 0) return false;
+
+ if (tag[1] == '/') {
+ if (depth == 0 or !std.mem.eql(u8, stack[depth - 1], name)) return false;
+ depth -= 1;
+ } else if (std.mem.endsWith(u8, tag, "/>") or isVoid(name)) {
+ // Self-contained element; nothing to balance.
+ } else {
+ if (depth >= max_depth) return false;
+ stack[depth] = name;
+ depth += 1;
+ }
+ }
+ return depth == 0;
+}
+
+fn startsAt(s: []const u8, at: usize, prefix: []const u8) bool {
+ return at + prefix.len <= s.len and std.mem.eql(u8, s[at .. at + prefix.len], prefix);
+}
+
+/// Index of the `>` closing the tag opened at `lt`, ignoring `>` inside quotes.
+fn tagEnd(s: []const u8, lt: usize) ?usize {
+ var i = lt + 1;
+ var quote: u8 = 0;
+ while (i < s.len) : (i += 1) {
+ const c = s[i];
+ if (quote != 0) {
+ if (c == quote) quote = 0;
+ } else switch (c) {
+ '"', '\'' => quote = c,
+ '>' => return i,
+ else => {},
+ }
+ }
+ return null;
+}
+
+/// Remove characters XML 1.0 forbids: C0 controls except tab/newline/return.
+/// Some older docs embed raw terminal escape codes (e.g. `\x1b[0K`) in captured
+/// code samples, which are invalid in any XML document. Caller owns the result.
+fn stripControlChars(allocator: std.mem.Allocator, html: []const u8) ![]u8 {
+ var out: std.ArrayList(u8) = .empty;
+ errdefer out.deinit(allocator);
+ try out.ensureTotalCapacity(allocator, html.len);
+
+ for (html) |c| {
+ const forbidden = c < 0x20 and c != '\t' and c != '\n' and c != '\r';
+ if (!forbidden) out.appendAssumeCapacity(c);
+ }
+ return out.toOwnedSlice(allocator);
+}
+
/// XML predefines only these five entities; everything else must be numeric.
const xml_entities = [_][]const u8{ "amp", "lt", "gt", "quot", "apos" };
@@ -300,6 +516,69 @@ test "normalizes named entities and escapes stray ampersands" {
try std.testing.expect(std.mem.indexOf(u8, out, "&lt;x&gt;") != null);
}
+test "strips XML-invalid control characters but keeps whitespace" {
+ const gpa = std.testing.allocator;
+ const out = try toXhtml(gpa, "<html><body>a\x1b[0Kb\tc\nd</body></html>");
+ defer gpa.free(out);
+ try std.testing.expect(std.mem.indexOfScalar(u8, out, 0x1b) == null); // ESC removed
+ try std.testing.expect(std.mem.indexOf(u8, out, "a[0Kb") != null); // only the control byte dropped
+ try std.testing.expect(std.mem.indexOf(u8, out, "c\nd") != null); // tab/newline preserved
+}
+
+test "transform extracts TOC and removes the navigation sidebar" {
+ const gpa = std.testing.allocator;
+ const page =
+ "<!DOCTYPE html><html><body><h1>Title</h1>" ++
+ "<div id=\"main-wrapper\">" ++
+ "<div id=\"navigation\">" ++
+ "<nav aria-labelledby=\"zig-version\"><h2 id=\"zig-version\">0.x</h2></nav>" ++
+ "<nav aria-labelledby=\"table-of-contents\"><h2 id=\"table-of-contents\">Table of Contents</h2>" ++
+ "<ul><li><a id=\"toc-Intro\" href=\"#Intro\">Intro</a>" ++
+ "<ul><li><a href=\"#Sub\">Sub</a></li></ul></li></ul>" ++
+ "</nav></div>" ++
+ "<main id=\"contents\"><h2 id=\"Intro\">Intro</h2><p>body</p></main>" ++
+ "</div></body></html>";
+
+ var doc = try transform(gpa, page);
+ defer doc.deinit();
+
+ // Sidebar (and its version dropdown) is gone from the content.
+ try std.testing.expect(std.mem.indexOf(u8, doc.content, "id=\"navigation\"") == null);
+ try std.testing.expect(std.mem.indexOf(u8, doc.content, "zig-version") == null);
+ // Content itself is preserved.
+ try std.testing.expect(std.mem.indexOf(u8, doc.content, "<main id=\"contents\">") != null);
+ try std.testing.expect(std.mem.indexOf(u8, doc.content, "<h2 id=\"Intro\">") != null);
+ try std.testing.expect(isWellFormed(doc.content));
+
+ // TOC extracted, ul->ol, links rewritten into the content document.
+ try std.testing.expect(doc.toc != null);
+ const toc = doc.toc.?;
+ try std.testing.expect(std.mem.indexOf(u8, toc, "<ol>") != null);
+ try std.testing.expect(std.mem.indexOf(u8, toc, "<ul>") == null);
+ try std.testing.expect(std.mem.indexOf(u8, toc, "href=\"index.xhtml#Intro\"") != null);
+ try std.testing.expect(std.mem.indexOf(u8, toc, "href=\"index.xhtml#Sub\"") != null);
+}
+
+test "transform yields null TOC when no table of contents is present" {
+ const gpa = std.testing.allocator;
+ var doc = try transform(gpa, "<html><body><p>no toc here</p></body></html>");
+ defer doc.deinit();
+ try std.testing.expect(doc.toc == null);
+}
+
+test "isWellFormed accepts balanced markup and rejects mismatches" {
+ const decl = "<?xml version=\"1.0\"?>";
+ // Balanced, with self-closed void, comment, and a '>' inside an attribute.
+ try std.testing.expect(isWellFormed(decl ++ "<html><head><meta charset=\"u\"/></head>" ++
+ "<body><!-- c --><p title=\"a &gt; b\">x<br/></p></body></html>"));
+ // Unclosed <p> before </body>.
+ try std.testing.expect(!isWellFormed("<html><body><p>x</body></html>"));
+ // Stray closing tag.
+ try std.testing.expect(!isWellFormed("<html><body></span></body></html>"));
+ // Unbalanced at end of document.
+ try std.testing.expect(!isWellFormed("<html><body><div></body>"));
+}
+
test "leaves already self-closed void elements unchanged" {
const gpa = std.testing.allocator;
const out = try toXhtml(gpa, "<html><head><meta charset=\"utf-8\"/></head></html>");
diff --git a/src/main.zig b/src/main.zig
index a6bbe31..e9a9fa1 100644
--- a/src/main.zig
+++ b/src/main.zig
@@ -70,10 +70,19 @@ fn buildVersion(
const page = try fetch.get(gpa, client, v.docs_url);
defer gpa.free(page);
- const xhtml = try html.toXhtml(gpa, page);
- defer gpa.free(xhtml);
+ var doc = try html.transform(gpa, page);
+ defer doc.deinit();
- const bytes = try epub.build(gpa, .{ .title = title, .version = v.name, .xhtml = xhtml });
+ // Some older docs pages have malformed upstream HTML that can't be repaired
+ // without a full HTML5 tree-builder; skip them rather than emit invalid EPUB3.
+ if (!html.isWellFormed(doc.content)) return error.NotWellFormed;
+
+ const bytes = try epub.build(gpa, .{
+ .title = title,
+ .version = v.name,
+ .xhtml = doc.content,
+ .toc = doc.toc,
+ });
defer gpa.free(bytes);
assert(std.mem.startsWith(u8, bytes, "PK"));