├── .gitignore ├── HtmlTokenizer.zig ├── Layout.md ├── README.md ├── Refcounted.zig ├── alext.zig ├── build.zig ├── build.zig.zon ├── dom.zig ├── font ├── schrift.zig └── times-new-roman.ttf ├── html-css-renderer.template.html ├── htmlid.zig ├── imagerenderer.zig ├── layout.zig ├── lint.zig ├── make-renderer-webpage.zig ├── render.zig ├── revit.zig ├── test ├── hello.html └── svg.html ├── testrunner.zig ├── wasmrenderer.zig └── x11renderer.zig /.gitignore: -------------------------------------------------------------------------------- 1 | .zig-cache/ 2 | zig-out/ 3 | /dep/ 4 | /htmlidmaps.zig 5 | -------------------------------------------------------------------------------- /HtmlTokenizer.zig: -------------------------------------------------------------------------------- 1 | /// An html5 tokenizer. 2 | /// Implements the state machine described here: 3 | /// https://html.spec.whatwg.org/multipage/parsing.html#tokenization 4 | /// This tokenizer does not perform any processing/allocation, it simply 5 | /// splits the input text into higher-level tokens. 6 | const HtmlTokenizer = @This(); 7 | 8 | const std = @import("std"); 9 | 10 | start: [*]const u8, 11 | limit: [*]const u8, 12 | ptr: [*]const u8, 13 | state: State = .data, 14 | deferred_token: ?Token = null, 15 | current_input_character: struct { 16 | len: u3, 17 | val: u21, 18 | } = undefined, 19 | 20 | const DOCTYPE = "DOCTYPE"; 21 | const form_feed = 0xc; 22 | 23 | pub fn init(slice: []const u8) HtmlTokenizer { 24 | return .{ 25 | .start = slice.ptr, 26 | .limit = slice.ptr + slice.len, 27 | .ptr = slice.ptr, 28 | }; 29 | } 30 | 31 | pub const Span = struct { 32 | start: usize, 33 | limit: usize, 34 | pub fn slice(self: Span, text: []const u8) []const u8 { 35 | return text[self.start..self.limit]; 36 | } 37 | }; 38 | 39 | pub const Token = union(enum) { 40 | doctype: Doctype, 41 | start_tag: Span, 42 | end_tag: Span, 43 | start_tag_self_closed: usize, 44 | attr: struct { 45 | // NOTE: process the name_raw by replacing 46 | // - upper-case ascii alpha with lower case (add 0x20) 47 | // - 0 with U+FFFD 48 | name_raw: Span, 49 | // NOTE: process value...somehow... 50 | value_raw: ?Span, 51 | }, 52 | comment: Span, 53 | // TODO: maybe combine multiple utf8-encoded chars in a single string 54 | char: Span, 55 | parse_error: enum { 56 | unexpected_null_character, 57 | invalid_first_character_of_tag_name, 58 | incorrectly_opened_comment, 59 | missing_end_tag_name, 60 | eof_before_tag_name, 61 | eof_in_doctype, 62 | eof_in_tag, 63 | eof_in_comment, 64 | missing_whitespace_before_doctype_name, 65 | unexpected_character_in_attribute_name, 66 | missing_attribute_value, 67 | unexpected_solidus_in_tag, 68 | abrupt_closing_of_empty_comment, 69 | }, 70 | 71 | pub const Doctype = struct { 72 | // NOTE: process name_raw by replacing 73 | // - upper-case ascii alpha with lower case (add 0x20) 74 | // - 0 with U+FFFD 75 | name_raw: ?Span, 76 | force_quirks: bool, 77 | //public_id: usize, 78 | //system_id: usize, 79 | }; 80 | 81 | pub fn start(self: Token) ?usize { 82 | return switch (self) { 83 | .start_tag => |t| t.start, // todo: subtract 1 for '<'? 84 | .end_tag => |t| t.start, // todo: subtract 2 for ' |s| s, 86 | .char => |c| c.start, 87 | else => null, 88 | }; 89 | } 90 | }; 91 | 92 | const State = union(enum) { 93 | data: void, 94 | tag_open: usize, 95 | end_tag_open: usize, 96 | character_reference: void, 97 | markup_declaration_open: void, 98 | doctype: void, 99 | before_doctype_name: void, 100 | doctype_name: struct { 101 | name_offset: usize, 102 | }, 103 | after_doctype_name: struct { 104 | name_offset: usize, 105 | name_limit: usize, 106 | }, 107 | comment_start: usize, 108 | comment_start_dash: void, 109 | comment: usize, 110 | comment_end_dash: Span, 111 | comment_end: Span, 112 | tag_name: struct { 113 | is_end: bool, 114 | start: usize, 115 | }, 116 | self_closing_start_tag: void, 117 | before_attribute_name: void, 118 | attribute_name: usize, 119 | after_attribute_name: void, 120 | before_attribute_value: Span, 121 | attribute_value: struct { 122 | quote: enum { double, single }, 123 | name_raw: Span, 124 | start: usize, 125 | }, 126 | attribute_value_unquoted: struct { 127 | name_raw: Span, 128 | }, 129 | after_attribute_value: struct { 130 | }, 131 | bogus_comment: void, 132 | eof: void, 133 | }; 134 | 135 | fn consume(self: *HtmlTokenizer) !void { 136 | if (self.ptr == self.limit) { 137 | self.current_input_character = .{ .len = 0, .val = undefined }; 138 | return; 139 | } 140 | const len = try std.unicode.utf8CodepointSequenceLength(self.ptr[0]); 141 | if (@intFromPtr(self.ptr) + len > @intFromPtr(self.limit)) 142 | return error.Utf8ExpectedContinuation; 143 | self.current_input_character = .{ .len = len, .val = try std.unicode.utf8Decode(self.ptr[0 .. len]) }; 144 | self.ptr += len; 145 | } 146 | 147 | // why isn't this pub in std.unicode? 148 | const Utf8DecodeError = error { 149 | Utf8ExpectedContinuation, 150 | Utf8OverlongEncoding, 151 | Utf8EncodesSurrogateHalf, 152 | Utf8CodepointTooLarge, 153 | }; 154 | 155 | pub fn next(self: *HtmlTokenizer) Utf8DecodeError!?Token { 156 | //std.log.info("next: offset={}", .{@intFromPtr(self.ptr) - @intFromPtr(self.start)}); 157 | if (self.deferred_token) |t| { 158 | const token_copy = t; 159 | self.deferred_token = null; 160 | return token_copy; 161 | } 162 | const result = (self.next2() catch |err| switch (err) { 163 | // Why does std.unicode have both these errors? 164 | error.CodepointTooLarge => return error.Utf8CodepointTooLarge, 165 | error.NotImpl => @panic("not implemented"), 166 | else => |e| return e, 167 | }) orelse return null; 168 | if (result.deferred) |d| { 169 | self.deferred_token = d; 170 | } 171 | return result.token; 172 | } 173 | 174 | fn next2(self: *HtmlTokenizer) !?struct { 175 | token: Token, 176 | deferred: ?Token = null, 177 | } { 178 | while (true) { 179 | switch (self.state) { 180 | .data => { 181 | try self.consume(); 182 | if (self.current_input_character.len == 0) return null; 183 | switch (self.current_input_character.val) { 184 | //'&' => {} we don't process character references in the tokenizer 185 | '<' => self.state = .{ 186 | .tag_open = @intFromPtr(self.ptr) - self.current_input_character.len - @intFromPtr(self.start), 187 | }, 188 | 0 => { 189 | const limit = @intFromPtr(self.ptr) - @intFromPtr(self.start); 190 | return .{ 191 | .token = .{ .parse_error = .unexpected_null_character }, 192 | .deferred = .{ .char = .{ 193 | .start = limit - self.current_input_character.len, 194 | .limit = limit, 195 | }}, 196 | }; 197 | }, 198 | else => { 199 | const limit = @intFromPtr(self.ptr) - @intFromPtr(self.start); 200 | return .{ .token = .{ .char = .{ 201 | .start = limit - self.current_input_character.len, 202 | .limit = limit, 203 | }}}; 204 | }, 205 | } 206 | }, 207 | .tag_open => |tag_open_start| { 208 | try self.consume(); 209 | if (self.current_input_character.len == 0) { 210 | self.state = .eof; 211 | const limit = @intFromPtr(self.ptr) - @intFromPtr(self.start); 212 | return .{ 213 | .token = .{ .parse_error = .eof_before_tag_name }, 214 | .deferred = .{ .char = .{ 215 | .start = tag_open_start, 216 | .limit = limit, 217 | } }, 218 | }; 219 | } 220 | switch (self.current_input_character.val) { 221 | '!' => self.state = .markup_declaration_open, 222 | '/' => self.state = .{ .end_tag_open = tag_open_start }, 223 | '?' => return error.NotImpl, 224 | else => |c| if (isAsciiAlpha(c)) { 225 | self.state = .{ 226 | .tag_name = .{ 227 | .is_end = false, 228 | .start = @intFromPtr(self.ptr) - self.current_input_character.len - @intFromPtr(self.start), 229 | }, 230 | }; 231 | } else { 232 | self.state = .data; 233 | self.ptr -= self.current_input_character.len; 234 | return .{ 235 | .token = .{ .parse_error = .invalid_first_character_of_tag_name }, 236 | .deferred = .{ .char = .{ 237 | .start = tag_open_start, 238 | // TODO: hopefully the '<' was only 1 byte! 239 | .limit = tag_open_start + 1, 240 | } }, 241 | }; 242 | }, 243 | } 244 | }, 245 | .end_tag_open => |tag_open_start| { 246 | const save_previous_char_len = self.current_input_character.len; 247 | try self.consume(); 248 | if (self.current_input_character.len == 0) { 249 | // NOTE: this is implemented differently from the spec so we only need to 250 | // support 1 deferred token, but, should result in the same tokens. 251 | self.state = .data; 252 | self.ptr -= save_previous_char_len; 253 | return .{ 254 | .token = .{ .parse_error = .eof_before_tag_name }, 255 | .deferred = .{ .char = .{ 256 | .start = tag_open_start, 257 | // TODO: hopefully the '<' was only 1 byte! 258 | .limit = tag_open_start + 1, 259 | } }, 260 | }; 261 | } 262 | switch (self.current_input_character.val) { 263 | '>' => { 264 | self.state = .data; 265 | return .{ .token = .{ .parse_error = .missing_end_tag_name } }; 266 | }, 267 | else => |c| if (isAsciiAlpha(c)) { 268 | self.state = .{ 269 | .tag_name = .{ 270 | .is_end = true, 271 | .start = @intFromPtr(self.ptr) - self.current_input_character.len - @intFromPtr(self.start), 272 | }, 273 | }; 274 | } else { 275 | self.state = .bogus_comment; 276 | return .{ .token = .{ .parse_error = .invalid_first_character_of_tag_name } }; 277 | }, 278 | } 279 | }, 280 | .tag_name => |tag_state| { 281 | try self.consume(); 282 | if (self.current_input_character.len == 0) { 283 | self.state = .eof; 284 | return .{ .token = .{ .parse_error = .eof_in_tag } }; 285 | } 286 | switch (self.current_input_character.val) { 287 | '\t', '\n', form_feed, ' ' => { 288 | self.state = .before_attribute_name; 289 | const name_span = Span{ 290 | .start = tag_state.start, 291 | .limit = @intFromPtr(self.ptr) - self.current_input_character.len - @intFromPtr(self.start), 292 | }; 293 | return 294 | if (tag_state.is_end) .{ .token = .{ .end_tag = name_span } } 295 | else .{ .token = .{ .start_tag = name_span } }; 296 | }, 297 | '/' => self.state = .self_closing_start_tag, 298 | '>' => { 299 | self.state = .data; 300 | const name_span = Span{ 301 | .start = tag_state.start, 302 | .limit = @intFromPtr(self.ptr) - self.current_input_character.len - @intFromPtr(self.start), 303 | }; 304 | return 305 | if (tag_state.is_end) .{ .token = .{ .end_tag = name_span } } 306 | else .{ .token = .{ .start_tag = name_span } }; 307 | }, 308 | 0 => return .{ .token = .{ .parse_error = .unexpected_null_character } }, 309 | else => {}, 310 | } 311 | }, 312 | .self_closing_start_tag => { 313 | try self.consume(); 314 | if (self.current_input_character.len == 0) { 315 | self.state = .eof; 316 | return .{ .token = .{ .parse_error = .eof_in_tag } }; 317 | } else switch (self.current_input_character.val) { 318 | '>' => { 319 | self.state = .data; 320 | return .{ .token = .{ 321 | // TODO: can we assume the start will be 2 bytes back? 322 | .start_tag_self_closed = @intFromPtr(self.ptr) - 2 - @intFromPtr(self.start), 323 | }}; 324 | }, 325 | else => { 326 | self.state = .before_attribute_name; 327 | self.ptr -= self.current_input_character.len; 328 | return .{ .token = .{ .parse_error = .unexpected_solidus_in_tag } }; 329 | }, 330 | } 331 | }, 332 | .before_attribute_name => { 333 | try self.consume(); 334 | if (self.current_input_character.len == 0) { 335 | self.state = .after_attribute_name; 336 | } else switch (self.current_input_character.val) { 337 | '\t', '\n', form_feed, ' ' => {}, 338 | '/', '>' => { 339 | self.ptr -= self.current_input_character.len; 340 | self.state = .after_attribute_name; 341 | }, 342 | '=' => { 343 | // unexpected_equals_sign_before_attribute_name 344 | return error.NotImpl; 345 | }, 346 | else => self.state = .{ 347 | .attribute_name = @intFromPtr(self.ptr) - self.current_input_character.len - @intFromPtr(self.start), 348 | }, 349 | } 350 | }, 351 | .attribute_name => |start| { 352 | try self.consume(); 353 | if (self.current_input_character.len == 0) { 354 | self.state = .after_attribute_name; 355 | } else switch (self.current_input_character.val) { 356 | '\t', '\n', form_feed, ' ', '/', '>' => { 357 | self.ptr -= self.current_input_character.len; 358 | // TODO: pass something to after_attribute_name like start/limit? 359 | // .start = start, 360 | // .limit = @intFromPtr(self.ptr) - @intFromPtr(self.start), 361 | self.state = .after_attribute_name; 362 | }, 363 | '=' => self.state = .{ .before_attribute_value = .{ 364 | .start = start, 365 | .limit = @intFromPtr(self.ptr) - self.current_input_character.len - @intFromPtr(self.start), 366 | }}, 367 | '"', '\'', '<' => return .{ .token = .{ .parse_error = .unexpected_character_in_attribute_name } }, 368 | else => {}, 369 | } 370 | }, 371 | .after_attribute_name => return error.NotImpl, 372 | .before_attribute_value => |name_span| { 373 | try self.consume(); 374 | if (self.current_input_character.len == 0) { 375 | self.state = .{ .attribute_value_unquoted = .{ .name_raw = name_span } }; 376 | } else switch (self.current_input_character.val) { 377 | '\t', '\n', form_feed, ' ' => {}, 378 | '"' => self.state = .{ .attribute_value = .{ 379 | .name_raw = name_span, 380 | .quote = .double, 381 | .start = @intFromPtr(self.ptr) - @intFromPtr(self.start), 382 | } }, 383 | '\'' => self.state = .{ .attribute_value = .{ 384 | .name_raw = name_span, 385 | .quote = .single, 386 | .start = @intFromPtr(self.ptr) - @intFromPtr(self.start), 387 | } }, 388 | '>' => { 389 | self.state = .data; 390 | return .{ 391 | .token = .{ .parse_error = .missing_attribute_value }, 392 | // TODO: return an attribute name without a value? 393 | //.deferred = .{ .attribute = .{ .name = ..., .value = null } }, 394 | }; 395 | }, 396 | else => { 397 | self.ptr -= self.current_input_character.len; 398 | self.state = .{ .attribute_value_unquoted = .{ 399 | .name_raw = name_span, 400 | }}; 401 | }, 402 | } 403 | }, 404 | .attribute_value => |attr_state| { 405 | try self.consume(); 406 | if (self.current_input_character.len == 0) { 407 | self.state = .eof; 408 | // NOTE: spec doesn't say to emit the current tag? 409 | return .{ .token = .{ .parse_error = .eof_in_tag } }; 410 | } else switch (self.current_input_character.val) { 411 | '"' => switch (attr_state.quote) { 412 | .double => { 413 | self.state = .after_attribute_value; 414 | return .{ .token = .{ .attr = .{ 415 | .name_raw = attr_state.name_raw, 416 | .value_raw = .{ 417 | .start = attr_state.start, 418 | .limit = @intFromPtr(self.ptr) - self.current_input_character.len - @intFromPtr(self.start), 419 | }, 420 | }}}; 421 | }, 422 | .single => return error.NotImpl, 423 | }, 424 | '\'' => switch (attr_state.quote) { 425 | .double => return error.NotImpl, 426 | .single => return error.NotImpl, 427 | }, 428 | // TODO: the spec says the tokenizer should handle "character references" here, but, 429 | // that would require allocation, so, we should probably handle that elsewhere 430 | //'&' => return error.NotImpl, 431 | 0 => return .{ .token = .{ .parse_error = .unexpected_null_character } }, 432 | else => {}, 433 | } 434 | }, 435 | .attribute_value_unquoted => { 436 | return error.NotImpl; 437 | }, 438 | .after_attribute_value => { 439 | try self.consume(); 440 | if (self.current_input_character.len == 0) { 441 | self.state = .eof; 442 | // NOTE: spec doesn't say to emit the current tag? 443 | return .{ .token = .{ .parse_error = .eof_in_tag } }; 444 | } else switch (self.current_input_character.val) { 445 | '\t', '\n', form_feed, ' ' => self.state = .before_attribute_name, 446 | '>' => { 447 | self.state = .data; 448 | }, 449 | '/' => self.state = .self_closing_start_tag, 450 | else => |c| std.debug.panic("todo c={}", .{c}), 451 | } 452 | }, 453 | .markup_declaration_open => { 454 | if (self.nextCharsAre("--")) { 455 | self.ptr += 2; 456 | self.state = .{ .comment_start = @intFromPtr(self.ptr) - @intFromPtr(self.start) }; 457 | } else if (self.nextCharsAre(DOCTYPE)) { 458 | self.ptr += DOCTYPE.len; 459 | self.state = .doctype; 460 | } else if (self.nextCharsAre("[CDATA[")) { 461 | return error.NotImpl; 462 | } else { 463 | self.state = .bogus_comment; 464 | return .{ .token = .{ .parse_error = .incorrectly_opened_comment } }; 465 | } 466 | }, 467 | .character_reference => { 468 | return error.NotImpl; 469 | }, 470 | .doctype => { 471 | try self.consume(); 472 | if (self.current_input_character.len == 0) { 473 | self.state = .eof; 474 | return .{ 475 | .token = .{ .parse_error = .eof_in_doctype }, 476 | .deferred = .{ .doctype = .{ 477 | .force_quirks = true, 478 | .name_raw = null, 479 | }}, 480 | }; 481 | } 482 | switch (self.current_input_character.val) { 483 | '\t', '\n', form_feed, ' ' => self.state = .before_doctype_name, 484 | '>' => { 485 | self.ptr -= self.current_input_character.len; 486 | self.state = .before_doctype_name; 487 | }, 488 | else => { 489 | self.ptr -= self.current_input_character.len; 490 | self.state = .before_doctype_name; 491 | return .{ .token = .{ .parse_error = .missing_whitespace_before_doctype_name } }; 492 | }, 493 | } 494 | }, 495 | .before_doctype_name => { 496 | try self.consume(); 497 | if (self.current_input_character.len == 0) { 498 | self.state = .eof; 499 | return .{ 500 | .token = .{ .parse_error = .eof_in_doctype }, 501 | .deferred = .{ .doctype = .{ 502 | .force_quirks = true, 503 | .name_raw = null, 504 | }} 505 | }; 506 | } 507 | switch (self.current_input_character.val) { 508 | '\t', '\n', form_feed, ' ' => {}, 509 | 0 => { 510 | self.state = .{ .doctype_name = .{ 511 | .name_offset = @intFromPtr(self.ptr) - self.current_input_character.len - @intFromPtr(self.start), 512 | }}; 513 | return .{ .token = .{ .parse_error = .unexpected_null_character } }; 514 | }, 515 | '>' => { 516 | self.ptr -= self.current_input_character.len; 517 | self.state = .data; 518 | return .{ .token = .{ .doctype = .{ 519 | .force_quirks = true, 520 | .name_raw = null, 521 | }}}; 522 | }, 523 | else => { 524 | // NOTE: same thing for isAsciiAlphaUpper since we post-process the name 525 | self.state = .{ .doctype_name = .{ 526 | .name_offset = @intFromPtr(self.ptr) - self.current_input_character.len - @intFromPtr(self.start), 527 | }}; 528 | } 529 | } 530 | }, 531 | .doctype_name => |doctype_state| { 532 | try self.consume(); 533 | if (self.current_input_character.len == 0) { 534 | self.state = .eof; 535 | return .{ 536 | .token = .{ .parse_error = .eof_in_doctype }, 537 | .deferred = .{ .doctype = .{ 538 | .force_quirks = true, 539 | .name_raw = null, 540 | }}, 541 | }; 542 | } 543 | switch (self.current_input_character.val) { 544 | '\t', '\n', form_feed, ' ' => { 545 | self.state = .{ .after_doctype_name = .{ 546 | .name_offset = doctype_state.name_offset, 547 | .name_limit = @intFromPtr(self.ptr) - self.current_input_character.len - @intFromPtr(self.start), 548 | }}; 549 | }, 550 | '>' => { 551 | self.state = .data; 552 | return .{ .token = .{ .doctype = .{ 553 | .name_raw = .{ 554 | .start = doctype_state.name_offset, 555 | .limit = @intFromPtr(self.ptr) - self.current_input_character.len - @intFromPtr(self.start), 556 | }, 557 | .force_quirks = false, 558 | }}}; 559 | }, 560 | 0 => return .{ .token = .{ .parse_error = .unexpected_null_character } }, 561 | else => {}, 562 | } 563 | }, 564 | .after_doctype_name => { 565 | return error.NotImpl; 566 | }, 567 | .comment_start => |comment_start| { 568 | try self.consume(); 569 | if (self.current_input_character.len == 0) { 570 | self.ptr -= self.current_input_character.len; 571 | self.state = .{ .comment = comment_start }; 572 | } else switch (self.current_input_character.val) { 573 | '-' => self.state = .comment_start_dash, 574 | '>' => { 575 | self.state = .data; 576 | return .{ .token = .{ .parse_error = .abrupt_closing_of_empty_comment } }; 577 | }, 578 | else => { 579 | self.ptr -= self.current_input_character.len; 580 | self.state = .{ .comment = comment_start }; 581 | }, 582 | } 583 | }, 584 | .comment_start_dash => { 585 | return error.NotImpl; 586 | }, 587 | .comment => |comment_start| { 588 | try self.consume(); 589 | if (self.current_input_character.len == 0) { 590 | self.state = .eof; 591 | return .{ 592 | .token = .{ .parse_error = .eof_in_comment }, 593 | .deferred = .{ .comment = .{ 594 | .start = comment_start, 595 | .limit = @intFromPtr(self.ptr) - @intFromPtr(self.start), 596 | } }, 597 | }; 598 | } 599 | switch (self.current_input_character.val) { 600 | '<' => return error.NotImpl, 601 | '-' => self.state = .{ .comment_end_dash = .{ 602 | .start = comment_start, 603 | .limit = @intFromPtr(self.ptr) - self.current_input_character.len - @intFromPtr(self.start), 604 | }}, 605 | 0 => return error.NotImpl, 606 | else => {}, 607 | } 608 | }, 609 | .comment_end_dash => |comment_span| { 610 | try self.consume(); 611 | if (self.current_input_character.len == 0) { 612 | self.state = .eof; 613 | return .{ 614 | .token = .{ .parse_error = .eof_in_comment }, 615 | .deferred = .{ .comment = comment_span }, 616 | }; 617 | } 618 | switch (self.current_input_character.val) { 619 | '-' => self.state = .{ .comment_end = comment_span }, 620 | else => { 621 | self.ptr -= self.current_input_character.len; 622 | self.state = .{ .comment = comment_span.start }; 623 | }, 624 | } 625 | }, 626 | .comment_end => |comment_span| { 627 | try self.consume(); 628 | if (self.current_input_character.len == 0) { 629 | self.state = .eof; 630 | return .{ 631 | .token = .{ .parse_error = .eof_in_comment }, 632 | .deferred = .{ .comment = comment_span }, 633 | }; 634 | } 635 | switch (self.current_input_character.val) { 636 | '>' => { 637 | self.state = .data; 638 | return .{ .token = .{ .comment = comment_span } }; 639 | }, 640 | '!' => return error.NotImpl, 641 | '-' => return error.NotImpl, 642 | else => return error.NotImpl, 643 | } 644 | }, 645 | .bogus_comment => { 646 | return error.NotImpl; 647 | }, 648 | .eof => return null, 649 | } 650 | } 651 | } 652 | 653 | fn nextCharsAre(self: HtmlTokenizer, s: []const u8) bool { 654 | return (@intFromPtr(self.ptr) + s.len <= @intFromPtr(self.limit)) and 655 | std.mem.eql(u8, self.ptr[0 .. s.len], s); 656 | } 657 | 658 | fn isAsciiAlphaLower(c: u21) bool { 659 | return (c >= 'a' and c <= 'z'); 660 | } 661 | fn isAsciiAlphaUpper(c: u21) bool { 662 | return (c >= 'A' and c <= 'Z'); 663 | } 664 | fn isAsciiAlpha(c: u21) bool { 665 | return isAsciiAlphaLower(c) or isAsciiAlphaUpper(c); 666 | } 667 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Html Css Renderer 2 | 3 | An HTML/CSS Renderer. 4 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /build.zig: -------------------------------------------------------------------------------- 1 | const std = @import("std"); 2 | const htmlid = @import("htmlid.zig"); 3 | 4 | pub fn build(b: *std.Build) !void { 5 | const target = b.standardTargetOptions(.{}); 6 | const optimize = b.standardOptimizeOption(.{}); 7 | 8 | const id_maps_src = b.pathFromRoot("htmlidmaps.zig"); 9 | const gen_id_maps = b.addWriteFile(id_maps_src, allocIdMapSource(b.allocator)); 10 | 11 | { 12 | const exe = b.addExecutable(.{ 13 | .name = "lint", 14 | .root_source_file = b.path("lint.zig"), 15 | .target = target, 16 | .optimize = optimize, 17 | }); 18 | exe.step.dependOn(&gen_id_maps.step); 19 | b.installArtifact(exe); 20 | } 21 | 22 | { 23 | const exe = b.addExecutable(.{ 24 | .name = "imagerenderer", 25 | .root_source_file = b.path("imagerenderer.zig"), 26 | .target = target, 27 | .optimize = optimize, 28 | }); 29 | exe.step.dependOn(&gen_id_maps.step); 30 | const install = b.addInstallArtifact(exe, .{}); 31 | b.step("image", "build/install imagerenderer").dependOn(&install.step); 32 | } 33 | 34 | const zigx_dep = b.dependency("zigx", .{}); 35 | const zigx_mod = zigx_dep.module("zigx"); 36 | 37 | { 38 | const exe = b.addExecutable(.{ 39 | .name = "x11renderer", 40 | .root_source_file = b.path("x11renderer.zig"), 41 | .target = target, 42 | .optimize = optimize, 43 | }); 44 | exe.step.dependOn(&gen_id_maps.step); 45 | exe.root_module.addImport("x11", zigx_mod); 46 | const install = b.addInstallArtifact(exe, .{}); 47 | b.step("x11", "build/install the x11renderer").dependOn(&install.step); 48 | } 49 | 50 | { 51 | const exe = b.addExecutable(.{ 52 | .name = "wasmrenderer", 53 | .root_source_file = b.path("wasmrenderer.zig"), 54 | .target = b.resolveTargetQuery(.{ .cpu_arch = .wasm32, .os_tag = .freestanding }), 55 | .optimize = optimize, 56 | }); 57 | exe.entry = .disabled; 58 | //exe.export_table = true; 59 | exe.root_module.export_symbol_names = &.{ 60 | "alloc", 61 | "release", 62 | "onResize", 63 | "loadHtml", 64 | }; 65 | 66 | const make_exe = b.addExecutable(.{ 67 | .name = "make-renderer-webpage", 68 | .root_source_file = b.path("make-renderer-webpage.zig"), 69 | .target = b.graph.host, 70 | }); 71 | const run = b.addRunArtifact(make_exe); 72 | run.addArtifactArg(exe); 73 | run.addFileArg(b.path("html-css-renderer.template.html")); 74 | run.addArg(b.pathJoin(&.{ b.install_path, "html-css-renderer.html" })); 75 | b.step("wasm", "build the wasm-based renderer").dependOn(&run.step); 76 | } 77 | 78 | { 79 | const exe = b.addExecutable(.{ 80 | .name = "testrunner", 81 | .root_source_file = b.path("testrunner.zig"), 82 | .target = target, 83 | .optimize = optimize, 84 | }); 85 | exe.step.dependOn(&gen_id_maps.step); 86 | const install = b.addInstallArtifact(exe, .{}); 87 | const test_step = b.step("test", "run tests"); 88 | test_step.dependOn(&install.step); // make testrunner easily accessible 89 | inline for ([_][]const u8{"hello.html"}) |test_filename| { 90 | const run_step = b.addRunArtifact(exe); 91 | run_step.addFileArg(b.path("test/" ++ test_filename)); 92 | run_step.expectStdOutEqual("Success\n"); 93 | test_step.dependOn(&run_step.step); 94 | } 95 | } 96 | } 97 | 98 | fn allocIdMapSource(allocator: std.mem.Allocator) []const u8 { 99 | var src = std.ArrayList(u8).init(allocator); 100 | defer src.deinit(); 101 | writeIdMapSource(src.writer()) catch unreachable; 102 | return src.toOwnedSlice() catch unreachable; 103 | } 104 | 105 | fn writeIdMapSource(writer: anytype) !void { 106 | try writer.writeAll( 107 | \\const std = @import("std"); 108 | \\const htmlid = @import("htmlid.zig"); 109 | \\ 110 | ); 111 | try writeIdEnum(writer, htmlid.TagId, "tag"); 112 | try writeIdEnum(writer, htmlid.AttrId, "attr"); 113 | } 114 | fn writeIdEnum(writer: anytype, comptime Enum: type, name: []const u8) !void { 115 | try writer.print( 116 | \\ 117 | \\pub const {s} = struct {{ 118 | \\ pub const Enum = {s}; 119 | \\ pub const map = blk: {{ 120 | \\ @setEvalBranchQuota(6000); 121 | \\ break :blk std.ComptimeStringMap(Enum, .{{ 122 | \\ 123 | , .{name, @typeName(Enum)}); 124 | inline for (@typeInfo(Enum).Enum.fields) |field| { 125 | var lower_buf: [field.name.len]u8 = undefined; 126 | for (field.name, 0..) |c, i| { 127 | lower_buf[i] = std.ascii.toLower(c); 128 | } 129 | try writer.print(" .{{ \"{s}\", .{} }},\n", .{lower_buf, std.zig.fmtId(field.name)}); 130 | } 131 | try writer.writeAll( 132 | \\ }); 133 | \\ }; 134 | \\}; 135 | \\ 136 | ); 137 | } 138 | -------------------------------------------------------------------------------- /build.zig.zon: -------------------------------------------------------------------------------- 1 | .{ 2 | .name = "html-css-renderer", 3 | .version = "0.0.0", 4 | .minimum_zig_version = "0.13.0", 5 | .dependencies = .{ 6 | .zigx = .{ 7 | .url = "https://github.com/marler8997/zigx/archive/f09fd6fa5d593c759c0d9d35db4dfb5a150d366a.tar.gz", 8 | .hash = "122026f249798ac9b3ab3561b94d8460aaf1fb3487a5cb7c021387db1136cf08936d", 9 | }, 10 | }, 11 | .paths = .{ 12 | "", 13 | }, 14 | } 15 | -------------------------------------------------------------------------------- /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 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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /layout.zig: -------------------------------------------------------------------------------- 1 | const std = @import("std"); 2 | const dom = @import("dom.zig"); 3 | const revit = @import("revit.zig"); 4 | 5 | pub fn XY(comptime T: type) type { 6 | return struct { 7 | x: T, 8 | y: T, 9 | pub fn init(x: T, y: T) @This() { 10 | return .{ .x = x, .y = y }; 11 | } 12 | }; 13 | } 14 | 15 | // Sizes 16 | // content-box-size 17 | // border-box-size 18 | 19 | fn roundedMult(float: f32, int: anytype) @TypeOf(int) { 20 | return @intFromFloat(@round(float * @as(f32, @floatFromInt(int)))); 21 | } 22 | 23 | 24 | pub const StyleSize = union(enum) { 25 | px: u32, 26 | parent_ratio: f32, // represents percentage-based sizes 27 | content: void, 28 | }; 29 | pub const Style = struct { 30 | size: XY(StyleSize), 31 | mbp: MarginBorderPadding, 32 | // font_size: union(enum) { 33 | // px: u32, 34 | // em: f32, 35 | // }, 36 | }; 37 | 38 | const MarginBorderPadding = struct { 39 | margin: u32, 40 | border: u32, 41 | padding: u32, 42 | }; 43 | 44 | pub const Styler = struct { 45 | 46 | html: Style = .{ 47 | .size = .{ .x = .{ .parent_ratio = 1 }, .y = .content }, 48 | .mbp = .{ 49 | .margin = 0, 50 | .border = 0, 51 | .padding = 0, 52 | }, 53 | //.font_size = .{ .em = 1 }, 54 | }, 55 | body: Style = .{ 56 | .size = .{ .x = .{ .parent_ratio = 1 }, .y = .content }, 57 | .mbp = .{ 58 | .margin = 8, 59 | .border = 0, 60 | .padding = 0, 61 | }, 62 | //.font_size = .{ .em = 1 }, 63 | }, 64 | 65 | pub fn getBox( 66 | self: Styler, 67 | dom_nodes: []const dom.Node, 68 | dom_node_index: usize, 69 | parent_box: usize, 70 | parent_content_size: XY(ContentSize), 71 | ) Box { 72 | const tag = switch (dom_nodes[dom_node_index]) { 73 | .start_tag => |tag| tag, 74 | else => @panic("todo or maybe unreachable?"), 75 | }; 76 | 77 | const style_size: XY(StyleSize) = blk: { 78 | switch (tag.id) { 79 | .html => break :blk self.html.size, 80 | .body => break :blk self.body.size, 81 | else => { 82 | std.log.warn("TODO: use correct style size for <{s}>", .{@tagName(tag.id)}); 83 | if (dom.defaultDisplayIsBlock(tag.id)) 84 | break :blk .{ .x = .{ .parent_ratio = 1 }, .y = .content }; 85 | break :blk .{ .x = .content, .y = .content }; 86 | }, 87 | } 88 | }; 89 | const mbp: MarginBorderPadding = blk: { 90 | switch (tag.id) { 91 | .html => break :blk self.html.mbp, 92 | .body => break :blk self.body.mbp, 93 | .h1 => { 94 | std.log.warn("using hardcoded margin/border/padding for

