├── README.md ├── .gitignore ├── font ├── times-new-roman.ttf └── schrift.zig ├── alext.zig ├── test ├── hello.html └── svg.html ├── revit.zig ├── Refcounted.zig ├── make-renderer-webpage.zig ├── lint.zig ├── htmlid.zig ├── Layout.md ├── render.zig ├── wasmrenderer.zig ├── html-css-renderer.template.html ├── testrunner.zig ├── imagerenderer.zig ├── dom.zig ├── x11renderer.zig ├── layout.zig └── HtmlTokenizer.zig /README.md: -------------------------------------------------------------------------------- 1 | # Html Css Renderer 2 | 3 | An HTML/CSS Renderer. 4 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .zig-cache/ 2 | zig-out/ 3 | /dep/ 4 | /htmlidmaps.zig 5 | -------------------------------------------------------------------------------- /font/times-new-roman.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/marler8997/html-css-renderer/HEAD/font/times-new-roman.ttf -------------------------------------------------------------------------------- /alext.zig: -------------------------------------------------------------------------------- 1 | const std = @import("std"); 2 | 3 | pub const unmanaged = struct { 4 | pub fn finalize(comptime T: type, self: *std.ArrayListUnmanaged(T), allocator: std.mem.Allocator) void { 5 | const old_memory = self.allocatedSlice(); 6 | if (allocator.resize(old_memory, self.items.len)) { 7 | self.capacity = self.items.len; 8 | } 9 | } 10 | }; 11 | -------------------------------------------------------------------------------- /test/hello.html: -------------------------------------------------------------------------------- 1 | 2 | 10 | 14 | Hello 15 | -------------------------------------------------------------------------------- /revit.zig: -------------------------------------------------------------------------------- 1 | // PR to add this to std here: https://github.com/ziglang/zig/pull/13743 2 | fn ReverseIterator(comptime T: type) type { 3 | const info: struct { Child: type, Pointer: type } = blk: { 4 | switch (@typeInfo(T)) { 5 | .Pointer => |info| switch (info.size) { 6 | .Slice => break :blk .{ 7 | .Child = info.child, 8 | .Pointer = @Type(.{ .Pointer = .{ 9 | .size = .Many, 10 | .is_const = info.is_const, 11 | .is_volatile = info.is_volatile, 12 | .alignment = info.alignment, 13 | .address_space = info.address_space, 14 | .child = info.child, 15 | .is_allowzero = info.is_allowzero, 16 | .sentinel = info.sentinel, 17 | }}), 18 | }, 19 | else => {}, 20 | }, 21 | else => {}, 22 | } 23 | @compileError("reverse iterator expects slice, found " ++ @typeName(T)); 24 | }; 25 | return struct { 26 | ptr: info.Pointer, 27 | index: usize, 28 | pub fn next(self: *@This()) ?info.Child { 29 | if (self.index == 0) return null; 30 | self.index -= 1; 31 | return self.ptr[self.index]; 32 | } 33 | }; 34 | } 35 | pub fn reverseIterator(slice: anytype) ReverseIterator(@TypeOf(slice)) { 36 | return .{ .ptr = slice.ptr, .index = slice.len }; 37 | } 38 | -------------------------------------------------------------------------------- /Refcounted.zig: -------------------------------------------------------------------------------- 1 | const Refcounted = @This(); 2 | 3 | const std = @import("std"); 4 | const arc = std.log.scoped(.arc); 5 | 6 | const Metadata = struct { 7 | refcount: usize, 8 | }; 9 | const alloc_prefix_len = std.mem.alignForward(usize, @sizeOf(Metadata), @alignOf(Metadata)); 10 | 11 | data_ptr: [*]u8, 12 | pub fn alloc(allocator: std.mem.Allocator, len: usize) error{OutOfMemory}!Refcounted { 13 | const alloc_len = Refcounted.alloc_prefix_len + len; 14 | const full = try allocator.alignedAlloc(u8, @alignOf(Refcounted.Metadata), alloc_len); 15 | const buf = Refcounted{ .data_ptr = full.ptr + Refcounted.alloc_prefix_len }; 16 | buf.getMetadataRef().refcount = 1; 17 | arc.debug( 18 | "alloc {} (full={}) returning data_ptr 0x{x}", 19 | .{len, alloc_len, @intFromPtr(buf.data_ptr)}, 20 | ); 21 | return buf; 22 | } 23 | pub fn getMetadataRef(self: Refcounted) *Metadata { 24 | const addr = @intFromPtr(self.data_ptr); 25 | return @ptrFromInt(addr - alloc_prefix_len); 26 | } 27 | pub fn addRef(self: Refcounted) void { 28 | // TODO: what is AtomicOrder supposed to be? 29 | const old_count = @atomicRmw(usize, &self.getMetadataRef().refcount, .Add, 1, .seq_cst); 30 | arc.debug("addRef data_ptr=0x{x} new_count={}", .{@intFromPtr(self.data_ptr), old_count + 1}); 31 | } 32 | pub fn unref(self: Refcounted, allocator: std.mem.Allocator, len: usize) void { 33 | const base_addr = @intFromPtr(self.data_ptr) - alloc_prefix_len; 34 | // TODO: what is AtomicOrder supposed to be? 35 | const old_count = @atomicRmw(usize, &@as(*Metadata, @ptrFromInt(base_addr)).refcount, .Sub, 1, .seq_cst); 36 | std.debug.assert(old_count != 0); 37 | if (old_count == 1) { 38 | arc.debug("free full_len={} (data_ptr=0x{x})", .{alloc_prefix_len + len, @intFromPtr(self.data_ptr)}); 39 | allocator.free(@as([*]u8, @ptrFromInt(base_addr))[0 .. alloc_prefix_len + len]); 40 | } else { 41 | arc.debug("unref full_len={} (data_ptr=0x{x}) new_count={}", .{ 42 | alloc_prefix_len + len, 43 | @intFromPtr(self.data_ptr), 44 | old_count - 1, 45 | }); 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /make-renderer-webpage.zig: -------------------------------------------------------------------------------- 1 | const std = @import("std"); 2 | 3 | fn oom(e: error{OutOfMemory}) noreturn { 4 | @panic(@errorName(e)); 5 | } 6 | fn fatal(comptime fmt: []const u8, args: anytype) noreturn { 7 | std.log.err(fmt, args); 8 | std.process.exit(0xff); 9 | } 10 | 11 | var arena = std.heap.ArenaAllocator.init(std.heap.page_allocator); 12 | 13 | pub fn main() !u8 { 14 | const args = blk: { 15 | const all_args = try std.process.argsAlloc(arena.allocator()); 16 | var non_option_len: usize = 0; 17 | for (all_args[1..]) |arg| { 18 | if (!std.mem.startsWith(u8, arg, "-")) { 19 | all_args[non_option_len] = arg; 20 | non_option_len += 1; 21 | } else { 22 | fatal("unknown cmdline option '{s}'", .{arg}); 23 | } 24 | } 25 | break :blk all_args[0 .. non_option_len]; 26 | }; 27 | if (args.len != 3) { 28 | try std.io.getStdErr().writer().writeAll( 29 | "Usage: make-renderer-webpage WASM_FILE HTML_TEMPLATE OUT_FILE\n", 30 | ); 31 | return 0xff; 32 | } 33 | const wasm_filename = args[0]; 34 | const html_template_filename = args[1]; 35 | const out_filename = args[2]; 36 | const wasm_base64 = try getWasmBase64(wasm_filename); 37 | const html_template = try readFile(html_template_filename); 38 | const marker = "<@INSERT_WASM_HERE@>"; 39 | const wasm_marker = std.mem.indexOf(u8, html_template, marker) orelse { 40 | std.log.err("{s} is missing wasm marker '{s}'", .{html_template_filename, marker}); 41 | return 0xff; 42 | }; 43 | { 44 | if (std.fs.path.dirname(out_filename)) |out_dir| { 45 | try std.fs.cwd().makePath(out_dir); 46 | } 47 | var out_file = try std.fs.cwd().createFile(out_filename, .{}); 48 | defer out_file.close(); 49 | try out_file.writer().writeAll(html_template[0 .. wasm_marker]); 50 | try out_file.writer().writeAll(wasm_base64); 51 | try out_file.writer().writeAll(html_template[wasm_marker + marker.len..]); 52 | } 53 | return 0; 54 | } 55 | 56 | fn getWasmBase64(filename: []const u8) ![]u8 { 57 | const bin = try readFile(filename); 58 | defer arena.allocator().free(bin); 59 | 60 | const encoder = &std.base64.standard.Encoder; 61 | const b64 = try arena.allocator().alloc(u8, encoder.calcSize(bin.len)); 62 | const len = encoder.encode(b64, bin).len; 63 | std.debug.assert(len == b64.len); 64 | return b64; 65 | } 66 | 67 | 68 | fn readFile(filename: []const u8) ![]u8 { 69 | var file = try std.fs.cwd().openFile(filename, .{}); 70 | defer file.close(); 71 | return file.readToEndAlloc(arena.allocator(), std.math.maxInt(usize)); 72 | } 73 | -------------------------------------------------------------------------------- /lint.zig: -------------------------------------------------------------------------------- 1 | const builtin = @import("builtin"); 2 | const std = @import("std"); 3 | const dom = @import("dom.zig"); 4 | const alext = @import("alext.zig"); 5 | 6 | pub fn oom(e: error{OutOfMemory}) noreturn { 7 | @panic(@errorName(e)); 8 | } 9 | 10 | pub fn fatal(comptime fmt: []const u8, args: anytype) noreturn { 11 | std.log.err(fmt, args); 12 | std.process.exit(0xff); 13 | } 14 | 15 | var windows_args_arena = if (builtin.os.tag == .windows) 16 | std.heap.ArenaAllocator.init(std.heap.page_allocator) else struct{}{}; 17 | 18 | pub fn cmdlineArgs() [][*:0]u8 { 19 | if (builtin.os.tag == .windows) { 20 | const slices = std.process.argsAlloc(windows_args_arena.allocator()) catch |err| switch (err) { 21 | error.OutOfMemory => oom(error.OutOfMemory), 22 | error.Overflow => @panic("Overflow while parsing command line"), 23 | }; 24 | const args = windows_args_arena.allocator().alloc([*:0]u8, slices.len - 1) catch |e| oom(e); 25 | for (slices[1..], 0..) |slice, i| { 26 | args[i] = slice.ptr; 27 | } 28 | return args; 29 | } 30 | return std.posix.argv.ptr[1 .. std.posix.argv.len]; 31 | } 32 | 33 | pub fn main() !u8 { 34 | const args = blk: { 35 | const all_args = cmdlineArgs(); 36 | var non_option_len: usize = 0; 37 | for (all_args) |arg_ptr| { 38 | const arg = std.mem.span(arg_ptr); 39 | if (!std.mem.startsWith(u8, arg, "-")) { 40 | all_args[non_option_len] = arg; 41 | non_option_len += 1; 42 | } else { 43 | fatal("unknown cmdline option '{s}'", .{arg}); 44 | } 45 | } 46 | break :blk all_args[0 .. non_option_len]; 47 | }; 48 | if (args.len != 1) { 49 | try std.io.getStdErr().writer().writeAll("Usage: lint FILE\n"); 50 | return 0xff; 51 | } 52 | const filename = std.mem.span(args[0]); 53 | 54 | var arena = std.heap.ArenaAllocator.init(std.heap.page_allocator); 55 | defer arena.deinit(); 56 | 57 | const content = blk: { 58 | var file = std.fs.cwd().openFile(filename, .{}) catch |err| { 59 | std.log.err("failed to open '{s}' with {s}", .{filename, @errorName(err)}); 60 | return 0xff; 61 | }; 62 | defer file.close(); 63 | break :blk try file.readToEndAlloc(arena.allocator(), std.math.maxInt(usize)); 64 | }; 65 | 66 | var parse_context = ParseContext{ .filename = filename }; 67 | var nodes = dom.parse(arena.allocator(), content, .{ 68 | .context = &parse_context, 69 | .on_error = onParseError, 70 | }) catch |err| switch (err) { 71 | error.ReportedParseError => return 0xff, 72 | else => |e| return e, 73 | }; 74 | alext.unmanaged.finalize(dom.Node, &nodes, arena.allocator()); 75 | try dom.dump(content, nodes.items); 76 | return 0; 77 | } 78 | 79 | const ParseContext = struct { 80 | filename: []const u8, 81 | }; 82 | 83 | fn onParseError(context_ptr: ?*anyopaque, msg: []const u8) void { 84 | const context: *ParseContext = @alignCast(@ptrCast(context_ptr)); 85 | std.io.getStdErr().writer().print("{s}: parse error: {s}\n", .{context.filename, msg}) catch |err| 86 | std.debug.panic("failed to print parse error with {s}", .{@errorName(err)}); 87 | } 88 | -------------------------------------------------------------------------------- /htmlid.zig: -------------------------------------------------------------------------------- 1 | pub const TagId = enum { 2 | a, abbr, acronym, defines, address, applet, area, article, aside, audio, 3 | b, base, basefont, Specifies, bdi, bdo, big, blockquote, body, br, button, 4 | canvas, caption, center, cite, code, col, colgroup, command, 5 | data, datalist, dd, del, details, dfn, dialog, dir, div, dl, dt, em, embed, 6 | fieldset, figcaption, figure, font, footer, form, frame, frameset, 7 | h1, h2, h3, h4, h5, h6, head, header, hr, html, i, iframe, img, input, ins, kbd, keygen, 8 | label, legend, li, link, main, map, mark, meta, meter, nav, noframes, noscript, 9 | object, ol, optgroup, option, output, p, param, picture, pre, progress, q, 10 | rp, rt, ruby, s, samp, script, section, select, small, source, span, strike, 11 | strong, style, sub, summary, sup, svg, 12 | table, tbody, td, template, textarea, tfoot, th, thead, time, title, tr, track, tt, 13 | u, ul, @"var", video, wbr, 14 | 15 | // SVG IDs (maybe this should be its own type?) 16 | circle, defs, g, line, path, polygon, polyline, text, tspan, use, 17 | }; 18 | pub const AttrId = enum { 19 | accept, @"accept-charset", accesskey, action, @"align", alt, as, @"async", autocomplete, autofocus, autoplay, 20 | bgcolor, border, charset, checked, cite, class, color, cols, colspan, content, contenteditable, controls, coords, 21 | data, datetime, default, @"defer", dir, dirname, disabled, download, draggable, 22 | enctype, @"for", form, formaction, headers, height, hidden, high, href, hreflang, @"http-equiv", 23 | id, ismap, kind, label, lang, list, loop, low, max, maxlength, media, method, min, multiple, muted, 24 | name, nomodule, novalidate, onabort, onafterprint, onbeforeprint, onbeforeunload, onblur, oncanplay, 25 | oncanplaythrough, onchange, onclick, oncontextmenu, oncopy, oncuechange, oncut, ondblclick, 26 | ondrag, ondragend, ondragenter, ondragleave, ondragover, ondragstart, ondrop, ondurationchange, 27 | onemptied, onended, onerror, onfocus, onhashchange, oninput, oninvalid, onkeydown, onkeypress, 28 | onkeyup, onload, onloadeddata, onloadedmetadata, onloadstart, onmousedown, onmousemove, 29 | onmouseout, onmouseover, onmouseup, onmousewheel, onoffline, ononline, onpagehide, onpageshow, 30 | onpaste, onpause, onplay, onplaying, onpopstate, onprogress, onratechange, onreset, onresize, 31 | onscroll, onsearch, onseeked, onseeking, onselect, onstalled, onstorage, onsubmit, onsuspend, 32 | ontimeupdate, ontoggle, onunload, onvolumechange, onwaiting, onwheel, open, optimum, pattern, 33 | placeholder, poster, preload, readonly, rel, required, reversed, rows, rowspan, 34 | sandbox, scope, selected, shape, size, sizes, span, spellcheck, src, srcdoc, srclang, srcset, 35 | start, step, style, tabindex, target, title, translate, type, usemap, value, width, wrap, 36 | 37 | // SVG IDs (maybe this should be its own type?) 38 | @"alignment-baseline", @"baseline-shift", clip, @"clip-path", @"clip-rule", 39 | @"color-interpolation", @"color-interpolation-filters", @"color-profile", @"color-rendering", 40 | cursor, cx, cy, d, direction, display, @"dominant-baseline", @"enable-background", fill, @"fill-opacity", 41 | @"fill-rule", filter, @"flood-color", @"flood-opacity", @"font-family", @"font-size", @"font-size-adjust", 42 | @"font-stretch", @"font-style", @"font-variant", @"font-weight", @"glyph-orientation-horizontal", 43 | @"glyph-orientation-vertical", @"image-rendering", kerning, @"letter-spacing", @"lighting-color", 44 | @"marker-end", @"marker-mid", @"marker-start", mask, opacity, overflow, @"pointer-events", points, r, 45 | @"shape-rendering", space, @"solid-color", @"solid-opacity", @"stop-color", @"stop-opacity", stroke, 46 | @"stroke-dasharray", @"stroke-dashoffset", @"stroke-linecap", @"stroke-linejoin", @"stroke-miterlimit", 47 | @"stroke-opacity", @"stroke-width", @"text-anchor", @"text-decoration", @"text-rendering", transform, 48 | @"unicode-bidi", @"vector-effect", viewBox, visibility, @"word-spacing", @"writing-mode", 49 | x, x1, x2, xmlns, y, y1, y2, 50 | }; 51 | -------------------------------------------------------------------------------- /Layout.md: -------------------------------------------------------------------------------- 1 | # Layout 2 | 3 | My notes on HTML Layout. 4 | 5 | ## Differences between Horizontal and Vertical 6 | 7 | Who determines the size of things in an HTML/CSS layout? 8 | Here's my understanding of the defaults so far: 9 | 10 | ```css 11 | /*[viewport]*/ { 12 | width: [readonly-set-for-us]; 13 | height: [readonly-set-for-us]; 14 | } 15 | html { 16 | width: auto; 17 | height: max-content; /* is this right, maybe fit or min content */ 18 | } 19 | body { 20 | width: auto; 21 | height: max-content; /* is this right, maybe fit or min content */ 22 | margin: 8; // seems to be the default in chrome at least 23 | } 24 | ``` 25 | 26 | My understanding is that for `display: block` elements, `width: auto` means `width: 100%`. 27 | Note that percentage sizes are a percentage of the size of the parent container. 28 | This means the size comes from the parent container rather than the content. 29 | 30 | From the defaults above, the top-level elements get their width from the viewport and their 31 | height from their content, meaning that HTML behaves differently in the X/Y direction by default. 32 | 33 | > NOTE: for `display: inline-block` elements, `width: auto` means `max-content` I think? 34 | you can see this by setting display to `inline-block` on the body and see that its 35 | width will grow to fit its content like it normally does in the y direction. 36 | 37 | Also note that `display: flex` seems to behave like `display: block` in this respect, namely, 38 | that by default its width is `100%` (even for elements who default to `display: inline-block` like `span`) 39 | and its height is `max-content` (I think?). 40 | 41 | NOTE: fit-content is a value between min/max content determined by this conditional: 42 | ``` 43 | if available >= max-content 44 | fit-content = max-content 45 | if available >= min-content 46 | fit-content = available 47 | else 48 | fit-content = min-content 49 | ``` 50 | 51 | ## Flexbox 52 | 53 | There's a "main axis" and "cross axis". 54 | Set `display: flex` to make an element a "flex container". 55 | All its "direct children" become "flex items". 56 | 57 | ### Flex Container Properties 58 | 59 | #### flex-direction: direction to place items 60 | 61 | - row: left to right 62 | - row-reverse: right to left 63 | - column: top to bottom 64 | - coloumn-reverse: bottom to top 65 | 66 | #### justify-content: where to put the "extra space" on the main axis 67 | 68 | - flex-start (default): items packed to start so all "extra space" at the end 69 | - flex-end: items packed to end so all "extra space" at the start 70 | - center: "extra space" evenly split between start/end 71 | - space-between: "extra space" evenly split between all items 72 | - space-evenly: "exta space" evently split between and around all items 73 | - space-around (dumb): like space-evenly but start/end space is halfed 74 | 75 | #### align-items: how to align (or stretch) items on the cross axis 76 | 77 | - flex-start 78 | - flex-end 79 | - center 80 | - baseline: all items aligned so their "baselines" align 81 | - stretch 82 | 83 | 84 | By default flexbox only has a single main axis, the following properties apply to flex containers 85 | that allow multiple lines: 86 | 87 | #### flex-wrap 88 | 89 | - nowrap (default): keep all items on the same main axis, may cause overflow 90 | - wrap: allow multiple "main axis" 91 | - wrap-reverse: new axis are added in the "opposite cross direction" of a normal wrap 92 | for example, for flex-direction "row", new wrapped lines would go 93 | on top of the previous line instead of below. 94 | 95 | ### align-content: where to put the "extra space" on the cross axis 96 | 97 | Note that this is only applicable when wrapping multiple lines. 98 | 99 | Same values as "justify-content" except it doesn't have "space-evenly" 100 | and it adds "stretch", which is the default. 101 | 102 | #### flex-flow 103 | 104 | Shorthand for `flex-direction` and `flex-wrap`. 105 | 106 | ### Flex Item Properties 107 | 108 | #### order: set the item's "order group" 109 | 110 | All items in a lower "order group" come first. 111 | The default "order group" is 0. 112 | Order can be negative. 113 | 114 | #### align-self: how to align (or strech) this item on the cross axis 115 | 116 | Same as "align-items" on the container except it affects this one item. 117 | 118 | 119 | ### Flex Layout Algorithm 120 | 121 | See if I can come up with a set of steps that can be done independently of each other to layout a flexbox. 122 | 123 | - Step ?: if there is "extra space" on the main axis, position items based on justify-content 124 | -------------------------------------------------------------------------------- /test/svg.html: -------------------------------------------------------------------------------- 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 | 0.59 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | +0.30 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | 50 | +0.11 51 | 52 | 53 | 54 | 55 | 56 | 57 | 58 | 59 | 60 | 61 | 100% 62 | 89% 63 | 70% 64 | 59% 65 | 41% 66 | 30% 67 | 11% 68 | 0% 69 | 70 | TEST 71 | TEST 72 | 73 | 74 | -------------------------------------------------------------------------------- /render.zig: -------------------------------------------------------------------------------- 1 | const std = @import("std"); 2 | const dom = @import("dom.zig"); 3 | const layout = @import("layout.zig"); 4 | const XY = layout.XY; 5 | const LayoutNode = layout.LayoutNode; 6 | 7 | pub const Op = union(enum) { 8 | rect: struct { 9 | x: u32, y: u32, 10 | w: u32, h: u32, 11 | fill: bool, 12 | color: u32, 13 | }, 14 | text: struct { 15 | x: u32, y: u32, 16 | size: u32, 17 | slice: []const u8, 18 | }, 19 | }; 20 | 21 | // TODO: it would be better to make this an iterator, but, that would be 22 | // more complex/harder to implement. I can maybe do that later an 23 | // an improvement to the API. 24 | pub fn render( 25 | html: []const u8, 26 | dom_nodes: []const dom.Node, 27 | layout_nodes: []const LayoutNode, 28 | comptime Ctx: type, 29 | onRender: anytype, 30 | ctx: Ctx, 31 | ) !void { 32 | _ = html; 33 | 34 | var next_color_index: usize = 0; 35 | var next_no_relative_position_box_y: i32 = 200; 36 | 37 | var current_box_content_pos = XY(i32){ .x = 0, .y = 0 }; 38 | 39 | for (layout_nodes, 0..) |node, node_index| switch (node) { 40 | .box => |b| { 41 | if (b.content_size.x.getResolved() == null or b.content_size.y.getResolved() == null) { 42 | std.log.warn("box size at index {} not resolved, should be impossible once fully implemented", .{node_index}); 43 | } else { 44 | const content_size = XY(u32){ 45 | .x = b.content_size.x.getResolved().?, 46 | .y = b.content_size.y.getResolved().?, 47 | }; 48 | 49 | const color = unique_colors[next_color_index]; 50 | next_color_index = (next_color_index + 1) % unique_colors.len; 51 | 52 | const x = current_box_content_pos.x + @as(i32, @intCast(b.relative_content_pos.x)); 53 | { 54 | const y = current_box_content_pos.y + @as(i32, @intCast(b.relative_content_pos.y)); 55 | try onRender(ctx, .{ .rect = .{ 56 | .x = @intCast(x), .y = @intCast(y), 57 | .w = content_size.x, .h = content_size.y, 58 | .fill = true, .color = color, 59 | }}); 60 | } 61 | 62 | const explode_view = true; 63 | if (explode_view) { 64 | const y = next_no_relative_position_box_y; 65 | next_no_relative_position_box_y += @as(i32, @intCast(content_size.y)) + 5; 66 | 67 | try onRender(ctx, .{ .rect = .{ 68 | .x = @intCast(x), .y = @intCast(y), 69 | .w = content_size.x, .h = content_size.y, 70 | .fill = false, .color = color, 71 | }}); 72 | var text_buf: [300]u8 = undefined; 73 | const msg = std.fmt.bufPrint( 74 | &text_buf, 75 | "box index={} {s} {}x{}", .{ 76 | node_index, 77 | switch (dom_nodes[b.dom_node]) { 78 | .start_tag => |t| @tagName(t.id), 79 | .text => @as([]const u8, "text"), 80 | else => unreachable, 81 | }, 82 | content_size.x, 83 | content_size.y, 84 | }, 85 | ) catch unreachable; 86 | const font_size = 10; 87 | try onRender(ctx, .{ .text = .{ 88 | .x = @as(u32, @intCast(x))+1, .y = @as(u32, @intCast(y))+1, 89 | .size = font_size, .slice = msg, 90 | }}); 91 | } 92 | } 93 | 94 | current_box_content_pos = .{ 95 | .x = current_box_content_pos.x + @as(i32, @intCast(b.relative_content_pos.x)), 96 | .y = current_box_content_pos.y + @as(i32, @intCast(b.relative_content_pos.y)), 97 | }; 98 | }, 99 | .end_box => |box_index| { 100 | const b = switch (layout_nodes[box_index]) { 101 | .box => |*b| b, 102 | else => unreachable, 103 | }; 104 | current_box_content_pos = .{ 105 | .x = current_box_content_pos.x - @as(i32, @intCast(b.relative_content_pos.x)), 106 | .y = current_box_content_pos.y - @as(i32, @intCast(b.relative_content_pos.y)), 107 | }; 108 | }, 109 | .text => |t| { 110 | var line_it = layout.textLineIterator(t.font, t.first_line_x, t.max_width, t.slice); 111 | 112 | const first_line = line_it.first(); 113 | // TODO: set this correctly 114 | const abs_x_i32 = current_box_content_pos.x + @as(i32, @intCast(t.relative_content_pos.x)); 115 | var abs_y_i32 = current_box_content_pos.y + @as(i32, @intCast(t.relative_content_pos.y)); 116 | // TODO: abs_x should be signed 117 | const abs_x_u32: u32 = @intCast(abs_x_i32); 118 | // TODO: abs_y should be signed 119 | var abs_y_u32: u32 = @intCast(abs_y_i32); 120 | try onRender(ctx, .{ .text = .{ .x = abs_x_u32 + t.first_line_x, .y = abs_y_u32, .size = t.font.size, .slice = first_line.slice }}); 121 | // TODO: this first_line_height won't be correct right now if there 122 | // is another element after us on the same line with a bigger height 123 | abs_y_i32 += @intCast(t.first_line_height); 124 | abs_y_u32 += t.first_line_height; 125 | while (line_it.next()) |line| { 126 | try onRender(ctx, .{ .text = .{ .x = abs_x_u32, .y = abs_y_u32, .size = t.font.size, .slice = line.slice }}); 127 | abs_y_i32 += @intCast(t.font.getLineHeight()); 128 | abs_y_u32 += t.font.getLineHeight(); 129 | } 130 | }, 131 | .svg => { 132 | std.log.info("TODO: draw svg!", .{}); 133 | }, 134 | }; 135 | } 136 | 137 | var unique_colors = [_]u32 { 138 | 0xe6194b, 0x3cb44b, 0xffe119, 0x4363d8, 0xf58231, 0x911eb4, 0x46f0f0, 139 | 0xf032e6, 0xbcf60c, 0xfabebe, 0x008080, 0xe6beff, 0x9a6324, 0xfffac8, 140 | 0x800000, 0xaaffc3, 0x808000, 0xffd8b1, 0x000075, 0x808080, 141 | }; 142 | -------------------------------------------------------------------------------- /wasmrenderer.zig: -------------------------------------------------------------------------------- 1 | const std = @import("std"); 2 | const dom = @import("dom.zig"); 3 | const layout = @import("layout.zig"); 4 | const Styler = layout.Styler; 5 | const LayoutNode = layout.LayoutNode; 6 | const alext = @import("alext.zig"); 7 | const render = @import("render.zig"); 8 | 9 | const Refcounted = @import("Refcounted.zig"); 10 | 11 | const XY = layout.XY; 12 | 13 | const js = struct { 14 | extern fn logWrite(ptr: [*]const u8, len: usize) void; 15 | extern fn logFlush() void; 16 | extern fn initCanvas() void; 17 | extern fn canvasClear() void; 18 | extern fn strokeRgb(rgb: u32) void; 19 | extern fn strokeRect(x: u32, y: u32, width: u32, height: u32) void; 20 | extern fn fillRgb(rgb: u32) void; 21 | extern fn fillRect(x: u32, y: u32, width: u32, height: u32) void; 22 | extern fn drawText(x: u32, y: u32, font_size: usize, ptr: [*]const u8, len: usize) void; 23 | }; 24 | 25 | var gpa = std.heap.GeneralPurposeAllocator(.{}){ }; 26 | 27 | export fn alloc(len: usize) ?[*]u8 { 28 | //std.log.debug("alloc {}", .{len}); 29 | const buf = Refcounted.alloc(gpa.allocator(), len) catch { 30 | std.log.warn("alloc failed with OutOfMemory", .{}); 31 | return null; 32 | }; 33 | //std.log.debug("alloc returning 0x{x}", .{@ptrToInt(buf.data_ptr)}); 34 | return buf.data_ptr; 35 | } 36 | export fn release(ptr: [*]u8, len: usize) void { 37 | //std.log.debug("free {} (ptr=0x{x})", .{len, ptr}); 38 | const buf = Refcounted{ .data_ptr = ptr }; 39 | buf.unref(gpa.allocator(), len); 40 | } 41 | 42 | export fn onResize(width: u32, height: u32) void { 43 | const html_buf = global_opt_html_buf orelse { 44 | std.log.warn("onResize called without an html doc being loaded", .{}); 45 | return; 46 | }; 47 | const html = html_buf.buf.data_ptr[0 .. html_buf.len]; 48 | const dom_nodes = global_opt_dom_nodes orelse { 49 | std.log.warn("onResize called but there's no dom nodes", .{}); 50 | return; 51 | }; 52 | 53 | doRender(html, XY(u32).init(width, height), dom_nodes.items); 54 | } 55 | 56 | var global_opt_html_buf: ?struct { 57 | buf: Refcounted, 58 | len: usize, 59 | } = null; 60 | var global_opt_dom_nodes: ?std.ArrayListUnmanaged(dom.Node) = null; 61 | 62 | export fn loadHtml( 63 | name_ptr: [*]u8, name_len: usize, 64 | html_ptr: [*]u8, html_len: usize, 65 | viewport_width: u32, viewport_height: u32, 66 | ) void { 67 | const name = name_ptr[0 .. name_len]; 68 | 69 | if (global_opt_html_buf) |html_buf| { 70 | html_buf.buf.unref(gpa.allocator(), html_buf.len); 71 | global_opt_html_buf = null; 72 | } 73 | global_opt_html_buf = .{ .buf = Refcounted{ .data_ptr = html_ptr }, .len = html_len }; 74 | global_opt_html_buf.?.buf.addRef(); 75 | 76 | loadHtmlSlice(name, html_ptr[0 .. html_len], XY(u32).init(viewport_width, viewport_height)); 77 | } 78 | 79 | fn loadHtmlSlice( 80 | name: []const u8, 81 | html: []const u8, 82 | viewport_size: XY(u32), 83 | ) void { 84 | if (global_opt_dom_nodes) |*nodes| { 85 | nodes.deinit(gpa.allocator()); 86 | global_opt_dom_nodes = null; 87 | } 88 | 89 | std.log.info("load html from '{s}'...", .{name}); 90 | var parse_context = ParseContext{ .name = name }; 91 | 92 | var nodes = dom.parse(gpa.allocator(), html, .{ 93 | .context = &parse_context, 94 | .on_error = onParseError, 95 | }) catch |err| switch (err) { 96 | error.ReportedParseError => return, 97 | else => |e| { 98 | onParseError(&parse_context, @errorName(e)); 99 | return; 100 | }, 101 | }; 102 | alext.unmanaged.finalize(dom.Node, &nodes, gpa.allocator()); 103 | global_opt_dom_nodes = nodes; 104 | 105 | js.initCanvas(); 106 | doRender(html, viewport_size, nodes.items); 107 | } 108 | 109 | fn doRender( 110 | html: []const u8, 111 | viewport_size: XY(u32), 112 | dom_nodes: []const dom.Node, 113 | ) void { 114 | js.canvasClear(); 115 | 116 | var arena = std.heap.ArenaAllocator.init(gpa.allocator()); 117 | defer arena.deinit(); 118 | 119 | var layout_nodes = layout.layout( 120 | arena.allocator(), 121 | html, 122 | dom_nodes, 123 | viewport_size, 124 | Styler{ }, 125 | ) catch |err| { 126 | // TODO: maybe draw this error as text? 127 | std.log.err("layout failed, error={s}", .{@errorName(err)}); 128 | return; 129 | }; 130 | alext.unmanaged.finalize(LayoutNode, &layout_nodes, gpa.allocator()); 131 | render.render( 132 | html, 133 | dom_nodes, 134 | layout_nodes.items, 135 | void, 136 | &onRender, 137 | {}, 138 | ) catch |err| switch (err) { }; 139 | } 140 | 141 | fn onRender(ctx: void, op: render.Op) !void { 142 | _ = ctx; 143 | switch (op) { 144 | .rect => |r| { 145 | if (r.fill) { 146 | js.fillRgb(r.color); 147 | js.fillRect(r.x, r.y, r.w, r.h); 148 | } else { 149 | js.strokeRgb(r.color); 150 | js.strokeRect(r.x, r.y, r.w, r.h); 151 | } 152 | }, 153 | .text => |t| { 154 | js.drawText(t.x, t.y + t.size, t.size, t.slice.ptr, t.slice.len); 155 | }, 156 | } 157 | } 158 | 159 | const ParseContext = struct { 160 | name: []const u8, 161 | }; 162 | fn onParseError(context_ptr: ?*anyopaque, msg: []const u8) void { 163 | const context: *ParseContext = @alignCast(@ptrCast(context_ptr)); 164 | std.log.err("{s}: parse error: {s}", .{context.name, msg}); 165 | } 166 | 167 | const JsLogWriter = std.io.Writer(void, error{}, jsLogWrite); 168 | fn jsLogWrite(context: void, bytes: []const u8) !usize { 169 | _ = context; 170 | js.logWrite(bytes.ptr, bytes.len); 171 | return bytes.len; 172 | } 173 | pub const std_options: std.Options = .{ 174 | .logFn = log, 175 | }; 176 | pub fn log( 177 | comptime message_level: std.log.Level, 178 | comptime scope: @Type(.EnumLiteral), 179 | comptime format: []const u8, 180 | args: anytype, 181 | ) void { 182 | const level_txt = comptime message_level.asText(); 183 | const prefix = if (scope == .default) ": " else "(" ++ @tagName(scope) ++ "): "; 184 | const log_fmt = level_txt ++ prefix ++ format; 185 | const writer = JsLogWriter{ .context = {} }; 186 | std.fmt.format(writer, log_fmt, args) catch unreachable; 187 | js.logFlush(); 188 | } 189 | -------------------------------------------------------------------------------- /html-css-renderer.template.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | html-css-renderer 4 | 43 | 198 | 199 | 200 |
201 |
202 | URL: 203 | 204 |
205 |
206 | Text: 207 | 208 |
209 |
210 |
211 |
212 |

