code wiki / (root) / nx_layout_from_dom.nx

nx_layout_from_dom.nx source

↩ module page · 630 lines · 35204 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// RETIRED bare-tag drop (2026-07-21): <nav>/<aside> used to be dropped WHOLESALE here, on the premise 145// "without the page's own CSS we can't arrange these". That premise is obsolete -- the engine now runs 146// the real cascade (var/clamp/grid/flex/atomic inline-block), and every modern site's PRIMARY nav is a 147// <nav> of inline-block pills, so the wholesale drop blanked real-page navigation (proven by shot). 148// Chrome suppression stays via the PRECISE signals below: role="navigation" (what MediaWiki marks its 149// TOC/menus with), chrome marker classes, and inline display:none. Rule 25: improve, never strip. 150func _lfd_chrome_tag(src: *u8, off: i64, len: i64) -> i64 { 151 return 0 152} 153// Does the tag source contain role="navigation" (ARIA standard for nav landmarks -- the TOC, language 154// lists, menus mark themselves this way even when the element is a <div>)? Scans for role= then checks 155// the quoted value == navigation. Self-contained (no DOM-query dependency in this core organ). 156func _lfd_has_role_nav(src: *u8, src_off: i64, src_len: i64) -> i64 { 157 let end: i64 = src_off + src_len 158 var i: i64 = src_off 159 while i + 5 <= end { 160 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 { 161 var j: i64 = i + 5 162 if j < end { let q: i64 = src[j]&0xff; if q==34 { j = j + 1 } else { if q==39 { j = j + 1 } } } 163 if j + 10 <= end { if _lfd_name_eq(src, j, 10, "navigation\x00" as *u8) == 1 { return 1 } } 164 } } } } } 165 i = i + 1 166 } 167 return 0 168} 169// Does src[off..off+len) contain the NUL-terminated literal `lit` (case-sensitive substring)? Used to 170// detect chrome marker CLASSES in a start tag's source (e.g. "vector-dropdown"). Self-contained. 171func _lfd_src_has_sub(src: *u8, off: i64, len: i64, lit: *u8) -> i64 { 172 var ll: i64 = 0 173 while lit[ll] != (0 as u8) { ll = ll + 1 } 174 if ll == 0 { return 1 } 175 let end: i64 = off + len 176 var i: i64 = off 177 while i + ll <= end { 178 var j: i64 = 0 179 var ok: i64 = 1 180 while j < ll { if (src[i+j]&0xff) != (lit[j]&0xff) { ok = 0; j = ll } else { j = j + 1 } } 181 if ok == 1 { return 1 } 182 i = i + 1 183 } 184 return 0 185} 186// Collapsed-DROPDOWN chrome (Vector skin language selector / tools / page menus): rendered EXPANDED by 187// our subset (we can't honor the checkbox-collapse hack), and the page's OWN fetched CSS RE-SHOWS them 188// even when our UA sheet sets display:none -- so we drop the subtree at LAYOUT level (before any CSS), 189// the same way <nav>/<aside> are dropped. Matches class tokens "vector-dropdown" (the dropdown wrapper) 190// and "interlanguage-link" (the interwiki list items) in the start tag source. 191func _lfd_has_chrome_class(src: *u8, src_off: i64, src_len: i64) -> i64 { 192 if _lfd_src_has_sub(src, src_off, src_len, "vector-dropdown\x00" as *u8) == 1 { return 1 } 193 if _lfd_src_has_sub(src, src_off, src_len, "interlanguage-link\x00" as *u8) == 1 { return 1 } 194 return 0 195} 196// Does the start tag carry an inline style="...display:none..."? We don't run a full inline-style cascade, 197// but display:none is the dominant inline use (hidden metadata: shortdescription, JS-toggled panels, ...). 198// The page MEANS it hidden in the initial paint (no JS here to reveal it), so drop the subtree at layout 199// level. Scans the start-tag source for the "display:none" / "display: none" substring. 200func _lfd_inline_hidden(src: *u8, src_off: i64, src_len: i64) -> i64 { 201 if _lfd_src_has_sub(src, src_off, src_len, "display:none\x00" as *u8) == 1 { return 1 } 202 if _lfd_src_has_sub(src, src_off, src_len, "display: none\x00" as *u8) == 1 { return 1 } 203 return 0 204} 205// A START token is chrome (a dropped subtree) if it's <nav>/<aside> OR carries role="navigation" OR a 206// collapsed-dropdown chrome class the page's CSS would re-show OR an inline style="display:none". 207func _lfd_is_chrome_start(src: *u8, name_off: i64, name_len: i64, src_off: i64, src_len: i64) -> i64 { 208 if _lfd_chrome_tag(src, name_off, name_len) == 1 { return 1 } 209 if _lfd_has_role_nav(src, src_off, src_len) == 1 { return 1 } 210 if _lfd_has_chrome_class(src, src_off, src_len) == 1 { return 1 } 211 if _lfd_inline_hidden(src, src_off, src_len) == 1 { return 1 } 212 return 0 213} 214// Find attribute `name=` inside a start tag's source span; writes the VALUE span to out[0]=off, 215// out[1]=len (out[0]=-1 when absent). The attribute name must be preceded by whitespace so 216// `data-value=` can never match `value=`. Handles double-quoted, single-quoted, and bare values. 217// Self-contained scanner in the from_dom local-helper style (no DOM-query import in this core organ). 218func _lfd_attr_val(src: *u8, src_off: i64, src_len: i64, name: *u8, out: *i64) -> i64 { 219 out[0] = 0 - 1 220 out[1] = 0 221 var nl: i64 = 0 222 while name[nl] != (0 as u8) { nl = nl + 1 } 223 let end: i64 = src_off + src_len 224 var i: i64 = src_off + 1 225 while i + nl + 1 <= end { 226 var ws: i64 = 0 227 let pc: i64 = src[i-1] & 0xff 228 if pc == 32 { ws = 1 } 229 if pc == 9 { ws = 1 } 230 if pc == 10 { ws = 1 } 231 if pc == 13 { ws = 1 } 232 if ws == 1 { 233 var j: i64 = 0 234 var ok: i64 = 1 235 while j < nl { if _lfd_lc(src[i+j] & 0xff) != _lfd_lc(name[j] & 0xff) { ok = 0; j = nl } else { j = j + 1 } } 236 if ok == 1 { if (src[i+nl] & 0xff) == 61 { 237 var v: i64 = i + nl + 1 238 if v >= end { return 0 } 239 let q: i64 = src[v] & 0xff 240 if q == 34 { 241 var e2: i64 = v + 1 242 while e2 < end { if (src[e2]&0xff) == 34 { out[0] = v + 1; out[1] = e2 - (v + 1); e2 = end } else { e2 = e2 + 1 } } 243 return 0 244 } 245 if q == 39 { 246 var e3: i64 = v + 1 247 while e3 < end { if (src[e3]&0xff) == 39 { out[0] = v + 1; out[1] = e3 - (v + 1); e3 = end } else { e3 = e3 + 1 } } 248 return 0 249 } 250 // UNQUOTED attribute value. HTML5 13.2.5.36 ends this state ONLY at tab, LF, FF, CR, 251 // space or '>'. A '/' is an ORDINARY CHARACTER here -- it is the self-closing marker 252 // only where an attribute NAME would start, i.e. straight after whitespace. 253 // 254 // 2026-08-27 -- MEASURED ON danluu.com, BY LOOKING AT THE RENDER. Every article title 255 // painted as plain black text while one link ("Patreon posts") painted blue. The site 256 // writes its hrefs unquoted -- `<a href=https://danluu.com/perf-opt/>` -- and this scan 257 // stopped at the first '/', so every href collapsed to the useless value "https:". 258 // The one link that worked was `href=#pt`, the only href on the page with no slash. 259 // A whole page of links silently became non-links, and nothing failed loudly. 260 // LF/CR/FF were ALSO missing from the terminator set, so an unquoted value followed by 261 // a newline before '>' swallowed the newline and everything after it. 262 var e4: i64 = v 263 var sc4: i64 = 1 264 while sc4 == 1 { 265 if e4 >= end { sc4 = 0 } 266 else { let c4: i64 = src[e4]&0xff 267 var term: i64 = 0 268 if c4 == 32 { term = 1 } 269 if c4 == 62 { term = 1 } 270 if c4 == 9 { term = 1 } 271 if c4 == 10 { term = 1 } 272 if c4 == 12 { term = 1 } 273 if c4 == 13 { term = 1 } 274 if term == 1 { sc4 = 0 } else { e4 = e4 + 1 } } 275 } 276 out[0] = v 277 out[1] = e4 - v 278 return 0 279 } } 280 } 281 i = i + 1 282 } 283 return 0 284} 285// Should this <input>'s value paint as its visible label? Chrome paints the value of text-like and 286// button-like inputs; hidden carries data, password must be masked (we have no masking in this pass), 287// checkbox/radio values are submission tokens ("on"), image/file have no textual face. Absent type = 288// text input = paintable. 289func _lfd_input_paintable(src: *u8, src_off: i64, src_len: i64) -> i64 { 290 let t: *i64 = sys_mmap(16) as *i64 291 _lfd_attr_val(src, src_off, src_len, "type\x00" as *u8, t) 292 if t[0] < 0 { return 1 } 293 if _lfd_name_eq(src, t[0], t[1], "hidden\x00" as *u8) == 1 { return 0 } 294 if _lfd_name_eq(src, t[0], t[1], "password\x00" as *u8) == 1 { return 0 } 295 if _lfd_name_eq(src, t[0], t[1], "checkbox\x00" as *u8) == 1 { return 0 } 296 if _lfd_name_eq(src, t[0], t[1], "radio\x00" as *u8) == 1 { return 0 } 297 if _lfd_name_eq(src, t[0], t[1], "image\x00" as *u8) == 1 { return 0 } 298 if _lfd_name_eq(src, t[0], t[1], "file\x00" as *u8) == 1 { return 0 } 299 return 1 300} 301// VOID ELEMENTS (HTML5 ยง13.1.2): no end tag EVER arrives for these, so pushing one as a container 302// skews the stack -- every later END_TAG pops one level wrong, and the whole rest of the document 303// mis-nests. hackernews' slashless <img src="y18.svg"> in its header detached the story rows from 304// their table (subtext laid at x=999, off-viewport); wikipedia dodged the bug only because MediaWiki 305// emits XHTML-style <img/> (SELF_CLOSING -> never pushed). Probe-proven both ways 2026-07-28. 306func _lfd_is_void_tag(src: *u8, off: i64, len: i64) -> i64 { 307 if _lfd_name_eq(src, off, len, "img\x00" as *u8) == 1 { return 1 } 308 if _lfd_name_eq(src, off, len, "br\x00" as *u8) == 1 { return 1 } 309 if _lfd_name_eq(src, off, len, "hr\x00" as *u8) == 1 { return 1 } 310 if _lfd_name_eq(src, off, len, "input\x00" as *u8) == 1 { return 1 } 311 if _lfd_name_eq(src, off, len, "meta\x00" as *u8) == 1 { return 1 } 312 if _lfd_name_eq(src, off, len, "link\x00" as *u8) == 1 { return 1 } 313 if _lfd_name_eq(src, off, len, "base\x00" as *u8) == 1 { return 1 } 314 if _lfd_name_eq(src, off, len, "area\x00" as *u8) == 1 { return 1 } 315 if _lfd_name_eq(src, off, len, "col\x00" as *u8) == 1 { return 1 } 316 if _lfd_name_eq(src, off, len, "embed\x00" as *u8) == 1 { return 1 } 317 if _lfd_name_eq(src, off, len, "source\x00" as *u8) == 1 { return 1 } 318 if _lfd_name_eq(src, off, len, "track\x00" as *u8) == 1 { return 1 } 319 if _lfd_name_eq(src, off, len, "wbr\x00" as *u8) == 1 { return 1 } 320 if _lfd_name_eq(src, off, len, "param\x00" as *u8) == 1 { return 1 } 321 return 0 322} 323// COLSPAN STASH (seq1159, 2026-07-28): a table cell's colspan attribute must reach the layout, but 324// LayoutBox carries no attributes. `source_node_idx` is written as -1 by every append in this builder 325// and read by nothing downstream, so a cell box with colspan=N carries N there (only when N >= 2 -- 326// the -1 default remains "no span" everywhere else). The table column layout in nx_layout_block reads 327// it: a spanning cell occupies N column slots (news.ycombinator.com hides its subtext row behind 328// <td colspan=2>; positional cell counting put the subtext in the votearrow column and inflated it). 329func _lfd_colspan_stash(src: *u8, tok: *HtmlToken, tree: *LayoutTree, elem_idx: i64) -> i64 { 330 let csp: *i64 = sys_mmap(16) as *i64 331 _lfd_attr_val(src, tok.src_off, tok.src_len, "colspan\x00" as *u8, csp) 332 if csp[0] < 0 { return 0 } 333 if csp[1] <= 0 { return 0 } 334 var n: i64 = 0 335 var i: i64 = 0 336 while i < csp[1] { 337 let c: i64 = src[csp[0] + i] & 0xff 338 if c >= 48 { if c <= 57 { n = n * 10 + (c - 48) } } 339 i = i + 1 340 } 341 if n < 2 { return 0 } 342 if n > 16 { n = 16 } // a hostile colspan=9999 cannot wedge the columns 343 let b: *LayoutBox = (tree.boxes as *u8 + (elem_idx as nx_size) * (NX_LAYOUT_BOX_BYTES as nx_size)) as *LayoutBox 344 b.source_node_idx = n 345 return 1 346} 347// INPUT VALUE SYNTHESIS (the google-blank root, 2026-07-28): an <input>'s visible label lives in its 348// value= ATTRIBUTE, not a text node -- google's first screen is a logo image plus 349// <input type=submit value="Google Search"> etc., and a text renderer that ignores input values paints 350// a WHITE VOID (census recall 0 with the words sitting right there in the source). Synthesizing the 351// value bytes as a TEXT child box makes everything downstream work unchanged: inline-block 352// shrink-to-fit sizes the control from its text subtree, the normal text pass paints the label, OCR 353// reads it. Shared by the START_TAG and SELF_CLOSING paths. 354func _lfd_input_value_child(src: *u8, tok: *HtmlToken, tree: *LayoutTree, elem_idx: i64) -> i64 { 355 if _lfd_name_eq(src, tok.name_off, tok.name_len, "input\x00" as *u8) == 0 { return 0 } 356 if _lfd_input_paintable(src, tok.src_off, tok.src_len) == 0 { return 0 } 357 let vsp: *i64 = sys_mmap(16) as *i64 358 _lfd_attr_val(src, tok.src_off, tok.src_len, "value\x00" as *u8, vsp) 359 if vsp[0] < 0 { return 0 } 360 if vsp[1] <= 0 { return 0 } 361 let vtx: i64 = nx_layout_box_append(tree, NX_LAYOUT_BOX_TEXT, -1) 362 if vtx < 0 { return 0 } 363 if nx_layout_box_attach_child(tree, elem_idx, vtx) < 0 { return 0 } 364 nx_layout_box_set_text(tree, vtx, vsp[0], vsp[1]) 365 return 1 366} 367// is this text run entirely whitespace? (inter-tag indentation/newlines -> no box, avoids huge gaps) 368func _lfd_is_blank(src: *u8, off: i64, len: i64) -> i64 { 369 var i: i64 = 0 370 while i < len { 371 let c: i64 = src[off+i] & 0xff 372 if c != 32 { if c != 9 { if c != 10 { if c != 13 { if c != 12 { return 0 } } } } } 373 i = i + 1 374 } 375 return 1 376} 377func _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 } 378// Is the parent's LAST child inline-level? This is the discriminator CSS needs for a whitespace-only 379// text node, and it decides whether words run together. 380// 381// 2026-08-26 -- MEASURED ON news.ycombinator.com, BY LOOKING AT THE RENDER. Its header is 382// `<a ...>Hacker News</a> 383<a href="newest">new</a>` and the story rows are 384// `<a ...>nderjung</a> 385<span class="age">4 hours ago</span>`. The whitespace between the two 386// anchors is its OWN text node, entirely whitespace, so _lfd_is_blank dropped it and the page 387// rendered "Hacker Newsnew" and "nderjung4 hours ago". Under CSS white-space:normal that run 388// collapses to a SINGLE SPACE between inline-level boxes -- it only disappears at the edges of a 389// line box. Dropping it is only correct between BLOCK siblings (source indentation), which is the 390// case _lfd_is_blank was written for; it over-applied to the inline case. 391// 392// The previous sibling is the part of the rule we can answer while streaming (the next element is 393// not parsed yet). That is enough: an inline previous sibling means we are mid-line, so the space 394// is required. First-child whitespace is still skipped, which is also what CSS does. 395func _lfd_last_kid_inline(tree: *LayoutTree, parent_idx: i64) -> i64 { 396 if parent_idx < 0 { return 0 } 397 let p: *LayoutBox = (tree.boxes as *u8 + (parent_idx as nx_size) * (NX_LAYOUT_BOX_BYTES as nx_size)) as *LayoutBox 398 var last: i64 = p.first_child_idx 399 if last < 0 { return 0 } 400 var walking: i64 = 1 401 while walking == 1 { 402 let c: *LayoutBox = (tree.boxes as *u8 + (last as nx_size) * (NX_LAYOUT_BOX_BYTES as nx_size)) as *LayoutBox 403 if c.next_sibling_idx < 0 { walking = 0 } else { last = c.next_sibling_idx } 404 } 405 let lb: *LayoutBox = (tree.boxes as *u8 + (last as nx_size) * (NX_LAYOUT_BOX_BYTES as nx_size)) as *LayoutBox 406 if lb.kind == NX_LAYOUT_BOX_INLINE { return 1 } 407 if lb.kind == NX_LAYOUT_BOX_INLINE_BLOCK { return 1 } 408 if lb.kind == NX_LAYOUT_BOX_TEXT { return 1 } 409 return 0 410} 411// Trim an EDGE whitespace run only when it contains a newline. Rationale: HTML source indentation 412// between a block tag and its text ("\n\t\tNavigation\n\t") contains newlines and MUST go -- a leading 413// '\n' makes nx_paint_text line-break, shoving glyphs into the next row (heading overlap). But the 414// lone space separating adjacent inline text and links ("...to the " + "programming") has NO newline 415// and MUST be kept, or the words run together ("theprogrammingof"). So: newline in the edge run -> 416// trim it; lone space -> keep it. This approximates CSS collapse without a scratch rewrite buffer. 417func _lfd_trim_start(src: *u8, off: i64, end: i64) -> i64 { 418 var s: i64 = off 419 var has_nl: i64 = 0 420 var keep: i64 = 1 421 while keep == 1 { 422 if s >= end { keep = 0 } 423 else { 424 let c: i64 = src[s] & 0xff 425 if _lfd_ws(c) == 1 { if c == 10 { has_nl = 1 } if c == 13 { has_nl = 1 } s = s + 1 } 426 else { keep = 0 } 427 } 428 } 429 if has_nl == 1 { return s } 430 return off 431} 432func _lfd_trim_end(src: *u8, off: i64, end: i64) -> i64 { 433 var e: i64 = end 434 var has_nl: i64 = 0 435 var keep: i64 = 1 436 while keep == 1 { 437 if e <= off { keep = 0 } 438 else { 439 let c: i64 = src[e-1] & 0xff 440 if _lfd_ws(c) == 1 { if c == 10 { has_nl = 1 } if c == 13 { has_nl = 1 } e = e - 1 } 441 else { keep = 0 } 442 } 443 } 444 if has_nl == 1 { return e } 445 return end 446} 447func nx_layout_from_dom(src: *u8, src_len: i64, 448 tree: *LayoutTree, 449 stk: *LayoutFromDomStack) -> i64 { 450 // Synthetic root. 451 let root_idx: i64 = nx_layout_box_append(tree, NX_LAYOUT_BOX_BLOCK, -1) 452 if root_idx < 0 { return -1 } 453 if _lfd_push(stk, root_idx) < 0 { return -1 } 454 // TAG-NAME MIRROR of the open-element stack (indexed by depth; pushes write, pops just shrink). 455 // Powers HTML5-lite end-tag matching below -- without names, every END popped blind, and an end 456 // tag whose element was already resync-popped popped an ANCESTOR (the hackernews mid-row detach: 457 // <center><a><div> -- resync pops the <a> for the div, then </a> popped <center>, </center> popped 458 // the td, </td> popped the row; every element after the votelinks cell escaped its table). 459 let tgo: *i64 = sys_mmap(8 * 128) as *i64 460 let tgl: *i64 = sys_mmap(8 * 128) as *i64 461 462 let cursor: *HtmlCursor = (sys_mmap(48)) as *HtmlCursor 463 nx_html_cursor_init(cursor, src, src_len) 464 465 let tok: *HtmlToken = (sys_mmap(64)) as *HtmlToken 466 467 var safety: i64 = 0 468 let MAX_TOKENS: i64 = NX_MAGIC_65536 469 var keep: i64 = 1 470 var suppress: i64 = 0 // >0 when inside <script>/<style>/<head>/<title>/<noscript> 471 var chrome_skip: i64 = 0 // >0 when inside a dropped <nav>/<aside> chrome subtree 472 while keep == 1 { 473 if safety >= MAX_TOKENS { keep = 0 } 474 else { 475 safety = safety + 1 476 let rc: i64 = nx_html_next_token(cursor, tok) 477 if rc < 0 { return -1 } 478 let k: i64 = tok.kind 479 if k == NX_HTML_TOK_EOF { keep = 0 } 480 else { 481 // chrome-skip gate: drop <nav>/<aside>/role=navigation subtrees (menus, TOC, language 482 // lists). DEPTH-counted -- a role=navigation <div> closes with a generic </div>, so we 483 // count ALL nested tags until the subtree closes; process NOTHING inside (no boxes). 484 var _proc: i64 = 1 485 if chrome_skip > 0 { 486 // void START tags never get an END -- exempt them from the depth count too 487 if k == NX_HTML_TOK_START_TAG { if _lfd_is_void_tag(src, tok.name_off, tok.name_len) == 0 { chrome_skip = chrome_skip + 1 } } 488 if k == NX_HTML_TOK_END_TAG { chrome_skip = chrome_skip - 1 } 489 _proc = 0 490 } else { 491 if k == NX_HTML_TOK_START_TAG { 492 if _lfd_is_chrome_start(src, tok.name_off, tok.name_len, tok.src_off, tok.src_len) == 1 { chrome_skip = 1; _proc = 0 } 493 } 494 } 495 if _proc == 1 { 496 if k == NX_HTML_TOK_START_TAG { 497 let kind: i64 = nx_layout_default_display(src, 498 tok.name_off, tok.name_len) 499 // HTML: a block-level element closes any open inline formatting elements. This 500 // also RESYNCS the parent stack after a malformed/unclosed inline tag, so a block 501 // never ends up (mis)nested under an inline -- which the inline-flow layout would 502 // otherwise render horizontally instead of stacking. (See nx_layout_block.) 503 if kind == NX_LAYOUT_BOX_BLOCK { 504 var _kp: i64 = 1 505 while _kp == 1 { 506 if stk.depth <= 1 { _kp = 0 } 507 else { 508 let _tk: i64 = _lfd_top_kind(tree, stk) 509 if _tk == NX_LAYOUT_BOX_INLINE { _lfd_pop(stk) } 510 else { if _tk == NX_LAYOUT_BOX_INLINE_BLOCK { _lfd_pop(stk) } else { _kp = 0 } } 511 } 512 } 513 } 514 let parent_idx: i64 = _lfd_top(stk) 515 let new_idx: i64 = nx_layout_box_append(tree, kind, -1) 516 if new_idx < 0 { return -1 } 517 if nx_layout_box_attach_child(tree, parent_idx, new_idx) < 0 { 518 return -1 519 } 520 if _lfd_push(stk, new_idx) < 0 { return -1 } 521 if stk.depth <= 128 { tgo[stk.depth - 1] = tok.name_off; tgl[stk.depth - 1] = tok.name_len } 522 _lfd_input_value_child(src, tok, tree, new_idx) // <input value=...> label (no-op for other tags) 523 _lfd_colspan_stash(src, tok, tree, new_idx) // td/th colspan -> source_node_idx (seq1159) 524 if _lfd_is_void_tag(src, tok.name_off, tok.name_len) == 1 { _lfd_pop(stk) } // void: box kept, never a container 525 if _lfd_suppress_tag(src, tok.name_off, tok.name_len) == 1 { suppress = suppress + 1 } 526 // RAW-TEXT elements (script/style/textarea/title): consume the body via the HTML5 527 // raw-text scanner so a bare `<` inside the body (e.g. JS `a<b`) is NOT tokenized as a 528 // start tag whose attribute-skip eats the real </script> -- the google-blank root 529 // (2026-07-16): that ate the closer, left `suppress` stuck >0, and every following 530 // visible text was dropped. The other 3 tokenizer consumers (extract_links/imgs, 531 // render_html) already do this; layout_from_dom was the one that forgot. After 532 // consuming, this element has no children -> pop it and undo the suppress bump. 533 if nx_html_is_raw_text_tag(src, tok.name_off, tok.name_len) == 1 { 534 let rawtok: *HtmlToken = (sys_mmap(64)) as *HtmlToken 535 nx_html_consume_raw_text(cursor, (src as i64 + tok.name_off) as *u8, tok.name_len, rawtok) 536 // CONTRACT (nx_html_tokenizer): consume leaves the cursor AT the `<` of the close 537 // tag -- the NEXT token IS the END_TAG, whose normal handling pops the element and 538 // undoes the suppress bump. The old eager pop+undo here DOUBLE-POPPED: the arriving 539 // END_TAG then popped the PARENT, so every script/style/title amputated one 540 // ancestor level -- body attached to the root on every page, a <script> inside 541 // <body> re-parented all following content to the root (probe-proven 2026-07-28: 542 // x=44 -> 0), and script-bearing containers lost their spines = the "flattening" 543 // that broke hide-subtree and link hit-tests (seq1137). Only an UNCLOSED raw-text 544 // body (consumed to EOF, no END_TAG coming) still needs the eager cleanup. 545 if cursor.pos >= cursor.src_len { 546 if suppress > 0 { suppress = suppress - 1 } 547 _lfd_pop(stk) 548 } 549 } 550 } else { 551 if k == NX_HTML_TOK_END_TAG { 552 if _lfd_suppress_tag(src, tok.name_off, tok.name_len) == 1 { if suppress > 0 { suppress = suppress - 1 } } 553 // HTML5-lite end-tag matching (13.2.6.4-lite, 2026-07-28): find the NEAREST 554 // open element with this tag name. None on the stack -> IGNORE the end tag 555 // (its element was resync-popped -- popping blind here amputated an ancestor 556 // per stray end, the hackernews mid-row detach). Found deeper -> pop THROUGH 557 // it (implied ends for the unclosed elements above it). 558 var mdep: i64 = 0 - 1 559 var si2: i64 = stk.depth - 1 560 while si2 >= 1 { 561 if mdep < 0 { 562 if tgl[si2] == tok.name_len { 563 var mm: i64 = 1 564 var mk2: i64 = 0 565 while mk2 < tok.name_len { 566 if _lfd_lc(src[tgo[si2] + mk2] & 0xff) != _lfd_lc(src[tok.name_off + mk2] & 0xff) { mm = 0; mk2 = tok.name_len } else { mk2 = mk2 + 1 } 567 } 568 if mm == 1 { mdep = si2 } 569 } 570 } 571 si2 = si2 - 1 572 } 573 if mdep >= 1 { while stk.depth > mdep { _lfd_pop(stk) } } 574 } else { 575 if k == NX_HTML_TOK_SELF_CLOSING { 576 let kind_sc: i64 = nx_layout_default_display(src, 577 tok.name_off, tok.name_len) 578 let parent_sc: i64 = _lfd_top(stk) 579 let new_sc: i64 = nx_layout_box_append(tree, 580 kind_sc, -1) 581 if new_sc < 0 { return -1 } 582 if nx_layout_box_attach_child(tree, parent_sc, new_sc) < 0 { 583 return -1 584 } 585 _lfd_input_value_child(src, tok, tree, new_sc) // <input value=...> label 586 _lfd_colspan_stash(src, tok, tree, new_sc) // colspan (seq1159) 587 // Do NOT push for self-closing. 588 } else { 589 if k == NX_HTML_TOK_TEXT { if suppress == 0 { if _lfd_is_blank(src, tok.body_off, tok.body_len) == 0 { 590 // text inside <script>/<style>/<head>/... suppressed; whitespace-only skipped. 591 let parent_t: i64 = _lfd_top(stk) 592 let new_t: i64 = nx_layout_box_append(tree, 593 NX_LAYOUT_BOX_TEXT, -1) 594 if new_t < 0 { return -1 } 595 if nx_layout_box_attach_child(tree, parent_t, new_t) < 0 { 596 return -1 597 } 598 let _t_end: i64 = tok.body_off + tok.body_len 599 let _t_s: i64 = _lfd_trim_start(src, tok.body_off, _t_end) 600 let _t_e: i64 = _lfd_trim_end(src, _t_s, _t_end) 601 nx_layout_box_set_text(tree, new_t, 602 _t_s, _t_e - _t_s) 603 // TEXT is a leaf -- no push. 604 } else { 605 // Whitespace-only run AFTER an inline box: collapse to ONE SPACE 606 // rather than deleting it (see _lfd_last_kid_inline). The byte is 607 // FORCED to 0x20 because a raw newline reaching nx_paint_text is 608 // treated as a hard break -- which is the very failure the blanket 609 // drop was introduced to avoid. 610 if tok.body_len > 0 { if _lfd_last_kid_inline(tree, _lfd_top(stk)) == 1 { 611 let parent_b: i64 = _lfd_top(stk) 612 let new_b: i64 = nx_layout_box_append(tree, NX_LAYOUT_BOX_TEXT, -1) 613 if new_b < 0 { return -1 } 614 if nx_layout_box_attach_child(tree, parent_b, new_b) < 0 { return -1 } 615 src[tok.body_off] = 32 as u8 616 nx_layout_box_set_text(tree, new_b, tok.body_off, 1) 617 } } 618 } } } 619 // DOCTYPE / COMMENT / UNKNOWN: skip 620 // (don't create boxes; don't push). 621 } 622 } 623 } 624 } 625 } 626 } 627 } 628 629 return root_idx 630}