aboutsummaryrefslogtreecommitdiffstats
path: root/src/html.zig
blob: 56c5d917559f994c647090c6624049a40025a252 (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
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
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. `&mdash;`) to numeric references,
//!      since XML predefines only `&amp; &lt; &gt; &quot; &apos;`,
//!   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 `&amp;` 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("&amp;");
                i = amp + 1;
                continue;
            }
            i = amp + tok.len + 2;
        } else {
            // Bare '&' not forming an entity reference.
            try out.writer.writeAll("&amp;");
            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&mdash;b &amp; c&nbsp;d R&D &lt;x&gt; &#8212; &unknownent;</body></html>");
    defer gpa.free(out);
    try std.testing.expect(std.mem.indexOf(u8, out, "&mdash;") == null);
    try std.testing.expect(std.mem.indexOf(u8, out, "&#8212;") != null); // mdash -> numeric
    try std.testing.expect(std.mem.indexOf(u8, out, "&#160;") != null); // nbsp -> numeric
    try std.testing.expect(std.mem.indexOf(u8, out, "R&amp;D") != null); // stray & escaped
    try std.testing.expect(std.mem.indexOf(u8, out, "&amp;unknownent;") != null); // unknown name escaped
    try std.testing.expect(std.mem.indexOf(u8, out, "&amp; c") != null); // existing &amp; kept (not double-escaped)
    try std.testing.expect(std.mem.indexOf(u8, out, "&lt;x&gt;") != 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);
}