diff options
Diffstat (limited to 'src/html.zig')
| -rw-r--r-- | src/html.zig | 309 |
1 files changed, 309 insertions, 0 deletions
diff --git a/src/html.zig b/src/html.zig new file mode 100644 index 0000000..56c5d91 --- /dev/null +++ b/src/html.zig @@ -0,0 +1,309 @@ +//! 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, +//! since XML predefines only `& < > " '`, +//! 5. self-close void elements (`<meta>` -> `<meta/>`, etc.). + +const std = @import("std"); +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"; + +/// 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 { + 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); + defer allocator.free(escaped); + + var out: std.Io.Writer.Allocating = .init(allocator); + defer out.deinit(); + try out.writer.writeAll(xml_decl); + try writeWithFixups(&out.writer, escaped); + + const result = try out.toOwnedSlice(); + assert(std.mem.startsWith(u8, result, "<?xml")); + return result; +} + +/// Drop a leading `<!doctype html>` (case-insensitive), preserving the rest. +fn stripDoctype(page: []const u8) []const u8 { + const start = std.mem.indexOfNone(u8, page, " \t\r\n") orelse 0; + const rest = page[start..]; + if (rest.len >= 9 and std.ascii.eqlIgnoreCase(rest[0..9], "<!doctype")) { + const end = std.mem.indexOfScalar(u8, rest, '>') orelse return rest; + return rest[end + 1 ..]; + } + return page; +} + +/// Remove every `<script ...>...</script>` block. Caller owns the result. +fn stripScripts(allocator: std.mem.Allocator, html: []const u8) ![]u8 { + var out: std.Io.Writer.Allocating = .init(allocator); + defer out.deinit(); + + var i: usize = 0; + var guard: usize = 0; + while (i < html.len) { + guard += 1; + assert(guard <= html.len + 1); + + const open = findTagOpen(html, i, "script") orelse { + try out.writer.writeAll(html[i..]); + break; + }; + try out.writer.writeAll(html[i..open]); + + const close = findClose(html, open, "</script>") orelse { + // Unterminated script: drop the remainder rather than emit broken markup. + break; + }; + i = close; + } + return out.toOwnedSlice(); +} + +/// XML predefines only these five entities; everything else must be numeric. +const xml_entities = [_][]const u8{ "amp", "lt", "gt", "quot", "apos" }; + +/// Common HTML named entities → Unicode code point. Not exhaustive; any name not +/// listed (and not XML-predefined or numeric) has its `&` escaped to `&` so +/// output stays well-formed regardless of input. +const named_entities = std.StaticStringMap(u21).initComptime(.{ + .{ "nbsp", 0x00A0 }, .{ "copy", 0x00A9 }, .{ "reg", 0x00AE }, .{ "trade", 0x2122 }, + .{ "mdash", 0x2014 }, .{ "ndash", 0x2013 }, .{ "hellip", 0x2026 }, .{ "deg", 0x00B0 }, + .{ "plusmn", 0x00B1 }, .{ "times", 0x00D7 }, .{ "divide", 0x00F7 }, .{ "micro", 0x00B5 }, + .{ "middot", 0x00B7 }, .{ "bull", 0x2022 }, .{ "dagger", 0x2020 }, .{ "sect", 0x00A7 }, + .{ "para", 0x00B6 }, .{ "laquo", 0x00AB }, .{ "raquo", 0x00BB }, .{ "lsquo", 0x2018 }, + .{ "rsquo", 0x2019 }, .{ "ldquo", 0x201C }, .{ "rdquo", 0x201D }, .{ "larr", 0x2190 }, + .{ "rarr", 0x2192 }, .{ "uarr", 0x2191 }, .{ "darr", 0x2193 }, .{ "harr", 0x2194 }, + .{ "le", 0x2264 }, .{ "ge", 0x2265 }, .{ "ne", 0x2260 }, .{ "infin", 0x221E }, + .{ "sum", 0x2211 }, .{ "prod", 0x220F }, .{ "radic", 0x221A }, .{ "asymp", 0x2248 }, + .{ "equiv", 0x2261 }, .{ "frac12", 0x00BD }, .{ "frac14", 0x00BC }, .{ "frac34", 0x00BE }, + .{ "euro", 0x20AC }, .{ "pound", 0x00A3 }, .{ "yen", 0x00A5 }, .{ "cent", 0x00A2 }, +}); + +/// Longest entity name we will look ahead for (e.g. "frac34"). +const max_entity_name = 8; + +/// Rewrite `&...;` tokens so the output is XML well-formed. Caller owns the result. +fn escapeEntities(allocator: std.mem.Allocator, html: []const u8) ![]u8 { + var out: std.Io.Writer.Allocating = .init(allocator); + defer out.deinit(); + + var i: usize = 0; + while (i < html.len) { + const amp = std.mem.indexOfScalarPos(u8, html, i, '&') orelse { + try out.writer.writeAll(html[i..]); + break; + }; + try out.writer.writeAll(html[i..amp]); + + if (entityToken(html, amp)) |tok| { + if (isKeptEntity(tok)) { + try out.writer.writeAll(html[amp .. amp + tok.len + 2]); // include & and ; + } else if (named_entities.get(tok)) |cp| { + try out.writer.print("&#{d};", .{cp}); + } else { + try out.writer.writeAll("&"); + i = amp + 1; + continue; + } + i = amp + tok.len + 2; + } else { + // Bare '&' not forming an entity reference. + try out.writer.writeAll("&"); + i = amp + 1; + } + } + return out.toOwnedSlice(); +} + +/// If an `&...;` reference starts at `amp`, return the name between `&` and `;`. +fn entityToken(html: []const u8, amp: usize) ?[]const u8 { + assert(html[amp] == '&'); + const max = @min(html.len, amp + 2 + max_entity_name); + const semi = std.mem.indexOfScalarPos(u8, html[0..max], amp + 1, ';') orelse return null; + const tok = html[amp + 1 .. semi]; + if (tok.len == 0) return null; + return tok; +} + +/// True for entities XML keeps verbatim: the predefined five and numeric refs. +fn isKeptEntity(tok: []const u8) bool { + if (tok[0] == '#') { + const digits = tok[1..]; + if (digits.len == 0) return false; + const hex = digits[0] == 'x' or digits[0] == 'X'; + const rest = if (hex) digits[1..] else digits; + if (rest.len == 0) return false; + for (rest) |c| { + const ok = if (hex) std.ascii.isHex(c) else std.ascii.isDigit(c); + if (!ok) return false; + } + return true; + } + for (xml_entities) |e| { + if (std.mem.eql(u8, tok, e)) return true; + } + return false; +} + +/// Copy `html` to `w`, adding the XHTML namespace to <html> and self-closing +/// void elements as they are encountered. +fn writeWithFixups(w: *std.Io.Writer, html: []const u8) !void { + var i: usize = 0; + while (i < html.len) { + const lt = std.mem.indexOfScalarPos(u8, html, i, '<') orelse { + try w.writeAll(html[i..]); + return; + }; + try w.writeAll(html[i..lt]); + + const gt = std.mem.indexOfScalarPos(u8, html, lt, '>') orelse { + try w.writeAll(html[lt..]); + return; + }; + const tag = html[lt .. gt + 1]; // includes < and > + try writeTag(w, tag); + i = gt + 1; + } +} + +/// Emit a single tag (`<...>`), applying namespace/void-element fixups. +fn writeTag(w: *std.Io.Writer, tag: []const u8) !void { + assert(tag.len >= 2); + assert(tag[0] == '<' and tag[tag.len - 1] == '>'); + + const name = tagName(tag); + const is_closing = tag.len >= 2 and tag[1] == '/'; + + if (!is_closing and std.ascii.eqlIgnoreCase(name, "html") and std.mem.indexOf(u8, tag, "xmlns") == null) { + try w.print("<html xmlns=\"{s}\"", .{xhtml_ns}); + try w.writeAll(tag[1 + name.len ..]); + return; + } + + if (isVoid(name) and !std.mem.endsWith(u8, tag, "/>")) { + try w.writeAll(tag[0 .. tag.len - 1]); + try w.writeAll("/>"); + return; + } + + try w.writeAll(tag); +} + +/// Extract the element name from a tag slice (without `<`, `>`, `/`, attrs). +fn tagName(tag: []const u8) []const u8 { + var s: usize = 1; // skip '<' + if (s < tag.len and tag[s] == '/') s += 1; + var e = s; + while (e < tag.len and isNameChar(tag[e])) e += 1; + return tag[s..e]; +} + +fn isNameChar(c: u8) bool { + return std.ascii.isAlphanumeric(c) or c == '-' or c == ':'; +} + +fn isVoid(name: []const u8) bool { + for (void_elements) |v| { + if (std.ascii.eqlIgnoreCase(name, v)) return true; + } + return false; +} + +/// Find the next `<name` opening tag at or after `from`, returning the `<` index. +fn findTagOpen(html: []const u8, from: usize, name: []const u8) ?usize { + var i = from; + while (std.mem.indexOfScalarPos(u8, html, i, '<')) |lt| { + const after = lt + 1; + if (after + name.len <= html.len and + std.ascii.eqlIgnoreCase(html[after .. after + name.len], name)) + { + const next = html[after + name.len]; + if (next == '>' or next == ' ' or next == '\t' or next == '\n' or next == '/') return lt; + } + i = lt + 1; + } + return null; +} + +/// Find the index just past a closing tag (e.g. `</script>`) at or after `from`. +fn findClose(html: []const u8, from: usize, close: []const u8) ?usize { + const idx = std.ascii.indexOfIgnoreCasePos(html, from, close) orelse return null; + return idx + close.len; +} + +test "strips doctype and adds xml declaration" { + const gpa = std.testing.allocator; + const out = try toXhtml(gpa, "<!DOCTYPE html>\n<html><body>x</body></html>"); + defer gpa.free(out); + try std.testing.expect(std.mem.startsWith(u8, out, "<?xml")); + try std.testing.expect(std.mem.indexOf(u8, out, "<!DOCTYPE") == null); +} + +test "adds xhtml namespace to html element" { + const gpa = std.testing.allocator; + const out = try toXhtml(gpa, "<html lang=\"en\"><head></head></html>"); + defer gpa.free(out); + try std.testing.expect(std.mem.indexOf(u8, out, "xmlns=\"http://www.w3.org/1999/xhtml\"") != null); + try std.testing.expect(std.mem.indexOf(u8, out, "lang=\"en\"") != null); + // The closing tag must be left intact (not rewritten with a namespace). + try std.testing.expect(std.mem.endsWith(u8, out, "</html>")); + try std.testing.expect(std.mem.indexOf(u8, out, "xhtml\"l>") == null); +} + +test "self-closes void elements" { + const gpa = std.testing.allocator; + const out = try toXhtml(gpa, "<html><head><meta charset=\"utf-8\"><br></head></html>"); + defer gpa.free(out); + try std.testing.expect(std.mem.indexOf(u8, out, "<meta charset=\"utf-8\"/>") != null); + try std.testing.expect(std.mem.indexOf(u8, out, "<br/>") != null); +} + +test "strips script blocks" { + const gpa = std.testing.allocator; + const out = try toXhtml(gpa, "<html><body>a<script>var x = 1 < 2;</script>b</body></html>"); + defer gpa.free(out); + try std.testing.expect(std.mem.indexOf(u8, out, "<script") == null); + try std.testing.expect(std.mem.indexOf(u8, out, "var x") == null); + try std.testing.expect(std.mem.indexOf(u8, out, ">a") != null); + try std.testing.expect(std.mem.indexOf(u8, out, "b<") != null); +} + +test "normalizes named entities and escapes stray ampersands" { + const gpa = std.testing.allocator; + const out = try toXhtml(gpa, "<html><body>a—b & c d R&D <x> — &unknownent;</body></html>"); + defer gpa.free(out); + try std.testing.expect(std.mem.indexOf(u8, out, "—") == null); + try std.testing.expect(std.mem.indexOf(u8, out, "—") != null); // mdash -> numeric + try std.testing.expect(std.mem.indexOf(u8, out, " ") != null); // nbsp -> numeric + try std.testing.expect(std.mem.indexOf(u8, out, "R&D") != null); // stray & escaped + try std.testing.expect(std.mem.indexOf(u8, out, "&unknownent;") != null); // unknown name escaped + try std.testing.expect(std.mem.indexOf(u8, out, "& c") != null); // existing & kept (not double-escaped) + try std.testing.expect(std.mem.indexOf(u8, out, "<x>") != null); +} + +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>"); + defer gpa.free(out); + try std.testing.expect(std.mem.indexOf(u8, out, "<meta charset=\"utf-8\"//>") == null); + try std.testing.expect(std.mem.indexOf(u8, out, "<meta charset=\"utf-8\"/>") != null); +} |