", .{}); 95 | break :blk .{ .margin = 21, .border = 0, .padding = 0 }; 96 | }, 97 | .p => { 98 | std.log.warn("using hardcoded margin/border/padding for

", .{}); 99 | // make margin 1em instead? 100 | break :blk .{ .margin = 18, .border = 0, .padding = 0 }; 101 | }, 102 | else => { 103 | std.log.warn("TODO: use correct margin/border/padding for <{s}>", .{@tagName(tag.id)}); 104 | break :blk .{ .margin = 0, .border = 0, .padding = 0 }; 105 | }, 106 | } 107 | }; 108 | 109 | // TODO: check for attributes 110 | for (dom_nodes[dom_node_index + 1..]) |node| switch (node) { 111 | .attr => |a| { 112 | std.log.info("TODO: handle attribute '{s}'", .{@tagName(a.id)}); 113 | }, 114 | else => break, 115 | }; 116 | 117 | return Box{ 118 | .dom_node = dom_node_index, 119 | .parent_box = parent_box, 120 | .relative_content_pos = undefined, 121 | .content_size = XY(ContentSize){ 122 | .x = switch (style_size.x) { 123 | .px => |p| .{ .resolved = p }, 124 | .parent_ratio => |r| switch (parent_content_size.x) { 125 | .resolved => |x| .{ .resolved = roundedMult(r, x) - 2 * mbp.border - 2 * mbp.margin }, 126 | .unresolved => |u| .{ .unresolved = u }, 127 | }, 128 | .content => .{ .unresolved = .fit }, 129 | }, 130 | .y = switch (style_size.y) { 131 | .px => |p| .{ .resolved = p }, 132 | .parent_ratio => |r| switch (parent_content_size.y) { 133 | .resolved => |y| .{ .resolved = roundedMult(r, y) - 2 * mbp.border - 2 * mbp.margin }, 134 | .unresolved => |u| .{ .unresolved = u }, 135 | }, 136 | .content => .{ .unresolved = .fit }, 137 | }, 138 | }, 139 | .mbp = mbp, 140 | }; 141 | } 142 | 143 | pub fn getSizeY(self: Styler, dom_nodes: []const dom.Node) StyleSize { 144 | const tag = switch (dom_nodes[0]) { 145 | .start_tag => |tag| tag, 146 | else => @panic("todo or maybe unreachable?"), 147 | }; 148 | 149 | // TODO: check for attributes 150 | for (dom_nodes[1..]) |node| switch (node) { 151 | .attr => |a| { 152 | std.log.info("TODO: handle attribute '{s}'", .{@tagName(a.id)}); 153 | }, 154 | else => break, 155 | }; 156 | 157 | switch (tag.id) { 158 | .html => return self.html.size.y, 159 | .body => return self.body.size.y, 160 | else => { 161 | std.log.warn("TODO: return correct size y for <{s}>", .{@tagName(tag.id)}); 162 | return .content; 163 | }, 164 | } 165 | } 166 | 167 | pub fn getFont(self: Styler, dom_nodes: []const dom.Node, node_index: usize) ?Font { 168 | _ = self; 169 | switch (dom_nodes[node_index]) { 170 | .start_tag => |tag| { 171 | switch (tag.id) { 172 | .h1 => { 173 | std.log.warn("returning hardcoded font size {} for h1", .{default_font_size*2}); 174 | return .{ .size = default_font_size * 2 }; 175 | }, 176 | else => {}, 177 | } 178 | std.log.warn("TODO: handle font size for <{s}>", .{@tagName(tag.id)}); 179 | return null; 180 | }, 181 | else => |n| std.debug.panic("todo handle {s}", .{@tagName(n)}), 182 | } 183 | } 184 | }; 185 | 186 | const default_font_size = 16; 187 | 188 | fn pop(comptime T: type, al: *std.ArrayListUnmanaged(T)) void { 189 | std.debug.assert(al.items.len > 0); 190 | al.items.len -= 1; 191 | } 192 | 193 | const ContentSize = union(enum) { 194 | resolved: u32, 195 | unresolved: enum { fit, min, max }, 196 | pub fn getResolved(self: ContentSize) ?u32 { 197 | return switch (self) { 198 | .resolved => |r| r, 199 | .unresolved => null, 200 | }; 201 | } 202 | pub fn format( 203 | self: ContentSize, 204 | comptime fmt: []const u8, 205 | options: std.fmt.FormatOptions, 206 | writer: anytype, 207 | ) !void { 208 | _ = fmt; _ = options; 209 | switch (self) { 210 | .resolved => |r| try writer.print("{}", .{r}), 211 | .unresolved => |u| try writer.print("unresolved({s})", .{@tagName(u)}), 212 | } 213 | } 214 | }; 215 | 216 | const Box = struct { 217 | dom_node: usize, 218 | parent_box: usize, 219 | relative_content_pos: XY(u32), 220 | content_size: XY(ContentSize), 221 | mbp: MarginBorderPadding, 222 | 223 | pub fn serialize(self: Box, writer: anytype) !void { 224 | var parent_buf: [30]u8 = undefined; 225 | const parent = blk: { 226 | if (self.parent_box == std.math.maxInt(usize)) 227 | break :blk std.fmt.bufPrint(&parent_buf, "none", .{}) catch unreachable; 228 | break :blk std.fmt.bufPrint(&parent_buf, "{}", .{self.parent_box}) catch unreachable; 229 | }; 230 | try writer.print( 231 | "box dom={} parent={s} relative_content_pos={},{} content_size={}x{} margin={} border={} padding={}\n", 232 | .{self.dom_node, parent, self.relative_content_pos.x, self.relative_content_pos.y, 233 | self.content_size.x, self.content_size.y, self.mbp.margin, 234 | self.mbp.border, self.mbp.padding}, 235 | ); 236 | } 237 | }; 238 | 239 | pub const LayoutNode = union(enum) { 240 | box: Box, 241 | end_box: usize, 242 | text: struct { 243 | slice: []const u8, 244 | font: Font, 245 | first_line_x: u32, 246 | first_line_height: u32, 247 | max_width: u32, 248 | relative_content_pos: XY(u32), 249 | }, 250 | svg: struct { 251 | start_dom_node: usize, 252 | }, 253 | /// pub fn format(value: ?, comptime fmt: []const u8, options: std.fmt.FormatOptions, writer: anytype) !void 254 | pub fn serialize(self: LayoutNode, writer: anytype) !void { 255 | switch (self) { 256 | .box => |*b| try b.serialize(writer), 257 | .end_box => |idx| try writer.print("end_box {}\n", .{idx}), 258 | .text => |t| try writer.print( 259 | "text slice='{s}' font={} first_line(x={} height={}) max_width={} relative_content_pos={},{}\n", 260 | .{ t.slice, t.font, t.first_line_x, t.first_line_height, t.max_width, t.relative_content_pos.x, t.relative_content_pos.y}, 261 | ), 262 | .svg => try writer.writeAll("svg\n"), 263 | } 264 | } 265 | }; 266 | 267 | // Layout Algorithm: 268 | // ================================================================================ 269 | // We traverse the DOM tree depth first. 270 | // 271 | // When we visit a node on our way "down" the tree, we create a box in the "layout" tree. 272 | // 273 | // We determine the size/position if possible and continue down the tree. 274 | // Note that for "tag nodes" that can have children, we could only determine the size/position 275 | // it's only dependent on the parent node (i.e. `width: 100%`). 276 | // 277 | // Before any node returns back up the tree, it must determine its content size. 278 | // 279 | // This means that all parent nodes can use their children content sizes to determine its 280 | // own position/size and reposition all the children. 281 | // 282 | pub fn layout( 283 | allocator: std.mem.Allocator, 284 | text: []const u8, 285 | dom_nodes: []const dom.Node, 286 | viewport_size: XY(u32), 287 | styler: Styler, 288 | ) !std.ArrayListUnmanaged(LayoutNode) { 289 | var nodes = std.ArrayListUnmanaged(LayoutNode){ }; 290 | errdefer nodes.deinit(allocator); 291 | 292 | { 293 | const viewport_content_size = XY(ContentSize){ 294 | .x = .{ .resolved = viewport_size.x }, 295 | .y = .{ .resolved = viewport_size.y }, 296 | }; 297 | try nodes.append(allocator, .{ .box = styler.getBox(dom_nodes, 0, std.math.maxInt(usize), viewport_content_size)}); 298 | nodes.items[0].box.relative_content_pos = .{ .x = 0, .y = 0 }; 299 | } 300 | std.log.info(" content size is {} x {}", .{nodes.items[0].box.content_size.x, nodes.items[0].box.content_size.y}); 301 | 302 | var dom_node_index: usize = 0; 303 | 304 | find_body_loop: 305 | while (true) : (dom_node_index += 1) { 306 | if (dom_node_index == dom_nodes.len) return nodes; 307 | switch (dom_nodes[dom_node_index]) { 308 | .start_tag => |t| if (t.id == .body) break :find_body_loop, 309 | else => {}, 310 | } 311 | } 312 | { 313 | const parent_content_size = nodes.items[0].box.content_size; 314 | try nodes.append(allocator, .{ .box = styler.getBox(dom_nodes, dom_node_index, 0, parent_content_size) }); 315 | } 316 | std.log.info(" content size is {?} x {?}", .{nodes.items[1].box.content_size.x, nodes.items[1].box.content_size.y}); 317 | dom_node_index += 1; 318 | 319 | // NOTE: we don't needs a stack for these states because they can only go 1 level deep 320 | var in_script = false; 321 | 322 | var parent_box: usize = 1; 323 | 324 | body_loop: 325 | while (dom_node_index < dom_nodes.len) : (dom_node_index += 1) { 326 | const dom_node = &dom_nodes[dom_node_index]; 327 | switch (dom_node.*) { 328 | .start_tag => |tag| { 329 | const parent_content_size = nodes.items[parent_box].box.content_size; 330 | try nodes.append(allocator, .{ .box = styler.getBox(dom_nodes, dom_node_index, parent_box, parent_content_size) }); 331 | std.log.info("<{s}> content size is {?} x {?}", .{ 332 | @tagName(tag.id), 333 | nodes.items[nodes.items.len - 1].box.content_size.x, 334 | nodes.items[nodes.items.len - 1].box.content_size.y, 335 | }); 336 | parent_box = nodes.items.len - 1; 337 | 338 | switch (tag.id) { 339 | .script => in_script = true, 340 | .svg => { 341 | const svg_start_node = dom_node_index; 342 | find_svg_end_loop: 343 | while (true) { 344 | dom_node_index += 1; 345 | switch (dom_nodes[dom_node_index]) { 346 | .end_tag => |id| if (id == .svg) break :find_svg_end_loop, 347 | else => {}, 348 | } 349 | } 350 | try nodes.append(allocator, .{ .svg = .{ .start_dom_node = svg_start_node } }); 351 | }, 352 | else => {}, 353 | } 354 | }, 355 | .attr => {}, 356 | .end_tag => |id| { 357 | std.log.info("DEBUG: layout ", .{@tagName(id)}); 358 | endParentBox(text, dom_nodes, styler, nodes.items, parent_box); 359 | try nodes.append(allocator, .{ .end_box = parent_box }); 360 | parent_box = nodes.items[parent_box].box.parent_box; 361 | if (parent_box == std.math.maxInt(usize)) 362 | break :body_loop; 363 | std.log.info("DEBUG: restore parent box to <{s}> (index={})", .{ 364 | // all boxes that become "parents" should be start_tags I think? 365 | @tagName(dom_nodes[nodes.items[parent_box].box.dom_node].start_tag.id), parent_box, 366 | }); 367 | switch (id) { 368 | .script => in_script = false, 369 | .svg => unreachable, // should be impossible 370 | else => {}, 371 | } 372 | }, 373 | .text => |span| { 374 | if (in_script) continue; 375 | 376 | const full_slice = span.slice(text); 377 | const slice = std.mem.trim(u8, full_slice, " \t\r\n"); 378 | if (slice.len == 0) continue; 379 | try nodes.append(allocator, .{ .text = .{ 380 | .slice = slice, 381 | .font = undefined, 382 | .first_line_x = undefined, 383 | .first_line_height = undefined, 384 | .max_width = undefined, 385 | .relative_content_pos = undefined, 386 | }}); 387 | }, 388 | } 389 | } 390 | 391 | return nodes; 392 | } 393 | 394 | fn resolveFont(opt_font: *?Font, dom_nodes: []const dom.Node, styler: Styler, nodes: []LayoutNode, first_parent_box: usize) Font { 395 | if (opt_font.* == null) { 396 | opt_font.* = getFont(dom_nodes, styler, nodes, first_parent_box); 397 | } 398 | return opt_font.*.?; 399 | } 400 | 401 | fn getFont(dom_nodes: []const dom.Node, styler: Styler, nodes: []LayoutNode, first_parent_box: usize) Font { 402 | std.debug.assert(nodes.len > 0); 403 | var it = layoutParentIterator(nodes[0 .. first_parent_box + 1]); 404 | while (it.next()) |parent_box| { 405 | //std.log.info(" parent it '{s}'", .{@tagName(dom_nodes[parent_box.dom_node].start_tag.id)}); 406 | if (styler.getFont(dom_nodes, parent_box.dom_node)) |font| { 407 | return font; 408 | } 409 | } 410 | return .{ .size = default_font_size }; 411 | } 412 | 413 | fn endParentBox( 414 | text: []const u8, 415 | dom_nodes: []const dom.Node, 416 | styler: Styler, 417 | nodes: []LayoutNode, 418 | parent_box_index: usize, 419 | ) void { 420 | _ = text; 421 | const parent_box = switch (nodes[parent_box_index]) { 422 | .box => |*b| b, 423 | else => unreachable, 424 | }; 425 | const dom_node_ref = switch (dom_nodes[parent_box.dom_node]) { 426 | .start_tag => |*t| t, 427 | // all boxes that become "parents" should be start_tags I think? 428 | else => unreachable, 429 | }; 430 | 431 | if (parent_box.content_size.x.getResolved() == null or parent_box.content_size.y.getResolved() == null) { 432 | 433 | const content_size_x = parent_box.content_size.x.getResolved() orelse { 434 | std.log.err("TODO: not having a resolved width is not implemented", .{}); 435 | @panic("todo"); 436 | }; 437 | if (2 * parent_box.mbp.padding >= content_size_x) { 438 | std.log.err("TODO: no room for content", .{}); 439 | @panic("todo"); 440 | } 441 | const padded_content_size_x = content_size_x - 2 * parent_box.mbp.padding; 442 | 443 | var pos_y: u32 = parent_box.mbp.padding; 444 | const CurrentLine = struct { 445 | x: u32, 446 | max_height: u32, 447 | }; 448 | var opt_current_line: ?CurrentLine = null; 449 | var cached_font: ?Font = null; 450 | 451 | var content_height: u32 = 2 * parent_box.mbp.margin + 2 * parent_box.mbp.border + 2 * parent_box.mbp.padding; 452 | var child_it = directChildIterator(nodes[parent_box_index..]); 453 | while (child_it.next()) |node_ref| { 454 | switch(node_ref.*) { 455 | .box => |*box| { 456 | const box_content_size = XY(u32) { 457 | // My algorithm should guarantee that content size MUST be set at this point 458 | .x = box.content_size.x.getResolved().?, 459 | .y = box.content_size.y.getResolved().?, 460 | }; 461 | 462 | // TODO: does this box go on the current line or the next line 463 | if (opt_current_line) |line| { 464 | _ = line; 465 | std.log.err("TODO: handle box when we have already started the line", .{}); 466 | @panic("todo"); 467 | } 468 | 469 | box.relative_content_pos = .{ 470 | .x = parent_box.mbp.padding + box.mbp.margin + box.mbp.border, 471 | .y = pos_y + box.mbp.margin + box.mbp.border, 472 | }; 473 | 474 | content_height += box_content_size.y; 475 | content_height += 2*box.mbp.border + 2*box.mbp.margin; 476 | 477 | // if is block element 478 | if (dom.defaultDisplayIsBlock(dom_nodes[box.dom_node].start_tag.id)) { 479 | opt_current_line = null; 480 | } else { 481 | std.log.err("TODO: update the current line", .{}); 482 | } 483 | }, 484 | .end_box => unreachable, // should be impossible 485 | .text => |*text_node| { 486 | text_node.relative_content_pos = .{ 487 | .x = parent_box.mbp.padding, 488 | .y = pos_y, 489 | }; 490 | const current_line = opt_current_line orelse CurrentLine{ .x = 0, .max_height = 0 }; 491 | text_node.first_line_x = current_line.x; 492 | text_node.max_width = padded_content_size_x; 493 | 494 | text_node.font = resolveFont(&cached_font, dom_nodes, styler, nodes, parent_box_index); 495 | var line_it = textLineIterator(text_node.font, current_line.x, padded_content_size_x, text_node.slice); 496 | 497 | const first_line = line_it.first(); 498 | text_node.first_line_x = current_line.x; 499 | opt_current_line = .{ 500 | .x = current_line.x + first_line.width, 501 | .max_height = @max(current_line.max_height, text_node.font.getLineHeight()), 502 | }; 503 | // TODO: this value wont' be correct if there is another element 504 | // come after us on the same line with a higher height. 505 | text_node.first_line_height = opt_current_line.?.max_height; 506 | while (line_it.next()) |line| { 507 | pos_y += opt_current_line.?.max_height; 508 | opt_current_line = .{ 509 | .x = line.width, 510 | .max_height = text_node.font.getLineHeight(), 511 | }; 512 | } 513 | }, 514 | .svg => {}, // ignore for now 515 | } 516 | } 517 | parent_box.content_size.y = .{ .resolved = content_height }; 518 | std.log.info("content height for <{s}> resolved to {}", .{@tagName(dom_node_ref.id), content_height}); 519 | } 520 | } 521 | 522 | pub const Font = struct { 523 | size: u32, 524 | pub fn getLineHeight(self: Font) u32 { 525 | // a silly hueristic 526 | return self.size; 527 | } 528 | pub fn getSpaceWidth(self: Font) u32 { 529 | // just a silly hueristic for now 530 | return @intFromFloat(@as(f32, @floatFromInt(self.size)) * 0.48); 531 | } 532 | }; 533 | 534 | 535 | pub const TextLineResult = struct { 536 | slice: []const u8, 537 | width: u32, 538 | }; 539 | pub const TextLineIterator = struct { 540 | font: Font, 541 | start_x: u32, 542 | max_width: u32, 543 | slice: []const u8, 544 | index: usize, 545 | 546 | pub fn first(self: *TextLineIterator) TextLineResult { 547 | if (self.start_x == 0) { 548 | const r = self.next() orelse unreachable; 549 | return .{ .slice = r.slice, .width = r.width }; 550 | } 551 | 552 | std.log.info("TODO: implement TextLineIterator.first", .{}); 553 | self.start_x = 0; // allow next to be called now 554 | @panic("todo"); 555 | } 556 | pub fn next(self: *TextLineIterator) ?TextLineResult { 557 | std.debug.assert(self.start_x == 0); 558 | if (self.index == self.slice.len) return null; 559 | 560 | const start = self.index; 561 | const line = calcTextLineWidth(self.font, self.slice[start..], self.max_width) catch unreachable; 562 | std.debug.assert(line.consumed > 0); 563 | self.index += line.consumed; 564 | 565 | return .{ 566 | .slice = self.slice[start .. start + line.consumed], 567 | .width = line.width, 568 | }; 569 | } 570 | }; 571 | pub fn textLineIterator(font: Font, start_x: u32, max_width: u32, slice: []const u8) TextLineIterator { 572 | return .{ 573 | .font = font, 574 | .start_x = start_x, 575 | .max_width = max_width, 576 | .slice = slice, 577 | .index = 0, 578 | }; 579 | } 580 | 581 | const LineResult = struct { 582 | consumed: usize, 583 | width: u32, 584 | }; 585 | fn calcTextLineWidth(font: Font, text: []const u8, max_width: u32) !LineResult { 586 | var total_width: u32 = 0; 587 | var it = HtmlCharIterator.init(text); 588 | 589 | while (true) { 590 | // skip whitespace 591 | const start = blk: { 592 | const start = it.index; 593 | while (try it.next()) |c| { 594 | if (!isWhitespace(c)) break :blk start; 595 | } 596 | return .{ .consumed = text.len, .width = total_width }; 597 | }; 598 | 599 | if (total_width > 0) { 600 | const next_width = total_width + font.getSpaceWidth(); 601 | if (next_width >= max_width) 602 | return .{ .consumed = start, .width = total_width }; 603 | total_width = next_width; 604 | } 605 | const word = try calcWordWidth(font, text[start..]); 606 | const next_width = total_width + word.width; 607 | if (total_width > 0) { 608 | if (next_width >= max_width) 609 | return .{ .consumed = start, .width = total_width }; 610 | } 611 | total_width = next_width; 612 | it.index = start + word.byte_len; 613 | } 614 | } 615 | 616 | const WordWidth = struct { 617 | byte_len: usize, 618 | width: u32, 619 | }; 620 | // assumption: text starts with at least one non-whitespace character 621 | fn calcWordWidth(font: Font, text: []const u8) !WordWidth { 622 | std.debug.assert(text.len > 0); 623 | 624 | var it = HtmlCharIterator.init(text); 625 | var c = (it.next() catch unreachable).?; 626 | std.debug.assert(!isWhitespace(c)); 627 | 628 | var total_width: u32 = 0; 629 | while (true) { 630 | total_width += calcCharWidth(font, c); 631 | c = (try it.next()) orelse break; 632 | if (isWhitespace(c)) break; 633 | } 634 | return .{ .byte_len = text.len, .width = total_width }; 635 | } 636 | 637 | // NOTE! it's very possible that characters could be different widths depending on 638 | // their surrounding letters, but, for simplicity we'll just assume this for now 639 | fn calcCharWidth(font: Font, char: u21) u32 { 640 | std.debug.assert(!isWhitespace(char)); 641 | // this is not right, just an initial dumb implementation 642 | return font.getSpaceWidth(); 643 | } 644 | 645 | const HtmlCharIterator = struct { 646 | text: []const u8, 647 | index: usize, 648 | pub fn init(text: []const u8) HtmlCharIterator { 649 | return .{ .text = text, .index = 0 }; 650 | } 651 | pub fn next(self: *HtmlCharIterator) !?u21 { 652 | if (self.index == self.text.len) return null; 653 | const len = try std.unicode.utf8CodepointSequenceLength(self.text[0]); 654 | if (self.index + len > self.text.len) 655 | return error.Utf8TruncatedInput; 656 | const c = try std.unicode.utf8Decode(self.text[0 .. len]); 657 | if (c == '&') { 658 | return error.ImplementCharReference; 659 | } 660 | self.index += len; 661 | return c; 662 | } 663 | }; 664 | 665 | const LayoutParentIterator = struct { 666 | ptr: [*]const LayoutNode, 667 | index: usize, 668 | pub fn next(self: *LayoutParentIterator) ?*const Box { 669 | if (self.index == std.math.maxInt(usize)) return null; 670 | 671 | switch (self.ptr[self.index]) { 672 | .box => |*box| { 673 | self.index = box.parent_box; 674 | return box; 675 | }, 676 | else => unreachable, 677 | } 678 | } 679 | }; 680 | fn layoutParentIterator(nodes: []const LayoutNode) LayoutParentIterator { 681 | std.debug.assert(nodes.len > 0); 682 | switch (nodes[nodes.len-1]) { 683 | .box => {}, 684 | else => unreachable, 685 | } 686 | return LayoutParentIterator{ .ptr = nodes.ptr, .index = nodes.len - 1 }; 687 | } 688 | 689 | const DirectChildIterator = struct { 690 | nodes: []LayoutNode, 691 | index: usize, 692 | last_node_was_container: bool, 693 | pub fn next(self: *DirectChildIterator) ?*LayoutNode { 694 | std.debug.assert(self.index != 0); 695 | if (self.last_node_was_container) { 696 | var depth: usize = 1; 697 | node_loop: 698 | while (true) { 699 | self.index += 1; 700 | switch (self.nodes[self.index - 1]) { 701 | .box => depth += 1, 702 | .end_box => { 703 | depth -= 1; 704 | if (depth == 0) break :node_loop; 705 | }, 706 | .text => {}, 707 | .svg => {}, 708 | } 709 | std.debug.assert(self.index < self.nodes.len); 710 | } 711 | } 712 | if (self.index == self.nodes.len) { 713 | self.last_node_was_container = false; 714 | return null; 715 | } 716 | self.last_node_was_container = switch (self.nodes[self.index]) { 717 | .box => true, 718 | // should be impossible because the list of nodes should not include the top-level box's end_box 719 | .end_box => unreachable, 720 | .text => false, 721 | .svg => false, 722 | }; 723 | self.index += 1; 724 | return &self.nodes[self.index-1]; 725 | } 726 | }; 727 | fn directChildIterator(nodes: []LayoutNode) DirectChildIterator { 728 | std.debug.assert(nodes.len >= 1); 729 | switch (nodes[0]) { 730 | .box => {}, 731 | .end_box => unreachable, 732 | .text => unreachable, 733 | .svg => unreachable, 734 | } 735 | return DirectChildIterator{ .nodes = nodes, .index = 1, .last_node_was_container = false }; 736 | } 737 | 738 | fn isWhitespace(c: u21) bool { 739 | return c == ' ' or c == '\t' or c == '\r' or c == '\n'; 740 | } 741 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /test/hello.html: -------------------------------------------------------------------------------- 1 | 2 | 10 | 14 | Hello 15 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /x11renderer.zig: -------------------------------------------------------------------------------- 1 | const builtin = @import("builtin"); 2 | const std = @import("std"); 3 | const x11 = @import("x11"); 4 | 5 | const dom = @import("dom.zig"); 6 | const layout = @import("layout.zig"); 7 | const XY = layout.XY; 8 | const Styler = layout.Styler; 9 | const render = @import("render.zig"); 10 | const alext = @import("alext.zig"); 11 | 12 | var global_arena = std.heap.ArenaAllocator.init(std.heap.page_allocator); 13 | 14 | const window_width = 600; 15 | const window_height = 600; 16 | 17 | pub fn oom(e: error{OutOfMemory}) noreturn { 18 | @panic(@errorName(e)); 19 | } 20 | 21 | pub fn fatal(comptime fmt: []const u8, args: anytype) noreturn { 22 | std.log.err(fmt, args); 23 | std.process.exit(0xff); 24 | } 25 | 26 | var windows_args_arena = if (builtin.os.tag == .windows) 27 | std.heap.ArenaAllocator.init(std.heap.page_allocator) else struct{}{}; 28 | 29 | pub fn cmdlineArgs() [][*:0]u8 { 30 | if (builtin.os.tag == .windows) { 31 | const slices = std.process.argsAlloc(windows_args_arena.allocator()) catch |err| switch (err) { 32 | error.OutOfMemory => oom(error.OutOfMemory), 33 | error.Overflow => @panic("Overflow while parsing command line"), 34 | }; 35 | const args = windows_args_arena.allocator().alloc([*:0]u8, slices.len - 1) catch |e| oom(e); 36 | for (slices[1..], 0..) |slice, i| { 37 | args[i] = slice.ptr; 38 | } 39 | return args; 40 | } 41 | return std.os.argv.ptr[1 .. std.os.argv.len]; 42 | } 43 | 44 | pub fn main() !u8 { 45 | try x11.wsaStartup(); 46 | const args = blk: { 47 | const all_args = cmdlineArgs(); 48 | var non_option_len: usize = 0; 49 | for (all_args) |arg_ptr| { 50 | const arg = std.mem.span(arg_ptr); 51 | if (!std.mem.startsWith(u8, arg, "-")) { 52 | all_args[non_option_len] = arg; 53 | non_option_len += 1; 54 | } else { 55 | fatal("unknown cmdline option '{s}'", .{arg}); 56 | } 57 | } 58 | break :blk all_args[0 .. non_option_len]; 59 | }; 60 | if (args.len != 1) { 61 | try std.io.getStdErr().writer().writeAll("Usage: x11renderer FILE\n"); 62 | return 0xff; 63 | } 64 | const filename = std.mem.span(args[0]); 65 | 66 | const content = blk: { 67 | var file = std.fs.cwd().openFile(filename, .{}) catch |err| { 68 | std.log.err("failed to open '{s}' with {s}", .{filename, @errorName(err)}); 69 | return 0xff; 70 | }; 71 | defer file.close(); 72 | break :blk try file.readToEndAlloc(global_arena.allocator(), std.math.maxInt(usize)); 73 | }; 74 | 75 | var parse_context = ParseContext{ .filename = filename }; 76 | var nodes = dom.parse(global_arena.allocator(), content, .{ 77 | .context = &parse_context, 78 | .on_error = onParseError, 79 | }) catch |err| switch (err) { 80 | error.ReportedParseError => return 0xff, 81 | else => |e| return e, 82 | }; 83 | alext.unmanaged.finalize(dom.Node, &nodes, global_arena.allocator()); 84 | 85 | //try dom.dump(content, nodes); 86 | return try renderNodes(content, nodes.items); 87 | } 88 | 89 | const ParseContext = struct { 90 | filename: []const u8, 91 | }; 92 | 93 | fn onParseError(context_ptr: ?*anyopaque, msg: []const u8) void { 94 | const context: *ParseContext = @alignCast(@ptrCast(context_ptr)); 95 | std.io.getStdErr().writer().print("{s}: parse error: {s}\n", .{context.filename, msg}) catch |err| 96 | std.debug.panic("failed to print parse error with {s}", .{@errorName(err)}); 97 | } 98 | 99 | 100 | fn renderNodes(html_content: []const u8, html_nodes: []const dom.Node) !u8 { 101 | const conn = try connect(global_arena.allocator()); 102 | defer x11.disconnect(conn.sock); 103 | 104 | const screen = blk: { 105 | const fixed = conn.setup.fixed(); 106 | inline for (@typeInfo(@TypeOf(fixed.*)).Struct.fields) |field| { 107 | std.log.debug("{s}: {any}", .{field.name, @field(fixed, field.name)}); 108 | } 109 | std.log.debug("vendor: {s}", .{try conn.setup.getVendorSlice(fixed.vendor_len)}); 110 | const format_list_offset = x11.ConnectSetup.getFormatListOffset(fixed.vendor_len); 111 | const format_list_limit = x11.ConnectSetup.getFormatListLimit(format_list_offset, fixed.format_count); 112 | std.log.debug("fmt list off={} limit={}", .{format_list_offset, format_list_limit}); 113 | const formats = try conn.setup.getFormatList(format_list_offset, format_list_limit); 114 | for (formats, 0..) |format, i| { 115 | std.log.debug("format[{}] depth={:3} bpp={:3} scanpad={:3}", .{i, format.depth, format.bits_per_pixel, format.scanline_pad}); 116 | } 117 | const screen = conn.setup.getFirstScreenPtr(format_list_limit); 118 | inline for (@typeInfo(@TypeOf(screen.*)).Struct.fields) |field| { 119 | std.log.debug("SCREEN 0| {s}: {any}", .{field.name, @field(screen, field.name)}); 120 | } 121 | break :blk screen; 122 | }; 123 | 124 | // TODO: maybe need to call conn.setup.verify or something? 125 | 126 | const window_id = conn.setup.fixed().resource_id_base; 127 | { 128 | var msg_buf: [x11.create_window.max_len]u8 = undefined; 129 | const len = x11.create_window.serialize(&msg_buf, .{ 130 | .window_id = window_id, 131 | .parent_window_id = screen.root, 132 | .depth = 0, // we don't care, just inherit from the parent 133 | .x = 0, .y = 0, 134 | .width = window_width, .height = window_height, 135 | .border_width = 0, // TODO: what is this? 136 | .class = .input_output, 137 | .visual_id = screen.root_visual, 138 | }, .{ 139 | // .bg_pixmap = .copy_from_parent, 140 | .bg_pixel = 0xffffff, 141 | // //.border_pixmap = 142 | // .border_pixel = 0x01fa8ec9, 143 | // .bit_gravity = .north_west, 144 | // .win_gravity = .east, 145 | // .backing_store = .when_mapped, 146 | // .backing_planes = 0x1234, 147 | // .backing_pixel = 0xbbeeeeff, 148 | // .override_redirect = true, 149 | // .save_under = true, 150 | .event_mask = 151 | x11.event.key_press 152 | | x11.event.key_release 153 | | x11.event.button_press 154 | | x11.event.button_release 155 | | x11.event.enter_window 156 | | x11.event.leave_window 157 | | x11.event.pointer_motion 158 | // | x11.event.pointer_motion_hint WHAT THIS DO? 159 | // | x11.event.button1_motion WHAT THIS DO? 160 | // | x11.event.button2_motion WHAT THIS DO? 161 | // | x11.event.button3_motion WHAT THIS DO? 162 | // | x11.event.button4_motion WHAT THIS DO? 163 | // | x11.event.button5_motion WHAT THIS DO? 164 | // | x11.event.button_motion WHAT THIS DO? 165 | | x11.event.keymap_state 166 | | x11.event.exposure 167 | , 168 | // .dont_propagate = 1, 169 | }); 170 | try conn.send(msg_buf[0..len]); 171 | } 172 | 173 | const bg_gc_id = window_id + 1; 174 | { 175 | var msg_buf: [x11.create_gc.max_len]u8 = undefined; 176 | const len = x11.create_gc.serialize(&msg_buf, .{ 177 | .gc_id = bg_gc_id, 178 | .drawable_id = screen.root, 179 | }, .{ 180 | .foreground = screen.black_pixel, 181 | }); 182 | try conn.send(msg_buf[0..len]); 183 | } 184 | const fg_gc_id = window_id + 2; 185 | { 186 | var msg_buf: [x11.create_gc.max_len]u8 = undefined; 187 | const len = x11.create_gc.serialize(&msg_buf, .{ 188 | .gc_id = fg_gc_id, 189 | .drawable_id = screen.root, 190 | }, .{ 191 | .background = 0xffffff, 192 | .foreground = 0x111111, 193 | }); 194 | try conn.send(msg_buf[0..len]); 195 | } 196 | 197 | // get some font information 198 | { 199 | const text_literal = [_]u16 { 'm' }; 200 | const text = x11.Slice(u16, [*]const u16) { .ptr = &text_literal, .len = text_literal.len }; 201 | var msg: [x11.query_text_extents.getLen(text.len)]u8 = undefined; 202 | x11.query_text_extents.serialize(&msg, fg_gc_id, text); 203 | try conn.send(&msg); 204 | } 205 | 206 | const double_buf = try x11.DoubleBuffer.init( 207 | std.mem.alignForward(usize, 1000, std.mem.page_size), 208 | .{ .memfd_name = "ZigX11DoubleBuffer" }, 209 | ); 210 | // double_buf.deinit() (not necessary) 211 | std.log.info("read buffer capacity is {}", .{double_buf.half_len}); 212 | var buf = double_buf.contiguousReadBuffer(); 213 | // no need to deinit 214 | 215 | const font_dims: FontDims = blk: { 216 | _ = try x11.readOneMsg(conn.reader(), @alignCast(buf.nextReadBuffer())); 217 | switch (x11.serverMsgTaggedUnion(@alignCast(buf.double_buffer_ptr))) { 218 | .reply => |msg_reply| { 219 | const msg: *x11.ServerMsg.QueryTextExtents = @ptrCast(msg_reply); 220 | break :blk .{ 221 | .width = @intCast(msg.overall_width), 222 | .height = @intCast(msg.font_ascent + msg.font_descent), 223 | .font_left = @intCast(msg.overall_left), 224 | .font_ascent = msg.font_ascent, 225 | }; 226 | }, 227 | else => |msg| { 228 | std.log.err("expected a reply but got {}", .{msg}); 229 | return 1; 230 | }, 231 | } 232 | }; 233 | 234 | // TODO: set the window title 235 | // extract the title from the dom nodes 236 | std.log.warn("TODO: set the window title", .{}); 237 | 238 | { 239 | var msg: [x11.map_window.len]u8 = undefined; 240 | x11.map_window.serialize(&msg, window_id); 241 | try conn.send(&msg); 242 | } 243 | 244 | while (true) { 245 | { 246 | const recv_buf = buf.nextReadBuffer(); 247 | if (recv_buf.len == 0) { 248 | std.log.err("buffer size {} not big enough!", .{buf.half_len}); 249 | return 1; 250 | } 251 | const len = try x11.readSock(conn.sock, recv_buf, 0); 252 | if (len == 0) { 253 | std.log.info("X server connection closed", .{}); 254 | return 0; 255 | } 256 | buf.reserve(len); 257 | } 258 | while (true) { 259 | const data = buf.nextReservedBuffer(); 260 | if (data.len < 32) 261 | break; 262 | const msg_len = x11.parseMsgLen(data[0..32].*); 263 | if (data.len < msg_len) 264 | break; 265 | buf.release(msg_len); 266 | //buf.resetIfEmpty(); 267 | switch (x11.serverMsgTaggedUnion(@alignCast(data.ptr))) { 268 | .err => |msg| { 269 | std.log.err("{}", .{msg}); 270 | return 1; 271 | }, 272 | .reply => |msg| { 273 | std.log.info("todo: handle a reply message {}", .{msg}); 274 | return error.TodoHandleReplyMessage; 275 | }, 276 | .key_press => |msg| { 277 | std.log.info("key_press: keycode={}", .{msg.keycode}); 278 | }, 279 | .key_release => |msg| { 280 | std.log.info("key_release: keycode={}", .{msg.keycode}); 281 | }, 282 | .button_press => |msg| { 283 | std.log.info("button_press: {}", .{msg}); 284 | }, 285 | .button_release => |msg| { 286 | std.log.info("button_release: {}", .{msg}); 287 | }, 288 | .enter_notify => |msg| { 289 | std.log.info("enter_window: {}", .{msg}); 290 | }, 291 | .leave_notify => |msg| { 292 | std.log.info("leave_window: {}", .{msg}); 293 | }, 294 | .motion_notify => |msg| { 295 | // too much logging 296 | _ = msg; 297 | //std.log.info("pointer_motion: {}", .{msg}); 298 | }, 299 | .keymap_notify => |msg| { 300 | std.log.info("keymap_state: {}", .{msg}); 301 | }, 302 | .expose => |msg| { 303 | std.log.info("expose: {}", .{msg}); 304 | try doRender(conn.sock, window_id, fg_gc_id, font_dims, html_content, html_nodes); 305 | }, 306 | .mapping_notify => |msg| { 307 | std.log.info("mapping_notify: {}", .{msg}); 308 | }, 309 | .unhandled => |msg| { 310 | std.log.info("todo: server msg {}", .{msg}); 311 | return error.UnhandledServerMsg; 312 | }, 313 | .no_exposure, 314 | .map_notify, 315 | .reparent_notify, 316 | .configure_notify, 317 | => unreachable, // did not register for these 318 | } 319 | } 320 | } 321 | } 322 | 323 | const FontDims = struct { 324 | width: u8, 325 | height: u8, 326 | font_left: i16, // pixels to the left of the text basepoint 327 | font_ascent: i16, // pixels up from the text basepoint to the top of the text 328 | }; 329 | 330 | fn doRender( 331 | sock: std.posix.socket_t, 332 | drawable_id: u32, 333 | //bg_gc_id: u32, 334 | fg_gc_id: u32, 335 | font_dims: FontDims, 336 | html_content: []const u8, 337 | dom_nodes: []const dom.Node, 338 | ) !void { 339 | { 340 | var msg: [x11.clear_area.len]u8 = undefined; 341 | x11.clear_area.serialize(&msg, false, drawable_id, .{ 342 | .x = 0, .y = 0, .width = window_width, .height = window_height, 343 | }); 344 | try send(sock, &msg); 345 | } 346 | 347 | var arena = std.heap.ArenaAllocator.init(std.heap.page_allocator); 348 | defer arena.deinit(); 349 | 350 | var layout_nodes = layout.layout( 351 | arena.allocator(), 352 | html_content, 353 | dom_nodes, 354 | .{ .x = window_width, .y = window_height }, 355 | Styler{ }, 356 | ) catch |err| { 357 | // TODO: maybe draw this error as text? 358 | std.log.err("layout failed, error={s}", .{@errorName(err)}); 359 | return; 360 | }; 361 | alext.unmanaged.finalize(layout.LayoutNode, &layout_nodes, arena.allocator()); 362 | 363 | const render_ctx = RenderCtx { 364 | .sock = sock, 365 | .drawable_id = drawable_id, 366 | .fg_gc_id = fg_gc_id, 367 | .font_dims = font_dims, 368 | }; 369 | try render.render(html_content, dom_nodes, layout_nodes.items, RenderCtx, &onRender, render_ctx); 370 | } 371 | 372 | const RenderCtx = struct { 373 | sock: std.posix.socket_t, 374 | drawable_id: u32, 375 | fg_gc_id: u32, 376 | font_dims: FontDims, 377 | }; 378 | 379 | fn onRender(ctx: RenderCtx, op: render.Op) !void { 380 | switch (op) { 381 | .rect => |r| { 382 | try changeGcColor(ctx.sock, ctx.fg_gc_id, r.color, 0xffffff); 383 | const rectangles = [_]x11.Rectangle{.{ 384 | .x = @intCast(r.x), 385 | .y = @intCast(r.y), 386 | .width = @intCast(r.w), 387 | .height = @intCast(r.h), 388 | }}; 389 | if (r.fill) { 390 | var msg: [x11.poly_fill_rectangle.getLen(rectangles.len)]u8 = undefined; 391 | x11.poly_fill_rectangle.serialize(&msg, .{ 392 | .drawable_id = ctx.drawable_id, 393 | .gc_id = ctx.fg_gc_id, 394 | }, &rectangles); 395 | try send(ctx.sock, &msg); 396 | } else { 397 | var msg: [x11.poly_rectangle.getLen(rectangles.len)]u8 = undefined; 398 | x11.poly_rectangle.serialize(&msg, .{ 399 | .drawable_id = ctx.drawable_id, 400 | .gc_id = ctx.fg_gc_id, 401 | }, &rectangles); 402 | try send(ctx.sock, &msg); 403 | } 404 | }, 405 | .text => |t| { 406 | const max_text_len = 255; 407 | const text_len = std.math.cast(u8, t.slice.len) orelse max_text_len; 408 | try changeGcColor(ctx.sock, ctx.fg_gc_id, 0x111111, 0xffffff); 409 | var msg: [x11.image_text8.getLen(max_text_len)]u8 = undefined; 410 | x11.image_text8.serialize( 411 | &msg, 412 | x11.Slice(u8, [*]const u8){ .ptr = t.slice.ptr, .len = text_len }, 413 | .{ 414 | .drawable_id = ctx.drawable_id, 415 | .gc_id = ctx.fg_gc_id, 416 | .x = @intCast(t.x), 417 | .y = @as(i16, @intCast(t.y)) + ctx.font_dims.font_ascent, 418 | }, 419 | ); 420 | try send(ctx.sock, msg[0 .. x11.image_text8.getLen(text_len)]); 421 | }, 422 | } 423 | } 424 | 425 | fn changeGcColor(sock: std.posix.socket_t, gc_id: u32, fg_color: u32, bg_color: u32) !void { 426 | var msg_buf: [x11.change_gc.max_len]u8 = undefined; 427 | const len = x11.change_gc.serialize(&msg_buf, gc_id, .{ 428 | .foreground = fg_color, 429 | .background = bg_color, 430 | }); 431 | try send(sock, msg_buf[0..len]); 432 | } 433 | 434 | pub const SocketReader = std.io.Reader(std.posix.socket_t, std.posix.RecvFromError, readSocket); 435 | 436 | pub fn send(sock: std.posix.socket_t, data: []const u8) !void { 437 | const sent = try x11.writeSock(sock, data, 0); 438 | if (sent != data.len) { 439 | std.log.err("send {} only sent {}\n", .{data.len, sent}); 440 | return error.DidNotSendAllData; 441 | } 442 | } 443 | 444 | const SelfModule = @This(); 445 | pub const ConnectResult = struct { 446 | sock: std.posix.socket_t, 447 | setup: x11.ConnectSetup, 448 | pub fn reader(self: ConnectResult) SocketReader { 449 | return .{ .context = self.sock }; 450 | } 451 | pub fn send(self: ConnectResult, data: []const u8) !void { 452 | try SelfModule.send(self.sock, data); 453 | } 454 | }; 455 | 456 | pub fn connect(allocator: std.mem.Allocator) !ConnectResult { 457 | const display = x11.getDisplay(); 458 | const parsed_display = x11.parseDisplay(display) catch |err| { 459 | std.log.err("invalid display '{s}': {s}", .{display, @errorName(err)}); 460 | std.process.exit(0xff); 461 | }; 462 | 463 | const sock = x11.connect(display, parsed_display) catch |err| { 464 | std.log.err("failed to connect to display '{s}': {s}", .{display, @errorName(err)}); 465 | std.process.exit(0xff); 466 | }; 467 | errdefer x11.disconnect(sock); 468 | 469 | { 470 | const len = comptime x11.connect_setup.getLen(0, 0); 471 | var msg: [len]u8 = undefined; 472 | x11.connect_setup.serialize(&msg, 11, 0, .{ .ptr = undefined, .len = 0 }, .{ .ptr = undefined, .len = 0 }); 473 | try send(sock, &msg); 474 | } 475 | 476 | const reader = SocketReader { .context = sock }; 477 | const connect_setup_header = try x11.readConnectSetupHeader(reader, .{}); 478 | switch (connect_setup_header.status) { 479 | .failed => { 480 | std.log.err("connect setup failed, version={}.{}, reason='{s}'", .{ 481 | connect_setup_header.proto_major_ver, 482 | connect_setup_header.proto_minor_ver, 483 | connect_setup_header.readFailReason(reader), 484 | }); 485 | return error.ConnectSetupFailed; 486 | }, 487 | .authenticate => { 488 | std.log.err("AUTHENTICATE! not implemented", .{}); 489 | return error.NotImplemetned; 490 | }, 491 | .success => { 492 | // TODO: check version? 493 | std.log.debug("SUCCESS! version {}.{}", .{connect_setup_header.proto_major_ver, connect_setup_header.proto_minor_ver}); 494 | }, 495 | else => |status| { 496 | std.log.err("Error: expected 0, 1 or 2 as first byte of connect setup reply, but got {}", .{status}); 497 | return error.MalformedXReply; 498 | } 499 | } 500 | 501 | const connect_setup = x11.ConnectSetup { 502 | .buf = try allocator.allocWithOptions(u8, connect_setup_header.getReplyLen(), 4, null), 503 | }; 504 | std.log.debug("connect setup reply is {} bytes", .{connect_setup.buf.len}); 505 | try x11.readFull(reader, connect_setup.buf); 506 | 507 | return ConnectResult{ .sock = sock, .setup = connect_setup }; 508 | } 509 | fn readSocket(sock: std.posix.socket_t, buffer: []u8) !usize { 510 | return x11.readSock(sock, buffer, 0); 511 | } 512 | --------------------------------------------------------------------------------