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 " -- 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 ': ' 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, "&" as *u8, cap); lit = 0 }
142 if c == IA_LT { if lit == 1 { o = ia_cat(dst, o, "<" as *u8, cap); lit = 0 } }
143 if c == IA_GT { if lit == 1 { o = ia_cat(dst, o, ">" as *u8, cap); lit = 0 } }
144 if c == IA_DQ { if lit == 1 { o = ia_cat(dst, o, """ 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, "&" as *u8, cap); lit = 0 }
172 if c == IA_LT { if lit == 1 { o = ia_cat(dst, o, "<" as *u8, cap); lit = 0 } }
173 if c == IA_GT { if lit == 1 { o = ia_cat(dst, o, ">" 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}