code wiki / (root) / nx_layout_from_dom.nx

nx_layout_from_dom.nx source

↩ module page · 417 lines · 21696 B

1// nx_layout_from_dom.nx -- build a LayoutBox tree from an HTML byte 2// stream by streaming nx_html_tokenizer tokens. Phase 3 fourth 3// primitive of NISHI_BROWSER_ROADMAP. THE bridge primitive between 4// Phase 1 (HTML parse) and Phase 3 (layout). Closes the gap that 5// prior layout smokes covered with hand-built fixtures. 6// 7// Algorithm: streaming token walk with a parent-stack arena. 8// 9// 1. Append a synthetic root LayoutBox of kind BLOCK at index 0. 10// (Production wraps this around a viewport-sized "html" box. 11// Phase 3b will detect the actual <html> / <body> root.) 12// 2. Maintain a stack of "current parent" LayoutBox indices. 13// Root is the bottom of the stack. 14// 3. For each token from the HTML tokenizer: 15// START_TAG -> create LayoutBox with kind from 16// nx_layout_default_display(tag name); 17// attach to current parent; push onto stack. 18// END_TAG -> pop the stack (back to parent). 19// SELF_CLOSING -> create LayoutBox; attach; do NOT push. 20// TEXT -> create LayoutBox of kind TEXT with the 21// text body offset/length recorded via 22// nx_layout_box_set_text. 23// DOCTYPE -> skip. 24// COMMENT -> skip. 25// UNKNOWN -> skip. 26// EOF -> end the walk. 27// 28// The output LayoutTree is ready for nx_layout_block to lay out 29// (provided cascade decls are computed via nx_css_apply against 30// the same source buffer first). 31// 32// What it does NOT handle yet (Phase 3b queued, explicit in header): 33// - HTML5 implicit-tag rules (auto-close on certain transitions, 34// e.g. <li><li> -> implicit </li> before second <li>; <p><div> 35// -> implicit </p>; <table> children must be <tr>; etc.) 36// - foster parenting of stray text inside <table> 37// - HTML5 raw-text content (script / style bodies are tokenized 38// as TEXT by nx_html_tokenizer's CAPABILITY_COMPLETENESS=PARTIAL; 39// CSS interpretation lives in Phase 2) 40// - foreign content (SVG, MathML namespace switches) 41// - implicit <html>/<head>/<body> insertion (we create a synthetic 42// root and let parsed tags hang off it) 43// - mismatched end tags (currently pops regardless; production 44// would walk the stack to find a matching open tag) 45// 46// Per cardinal feedback-honest-perf-verdict-no-aspirational-claims: 47// gap list is EXACT in this header. 48// 49// genealogy_id: w3c_html5_parser_tree_construction + 50// substrate_browser_phase_3_html_to_layout 51// lineage_id: nishi_browser_layout_from_dom_v1 52// 53// nx_safety_envelope: 54// intended_use: "HTML token stream -> LayoutBox tree" 55// sil_target: SIL1 56// evidence: [bounded_token_stream, 57// bounded_stack_arena, 58// no_internal_allocation_beyond_caller_arrays, 59// sealed_token_kind_dispatch] 60// verdict: NOT_YET_EVALUATED 61 62import "nx_syscalls.nx" 63import "nx_html_tokenizer.nx" 64import "nx_layout_box.nx" 65import "nx_layout_default_display.nx" 66const NX_MAGIC_65536: i64 = 65536 67 68// Parent-stack arena -- caller-supplied so the primitive owns no 69// allocation. Max depth ~64 is enough for andelinwest.com-class 70// pages; deeper trees can grow this. 71struct LayoutFromDomStack { 72 indices: *i64, 73 capacity: i64, 74 depth: i64 75} 76 77const NX_LAYOUT_FROM_DOM_STACK_BYTES: i64 = 24 78 79// ---- helpers ---- 80 81func _lfd_push(stk: *LayoutFromDomStack, idx: i64) -> i64 { 82 if stk.depth >= stk.capacity { return -1 } 83 stk.indices[stk.depth] = idx 84 stk.depth = stk.depth + 1 85 return 0 86} 87 88func _lfd_pop(stk: *LayoutFromDomStack) -> i64 { 89 if stk.depth <= 1 { return -1 } // never pop the root 90 stk.depth = stk.depth - 1 91 return 0 92} 93 94func _lfd_top(stk: *LayoutFromDomStack) -> i64 { 95 if stk.depth == 0 { return -1 } 96 return stk.indices[stk.depth - 1] 97} 98 99// Kind of the box currently on top of the parent stack (-1 if none). 100func _lfd_top_kind(tree: *LayoutTree, stk: *LayoutFromDomStack) -> i64 { 101 let t: i64 = _lfd_top(stk) 102 if t < 0 { return -1 } 103 let b: *LayoutBox = (tree.boxes as *u8 + (t as nx_size) * (NX_LAYOUT_BOX_BYTES as nx_size)) as *LayoutBox 104 return b.kind 105} 106 107// ---- public API ---- 108 109// Initialize the parent-stack arena. 110func nx_layout_from_dom_stack_init(stk: *LayoutFromDomStack, 111 indices: *i64, capacity: i64) -> i64 { 112 stk.indices = indices 113 stk.capacity = capacity 114 stk.depth = 0 115 return 0 116} 117 118// Build a LayoutBox tree from an HTML byte stream. Returns the index 119// of the synthetic root LayoutBox (always 0 on success, -1 on error). 120// 121// `src` is the HTML source buffer; cursor walks it via nx_html_next_token. 122// `tree` is a pre-initialized LayoutTree (via nx_layout_tree_init). 123// `stk` is a pre-initialized LayoutFromDomStack with capacity >= max 124// nesting depth. 125func _lfd_lc(b: i64) -> i64 { if b >= 65 { if b <= 90 { return b + 32 } } return b } 126func _lfd_name_eq(src: *u8, off: i64, len: i64, lit: *u8) -> i64 { 127 var ll: i64 = 0 128 while lit[ll] != (0 as u8) { ll = ll + 1 } 129 if len != ll { return 0 } 130 var i: i64 = 0 131 while i < ll { if _lfd_lc(src[off+i] & 0xff) != _lfd_lc(lit[i] & 0xff) { return 0 } i = i + 1 } 132 return 1 133} 134// Should this element's TEXT be suppressed from layout? script/style = raw code, head/title/noscript = 135// non-body metadata. Real browsers execute/apply these, never render their source as body text. 136func _lfd_suppress_tag(src: *u8, off: i64, len: i64) -> i64 { 137 if _lfd_name_eq(src, off, len, "script\x00" as *u8) == 1 { return 1 } 138 if _lfd_name_eq(src, off, len, "style\x00" as *u8) == 1 { return 1 } 139 if _lfd_name_eq(src, off, len, "head\x00" as *u8) == 1 { return 1 } 140 if _lfd_name_eq(src, off, len, "title\x00" as *u8) == 1 { return 1 } 141 if _lfd_name_eq(src, off, len, "noscript\x00" as *u8) == 1 { return 1 } 142 return 0 143} 144 145// THE HTML5 VOID ELEMENTS -- start tags that NEVER get a matching end tag. 146// WRITTEN 2026-07-31. This was the ONLY member of the _lfd_ family that did not exist: nx_browser_render.nx 147// :495 has called it since it was authored, its own comment saying "void START tags never get an END -- 148// exempt from depth (MIRRORS nx_layout_from_dom exactly)" -- and it mirrored a function nobody had written. 149// nx_cc FAILS OPEN on undefined identifiers (seq1012), so nothing ever said so; found by nx_undefscan, 150// where it poisoned EIGHT browser targets at once from this single missing definition. 151// WHY IT MATTERS, not just that it compiles: a depth counter that counts a <br> or <img> as an opening 152// tag never comes back down, because no </br> is coming. Inside a skipped subtree that means the skip 153// NEVER ENDS and every following element is silently dropped -- the same failure shape as the 154// google-blank root above, where a swallowed closer left `suppress` stuck and blanked the page. 155// The list is the HTML5 spec's, in full; a partial list is the same bug with a smaller trigger set. 156func _lfd_is_void_tag(src: *u8, off: i64, len: i64) -> i64 { 157 if _lfd_name_eq(src, off, len, "area\x00" as *u8) == 1 { return 1 } 158 if _lfd_name_eq(src, off, len, "base\x00" as *u8) == 1 { return 1 } 159 if _lfd_name_eq(src, off, len, "br\x00" as *u8) == 1 { return 1 } 160 if _lfd_name_eq(src, off, len, "col\x00" as *u8) == 1 { return 1 } 161 if _lfd_name_eq(src, off, len, "embed\x00" as *u8) == 1 { return 1 } 162 if _lfd_name_eq(src, off, len, "hr\x00" as *u8) == 1 { return 1 } 163 if _lfd_name_eq(src, off, len, "img\x00" as *u8) == 1 { return 1 } 164 if _lfd_name_eq(src, off, len, "input\x00" as *u8) == 1 { return 1 } 165 if _lfd_name_eq(src, off, len, "link\x00" as *u8) == 1 { return 1 } 166 if _lfd_name_eq(src, off, len, "meta\x00" as *u8) == 1 { return 1 } 167 if _lfd_name_eq(src, off, len, "param\x00" as *u8) == 1 { return 1 } 168 if _lfd_name_eq(src, off, len, "source\x00" as *u8) == 1 { return 1 } 169 if _lfd_name_eq(src, off, len, "track\x00" as *u8) == 1 { return 1 } 170 if _lfd_name_eq(src, off, len, "wbr\x00" as *u8) == 1 { return 1 } 171 return 0 172} 173// RETIRED bare-tag drop (2026-07-21): <nav>/<aside> used to be dropped WHOLESALE here, on the premise 174// "without the page's own CSS we can't arrange these". That premise is obsolete -- the engine now runs 175// the real cascade (var/clamp/grid/flex/atomic inline-block), and every modern site's PRIMARY nav is a 176// <nav> of inline-block pills, so the wholesale drop blanked real-page navigation (proven by shot). 177// Chrome suppression stays via the PRECISE signals below: role="navigation" (what MediaWiki marks its 178// TOC/menus with), chrome marker classes, and inline display:none. Rule 25: improve, never strip. 179func _lfd_chrome_tag(src: *u8, off: i64, len: i64) -> i64 { 180 return 0 181} 182// Does the tag source contain role="navigation" (ARIA standard for nav landmarks -- the TOC, language 183// lists, menus mark themselves this way even when the element is a <div>)? Scans for role= then checks 184// the quoted value == navigation. Self-contained (no DOM-query dependency in this core organ). 185func _lfd_has_role_nav(src: *u8, src_off: i64, src_len: i64) -> i64 { 186 let end: i64 = src_off + src_len 187 var i: i64 = src_off 188 while i + 5 <= end { 189 if (src[i]&0xff)==114 { if (src[i+1]&0xff)==111 { if (src[i+2]&0xff)==108 { if (src[i+3]&0xff)==101 { if (src[i+4]&0xff)==61 { 190 var j: i64 = i + 5 191 if j < end { let q: i64 = src[j]&0xff; if q==34 { j = j + 1 } else { if q==39 { j = j + 1 } } } 192 if j + 10 <= end { if _lfd_name_eq(src, j, 10, "navigation\x00" as *u8) == 1 { return 1 } } 193 } } } } } 194 i = i + 1 195 } 196 return 0 197} 198// Does src[off..off+len) contain the NUL-terminated literal `lit` (case-sensitive substring)? Used to 199// detect chrome marker CLASSES in a start tag's source (e.g. "vector-dropdown"). Self-contained. 200func _lfd_src_has_sub(src: *u8, off: i64, len: i64, lit: *u8) -> i64 { 201 var ll: i64 = 0 202 while lit[ll] != (0 as u8) { ll = ll + 1 } 203 if ll == 0 { return 1 } 204 let end: i64 = off + len 205 var i: i64 = off 206 while i + ll <= end { 207 var j: i64 = 0 208 var ok: i64 = 1 209 while j < ll { if (src[i+j]&0xff) != (lit[j]&0xff) { ok = 0; j = ll } else { j = j + 1 } } 210 if ok == 1 { return 1 } 211 i = i + 1 212 } 213 return 0 214} 215// Collapsed-DROPDOWN chrome (Vector skin language selector / tools / page menus): rendered EXPANDED by 216// our subset (we can't honor the checkbox-collapse hack), and the page's OWN fetched CSS RE-SHOWS them 217// even when our UA sheet sets display:none -- so we drop the subtree at LAYOUT level (before any CSS), 218// the same way <nav>/<aside> are dropped. Matches class tokens "vector-dropdown" (the dropdown wrapper) 219// and "interlanguage-link" (the interwiki list items) in the start tag source. 220func _lfd_has_chrome_class(src: *u8, src_off: i64, src_len: i64) -> i64 { 221 if _lfd_src_has_sub(src, src_off, src_len, "vector-dropdown\x00" as *u8) == 1 { return 1 } 222 if _lfd_src_has_sub(src, src_off, src_len, "interlanguage-link\x00" as *u8) == 1 { return 1 } 223 return 0 224} 225// Does the start tag carry an inline style="...display:none..."? We don't run a full inline-style cascade, 226// but display:none is the dominant inline use (hidden metadata: shortdescription, JS-toggled panels, ...). 227// The page MEANS it hidden in the initial paint (no JS here to reveal it), so drop the subtree at layout 228// level. Scans the start-tag source for the "display:none" / "display: none" substring. 229func _lfd_inline_hidden(src: *u8, src_off: i64, src_len: i64) -> i64 { 230 if _lfd_src_has_sub(src, src_off, src_len, "display:none\x00" as *u8) == 1 { return 1 } 231 if _lfd_src_has_sub(src, src_off, src_len, "display: none\x00" as *u8) == 1 { return 1 } 232 return 0 233} 234// A START token is chrome (a dropped subtree) if it's <nav>/<aside> OR carries role="navigation" OR a 235// collapsed-dropdown chrome class the page's CSS would re-show OR an inline style="display:none". 236func _lfd_is_chrome_start(src: *u8, name_off: i64, name_len: i64, src_off: i64, src_len: i64) -> i64 { 237 if _lfd_chrome_tag(src, name_off, name_len) == 1 { return 1 } 238 if _lfd_has_role_nav(src, src_off, src_len) == 1 { return 1 } 239 if _lfd_has_chrome_class(src, src_off, src_len) == 1 { return 1 } 240 if _lfd_inline_hidden(src, src_off, src_len) == 1 { return 1 } 241 return 0 242} 243// is this text run entirely whitespace? (inter-tag indentation/newlines -> no box, avoids huge gaps) 244func _lfd_is_blank(src: *u8, off: i64, len: i64) -> i64 { 245 var i: i64 = 0 246 while i < len { 247 let c: i64 = src[off+i] & 0xff 248 if c != 32 { if c != 9 { if c != 10 { if c != 13 { if c != 12 { return 0 } } } } } 249 i = i + 1 250 } 251 return 1 252} 253func _lfd_ws(c: i64) -> i64 { if c==32 {return 1} if c==9 {return 1} if c==10 {return 1} if c==13 {return 1} if c==12 {return 1} return 0 } 254// Trim an EDGE whitespace run only when it contains a newline. Rationale: HTML source indentation 255// between a block tag and its text ("\n\t\tNavigation\n\t") contains newlines and MUST go -- a leading 256// '\n' makes nx_paint_text line-break, shoving glyphs into the next row (heading overlap). But the 257// lone space separating adjacent inline text and links ("...to the " + "programming") has NO newline 258// and MUST be kept, or the words run together ("theprogrammingof"). So: newline in the edge run -> 259// trim it; lone space -> keep it. This approximates CSS collapse without a scratch rewrite buffer. 260func _lfd_trim_start(src: *u8, off: i64, end: i64) -> i64 { 261 var s: i64 = off 262 var has_nl: i64 = 0 263 var keep: i64 = 1 264 while keep == 1 { 265 if s >= end { keep = 0 } 266 else { 267 let c: i64 = src[s] & 0xff 268 if _lfd_ws(c) == 1 { if c == 10 { has_nl = 1 } if c == 13 { has_nl = 1 } s = s + 1 } 269 else { keep = 0 } 270 } 271 } 272 if has_nl == 1 { return s } 273 return off 274} 275func _lfd_trim_end(src: *u8, off: i64, end: i64) -> i64 { 276 var e: i64 = end 277 var has_nl: i64 = 0 278 var keep: i64 = 1 279 while keep == 1 { 280 if e <= off { keep = 0 } 281 else { 282 let c: i64 = src[e-1] & 0xff 283 if _lfd_ws(c) == 1 { if c == 10 { has_nl = 1 } if c == 13 { has_nl = 1 } e = e - 1 } 284 else { keep = 0 } 285 } 286 } 287 if has_nl == 1 { return e } 288 return end 289} 290func nx_layout_from_dom(src: *u8, src_len: i64, 291 tree: *LayoutTree, 292 stk: *LayoutFromDomStack) -> i64 { 293 // Synthetic root. 294 let root_idx: i64 = nx_layout_box_append(tree, NX_LAYOUT_BOX_BLOCK, -1) 295 if root_idx < 0 { return -1 } 296 if _lfd_push(stk, root_idx) < 0 { return -1 } 297 298 let cursor: *HtmlCursor = (sys_mmap(48)) as *HtmlCursor 299 nx_html_cursor_init(cursor, src, src_len) 300 301 let tok: *HtmlToken = (sys_mmap(64)) as *HtmlToken 302 303 var safety: i64 = 0 304 let MAX_TOKENS: i64 = NX_MAGIC_65536 305 var keep: i64 = 1 306 var suppress: i64 = 0 // >0 when inside <script>/<style>/<head>/<title>/<noscript> 307 var chrome_skip: i64 = 0 // >0 when inside a dropped <nav>/<aside> chrome subtree 308 while keep == 1 { 309 if safety >= MAX_TOKENS { keep = 0 } 310 else { 311 safety = safety + 1 312 let rc: i64 = nx_html_next_token(cursor, tok) 313 if rc < 0 { return -1 } 314 let k: i64 = tok.kind 315 if k == NX_HTML_TOK_EOF { keep = 0 } 316 else { 317 // chrome-skip gate: drop <nav>/<aside>/role=navigation subtrees (menus, TOC, language 318 // lists). DEPTH-counted -- a role=navigation <div> closes with a generic </div>, so we 319 // count ALL nested tags until the subtree closes; process NOTHING inside (no boxes). 320 var _proc: i64 = 1 321 if chrome_skip > 0 { 322 if k == NX_HTML_TOK_START_TAG { chrome_skip = chrome_skip + 1 } 323 if k == NX_HTML_TOK_END_TAG { chrome_skip = chrome_skip - 1 } 324 _proc = 0 325 } else { 326 if k == NX_HTML_TOK_START_TAG { 327 if _lfd_is_chrome_start(src, tok.name_off, tok.name_len, tok.src_off, tok.src_len) == 1 { chrome_skip = 1; _proc = 0 } 328 } 329 } 330 if _proc == 1 { 331 if k == NX_HTML_TOK_START_TAG { 332 let kind: i64 = nx_layout_default_display(src, 333 tok.name_off, tok.name_len) 334 // HTML: a block-level element closes any open inline formatting elements. This 335 // also RESYNCS the parent stack after a malformed/unclosed inline tag, so a block 336 // never ends up (mis)nested under an inline -- which the inline-flow layout would 337 // otherwise render horizontally instead of stacking. (See nx_layout_block.) 338 if kind == NX_LAYOUT_BOX_BLOCK { 339 var _kp: i64 = 1 340 while _kp == 1 { 341 if stk.depth <= 1 { _kp = 0 } 342 else { 343 let _tk: i64 = _lfd_top_kind(tree, stk) 344 if _tk == NX_LAYOUT_BOX_INLINE { _lfd_pop(stk) } 345 else { if _tk == NX_LAYOUT_BOX_INLINE_BLOCK { _lfd_pop(stk) } else { _kp = 0 } } 346 } 347 } 348 } 349 let parent_idx: i64 = _lfd_top(stk) 350 let new_idx: i64 = nx_layout_box_append(tree, kind, -1) 351 if new_idx < 0 { return -1 } 352 if nx_layout_box_attach_child(tree, parent_idx, new_idx) < 0 { 353 return -1 354 } 355 if _lfd_push(stk, new_idx) < 0 { return -1 } 356 if _lfd_suppress_tag(src, tok.name_off, tok.name_len) == 1 { suppress = suppress + 1 } 357 // RAW-TEXT elements (script/style/textarea/title): consume the body via the HTML5 358 // raw-text scanner so a bare `<` inside the body (e.g. JS `a<b`) is NOT tokenized as a 359 // start tag whose attribute-skip eats the real </script> -- the google-blank root 360 // (2026-07-16): that ate the closer, left `suppress` stuck >0, and every following 361 // visible text was dropped. The other 3 tokenizer consumers (extract_links/imgs, 362 // render_html) already do this; layout_from_dom was the one that forgot. After 363 // consuming, this element has no children -> pop it and undo the suppress bump. 364 if nx_html_is_raw_text_tag(src, tok.name_off, tok.name_len) == 1 { 365 let rawtok: *HtmlToken = (sys_mmap(64)) as *HtmlToken 366 nx_html_consume_raw_text(cursor, (src as i64 + tok.name_off) as *u8, tok.name_len, rawtok) 367 if suppress > 0 { suppress = suppress - 1 } 368 _lfd_pop(stk) 369 } 370 } else { 371 if k == NX_HTML_TOK_END_TAG { 372 if _lfd_suppress_tag(src, tok.name_off, tok.name_len) == 1 { if suppress > 0 { suppress = suppress - 1 } } 373 _lfd_pop(stk) 374 // Mismatched end tags: silently ignore for 375 // now. Phase 3b will walk-back-and-match per 376 // HTML5 spec section 13.2.6.4. 377 } else { 378 if k == NX_HTML_TOK_SELF_CLOSING { 379 let kind_sc: i64 = nx_layout_default_display(src, 380 tok.name_off, tok.name_len) 381 let parent_sc: i64 = _lfd_top(stk) 382 let new_sc: i64 = nx_layout_box_append(tree, 383 kind_sc, -1) 384 if new_sc < 0 { return -1 } 385 if nx_layout_box_attach_child(tree, parent_sc, new_sc) < 0 { 386 return -1 387 } 388 // Do NOT push for self-closing. 389 } else { 390 if k == NX_HTML_TOK_TEXT { if suppress == 0 { if _lfd_is_blank(src, tok.body_off, tok.body_len) == 0 { 391 // text inside <script>/<style>/<head>/... suppressed; whitespace-only skipped. 392 let parent_t: i64 = _lfd_top(stk) 393 let new_t: i64 = nx_layout_box_append(tree, 394 NX_LAYOUT_BOX_TEXT, -1) 395 if new_t < 0 { return -1 } 396 if nx_layout_box_attach_child(tree, parent_t, new_t) < 0 { 397 return -1 398 } 399 let _t_end: i64 = tok.body_off + tok.body_len 400 let _t_s: i64 = _lfd_trim_start(src, tok.body_off, _t_end) 401 let _t_e: i64 = _lfd_trim_end(src, _t_s, _t_end) 402 nx_layout_box_set_text(tree, new_t, 403 _t_s, _t_e - _t_s) 404 // TEXT is a leaf -- no push. 405 } } } 406 // DOCTYPE / COMMENT / UNKNOWN: skip 407 // (don't create boxes; don't push). 408 } 409 } 410 } 411 } 412 } 413 } 414 } 415 416 return root_idx 417}