diff options
| -rw-r--r-- | README.md | 23 | ||||
| -rw-r--r-- | epubs/zig-0.1.1.epub | bin | 201853 -> 0 bytes | |||
| -rw-r--r-- | epubs/zig-0.10.0.epub | bin | 1130432 -> 0 bytes | |||
| -rw-r--r-- | epubs/zig-0.10.1.epub | bin | 1133513 -> 0 bytes | |||
| -rw-r--r-- | epubs/zig-0.11.0.epub | bin | 1081055 -> 1083315 bytes | |||
| -rw-r--r-- | epubs/zig-0.12.0.epub | bin | 922795 -> 925023 bytes | |||
| -rw-r--r-- | epubs/zig-0.12.1.epub | bin | 933356 -> 935649 bytes | |||
| -rw-r--r-- | epubs/zig-0.13.0.epub | bin | 933399 -> 935698 bytes | |||
| -rw-r--r-- | epubs/zig-0.14.0.epub | bin | 964357 -> 966701 bytes | |||
| -rw-r--r-- | epubs/zig-0.14.1.epub | bin | 967595 -> 970004 bytes | |||
| -rw-r--r-- | epubs/zig-0.15.1.epub | bin | 970943 -> 973287 bytes | |||
| -rw-r--r-- | epubs/zig-0.15.2.epub | bin | 970121 -> 972519 bytes | |||
| -rw-r--r-- | epubs/zig-0.16.0.epub | bin | 983739 -> 986269 bytes | |||
| -rw-r--r-- | epubs/zig-0.2.0.epub | bin | 238448 -> 0 bytes | |||
| -rw-r--r-- | epubs/zig-0.3.0.epub | bin | 525326 -> 0 bytes | |||
| -rw-r--r-- | epubs/zig-0.4.0.epub | bin | 656333 -> 0 bytes | |||
| -rw-r--r-- | epubs/zig-0.5.0.epub | bin | 719277 -> 0 bytes | |||
| -rw-r--r-- | epubs/zig-0.6.0.epub | bin | 709449 -> 0 bytes | |||
| -rw-r--r-- | epubs/zig-0.7.0.epub | bin | 763214 -> 0 bytes | |||
| -rw-r--r-- | epubs/zig-0.7.1.epub | bin | 763304 -> 0 bytes | |||
| -rw-r--r-- | epubs/zig-0.8.0.epub | bin | 799052 -> 0 bytes | |||
| -rw-r--r-- | epubs/zig-0.8.1.epub | bin | 802163 -> 0 bytes | |||
| -rw-r--r-- | epubs/zig-0.9.0.epub | bin | 1113158 -> 1115228 bytes | |||
| -rw-r--r-- | epubs/zig-0.9.1.epub | bin | 1113237 -> 1115381 bytes | |||
| -rw-r--r-- | epubs/zig-master.epub | bin | 967090 -> 969372 bytes | |||
| -rw-r--r-- | src/epub.zig | 27 | ||||
| -rw-r--r-- | src/html.zig | 305 | ||||
| -rw-r--r-- | src/main.zig | 15 |
28 files changed, 346 insertions, 24 deletions
@@ -17,8 +17,8 @@ Four decoupled concerns, each a small module that does one thing: | ------------- | --------------------------------------------------------- | | `index.zig` | discover versions + docs URLs from `download/index.json` | | `fetch.zig` | HTTP GET via `std.http.Client` | -| `html.zig` | transform the page HTML5 → well-formed XHTML5 | -| `epub.zig` | assemble the EPUB3 container | +| `html.zig` | HTML5 → XHTML5; extract the in-page TOC; drop the sidebar | +| `epub.zig` | assemble the EPUB3 container (TOC navigation + content) | | `zip.zig` | minimal, reproducible ZIP writer (stored entries) | No external runtime dependencies — Zig's standard library provides HTTP and @@ -28,6 +28,25 @@ spec-compliant at the cost of larger files. Output is **byte-for-byte reproducible** (fixed ZIP timestamps), so CI only commits when the upstream docs actually change. +### Table of contents + +The docs page renders its table of contents in a `position: fixed` sidebar, which +e-readers repaint on every page. The tool extracts that TOC into a proper EPUB3 +navigation document (`nav.xhtml`): it shows in the reader's built-in Contents +menu, is the book's opening page, and each entry jumps to the matching section. +The original fixed sidebar (and its dead version-switcher dropdown) is removed +from the content so it no longer bleeds across pages. + +### Well-formedness gate + +Each transformed page is checked for well-formed, balanced XHTML before +packaging; versions that fail are **skipped** (logged as `NotWellFormed`) rather +than shipped as invalid EPUB3. A handful of older docs pages (currently 0.1.1–0.8.1 +and 0.10.x) contain malformed upstream HTML — unclosed `<p>`/`<li>` tags and +outright typos — that can't be repaired without a full HTML5 tree-builder. Every +EPUB this tool produces is therefore strictly well-formed; newer versions are +picked up automatically as they are released. + ## Usage Requires **Zig 0.15.2**. diff --git a/epubs/zig-0.1.1.epub b/epubs/zig-0.1.1.epub Binary files differdeleted file mode 100644 index 42c91ec..0000000 --- a/epubs/zig-0.1.1.epub +++ /dev/null diff --git a/epubs/zig-0.10.0.epub b/epubs/zig-0.10.0.epub Binary files differdeleted file mode 100644 index 49a331d..0000000 --- a/epubs/zig-0.10.0.epub +++ /dev/null diff --git a/epubs/zig-0.10.1.epub b/epubs/zig-0.10.1.epub Binary files differdeleted file mode 100644 index b153576..0000000 --- a/epubs/zig-0.10.1.epub +++ /dev/null diff --git a/epubs/zig-0.11.0.epub b/epubs/zig-0.11.0.epub Binary files differindex 7aecf9c..1dfc0f0 100644 --- a/epubs/zig-0.11.0.epub +++ b/epubs/zig-0.11.0.epub diff --git a/epubs/zig-0.12.0.epub b/epubs/zig-0.12.0.epub Binary files differindex c72d0d1..05bb36e 100644 --- a/epubs/zig-0.12.0.epub +++ b/epubs/zig-0.12.0.epub diff --git a/epubs/zig-0.12.1.epub b/epubs/zig-0.12.1.epub Binary files differindex d1be901..1907d2f 100644 --- a/epubs/zig-0.12.1.epub +++ b/epubs/zig-0.12.1.epub diff --git a/epubs/zig-0.13.0.epub b/epubs/zig-0.13.0.epub Binary files differindex bfd1991..98cce35 100644 --- a/epubs/zig-0.13.0.epub +++ b/epubs/zig-0.13.0.epub diff --git a/epubs/zig-0.14.0.epub b/epubs/zig-0.14.0.epub Binary files differindex 53a9137..39d7ea3 100644 --- a/epubs/zig-0.14.0.epub +++ b/epubs/zig-0.14.0.epub diff --git a/epubs/zig-0.14.1.epub b/epubs/zig-0.14.1.epub Binary files differindex a533700..1f14838 100644 --- a/epubs/zig-0.14.1.epub +++ b/epubs/zig-0.14.1.epub diff --git a/epubs/zig-0.15.1.epub b/epubs/zig-0.15.1.epub Binary files differindex 68bb208..be73887 100644 --- a/epubs/zig-0.15.1.epub +++ b/epubs/zig-0.15.1.epub diff --git a/epubs/zig-0.15.2.epub b/epubs/zig-0.15.2.epub Binary files differindex bce9cd8..f4845af 100644 --- a/epubs/zig-0.15.2.epub +++ b/epubs/zig-0.15.2.epub diff --git a/epubs/zig-0.16.0.epub b/epubs/zig-0.16.0.epub Binary files differindex 154ea4b..6490d41 100644 --- a/epubs/zig-0.16.0.epub +++ b/epubs/zig-0.16.0.epub diff --git a/epubs/zig-0.2.0.epub b/epubs/zig-0.2.0.epub Binary files differdeleted file mode 100644 index 34f1342..0000000 --- a/epubs/zig-0.2.0.epub +++ /dev/null diff --git a/epubs/zig-0.3.0.epub b/epubs/zig-0.3.0.epub Binary files differdeleted file mode 100644 index f34192d..0000000 --- a/epubs/zig-0.3.0.epub +++ /dev/null diff --git a/epubs/zig-0.4.0.epub b/epubs/zig-0.4.0.epub Binary files differdeleted file mode 100644 index 1d9afa6..0000000 --- a/epubs/zig-0.4.0.epub +++ /dev/null diff --git a/epubs/zig-0.5.0.epub b/epubs/zig-0.5.0.epub Binary files differdeleted file mode 100644 index 2b6ed40..0000000 --- a/epubs/zig-0.5.0.epub +++ /dev/null diff --git a/epubs/zig-0.6.0.epub b/epubs/zig-0.6.0.epub Binary files differdeleted file mode 100644 index 78ade26..0000000 --- a/epubs/zig-0.6.0.epub +++ /dev/null diff --git a/epubs/zig-0.7.0.epub b/epubs/zig-0.7.0.epub Binary files differdeleted file mode 100644 index 81e36ff..0000000 --- a/epubs/zig-0.7.0.epub +++ /dev/null diff --git a/epubs/zig-0.7.1.epub b/epubs/zig-0.7.1.epub Binary files differdeleted file mode 100644 index 02ccf63..0000000 --- a/epubs/zig-0.7.1.epub +++ /dev/null diff --git a/epubs/zig-0.8.0.epub b/epubs/zig-0.8.0.epub Binary files differdeleted file mode 100644 index 8105508..0000000 --- a/epubs/zig-0.8.0.epub +++ /dev/null diff --git a/epubs/zig-0.8.1.epub b/epubs/zig-0.8.1.epub Binary files differdeleted file mode 100644 index 43d73f5..0000000 --- a/epubs/zig-0.8.1.epub +++ /dev/null diff --git a/epubs/zig-0.9.0.epub b/epubs/zig-0.9.0.epub Binary files differindex 47ae0b9..514d283 100644 --- a/epubs/zig-0.9.0.epub +++ b/epubs/zig-0.9.0.epub diff --git a/epubs/zig-0.9.1.epub b/epubs/zig-0.9.1.epub Binary files differindex 212a0fd..9914fdd 100644 --- a/epubs/zig-0.9.1.epub +++ b/epubs/zig-0.9.1.epub diff --git a/epubs/zig-master.epub b/epubs/zig-master.epub Binary files differindex 4b59c34..139d828 100644 --- a/epubs/zig-master.epub +++ b/epubs/zig-master.epub 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. `—`) 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. `—`) to numeric references, //! since XML predefines only `& < > " '`, -//! 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, "<x>") != 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 > 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")); |