Custom Renderer

213 |
214 | 215 |
216 |
217 |
218 |

Native Iframe

219 |
220 | 221 |
222 |
223 |
224 | 225 | 226 | -------------------------------------------------------------------------------- /testrunner.zig: -------------------------------------------------------------------------------- 1 | const builtin = @import("builtin"); 2 | const std = @import("std"); 3 | 4 | //pub const log_level = .err; 5 | 6 | const dom = @import("dom.zig"); 7 | const layout = @import("layout.zig"); 8 | const XY = layout.XY; 9 | const Styler = layout.Styler; 10 | const render = @import("render.zig"); 11 | const alext = @import("alext.zig"); 12 | const schrift = @import("font/schrift.zig"); 13 | 14 | var global_arena = std.heap.ArenaAllocator.init(std.heap.page_allocator); 15 | 16 | pub fn oom(e: error{OutOfMemory}) noreturn { 17 | @panic(@errorName(e)); 18 | } 19 | 20 | pub fn fatal(comptime fmt: []const u8, args: anytype) noreturn { 21 | std.log.err(fmt, args); 22 | std.process.exit(0xff); 23 | } 24 | 25 | var windows_args_arena = if (builtin.os.tag == .windows) 26 | std.heap.ArenaAllocator.init(std.heap.page_allocator) else struct{}{}; 27 | 28 | pub fn cmdlineArgs() [][*:0]u8 { 29 | if (builtin.os.tag == .windows) { 30 | const slices = std.process.argsAlloc(windows_args_arena.allocator()) catch |err| switch (err) { 31 | error.OutOfMemory => oom(error.OutOfMemory), 32 | error.Overflow => @panic("Overflow while parsing command line"), 33 | }; 34 | const args = windows_args_arena.allocator().alloc([*:0]u8, slices.len - 1) catch |e| oom(e); 35 | for (slices[1..], 0..) |slice, i| { 36 | args[i] = slice.ptr; 37 | } 38 | return args; 39 | } 40 | return std.os.argv.ptr[1 .. std.os.argv.len]; 41 | } 42 | 43 | pub fn main() !u8 { 44 | const args = blk: { 45 | const all_args = cmdlineArgs(); 46 | var non_option_len: usize = 0; 47 | for (all_args) |arg_ptr| { 48 | const arg = std.mem.span(arg_ptr); 49 | if (!std.mem.startsWith(u8, arg, "-")) { 50 | all_args[non_option_len] = arg; 51 | non_option_len += 1; 52 | } else { 53 | fatal("unknown cmdline option '{s}'", .{arg}); 54 | } 55 | } 56 | break :blk all_args[0 .. non_option_len]; 57 | }; 58 | 59 | if (args.len == 0) { 60 | try std.io.getStdErr().writer().writeAll("Usage: testrunner TEST_FILE\n"); 61 | return 0xff; 62 | } 63 | if (args.len != 1) 64 | fatal("expected 1 cmd-line arg but got {}", .{args.len}); 65 | 66 | const filename = std.mem.span(args[0]); 67 | 68 | const content = blk: { 69 | var file = std.fs.cwd().openFile(filename, .{}) catch |err| 70 | fatal("failed to open '{s}' with {s}", .{filename, @errorName(err)}); 71 | defer file.close(); 72 | break :blk try file.readToEndAlloc(global_arena.allocator(), std.math.maxInt(usize)); 73 | }; 74 | 75 | 76 | const Expected = struct { 77 | out: []const u8, 78 | viewport_size: XY(u32), 79 | }; 80 | const expected: Expected = expected_blk: { 81 | var line_it = std.mem.split(u8, content, "\n"); 82 | { 83 | const line = line_it.first(); 84 | if (!std.mem.eql(u8, line, "")) 85 | fatal("expected first line of test file to be '' but got '{}'", .{std.zig.fmtEscapes(line)}); 86 | } 87 | { 88 | const line = line_it.next() orelse fatal("test file is missing '' to delimit end of expected output", .{}); 111 | if (std.mem.eql(u8, line, "-->")) break :expected_blk .{ 112 | .out = std.mem.trimRight(u8, content[start .. last_start], "\n"), 113 | .viewport_size = viewport_size, 114 | }; 115 | } 116 | }; 117 | 118 | var parse_context = ParseContext{ .filename = filename }; 119 | var dom_nodes = dom.parse(global_arena.allocator(), content, .{ 120 | .context = &parse_context, 121 | .on_error = onParseError, 122 | }) catch |err| switch (err) { 123 | error.ReportedParseError => return 0xff, 124 | else => |e| return e, 125 | }; 126 | alext.unmanaged.finalize(dom.Node, &dom_nodes, global_arena.allocator()); 127 | 128 | //try dom.dump(content, dom_nodes.items); 129 | var layout_nodes = layout.layout( 130 | global_arena.allocator(), 131 | content, 132 | dom_nodes.items, 133 | expected.viewport_size, 134 | Styler{ }, 135 | ) catch |err| 136 | // TODO: maybe draw this error as text? 137 | fatal("layout failed, error={s}", .{@errorName(err)}); 138 | alext.unmanaged.finalize(layout.LayoutNode, &layout_nodes, global_arena.allocator()); 139 | 140 | var actual_out_al = std.ArrayListUnmanaged(u8){ }; 141 | try actual_out_al.ensureTotalCapacity(global_arena.allocator(), expected.out.len); 142 | for (layout_nodes.items) |node| { 143 | try node.serialize(actual_out_al.writer(global_arena.allocator())); 144 | } 145 | const actual_out = std.mem.trimRight(u8, actual_out_al.items, "\n"); 146 | 147 | const stdout = std.io.getStdOut().writer(); 148 | if (std.mem.eql(u8, expected.out, actual_out)) { 149 | try stdout.writeAll("Success\n"); 150 | return 0; 151 | } 152 | try stdout.writeAll("Layout Mismatch:\n"); 153 | try stdout.print( 154 | \\------- expected ------- 155 | \\{s} 156 | \\------- actual ------- 157 | \\{s} 158 | \\------------------------ 159 | \\ 160 | , .{expected.out, actual_out}); 161 | { 162 | var file = try std.fs.cwd().createFile("expected", .{}); 163 | defer file.close(); 164 | try file.writer().writeAll(expected.out); 165 | } 166 | { 167 | var file = try std.fs.cwd().createFile("actual", .{}); 168 | defer file.close(); 169 | try file.writer().writeAll(actual_out); 170 | } 171 | return 0xff; 172 | } 173 | 174 | const ParseContext = struct { 175 | filename: []const u8, 176 | }; 177 | 178 | fn onParseError(context_ptr: ?*anyopaque, msg: []const u8) void { 179 | const context: *ParseContext = @alignCast(@ptrCast(context_ptr)); 180 | std.io.getStdErr().writer().print("{s}: parse error: {s}\n", .{context.filename, msg}) catch |err| 181 | std.debug.panic("failed to print parse error with {s}", .{@errorName(err)}); 182 | } 183 | -------------------------------------------------------------------------------- /imagerenderer.zig: -------------------------------------------------------------------------------- 1 | const builtin = @import("builtin"); 2 | const std = @import("std"); 3 | 4 | const dom = @import("dom.zig"); 5 | const layout = @import("layout.zig"); 6 | const XY = layout.XY; 7 | const Styler = layout.Styler; 8 | const render = @import("render.zig"); 9 | const alext = @import("alext.zig"); 10 | const schrift = @import("font/schrift.zig"); 11 | 12 | var global_arena = std.heap.ArenaAllocator.init(std.heap.page_allocator); 13 | 14 | pub fn oom(e: error{OutOfMemory}) noreturn { 15 | @panic(@errorName(e)); 16 | } 17 | 18 | pub fn fatal(comptime fmt: []const u8, args: anytype) noreturn { 19 | std.log.err(fmt, args); 20 | std.process.exit(0xff); 21 | } 22 | 23 | var windows_args_arena = if (builtin.os.tag == .windows) 24 | std.heap.ArenaAllocator.init(std.heap.page_allocator) else struct{}{}; 25 | 26 | pub fn cmdlineArgs() [][*:0]u8 { 27 | if (builtin.os.tag == .windows) { 28 | const slices = std.process.argsAlloc(windows_args_arena.allocator()) catch |err| switch (err) { 29 | error.OutOfMemory => oom(error.OutOfMemory), 30 | error.Overflow => @panic("Overflow while parsing command line"), 31 | }; 32 | const args = windows_args_arena.allocator().alloc([*:0]u8, slices.len - 1) catch |e| oom(e); 33 | for (slices[1..], 0..) |slice, i| { 34 | args[i] = slice.ptr; 35 | } 36 | return args; 37 | } 38 | return std.os.argv.ptr[1 .. std.os.argv.len]; 39 | } 40 | 41 | fn getCmdOpt(args: [][*:0]u8, i: *usize) []u8 { 42 | i.* += 1; 43 | if (i.* == args.len) { 44 | std.log.err("cmdline option '{s}' requires an argument", .{args[i.* - 1]}); 45 | std.process.exit(0xff); 46 | } 47 | return std.mem.span(args[i.*]); 48 | } 49 | 50 | pub fn main() !u8 { 51 | const viewport_width: u32 = 600; 52 | var viewport_height: u32 = 600; 53 | 54 | const args = blk: { 55 | const all_args = cmdlineArgs(); 56 | var non_option_len: usize = 0; 57 | var i: usize = 0; 58 | while (i < all_args.len) : (i += 1) { 59 | const arg = std.mem.span(all_args[i]); 60 | if (!std.mem.startsWith(u8, arg, "-")) { 61 | all_args[non_option_len] = arg; 62 | non_option_len += 1; 63 | } else if (std.mem.eql(u8, arg, "--height")) { 64 | const str = getCmdOpt(all_args, &i); 65 | viewport_height = std.fmt.parseInt(u32, str, 10) catch |err| 66 | fatal("invalid height '{s}': {s}", .{ str, @errorName(err) }); 67 | } else { 68 | fatal("unknown cmdline option '{s}'", .{arg}); 69 | } 70 | } 71 | break :blk all_args[0 .. non_option_len]; 72 | }; 73 | if (args.len != 1) { 74 | try std.io.getStdErr().writer().writeAll("Usage: imagerenderer FILE\n"); 75 | return 0xff; 76 | } 77 | const filename = std.mem.span(args[0]); 78 | 79 | const content = blk: { 80 | var file = std.fs.cwd().openFile(filename, .{}) catch |err| 81 | fatal("failed to open '{s}' with {s}", .{filename, @errorName(err)}); 82 | defer file.close(); 83 | break :blk try file.readToEndAlloc(global_arena.allocator(), std.math.maxInt(usize)); 84 | }; 85 | 86 | var parse_context = ParseContext{ .filename = filename }; 87 | var dom_nodes = dom.parse(global_arena.allocator(), content, .{ 88 | .context = &parse_context, 89 | .on_error = onParseError, 90 | }) catch |err| switch (err) { 91 | error.ReportedParseError => return 0xff, 92 | else => |e| return e, 93 | }; 94 | alext.unmanaged.finalize(dom.Node, &dom_nodes, global_arena.allocator()); 95 | 96 | //try dom.dump(content, dom_nodes.items); 97 | 98 | var layout_nodes = layout.layout( 99 | global_arena.allocator(), 100 | content, 101 | dom_nodes.items, 102 | .{ .x = viewport_width, .y = viewport_height }, 103 | Styler{ }, 104 | ) catch |err| 105 | // TODO: maybe draw this error as text? 106 | fatal("layout failed, error={s}", .{@errorName(err)}); 107 | alext.unmanaged.finalize(layout.LayoutNode, &layout_nodes, global_arena.allocator()); 108 | 109 | const render_ctx = RenderCtx { 110 | .viewport_width = viewport_width, 111 | .viewport_height = viewport_height, 112 | .stride = viewport_width * bytes_per_pixel, 113 | .image = try global_arena.allocator().alloc( 114 | u8, 115 | @as(usize, viewport_width) * @as(usize, viewport_height) * 3, 116 | ), 117 | }; 118 | @memset(render_ctx.image, 0xff); 119 | try render.render(content, dom_nodes.items, layout_nodes.items, RenderCtx, &onRender, render_ctx); 120 | 121 | var out_file = try std.fs.cwd().createFile("render.ppm", .{}); 122 | defer out_file.close(); 123 | const writer = out_file.writer(); 124 | try writer.print("P6\n{} {}\n255\n", .{viewport_width, viewport_height}); 125 | try writer.writeAll(render_ctx.image); 126 | 127 | return 0; 128 | } 129 | 130 | const ParseContext = struct { 131 | filename: []const u8, 132 | }; 133 | 134 | fn onParseError(context_ptr: ?*anyopaque, msg: []const u8) void { 135 | const context: *ParseContext = @alignCast(@ptrCast(context_ptr)); 136 | std.io.getStdErr().writer().print("{s}: parse error: {s}\n", .{context.filename, msg}) catch |err| 137 | std.debug.panic("failed to print parse error with {s}", .{@errorName(err)}); 138 | } 139 | 140 | const bytes_per_pixel = 3; 141 | 142 | const RenderCtx = struct { 143 | viewport_width: u32, 144 | viewport_height: u32, 145 | stride: usize, 146 | image: []u8, 147 | }; 148 | 149 | fn onRender(ctx: RenderCtx, op: render.Op) !void { 150 | switch (op) { 151 | .rect => |r| { 152 | // TODO: adjust the rectangle to make sure we only render 153 | // what's visible in the viewport 154 | var image_offset: usize = (r.y * ctx.stride) + (r.x * bytes_per_pixel); 155 | if (r.fill) { 156 | var row: usize = 0; 157 | while (row < r.h) : (row += 1) { 158 | if (image_offset >= ctx.image.len) return; 159 | drawRow(ctx.image[image_offset..], r.w, r.color); 160 | image_offset += ctx.stride; 161 | } 162 | } else { 163 | if (image_offset >= ctx.image.len) return; 164 | drawRow(ctx.image[image_offset..], r.w, r.color); 165 | var row: usize = 1; 166 | image_offset += ctx.stride; 167 | while (row + 1 < r.h) : (row += 1) { 168 | if (image_offset >= ctx.image.len) return; 169 | drawPixel(ctx.image[image_offset..], r.color); 170 | const right = image_offset + ((r.w - 1) * bytes_per_pixel); 171 | if (right >= ctx.image.len) return; 172 | drawPixel(ctx.image[right..], r.color); 173 | image_offset += ctx.stride; 174 | } 175 | if (image_offset >= ctx.image.len) return; 176 | drawRow(ctx.image[image_offset..], r.w, r.color); 177 | } 178 | }, 179 | .text => |t| { 180 | // TODO: maybe don't remap/unmap pages for each drawText call? 181 | var arena = std.heap.ArenaAllocator.init(std.heap.page_allocator); 182 | defer arena.deinit(); 183 | drawText(arena.allocator(), ctx.image, ctx.stride, t.x, t.y, t.size, t.slice); 184 | }, 185 | } 186 | } 187 | 188 | fn drawPixel(img: []u8, color: u32) void { 189 | img[0] = @as(u8, @intCast(0xff & (color >> 16))); 190 | img[1] = @as(u8, @intCast(0xff & (color >> 8))); 191 | img[2] = @as(u8, @intCast(0xff & (color >> 0))); 192 | } 193 | fn drawRow(img: []u8, width: u32, color: u32) void { 194 | var offset: usize = 0; 195 | const limit: usize = width * bytes_per_pixel; 196 | while (offset < limit) : (offset += bytes_per_pixel) { 197 | drawPixel(img[offset..], color); 198 | } 199 | } 200 | 201 | const times_new_roman = struct { 202 | pub const ttf = @embedFile("font/times-new-roman.ttf"); 203 | pub const info = schrift.getTtfInfo(ttf) catch unreachable; 204 | }; 205 | fn drawText( 206 | allocator: std.mem.Allocator, 207 | img: []u8, 208 | img_stride: usize, 209 | x: u32, y: u32, 210 | font_size: u32, 211 | text: []const u8, 212 | ) void { 213 | //std.log.info("drawText at {}, {} size={} text='{s}'", .{x, y, font_size, text}); 214 | const scale: f64 = @floatFromInt(font_size); 215 | 216 | var pixels_array_list = std.ArrayListUnmanaged(u8){ }; 217 | 218 | const lmetrics = schrift.lmetrics(times_new_roman.ttf, times_new_roman.info, scale) catch |err| 219 | std.debug.panic("failed to get lmetrics with {s}", .{@errorName(err)}); 220 | var next_x = x; 221 | 222 | var it = std.unicode.Utf8Iterator{ .bytes = text, .i = 0 }; 223 | while (it.nextCodepoint()) |c| { 224 | const gid = schrift.lookupGlyph(times_new_roman.ttf, c) catch |err| 225 | std.debug.panic("failed to get glyph id for {}: {s}", .{c, @errorName(err)}); 226 | const downward = true; 227 | const gmetrics = schrift.gmetrics( 228 | times_new_roman.ttf, 229 | times_new_roman.info, 230 | downward, 231 | .{ .x = scale, .y = scale }, 232 | .{ .x = 0, .y = 0 }, 233 | gid, 234 | ) catch |err| std.debug.panic("gmetrics for char {} failed with {s}", .{c, @errorName(err)}); 235 | const glyph_size = layout.XY(u32) { 236 | .x = std.mem.alignForward(u32, @intCast(gmetrics.min_width), 4), 237 | .y = @intCast(gmetrics.min_height), 238 | }; 239 | //std.log.info(" c={} size={}x{} adv={d:.0}", .{c, glyph_size.x, glyph_size.y, @ceil(gmetrics.advance_width)}); 240 | const pixel_len = @as(usize, glyph_size.x) * @as(usize, glyph_size.y); 241 | pixels_array_list.ensureTotalCapacity(allocator, pixel_len) catch |e| oom(e); 242 | const pixels = pixels_array_list.items.ptr[0 .. pixel_len]; 243 | schrift.render( 244 | allocator, 245 | times_new_roman.ttf, 246 | times_new_roman.info, 247 | downward, 248 | .{ .x = scale, .y = scale }, 249 | .{ .x = 0, .y = 0 }, 250 | pixels, 251 | .{ .x = @intCast(glyph_size.x), .y = @intCast(glyph_size.y) }, 252 | gid, 253 | ) catch |err| std.debug.panic("render for char {} failed with {s}", .{c, @errorName(err)}); 254 | 255 | { 256 | var glyph_y: usize = 0; 257 | while (glyph_y < glyph_size.y) : (glyph_y += 1) { 258 | const row_offset_i32 = ( 259 | @as(i32, @intCast(y)) + 260 | @as(i32, @intFromFloat(@ceil(lmetrics.ascender))) + 261 | gmetrics.y_offset + 262 | @as(i32, @intCast(glyph_y)) 263 | ) * @as(i32, @intCast(img_stride)); 264 | if (row_offset_i32 < 0) continue; 265 | 266 | const row_offset: usize = @intCast(row_offset_i32); 267 | 268 | var glyph_x: usize = 0; 269 | while (glyph_x < glyph_size.x) : (glyph_x += 1) { 270 | const image_pos = row_offset + (next_x + glyph_x) * bytes_per_pixel; 271 | if (image_pos + bytes_per_pixel <= img.len) { 272 | const grayscale = 255 - pixels[glyph_y * glyph_size.x + glyph_x]; 273 | const color = 274 | (@as(u32, grayscale) << 16) | 275 | (@as(u32, grayscale) << 8) | 276 | (@as(u32, grayscale) << 0) ; 277 | drawPixel(img[image_pos..], color); 278 | } 279 | } 280 | } 281 | } 282 | next_x += @intFromFloat(@ceil(gmetrics.advance_width)); 283 | } 284 | } 285 | -------------------------------------------------------------------------------- /dom.zig: -------------------------------------------------------------------------------- 1 | // see https://dom.spec.whatwg.org/#nodes 2 | const std = @import("std"); 3 | 4 | const HtmlTokenizer = @import("HtmlTokenizer.zig"); 5 | const Token = HtmlTokenizer.Token; 6 | 7 | const htmlid = @import("htmlid.zig"); 8 | const TagId = htmlid.TagId; 9 | const AttrId = htmlid.AttrId; 10 | const SvgTagId = htmlid.SvgTagId; 11 | const SvgAttrId = htmlid.SvgAttrId; 12 | 13 | const htmlidmaps = @import("htmlidmaps.zig"); 14 | 15 | pub const EventTarget = struct { 16 | 17 | // ctor 18 | // addEventListener 19 | // removeEventListener 20 | // dispatchEvent 21 | 22 | }; 23 | 24 | pub const EventListener = struct { 25 | // handleEvent 26 | }; 27 | 28 | pub const EventListenerOptions = struct { 29 | capture: bool = false, 30 | }; 31 | 32 | pub const AddEventListenerOptions = struct { 33 | base: EventListenerOptions, 34 | passive: bool, 35 | once: bool = false, 36 | //signal: AbortSignal, 37 | }; 38 | 39 | pub const DOMString = struct { 40 | }; 41 | pub const USVString = struct { 42 | }; 43 | 44 | pub const GetRootNodeOptions = struct { 45 | composed: bool = false, 46 | }; 47 | 48 | pub const NodeInterface = struct { 49 | pub const ELEMENT_NODE = 1; 50 | pub const ATTRIBUTE_NODE = 2; 51 | pub const TEXT_NODE = 3; 52 | pub const CDATA_SECTION_NODE = 4; 53 | pub const ENTITY_REFERENCE_NODE = 5; // legacy 54 | pub const ENTITY_NODE = 6; // legacy 55 | pub const PROCESSING_INSTRUCTION_NODE = 7; 56 | pub const COMMENT_NODE = 8; 57 | pub const DOCUMENT_NODE = 9; 58 | pub const DOCUMENT_TYPE_NODE = 10; 59 | pub const DOCUMENT_FRAGMENT_NODE = 11; 60 | pub const NOTATION_NODE = 12; // legacy 61 | 62 | // eventTarget: EventTarget, 63 | // nodeType: u16, 64 | // nodeName: DOMString, 65 | // baseURI: USVString, 66 | // isConnected: bool, 67 | // ownerDocument: ?Document, 68 | // parentNode: ?Node, 69 | // parentElement: ?Element, 70 | 71 | //pub fn nodeType(node: Node) u16 { ... } 72 | 73 | // fn getRootNode(options: GetRootNodeOptions) Node { 74 | // _ = options; 75 | // @panic("todo"); 76 | // } 77 | 78 | }; 79 | 80 | pub const Document = struct { 81 | node: Node, 82 | }; 83 | 84 | pub const Element = struct { 85 | node: Node, 86 | }; 87 | 88 | pub fn defaultDisplayIsBlock(id: TagId) bool { 89 | return switch (id) { 90 | .address, .article, .aside, .blockquote, .canvas, .dd, .div, 91 | .dl, .dt, .fieldset, .figcaption, .figure, .footer, .form, 92 | .h1, .h2, .h3, .h4, .h5, .h6, .header, .hr, .li, .main, .nav, 93 | .noscript, .ol, .p, .pre, .section, .table, .tfoot, .ul, .video, 94 | => true, 95 | else => false, 96 | }; 97 | } 98 | 99 | /// An element that can never have content 100 | pub fn isVoidElement(id: TagId) bool { 101 | return switch (id) { 102 | .area, .base, .br, .col, .command, .embed, .hr, .img, .input, 103 | .keygen, .link, .meta, .param, .source, .track, .wbr, 104 | => true, 105 | else => false, 106 | }; 107 | } 108 | 109 | fn lookupIdIgnoreCase(comptime map_namespace: type, name: []const u8) ?map_namespace.Enum { 110 | // need enough room for the max tag name 111 | var buf: [20]u8 = undefined; 112 | if (name.len > buf.len) return null; 113 | for (name, 0..) |c, i| { 114 | buf[i] = std.ascii.toLower(c); 115 | } 116 | return map_namespace.map.get(buf[0 .. name.len]); 117 | } 118 | fn lookupTagIgnoreCase(name: []const u8) ?TagId { 119 | return lookupIdIgnoreCase(htmlidmaps.tag, name); 120 | } 121 | fn lookupAttrIgnoreCase(name: []const u8) ?AttrId { 122 | return lookupIdIgnoreCase(htmlidmaps.attr, name); 123 | } 124 | 125 | pub const Node = union(enum) { 126 | start_tag: struct { 127 | id: TagId, 128 | //self_closing: bool, 129 | // TODO: maybe make this a u32? 130 | parent_index: usize, 131 | }, 132 | end_tag: TagId, 133 | attr: struct { 134 | id: AttrId, 135 | value: ?HtmlTokenizer.Span, 136 | }, 137 | text: HtmlTokenizer.Span, 138 | }; 139 | 140 | const ParseOptions = struct { 141 | context: ?*anyopaque = null, 142 | on_error: ?*const fn(context: ?*anyopaque, msg: []const u8) void = null, 143 | 144 | // allows void elements like
, and to include a trailing slash 145 | // i.e. "
" 146 | allow_trailing_slash_on_void_elements: bool = true, 147 | 148 | const max_error_message = 300; 149 | pub fn reportError2(self: ParseOptions, comptime fmt: []const u8, args: anytype, opt_token: ?Token) error{ReportedParseError} { 150 | if (self.on_error) |f| { 151 | var buf: [300]u8 = undefined; 152 | const prefix_len = blk: { 153 | if (opt_token) |t| { 154 | if (t.start()) |start| 155 | break :blk (std.fmt.bufPrint(&buf, "offset={}: ", .{start}) catch unreachable).len; 156 | } 157 | break :blk 0; 158 | }; 159 | const msg_part = std.fmt.bufPrint(buf[prefix_len..], fmt, args) catch { 160 | f(self.context, "error message to large to format, the following is its format string"); 161 | f(self.context, fmt); 162 | return error.ReportedParseError; 163 | }; 164 | f(self.context, buf[0 .. prefix_len + msg_part.len]); 165 | } 166 | return error.ReportedParseError; 167 | } 168 | pub fn reportError(self: ParseOptions, comptime fmt: []const u8, args: anytype) error{ReportedParseError} { 169 | return self.reportError2(fmt, args, null); 170 | } 171 | }; 172 | 173 | fn next(tokenizer: *HtmlTokenizer, saved_token: *?Token) !?Token { 174 | if (saved_token.*) |t| { 175 | // TODO: is t still valid if we set this to null here? 176 | saved_token.* = null; 177 | return t; 178 | } 179 | return tokenizer.next(); 180 | } 181 | 182 | // The parse should only succeed if the following guarantees are met 183 | // 1. all "spans" returned contain valid UTF8 sequences 184 | // 2. all start/end tags are balanced 185 | // 3. the and