code wiki / (root) / nx_imgattr_lib.nx

nx_imgattr_lib.nx source

↩ module page · 481 lines · 19397 B

1// nx_imgattr_lib.nx -- THE ONE PLACE THAT DECIDES HOW AN <img> IS WRITTEN, AND THE ONE PLACE THAT 2// READS ONE BACK. 3// 4// MEASURED 2026-08-26 ON OUR OWN HEADLINE SURFACE, served live at /compare/koikatsu: 5// <img src='/compare/koikatsu/native_kk396262.png' alt='The card's own portrait -- the oracle we 6// match against' loading='lazy'> 7// The apostrophe came straight out of the .gallery caption DATA and was written raw into a 8// single-quoted attribute. Every real HTML parser ends alt at the two words -The card-, reads the 9// remainder as bogus attribute names, and the stray quote then swallows loading='lazy' as well. The 10// accessible name of that image is -The card-, and the browser hint that keeps 14 large PNGs off the 11// critical path is gone. 12// 13// AND THE PAGE STILL VALIDATED GREEN. nx_page_verify's a11y check counts the byte pattern - alt=- 14// over the whole page and read 15 with-alt of 15 img. A SUBSTRING COUNTER CANNOT SEE ATTRIBUTE SCOPE, 15// so the emitter's defect and the ruler's blindness are ONE defect met twice -- and the ruler's 16// blindness is the half that let it ship and stay shipped. 17// 18// SO THE WRITER AND THE READER LIVE IN ONE LIB. The function that WRITES an img and the function that 19// CHECKS an img share one definition of -an img with a usable alt-, and disagreement between them is 20// impossible by construction rather than by discipline. 21// 22// THE INCUMBENT WAS EXTENDED, NOT DUPLICATED. nx_adnet_slot's aslot_cat_esc is the estate's existing 23// attribute escaper and its lit-flag shape is kept here verbatim. It is changed in exactly one 24// direction: it replaces an apostrophe with a SPACE, which is lossy -- -The card's- becomes -The 25// card s- -- and rule 25 says rewrite it better rather than strip it, so this escaper emits the 26// numeric character reference and KEEPS the byte. 27// 28// NO GUESSED CEILING LIVES HERE. Every buffer this lib fills is sized by the caller from the strings 29// the caller already holds (ia_alt_cap_for / ia_img_cap_for), so there is no bound to tune in either 30// direction and none can silently truncate. The guards remain and ANNOUNCE, for a caller that sizes 31// its own buffer wrong. 32// 33// DECLARED IMPRECISION, so the next reader does not trust this as exact: ia_scan is a LEXICAL scan. 34// It skips comments, script and style bodies, so markup quoted inside those cannot be miscounted, but 35// it does not build a tree -- an img inside a CDATA section or produced by script at runtime is 36// outside what it can see, and it reports on the bytes as served. 37// license_tier: ORIGINAL 38import "nx_syscalls.nx" 39 40// ---- ASCII identities. Named so the escaper reads as intent rather than as character codes, the same 41// reason nx_page_verify names PV_ATTR_DQ. These are the identities of characters, not tunables. 42const IA_TAB: i64 = 9 43const IA_LF: i64 = 10 44const IA_CR: i64 = 13 45const IA_SP: i64 = 32 46const IA_DQ: i64 = 34 47const IA_HASH: i64 = 35 48const IA_AMP: i64 = 38 49const IA_SQ: i64 = 39 50const IA_HYPHEN: i64 = 45 51const IA_DOT: i64 = 46 52const IA_SLASH: i64 = 47 53const IA_DIGIT_3: i64 = 51 54const IA_DIGIT_9: i64 = 57 55const IA_SEMI: i64 = 59 56const IA_LT: i64 = 60 57const IA_EQ: i64 = 61 58const IA_GT: i64 = 62 59const IA_UPPER_A: i64 = 65 60const IA_UPPER_Z: i64 = 90 61const IA_USCORE: i64 = 95 62const IA_CASE_DELTA: i64 = 32 63 64// The longest expansion this escaper can emit is &quot; -- six bytes. The guard reserves that plus the 65// NUL before writing ANY byte, so a value can never be cut in the middle of an entity. 66const IA_MAX_EXPANSION: i64 = 6 67// The space before the caller's extra attributes, and the terminating NUL. 68const IA_SEP_AND_NUL: i64 = 2 69 70// ---- the tag's own chrome, bound ONCE. Lengths are DERIVED from these with ia_slen and never hand 71// counted: a hand-counted length beside a string literal is a second copy of that literal's shape and 72// the two drift silently. 73const IA_TAG_OPEN: *u8 = "<img src='" as *u8 74const IA_TAG_MID: *u8 = "' alt='" as *u8 75const IA_TAG_ENDQ: *u8 = "'" as *u8 76const IA_TAG_CLOSE: *u8 = ">" as *u8 77const IA_IMG_OPEN: *u8 = "<img" as *u8 78 79// ---- ia_scan result slots 80const IA_S_IMGS: i64 = 0 81const IA_S_ALT_OK: i64 = 1 82const IA_S_SRC_OK: i64 = 2 83const IA_S_MALFORMED: i64 = 3 84// QUOTE-BREAK: an attribute NAME that contains a quote byte. That cannot happen in well-formed markup, 85// and it is the exact fingerprint of a raw quote inside an EARLIER attribute value: the value ended at 86// the stray quote and the rest of the data is now being read as attribute names. 87// THIS IS THE AXIS THAT CATCHES THE LIVE DEFECT, AND A PRESENT/ABSENT TEST CANNOT. Walking the served 88// koikatsu tag by hand: alt='The card's own portrait...' yields the value -The card-, which is 89// NON-EMPTY, then -s- -own- -portrait- -- and -against'- as attribute names, and the tag still closes 90// cleanly. So imgs=1, alt present, src present, nothing malformed -- every other axis acquits it. Only 91// the quote in the name -against'- says what happened. 92const IA_S_QUOTEBREAK: i64 = 4 93const IA_SCAN_SLOTS: i64 = 5 94const IA_SLOT_BYTES: i64 = 8 95 96func ia_slen(s: *u8) -> i64 { var n: i64 = 0; while s[n] != (0 as u8) { n = n + 1 } return n } 97 98func ia_is_space(c: i64) -> i64 { 99 if c == IA_SP { return 1 } 100 if c == IA_TAB { return 1 } 101 if c == IA_LF { return 1 } 102 if c == IA_CR { return 1 } 103 return 0 104} 105 106func ia_lower(c: i64) -> i64 { 107 if c >= IA_UPPER_A { if c <= IA_UPPER_Z { return c + IA_CASE_DELTA } } 108 return c 109} 110 111func ia_put(dst: *u8, off: i64, c: i64) -> i64 { dst[off] = c as u8; return off + 1 } 112 113func ia_cat(dst: *u8, off: i64, s: *u8, cap: i64) -> i64 { 114 var o: i64 = off 115 var i: i64 = 0 116 while s[i] != (0 as u8) { 117 if o + 1 >= cap { return o } 118 dst[o] = s[i] 119 o = o + 1 120 i = i + 1 121 } 122 return o 123} 124 125// ---- THE DECISION, HALF ONE: an attribute VALUE. 126// DELIMITER-AGNOSTIC ON PURPOSE. Both quote characters are escaped whatever delimiter the caller 127// chose, so switching the emitter from single to double quotes later can never re-open this hole. 128// The apostrophe becomes the NUMERIC reference and not &apos;: &apos; is defined in HTML5 only, while 129// the numeric form is valid in every HTML and XML profile. It is assembled byte-wise because the nx_cc 130// lexer refuses a literal hash inside a string. 131// trunc[0] is set to 1 when the caller's buffer could not hold the whole value -- a partial attribute 132// value is a broken tag, so this ANNOUNCES rather than returning a shorter answer that looks fine. 133func ia_esc_attr(dst: *u8, off: i64, s: *u8, cap: i64, trunc: *i64) -> i64 { 134 var o: i64 = off 135 var i: i64 = 0 136 trunc[0] = 0 137 while s[i] != (0 as u8) { 138 if o + IA_MAX_EXPANSION + 1 >= cap { trunc[0] = 1; return o } 139 let c: i64 = s[i] as i64 140 var lit: i64 = 1 141 if c == IA_AMP { o = ia_cat(dst, o, "&amp;" as *u8, cap); lit = 0 } 142 if c == IA_LT { if lit == 1 { o = ia_cat(dst, o, "&lt;" as *u8, cap); lit = 0 } } 143 if c == IA_GT { if lit == 1 { o = ia_cat(dst, o, "&gt;" as *u8, cap); lit = 0 } } 144 if c == IA_DQ { if lit == 1 { o = ia_cat(dst, o, "&quot;" as *u8, cap); lit = 0 } } 145 if c == IA_SQ { if lit == 1 { 146 o = ia_put(dst, o, IA_AMP) 147 o = ia_put(dst, o, IA_HASH) 148 o = ia_put(dst, o, IA_DIGIT_3) 149 o = ia_put(dst, o, IA_DIGIT_9) 150 o = ia_put(dst, o, IA_SEMI) 151 lit = 0 152 } } 153 if lit == 1 { dst[o] = c as u8; o = o + 1 } 154 i = i + 1 155 } 156 dst[o] = 0 as u8 157 return o 158} 159 160// TEXT context (element content, not an attribute): the two quote characters are ordinary text there, 161// so escaping them would put visible entities on the page. Named separately BECAUSE the two contexts 162// have different rules and one function serving both could never be right for either. 163func ia_esc_text(dst: *u8, off: i64, s: *u8, cap: i64, trunc: *i64) -> i64 { 164 var o: i64 = off 165 var i: i64 = 0 166 trunc[0] = 0 167 while s[i] != (0 as u8) { 168 if o + IA_MAX_EXPANSION + 1 >= cap { trunc[0] = 1; return o } 169 let c: i64 = s[i] as i64 170 var lit: i64 = 1 171 if c == IA_AMP { o = ia_cat(dst, o, "&amp;" as *u8, cap); lit = 0 } 172 if c == IA_LT { if lit == 1 { o = ia_cat(dst, o, "&lt;" as *u8, cap); lit = 0 } } 173 if c == IA_GT { if lit == 1 { o = ia_cat(dst, o, "&gt;" as *u8, cap); lit = 0 } } 174 if lit == 1 { dst[o] = c as u8; o = o + 1 } 175 i = i + 1 176 } 177 dst[o] = 0 as u8 178 return o 179} 180 181// Does this value carry a byte that an unescaped emit would let escape its own attribute? Used by the 182// gate's neg-controls and by any caller that wants to refuse rather than repair. 183func ia_attr_hostile(s: *u8) -> i64 { 184 var i: i64 = 0 185 while s[i] != (0 as u8) { 186 let c: i64 = s[i] as i64 187 if c == IA_SQ { return 1 } 188 if c == IA_DQ { return 1 } 189 if c == IA_LT { return 1 } 190 if c == IA_GT { return 1 } 191 if c == IA_AMP { return 1 } 192 i = i + 1 193 } 194 return 0 195} 196 197func ia_solid(p: *u8, a: i64, b: i64) -> i64 { 198 var i: i64 = a 199 while i < b { 200 if ia_is_space(p[i] as i64) == 0 { return 1 } 201 i = i + 1 202 } 203 return 0 204} 205 206// ---- THE DECISION, HALF TWO: what an image's ALT SAYS. 207// A content image's alt must say what the image is, and the data that produced the image already does. 208// The caption wins. When a row carries none, the alt is DERIVED FROM THE FILE'S OWN NAME -- basename, 209// extension dropped, separators as spaces -- which is data. It is never the empty string and never the 210// word -image-: an alt that says -image- is a screen reader announcing the word image. 211// Returns the byte length written; 0 means NOTHING could be derived, and the caller must refuse. 212func ia_alt_derive(caption: *u8, file: *u8, out: *u8, cap: i64) -> i64 { 213 var o: i64 = 0 214 out[0] = 0 as u8 215 let clen: i64 = ia_slen(caption) 216 if ia_solid(caption, 0, clen) == 1 { 217 var k: i64 = 0 218 while k < clen { 219 if o + 1 >= cap { out[o] = 0 as u8; return o } 220 out[o] = caption[k] 221 o = o + 1 222 k = k + 1 223 } 224 out[o] = 0 as u8 225 return o 226 } 227 let flen: i64 = ia_slen(file) 228 var last: i64 = 0 - 1 229 var j: i64 = 0 230 while j < flen { 231 if (file[j] as i64) == IA_SLASH { last = j } 232 j = j + 1 233 } 234 var cut: i64 = flen 235 var d: i64 = last + 1 236 while d < flen { 237 if (file[d] as i64) == IA_DOT { cut = d } 238 d = d + 1 239 } 240 var p: i64 = last + 1 241 while p < cut { 242 if o + 1 >= cap { out[o] = 0 as u8; return o } 243 var c: i64 = file[p] as i64 244 if c == IA_USCORE { c = IA_SP } 245 if c == IA_HYPHEN { c = IA_SP } 246 out[o] = c as u8 247 o = o + 1 248 p = p + 1 249 } 250 out[o] = 0 as u8 251 if ia_solid(out, 0, o) == 0 { out[0] = 0 as u8; return 0 } 252 return o 253} 254 255// ---- SIZING IS DERIVED, NEVER GUESSED (rule 11). The caller computes the exact worst case from the 256// strings it already holds, so no ceiling in this lib had to be picked and none can be reached. 257func ia_alt_cap_for(caption: *u8, file: *u8) -> i64 { 258 var a: i64 = ia_slen(caption) 259 let b: i64 = ia_slen(file) 260 if b > a { a = b } 261 return a + 1 262} 263 264func ia_img_cap_for(src: *u8, alt: *u8, extra: *u8) -> i64 { 265 var chrome: i64 = ia_slen(IA_TAG_OPEN) + ia_slen(IA_TAG_MID) 266 chrome = chrome + ia_slen(IA_TAG_ENDQ) + ia_slen(IA_TAG_CLOSE) 267 let payload: i64 = (ia_slen(src) + ia_slen(alt)) * IA_MAX_EXPANSION 268 return chrome + payload + ia_slen(extra) + IA_SEP_AND_NUL + IA_MAX_EXPANSION 269} 270 271// ---- THE ONE SITE THAT WRITES AN <img>. 272// It REFUSES to emit a tag whose alt would be empty or whose value would not fit, so -every emitted 273// img carries a non-empty alt- is true BY CONSTRUCTION and not by review. Returns bytes written; 274// 0 = REFUSED and NOTHING was written, and the caller owes the reader a named absence instead. 275// extra is caller-authored literal markup (loading, width, height) and is NOT escaped: it is the 276// caller's own bytes, never data, and escaping it would emit entities where attributes belong. 277func ia_img_emit(dst: *u8, off: i64, cap: i64, src: *u8, alt: *u8, extra: *u8) -> i64 { 278 dst[off] = 0 as u8 279 if ia_solid(src, 0, ia_slen(src)) == 0 { return 0 } 280 if ia_solid(alt, 0, ia_slen(alt)) == 0 { return 0 } 281 let tr: *i64 = sys_mmap(IA_SLOT_BYTES) as *i64 282 var o: i64 = off 283 o = ia_cat(dst, o, IA_TAG_OPEN, cap) 284 o = ia_esc_attr(dst, o, src, cap, tr) 285 if tr[0] == 1 { dst[off] = 0 as u8; return 0 } 286 o = ia_cat(dst, o, IA_TAG_MID, cap) 287 o = ia_esc_attr(dst, o, alt, cap, tr) 288 if tr[0] == 1 { dst[off] = 0 as u8; return 0 } 289 o = ia_cat(dst, o, IA_TAG_ENDQ, cap) 290 if extra[0] != (0 as u8) { 291 o = ia_put(dst, o, IA_SP) 292 o = ia_cat(dst, o, extra, cap) 293 } 294 o = ia_cat(dst, o, IA_TAG_CLOSE, cap) 295 dst[o] = 0 as u8 296 if o + 1 >= cap { dst[off] = 0 as u8; return 0 } 297 return o - off 298} 299 300// ---- THE READER. Case-insensitive literal match at a position. 301func ia_starts_ci(p: *u8, at: i64, n: i64, lit: *u8) -> i64 { 302 var k: i64 = 0 303 while lit[k] != (0 as u8) { 304 if at + k >= n { return 0 } 305 if ia_lower(p[at+k] as i64) != ia_lower(lit[k] as i64) { return 0 } 306 k = k + 1 307 } 308 return 1 309} 310 311// index just PAST the next occurrence of lit, or n when it never closes 312func ia_skip_past(p: *u8, from: i64, n: i64, lit: *u8) -> i64 { 313 let ll: i64 = ia_slen(lit) 314 var i: i64 = from 315 while i < n { 316 if ia_starts_ci(p, i, n, lit) == 1 { return i + ll } 317 i = i + 1 318 } 319 return n 320} 321 322// An img tag OPENS only when the name is followed by a delimiter. Without this a word like <images> 323// would be counted as an image, which is how a lexical counter invents subjects. 324func ia_is_img_open(p: *u8, at: i64, n: i64) -> i64 { 325 if ia_starts_ci(p, at, n, IA_IMG_OPEN) == 0 { return 0 } 326 let a: i64 = at + ia_slen(IA_IMG_OPEN) 327 if a >= n { return 0 } 328 let c: i64 = p[a] as i64 329 if ia_is_space(c) == 1 { return 1 } 330 if c == IA_GT { return 1 } 331 if c == IA_SLASH { return 1 } 332 return 0 333} 334 335func ia_name_is(p: *u8, ns: i64, nlen: i64, lit: *u8) -> i64 { 336 if nlen != ia_slen(lit) { return 0 } 337 var k: i64 = 0 338 while k < nlen { 339 if ia_lower(p[ns+k] as i64) != ia_lower(lit[k] as i64) { return 0 } 340 k = k + 1 341 } 342 return 1 343} 344 345// Walk ONE img tag's attributes with the quoting rules a browser uses. 346// res[0] = alt carries a non-blank value res[2] = the tag or a value never closed 347// res[1] = src carries a non-blank value res[3] = an attribute NAME holds a quote byte 348// Returns the index just past the tag. 349// THE VALUE ENDS HERE EXACTLY WHERE IT ENDS IN A BROWSER. That is what makes res[3] meaningful: the 350// scan does not need to know what the alt was SUPPOSED to say, only that the data spilled out of its 351// own quotes -- which is a property of the bytes alone. 352func ia_img_tag(p: *u8, at: i64, n: i64, res: *i64) -> i64 { 353 res[0] = 0 354 res[1] = 0 355 res[2] = 0 356 res[3] = 0 357 var i: i64 = at + ia_slen(IA_IMG_OPEN) 358 while i < n { 359 while i < n { 360 if ia_is_space(p[i] as i64) == 0 { break } 361 i = i + 1 362 } 363 if i >= n { res[2] = 1; return n } 364 let c: i64 = p[i] as i64 365 if c == IA_GT { return i + 1 } 366 if c == IA_LT { res[2] = 1; return i } 367 let ns: i64 = i 368 while i < n { 369 let d: i64 = p[i] as i64 370 if d == IA_EQ { break } 371 if d == IA_GT { break } 372 if d == IA_LT { break } 373 if ia_is_space(d) == 1 { break } 374 i = i + 1 375 } 376 let nlen: i64 = i - ns 377 var qn: i64 = 0 378 while qn < nlen { 379 let nc: i64 = p[ns+qn] as i64 380 if nc == IA_SQ { res[3] = 1 } 381 if nc == IA_DQ { res[3] = 1 } 382 qn = qn + 1 383 } 384 var vsolid: i64 = 0 385 var hasval: i64 = 0 386 let save: i64 = i 387 while i < n { 388 if ia_is_space(p[i] as i64) == 0 { break } 389 i = i + 1 390 } 391 if i < n { if (p[i] as i64) == IA_EQ { hasval = 1 } } 392 if hasval == 0 { i = save } 393 if hasval == 1 { 394 i = i + 1 395 while i < n { 396 if ia_is_space(p[i] as i64) == 0 { break } 397 i = i + 1 398 } 399 if i >= n { res[2] = 1; return n } 400 let q: i64 = p[i] as i64 401 if q == IA_DQ { 402 i = i + 1 403 let vs: i64 = i 404 while i < n { 405 if (p[i] as i64) == IA_DQ { break } 406 i = i + 1 407 } 408 if i >= n { res[2] = 1; return n } 409 vsolid = ia_solid(p, vs, i) 410 i = i + 1 411 } 412 if q == IA_SQ { 413 i = i + 1 414 let vt: i64 = i 415 while i < n { 416 if (p[i] as i64) == IA_SQ { break } 417 i = i + 1 418 } 419 if i >= n { res[2] = 1; return n } 420 vsolid = ia_solid(p, vt, i) 421 i = i + 1 422 } 423 if q != IA_DQ { if q != IA_SQ { 424 let vu: i64 = i 425 while i < n { 426 let e: i64 = p[i] as i64 427 if e == IA_GT { break } 428 if ia_is_space(e) == 1 { break } 429 i = i + 1 430 } 431 vsolid = ia_solid(p, vu, i) 432 } } 433 } 434 if vsolid == 1 { 435 if ia_name_is(p, ns, nlen, "alt" as *u8) == 1 { res[0] = 1 } 436 if ia_name_is(p, ns, nlen, "src" as *u8) == 1 { res[1] = 1 } 437 } 438 if nlen == 0 { if hasval == 0 { i = i + 1 } } 439 } 440 res[2] = 1 441 return n 442} 443 444// Whole-document scan. Comment, script and style bodies are SKIPPED so markup quoted inside them 445// cannot be counted as an element -- a detector with false positives is worse than none, and inline 446// script is where a lexical img counter finds them. 447func ia_scan(page: *u8, n: i64, out: *i64) -> i64 { 448 out[IA_S_IMGS] = 0 449 out[IA_S_ALT_OK] = 0 450 out[IA_S_SRC_OK] = 0 451 out[IA_S_MALFORMED] = 0 452 out[IA_S_QUOTEBREAK] = 0 453 let res: *i64 = sys_mmap(IA_SCAN_SLOTS * IA_SLOT_BYTES) as *i64 454 var i: i64 = 0 455 while i < n { 456 var step: i64 = 1 457 if ia_starts_ci(page, i, n, "<!--" as *u8) == 1 { 458 i = ia_skip_past(page, i + ia_slen("<!--" as *u8), n, "-->" as *u8) 459 step = 0 460 } 461 if step == 1 { if ia_starts_ci(page, i, n, "<script" as *u8) == 1 { 462 i = ia_skip_past(page, i + ia_slen("<script" as *u8), n, "</script" as *u8) 463 step = 0 464 } } 465 if step == 1 { if ia_starts_ci(page, i, n, "<style" as *u8) == 1 { 466 i = ia_skip_past(page, i + ia_slen("<style" as *u8), n, "</style" as *u8) 467 step = 0 468 } } 469 if step == 1 { if ia_is_img_open(page, i, n) == 1 { 470 out[IA_S_IMGS] = out[IA_S_IMGS] + 1 471 i = ia_img_tag(page, i, n, res) 472 out[IA_S_ALT_OK] = out[IA_S_ALT_OK] + res[0] 473 out[IA_S_SRC_OK] = out[IA_S_SRC_OK] + res[1] 474 out[IA_S_MALFORMED] = out[IA_S_MALFORMED] + res[2] 475 out[IA_S_QUOTEBREAK] = out[IA_S_QUOTEBREAK] + res[3] 476 step = 0 477 } } 478 if step == 1 { i = i + 1 } 479 } 480 return out[IA_S_IMGS] 481}