1
//! XML/HTML parsing module for the Azul toolkit.
2
//!
3
//! Provides two parsing paths:
4
//! - `parse_xml_string`: builds an `XmlNode` tree (used by `domxml_from_str`)
5
//! - `parse_xml_to_fast_dom_with_css`: builds an arena-based `FastDom` directly
6
//!   from XML tokens (used by `parse_xml_to_styled_dom`)
7
//!
8
//! Both paths handle HTML5-lite features: void elements, auto-closing tags,
9
//! XML entity decoding, `<style>` CSS extraction, and BOM/DOCTYPE stripping.
10
//!
11
//! Data types (`XmlNode`, `XmlError`, etc.) live in `azul_core::xml`; this
12
//! module provides the parsing implementations.
13

            
14
#![allow(unused_variables)]
15

            
16
use alloc::{boxed::Box, collections::BTreeMap, string::String, vec::Vec};
17
use core::fmt;
18
#[cfg(feature = "std")]
19
use std::path::Path;
20

            
21
#[cfg(feature = "svg")]
22
pub mod svg;
23

            
24
/// Decodes XML/HTML entities in a string.
25
/// Handles standard XML entities: &lt; &gt; &amp; &apos; &quot;
26
/// and numeric character references: &#60; &#x3C;
27
/// Returns `Cow::Borrowed` when no entities are found (zero-alloc fast path).
28
72088
fn decode_xml_entities(s: &str) -> std::borrow::Cow<'_, str> {
29
    // Fast path: if no ampersand, no entities to decode
30
72088
    if !s.contains('&') {
31
72016
        return std::borrow::Cow::Borrowed(s);
32
72
    }
33
72
    decode_xml_entities_slow(s)
34
72088
}
35

            
36
85
fn decode_xml_entities_slow(s: &str) -> std::borrow::Cow<'_, str> {
37
85
    let mut result = String::with_capacity(s.len());
38
85
    let mut chars = s.chars().peekable();
39
    
40
1060354
    while let Some(c) = chars.next() {
41
1060269
        if c == '&' {
42
            // Collect the entity reference
43
50179
            let mut entity = String::new();
44
50179
            let mut found_semicolon = false;
45
            
46
150501
            while let Some(&next) = chars.peek() {
47
150486
                if next == ';' {
48
50074
                    chars.next();
49
50074
                    found_semicolon = true;
50
50074
                    break;
51
100412
                }
52
100412
                if !next.is_alphanumeric() && next != '#' {
53
86
                    break;
54
100326
                }
55
100326
                entity.push(chars.next().unwrap());
56
100326
                if entity.len() > 10 {
57
                    // Entity too long, not a valid entity
58
4
                    break;
59
100322
                }
60
            }
61
            
62
50179
            if found_semicolon {
63
                // Try to decode the entity
64
50074
                match entity.as_str() {
65
50074
                    "lt" => result.push('<'),
66
65
                    "gt" => result.push('>'),
67
59
                    "amp" => result.push('&'),
68
43
                    "apos" => result.push('\''),
69
38
                    "quot" => result.push('"'),
70
33
                    "nbsp" => result.push('\u{00A0}'),
71
32
                    s if s.starts_with('#') => {
72
                        // Numeric character reference
73
22
                        let num_str = &s[1..];
74
22
                        let code_point = if num_str.starts_with('x') || num_str.starts_with('X') {
75
                            // Hexadecimal
76
13
                            u32::from_str_radix(&num_str[1..], 16).ok()
77
                        } else {
78
                            // Decimal
79
9
                            num_str.parse::<u32>().ok()
80
                        };
81
22
                        if let Some(cp) = code_point {
82
19
                            if let Some(ch) = char::from_u32(cp) {
83
13
                                result.push(ch);
84
13
                            } else {
85
6
                                // Invalid code point, keep original
86
6
                                result.push('&');
87
6
                                result.push_str(&entity);
88
6
                                result.push(';');
89
6
                            }
90
3
                        } else {
91
3
                            // Parse failed, keep original
92
3
                            result.push('&');
93
3
                            result.push_str(&entity);
94
3
                            result.push(';');
95
3
                        }
96
                    }
97
10
                    _ => {
98
10
                        // Unknown entity, keep original
99
10
                        result.push('&');
100
10
                        result.push_str(&entity);
101
10
                        result.push(';');
102
10
                    }
103
                }
104
105
            } else {
105
105
                // No semicolon found, not a valid entity reference
106
105
                result.push('&');
107
105
                result.push_str(&entity);
108
105
            }
109
1010090
        } else {
110
1010090
            result.push(c);
111
1010090
        }
112
    }
113
    
114
85
    std::borrow::Cow::Owned(result)
115
85
}
116

            
117
pub use azul_core::xml::*;
118
use azul_core::{dom::Dom, impl_from, styled_dom::StyledDom, window::StringPairVec};
119
#[cfg(feature = "parser")]
120
use azul_css::parser2::CssParseError;
121
use azul_css::{css::Css, AzString, OptionString, U8Vec};
122
use xmlparser::Tokenizer;
123

            
124
#[cfg(feature = "xml")]
125
2073
#[must_use] pub fn domxml_from_str(xml: &str, component_map: &ComponentMap) -> DomXml {
126
2073
    let error_css = Css::empty();
127

            
128
2073
    let parsed = match parse_xml_string(xml) {
129
2058
        Ok(parsed) => parsed,
130
15
        Err(e) => {
131
15
            return DomXml {
132
15
                parsed_dom: {
133
15
                    let mut dom = Dom::create_body()
134
15
                        .with_children(vec![Dom::create_p_with_text(format!("{e}"))].into());
135
15
                    StyledDom::create(&mut dom, error_css)
136
15
                },
137
15
            };
138
        }
139
    };
140

            
141
2058
    let parsed_dom = match str_to_dom(parsed.as_ref(), component_map, None) {
142
2050
        Ok(o) => o,
143
8
        Err(e) => {
144
8
            return DomXml {
145
8
                parsed_dom: {
146
8
                    let mut dom = Dom::create_body()
147
8
                        .with_children(vec![Dom::create_p_with_text(format!("{e}"))].into());
148
8
                    StyledDom::create(&mut dom, error_css)
149
8
                },
150
8
            };
151
        }
152
    };
153

            
154
2050
    DomXml { parsed_dom }
155
2073
}
156

            
157
/// Create a Dom (with CSS attached but not applied) from an already-parsed Xml structure.
158
///
159
/// Returns an unstyled `Dom` suitable for use in layout callbacks (which return `Dom`,
160
/// not `StyledDom`). The CSS from `<style>` tags is attached to the `Dom.css` field
161
/// and will be applied during the cascade pass.
162
// FFI-exported (api.json fn_body azul_layout::xml::dom_from_parsed_xml(xml)): owned Xml by value.
163
#[allow(clippy::needless_pass_by_value)]
164
51
#[must_use] pub fn dom_from_parsed_xml(xml: Xml) -> Dom {
165
51
    let component_map = ComponentMap::with_builtin();
166
51
    match str_to_dom_unstyled(xml.root.as_ref(), &component_map) {
167
47
        Ok(dom) => dom,
168
4
        Err(e) => Dom::create_body().with_children(vec![Dom::create_p_with_text(format!("{e}"))].into()),
169
    }
170
51
}
171

            
172
/// Fastest path: parse XML string directly into `FastDom` without intermediate `XmlNode` tree.
173
///
174
/// Feeds XML tokenizer events directly into `CompactDomBuilder`, skipping both the
175
/// `XmlNode` tree construction AND the Dom tree construction.
176
/// Parse XML string directly into a `FastDom` (arena-based DOM) in a single pass.
177
///
178
/// Also extracts `<style>` tag content as CSS. Returns both the `FastDom` and
179
/// collected CSS stylesheets. No intermediate `XmlNode` tree is built.
180
///
181
/// This is the fastest XML→DOM path: XML tokens feed directly into
182
/// `CompactDomBuilder`, and `<style>` text is collected inline.
183
/// # Errors
184
///
185
/// Returns an `XmlError` if the XML cannot be parsed.
186
129
pub fn parse_xml_to_fast_dom(xml: &str) -> Result<azul_core::dom::FastDom, XmlError> {
187
129
    let (fast_dom, _css) = parse_xml_to_fast_dom_with_css(xml)?;
188
107
    Ok(fast_dom)
189
129
}
190

            
191
/// Parse XML directly into `FastDom` + extracted CSS, ready for `StyledDom`.
192
#[allow(clippy::cast_precision_loss)] // bounded layout/render numeric cast
193
/// # Errors
194
///
195
/// Returns an `XmlError` if the XML cannot be parsed.
196
209
pub fn parse_xml_to_styled_dom(xml: &str) -> Result<StyledDom, XmlError> {
197
    // Optional per-phase RSS/timing breakdown.
198
    // Gated on AZ_PROFILE=memory — prints
199
    //   [XML] tokenize+fast_dom       : +XX MiB in YY ms
200
    //   [XML] css attach              : +XX MiB in YY ms
201
    //   [XML] create_from_fast_dom    : +XX MiB in YY ms
202
    // to locate which sub-phase of the parse-cascade dominates the
203
    // RSS jump seen between `page start` and `xml parsed`.
204
    static MEM_ENABLED: std::sync::OnceLock<bool> = std::sync::OnceLock::new();
205
209
    let mem_on = *MEM_ENABLED.get_or_init(azul_core::profile::memory_enabled);
206

            
207
209
    let rss0 = if mem_on { peak_rss_bytes() } else { 0 };
208
209
    let (mut fast_dom, css) = parse_xml_to_fast_dom_with_css(xml)?;
209
187
    if mem_on {
210
        let rss1 = peak_rss_bytes();
211
        eprintln!(
212
            "[XML] tokenize+fast_dom       : +{:.2} MiB",
213
            (rss1.saturating_sub(rss0)) as f64 / 1024.0 / 1024.0,
214
        );
215
187
    }
216

            
217
187
    let rss1 = if mem_on { peak_rss_bytes() } else { 0 };
218
    // Attach CSS to the FastDom
219
187
    if !css.is_empty() {
220
        // Rules AND keyframes: merging by rules alone silently dropped every
221
        // `@keyframes` block a `<style>` element declared, so
222
        // `-azul-animation-out: shrinkOut 1s` fell back to the default slide
223
        // at runtime while the unit parser tests stayed green.
224
172
        let mut combined_rules = Vec::new();
225
172
        let mut combined_keyframes = Vec::new();
226
344
        for c in css {
227
172
            combined_rules.extend(c.rules.into_library_owned_vec());
228
172
            combined_keyframes.extend(c.keyframes.into_library_owned_vec());
229
172
        }
230
172
        let mut combined_css = Css::new(combined_rules);
231
172
        combined_css.keyframes = combined_keyframes.into();
232
172
        fast_dom.css = vec![azul_core::dom::CssWithNodeId {
233
172
            node_id: 0, // global scope
234
172
            css: combined_css,
235
172
        }].into();
236
15
    }
237
187
    if mem_on {
238
        let rss2 = peak_rss_bytes();
239
        eprintln!(
240
            "[XML] css attach              : +{:.2} MiB",
241
            (rss2.saturating_sub(rss1)) as f64 / 1024.0 / 1024.0,
242
        );
243
187
    }
244

            
245
    // Hint the allocator to return pages freed by the CSS parser.
246
    // The tokenizer+parser created many small allocations (selectors,
247
    // declarations, strings) that are now packed into FastDom. Purging
248
    // here returns those pages before the cascade allocates more.
249
187
    crate::probe::hint_purge_allocator();
250

            
251
187
    let rss2 = if mem_on { peak_rss_bytes() } else { 0 };
252
187
    let styled = StyledDom::create_from_fast_dom(fast_dom);
253

            
254
    // Major purge point: the cascade just freed ~3 MiB of intermediate
255
    // allocations (build-phase Vecs, CSS selector matching state, pruned
256
    // properties). Tell the allocator to return those pages NOW before
257
    // the layout pass allocates more on top of them.
258
187
    crate::probe::hint_purge_allocator();
259

            
260
187
    if mem_on {
261
        let rss3 = peak_rss_bytes();
262
        eprintln!(
263
            "[XML] create_from_fast_dom    : +{:.2} MiB",
264
            (rss3.saturating_sub(rss2)) as f64 / 1024.0 / 1024.0,
265
        );
266
187
    }
267

            
268
187
    Ok(styled)
269
209
}
270

            
271
/// Resident-set bytes for RSS checkpoints — mirrors servo-shot's
272
/// `peak_rss_bytes()`. Uses `getrusage(RUSAGE_SELF)` via the
273
/// `probe` feature's `libc` dep; returns 0 without it so the
274
/// caller just doesn't emit meaningful deltas.
275
#[cfg(all(unix, feature = "probe"))]
276
fn peak_rss_bytes() -> u64 {
277
    let mut usage: libc::rusage = unsafe { std::mem::zeroed() };
278
    if unsafe { libc::getrusage(libc::RUSAGE_SELF, &raw mut usage) } != 0 {
279
        return 0;
280
    }
281
    let ru = usage.ru_maxrss as u64;
282
    // macOS reports bytes, Linux reports KiB.
283
    #[cfg(target_os = "macos")]
284
    { ru }
285
    #[cfg(not(target_os = "macos"))]
286
    { ru.saturating_mul(1024) }
287
}
288

            
289
#[cfg(not(all(unix, feature = "probe")))]
290
2
const fn peak_rss_bytes() -> u64 {
291
2
    0
292
2
}
293

            
294
/// Internal: parse XML into `FastDom` + collected CSS stylesheets.
295
#[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)] // bounded layout/render numeric cast
296
#[allow(clippy::too_many_lines, clippy::cognitive_complexity)] // large but cohesive: single-purpose layout/render/parse routine (one branch per case)
297
339
fn parse_xml_to_fast_dom_with_css(xml: &str) -> Result<(azul_core::dom::FastDom, Vec<Css>), XmlError> {
298
    use xmlparser::{ElementEnd::{Open, Empty, Close}, Token::{ElementStart, Attribute, ElementEnd, Text}, Tokenizer};
299
    use azul_core::dom::{NodeData, NodeType, IdOrClass, TabIndex};
300
    use azul_core::xml::CompactDomBuilder;
301

            
302
    const ESTIMATED_BYTES_PER_NODE: usize = 20;
303

            
304
    const VOID_ELEMENTS: &[&str] = &[
305
        "area", "base", "br", "col", "embed", "hr", "img", "input", "link", "meta",
306
        "param", "source", "track", "wbr",
307
    ];
308

            
309
    // Lowercase `src` into `dst`, reusing `dst`'s existing capacity.
310
    // Zero-alloc when dst's capacity is already ≥ src.len() AND no uppercase
311
    // conversion is needed (the happy path for HTML5 where tags are lowercase).
312
11600
    fn lowercase_into(dst: &mut String, src: &str) {
313
11600
        dst.clear();
314
35712
        if src.bytes().all(|b| !b.is_ascii_uppercase()) {
315
11597
            dst.push_str(src);
316
11597
        } else {
317
3
            dst.reserve(src.len());
318
11
            for b in src.bytes() {
319
11
                dst.push(b.to_ascii_lowercase() as char);
320
11
            }
321
        }
322
11600
    }
323

            
324
    // Strip BOM
325
339
    let xml = xml.strip_prefix('\u{FEFF}').unwrap_or(xml);
326
339
    let mut xml = xml.trim();
327

            
328
    // Skip <?xml ... ?>
329
339
    if xml.starts_with("<?") {
330
5
        if let Some(pos) = xml.find("?>") {
331
1
            xml = &xml[(pos + 2)..];
332
4
        }
333
334
    }
334

            
335
    // Skip <!DOCTYPE ...>
336
339
    let mut xml = xml.trim();
337
339
    if xml.len() > 9 && xml.is_char_boundary(9) && xml[..9].to_ascii_lowercase().starts_with("<!doctype") {
338
3
        if let Some(pos) = xml.find('>') {
339
3
            xml = &xml[(pos + 1)..];
340
3
        }
341
336
    } else if xml.starts_with("<!--") {
342
5
        if let Some(end) = xml.find("-->") {
343
1
            xml = &xml[(end + 3)..];
344
1
            xml = xml.trim();
345
4
        }
346
331
    }
347

            
348
339
    let tokenizer = Tokenizer::from_fragment(xml, 0..xml.len());
349

            
350
339
    let estimated_nodes = xml.len() / ESTIMATED_BYTES_PER_NODE;
351
339
    let mut builder = CompactDomBuilder::with_capacity(estimated_nodes);
352
339
    let mut collected_css: Vec<Css> = Vec::new();
353
339
    let mut inside_style_tag = false;
354
339
    let mut style_text = String::new();
355
    // Track <head> depth: skip DOM nodes inside <head> (still collect <style> CSS).
356
    // This ensures the FastDom contains only <html><body>... as the layout engine expects.
357
339
    let mut head_depth: usize = 0;
358

            
359
    // Temporary storage for current element's attributes
360
339
    let mut current_tag: String = String::new();
361
339
    let mut current_attrs: Vec<(String, String)> = Vec::new();
362
339
    let mut pending_open = false;
363

            
364
    // Pre-compute the CSS key map once (used for style= attribute parsing)
365
339
    let css_key_map = azul_css::props::property::get_css_key_map();
366

            
367
    // One bump arena for every AzString produced during this parse —
368
    // id/class tokens, text nodes, etc. Replaces ~1k small heap allocs
369
    // with a handful of 64 KiB chunks. Each AzString carries its own
370
    // Arc reference to the arena, so the arena survives until the last
371
    // string is dropped (typically when the StyledDom is dropped).
372
339
    let mut str_arena = azul_css::corety::StringArena::new();
373

            
374
    // Finalize the pending open element: create NodeData from tag + attrs, push to builder
375
    // tag is already lowercase
376
339
    let finalize_open = |
377
        builder: &mut CompactDomBuilder,
378
        str_arena: &mut azul_css::corety::StringArena,
379
        tag: &str,
380
        attrs: &[(String, String)],
381
        css_key_map: &azul_css::props::property::CssKeyMap,
382
11239
    | {
383
11239
        let node_type = tag_to_node_type(tag);
384
11239
        let mut nd = NodeData::create_node(node_type);
385

            
386
        // Apply attributes — build AttributeTypeVec directly (avoids the
387
        // clone + retain dance in set_ids_and_classes for fresh NodeData).
388
11239
        let mut attr_vec: Vec<azul_core::dom::AttributeType> = Vec::new();
389
12244
        for (key, value) in attrs {
390
1005
            match key.as_str() {
391
1005
                "id" => {
392
598
                    for id in value.split_whitespace() {
393
598
                        attr_vec.push(azul_core::dom::AttributeType::Id(str_arena.intern(id)));
394
598
                    }
395
                }
396
408
                "class" => {
397
298
                    for class in value.split_whitespace() {
398
298
                        attr_vec.push(azul_core::dom::AttributeType::Class(str_arena.intern(class)));
399
298
                    }
400
                }
401
112
                "focusable" => {
402
5
                    if let Some(f) = parse_bool(value.as_str()) {
403
2
                        nd.set_tab_index(if f { TabIndex::Auto } else { TabIndex::NoKeyboardFocus });
404
3
                    }
405
                }
406
107
                "tabindex" => {
407
84
                    if let Ok(ti) = value.parse::<isize>() {
408
7
                        match ti {
409
66
                            0 => nd.set_tab_index(TabIndex::Auto),
410
7
                            i if i > 0 => nd.set_tab_index(TabIndex::OverrideInParent(i as u32)),
411
2
                            _ => nd.set_tab_index(TabIndex::NoKeyboardFocus),
412
                        }
413
11
                    }
414
                }
415
23
                "style" => {
416
14
                    let mut css_attrs = Vec::new();
417
2026
                    for s in value.split(';') {
418
2026
                        let mut s = s.split(':');
419
2026
                        let Some(key) = s.next() else { continue };
420
2026
                        let Some(val) = s.next() else { continue };
421
                        // Called for its side effect (writes parsed props into
422
                        // `css_attrs`); the returned value is intentionally discarded.
423
2013
                        drop(azul_css::parser2::parse_css_declaration(
424
2013
                            key.trim(), val.trim(),
425
2013
                            azul_css::parser2::ErrorLocationRange::default(),
426
2013
                            css_key_map, &mut Vec::new(), &mut css_attrs,
427
                        ));
428
                    }
429
14
                    let props = css_attrs.into_iter().filter_map(|s| {
430
                        use azul_css::css::CssDeclaration;
431
                        use azul_css::dynamic_selector::CssPropertyWithConditions;
432
2
                        match s {
433
2
                            CssDeclaration::Static(s) => Some(CssPropertyWithConditions::simple(s)),
434
                            CssDeclaration::Dynamic(_) => None,
435
                        }
436
14
                    }).collect::<Vec<_>>();
437
14
                    if !props.is_empty() {
438
2
                        nd.set_css_props(props.into());
439
12
                    }
440
                }
441
9
                "contenteditable" => {
442
9
                    if parse_bool(value.as_str()).unwrap_or(false) {
443
5
                        nd.set_contenteditable(true);
444
8
                    }
445
                }
446
                _ => {}
447
            }
448
        }
449
11239
        if !attr_vec.is_empty() {
450
618
            nd.set_attributes(attr_vec.into());
451
10889
        }
452

            
453
11239
        builder.open_node(nd);
454
11239
    };
455

            
456
339
    let mut last_was_void = false;
457
339
    let mut tag_stack: Vec<String> = Vec::new(); // for matching close tags
458

            
459
38274
    for token in tokenizer {
460
37979
        let token = token.map_err(|e| XmlError::ParserError(translate_xmlparser_error(e)))?;
461
23179
        match token {
462
11600
            ElementStart { local, .. } => {
463
                // Flush any pending open element
464
11600
                if pending_open {
465
                    let is_void = VOID_ELEMENTS.contains(&current_tag.as_str());
466
                    if current_tag == "head" { head_depth += 1; }
467
                    if head_depth == 0 {
468
                        finalize_open(&mut builder, &mut str_arena, &current_tag, &current_attrs, &css_key_map);
469
                        if is_void { builder.close_node(); }
470
                    }
471
                    if !is_void {
472
                        tag_stack.push(core::mem::take(&mut current_tag));
473
                    }
474
11600
                }
475

            
476
                // Reuse the current_tag buffer — avoids ~1023 fresh String
477
                // allocations per parse (one per ElementStart).
478
11600
                lowercase_into(&mut current_tag, local.as_str());
479
11600
                current_attrs.clear();
480
11600
                pending_open = true;
481
11600
                last_was_void = VOID_ELEMENTS.contains(&current_tag.as_str());
482
            }
483
1014
            Attribute { local, value, .. } => {
484
1014
                // decode_xml_entities returns Cow::Borrowed when no entities
485
1014
                // are present (the common case), so `.into_owned()` is the
486
1014
                // only fresh allocation here. The key is copied via
487
1014
                // `to_string()` because we can't hold a borrow across token
488
1014
                // iterations. TODO: when we switch current_attrs to
489
1014
                // Vec<(&str, Cow<str>)> this becomes zero-alloc for the key.
490
1014
                current_attrs.push((local.to_string(), decode_xml_entities(value.as_str()).into_owned()));
491
1014
            }
492
            ElementEnd { end: Open, .. } => {
493
11584
                if pending_open {
494
11584
                    let is_void = VOID_ELEMENTS.contains(&current_tag.as_str());
495
11584
                    if current_tag == "style" {
496
174
                        inside_style_tag = true;
497
174
                        style_text.clear();
498
11410
                    }
499
11584
                    if current_tag == "head" { head_depth += 1; }
500
11584
                    if head_depth == 0 {
501
11227
                        finalize_open(&mut builder, &mut str_arena, &current_tag, &current_attrs, &css_key_map);
502
11227
                        if is_void { builder.close_node(); }
503
357
                    }
504
11584
                    if !is_void {
505
11584
                        // Use take() instead of clone() — after pending_open=false,
506
11584
                        // current_tag is not read again until the next ElementStart
507
11584
                        // reassigns it via lowercase_into.
508
11584
                        tag_stack.push(core::mem::take(&mut current_tag));
509
11584
                    }
510
11584
                    pending_open = false;
511
                }
512
            }
513
            ElementEnd { end: Empty, .. } => {
514
                // Self-closing element: open + immediately close
515
12
                if pending_open {
516
12
                    if current_tag == "head" { head_depth += 1; }
517
12
                    if head_depth == 0 {
518
12
                        finalize_open(&mut builder, &mut str_arena, &current_tag, &current_attrs, &css_key_map);
519
12
                        builder.close_node();
520
12
                    }
521
12
                    if current_tag == "head" && head_depth > 0 { head_depth -= 1; }
522
12
                    pending_open = false;
523
                }
524
            }
525
11583
            ElementEnd { end: Close(_, close_value), .. } => {
526
11583
                if pending_open {
527
                    let is_void = VOID_ELEMENTS.contains(&current_tag.as_str());
528
                    if current_tag == "head" { head_depth += 1; }
529
                    if head_depth == 0 {
530
                        finalize_open(&mut builder, &mut str_arena, &current_tag, &current_attrs, &css_key_map);
531
                        if is_void { builder.close_node(); }
532
                    }
533
                    if !is_void {
534
                        tag_stack.push(core::mem::take(&mut current_tag));
535
                    }
536
                    pending_open = false;
537
11583
                }
538

            
539
11583
                let close_lower = close_value.as_str().to_ascii_lowercase();
540
11583
                let close_str = close_lower.as_str();
541
11583
                if VOID_ELEMENTS.contains(&close_str) {
542
                    continue;
543
11583
                }
544

            
545
                // If closing a <style> tag, parse collected CSS
546
11583
                if close_str == "style" && inside_style_tag {
547
174
                    if !style_text.is_empty() {
548
174
                        let parsed_css = Css::from_string(core::mem::take(&mut style_text).into());
549
174
                        collected_css.push(parsed_css);
550
174
                    }
551
174
                    inside_style_tag = false;
552
11409
                }
553

            
554
                // Pop until we find matching tag
555
11591
                while let Some(top) = tag_stack.last() {
556
11565
                    let is_match = top == close_str;
557
11565
                    let was_head = top == "head";
558
                    // Pop this tag (unconditionally auto-close mismatched tags)
559
11565
                    let popped = tag_stack.pop().unwrap();
560
11565
                    if popped == "head" && head_depth > 0 { head_depth -= 1; }
561
11565
                    if head_depth == 0 && !was_head {
562
11216
                        builder.close_node();
563
11216
                    }
564
11565
                    if is_match { break; }
565
                }
566
            }
567
2142
            Text { text } => {
568
2142
                if pending_open {
569
                    let is_void = VOID_ELEMENTS.contains(&current_tag.as_str());
570
                    if current_tag == "style" {
571
                        inside_style_tag = true;
572
                        style_text.clear();
573
                    }
574
                    if current_tag == "head" { head_depth += 1; }
575
                    if head_depth == 0 {
576
                        finalize_open(&mut builder, &mut str_arena, &current_tag, &current_attrs, &css_key_map);
577
                        if is_void { builder.close_node(); }
578
                    }
579
                    if !is_void {
580
                        tag_stack.push(current_tag.clone());
581
                    }
582
                    pending_open = false;
583
2142
                }
584

            
585
2142
                let text_str = text.as_str();
586
2142
                if !text_str.is_empty() {
587
2142
                    if inside_style_tag {
588
174
                        style_text.push_str(text_str);
589
1968
                    } else if head_depth == 0 {
590
                        // Skip whitespace-only text at <html> level (between </head> and <body>)
591
                        // but keep whitespace inside <body> (it's significant for inline layout)
592
2765
                        let inside_body = tag_stack.iter().any(|t| t == "body");
593
1643
                        if inside_body || !text_str.trim().is_empty() {
594
1148
                            let decoded = decode_xml_entities(text_str);
595
1148
                            builder.add_leaf(NodeData::create_text_do_not_use_without_block_level_wrapper(str_arena.intern(&decoded)));
596
1157
                        }
597
325
                    }
598
                }
599
            }
600
            _ => {}
601
        }
602
    }
603

            
604
    // Close any remaining open elements
605
295
    if pending_open {
606
        finalize_open(&mut builder, &mut str_arena, &current_tag, &current_attrs, &css_key_map);
607
295
    }
608
306
    while tag_stack.pop().is_some() {
609
11
        builder.close_node();
610
11
    }
611

            
612
    // Drop the arena handle explicitly. AzStrings already embedded in
613
    // the FastDom keep the backing bytes alive via their cloned Arc refs.
614
295
    drop(str_arena);
615

            
616
295
    Ok((builder.finish(), collected_css))
617
339
}
618

            
619
/// Loads, parses and builds a DOM from an XML file
620
///
621
/// **Warning**: The file is reloaded from disk on every function call - do not
622
/// use this in release builds! This function deliberately never fails: In an error case,
623
/// the error gets rendered as a `NodeType::Label`.
624
#[cfg(all(feature = "std", feature = "xml"))]
625
4
pub fn domxml_from_file<I: AsRef<Path>>(
626
4
    file_path: I,
627
4
    component_map: &ComponentMap,
628
4
) -> DomXml {
629
    use std::fs;
630

            
631
4
    let error_css = Css::empty();
632

            
633
4
    let xml = match fs::read_to_string(file_path.as_ref()) {
634
        Ok(xml) => xml,
635
4
        Err(e) => {
636
4
            return DomXml {
637
4
                parsed_dom: {
638
4
                    let mut dom = Dom::create_body()
639
4
                        .with_children(
640
4
                            vec![Dom::create_p_with_text(format!(
641
4
                                "Error reading: \"{}\": {}",
642
4
                                file_path.as_ref().to_string_lossy(),
643
4
                                e
644
4
                            ))]
645
4
                            .into(),
646
4
                        );
647
4
                    StyledDom::create(&mut dom, error_css)
648
4
                },
649
4
            };
650
        }
651
    };
652

            
653
    domxml_from_str(&xml, component_map)
654
4
}
655

            
656
/// Parses the XML string into an XML tree, returns
657
/// the root `<app></app>` node, with the children attached to it.
658
///
659
/// Since the XML allows multiple root nodes, this function returns
660
/// a `Vec<XmlNode>` - which are the "root" nodes, containing all their
661
/// children recursively.
662
#[cfg(feature = "xml")]
663
#[allow(clippy::too_many_lines)] // large but cohesive: single-purpose layout/render/parse routine (one branch per case)
664
/// # Errors
665
///
666
/// Returns an `XmlError` if the XML cannot be parsed.
667
2490
pub fn parse_xml_string(xml: &str) -> Result<Vec<XmlNodeChild>, XmlError> {
668
    use xmlparser::{ElementEnd::{Empty, Close}, Token::{ElementStart, ElementEnd, Attribute, Text}, Tokenizer};
669

            
670
    use self::XmlParseError::*;
671

            
672
    // HTML5-lite parser: List of void elements that should auto-close
673
    // See: https://developer.mozilla.org/en-US/docs/Glossary/Void_element
674
    const VOID_ELEMENTS: &[&str] = &[
675
        "area", "base", "br", "col", "embed", "hr", "img", "input", "link", "meta", "param",
676
        "source", "track", "wbr",
677
    ];
678

            
679
    // HTML5-lite parser: Elements that auto-close when certain other elements are encountered
680
    // Format: (element_name, closes_when_encountering)
681
    const AUTO_CLOSE_RULES: &[(&str, &[&str])] = &[
682
        // List items close when encountering another list item or when parent closes
683
        ("li", &["li"]),
684
        // Table cells/rows have complex closing rules
685
        ("td", &["td", "th", "tr"]),
686
        ("th", &["td", "th", "tr"]),
687
        ("tr", &["tr"]),
688
        // Paragraphs close on block-level elements
689
        (
690
            "p",
691
            &[
692
                "address",
693
                "article",
694
                "aside",
695
                "blockquote",
696
                "div",
697
                "dl",
698
                "fieldset",
699
                "footer",
700
                "form",
701
                "h1",
702
                "h2",
703
                "h3",
704
                "h4",
705
                "h5",
706
                "h6",
707
                "header",
708
                "hr",
709
                "main",
710
                "nav",
711
                "ol",
712
                "p",
713
                "pre",
714
                "section",
715
                "table",
716
                "ul",
717
            ],
718
        ),
719
        // Option closes on another option or optgroup
720
        ("option", &["option", "optgroup"]),
721
        ("optgroup", &["optgroup"]),
722
        // DD/DT close on each other
723
        ("dd", &["dd", "dt"]),
724
        ("dt", &["dd", "dt"]),
725
    ];
726

            
727
2490
    let mut root_node = XmlNode::default();
728

            
729
    // Strip UTF-8 BOM if present (some W3C test files have it)
730
2490
    let xml = xml.strip_prefix('\u{FEFF}').unwrap_or(xml);
731

            
732
    // Search for "<?xml" and "?>" tags and delete them from the XML
733
2490
    let mut xml = xml.trim();
734
2490
    if xml.starts_with("<?") {
735
25
        let pos = xml.find("?>").ok_or(XmlError::MalformedHierarchy(
736
25
            MalformedHierarchyError {
737
25
                expected: "<?xml".into(),
738
25
                got: "?>".into(),
739
25
            },
740
25
        ))?;
741
19
        xml = &xml[(pos + 2)..];
742
2465
    }
743

            
744
    // Delete <!DOCTYPE ...> if necessary (case-insensitive)
745
2484
    let mut xml = xml.trim();
746
2484
    if xml.len() > 9 && xml.is_char_boundary(9) && xml[..9].to_ascii_lowercase().starts_with("<!doctype") {
747
3
        let pos = xml.find('>').ok_or(XmlError::MalformedHierarchy(
748
3
            MalformedHierarchyError {
749
3
                expected: "<!DOCTYPE".into(),
750
3
                got: ">".into(),
751
3
            },
752
3
        ))?;
753
2
        xml = &xml[(pos + 1)..];
754
2481
    } else if xml.starts_with("<!--") {
755
        // Skip HTML comments at the start
756
14
        if let Some(end) = xml.find("-->") {
757
10
            xml = &xml[(end + 3)..];
758
10
            xml = xml.trim();
759
13
        }
760
2467
    }
761

            
762
2483
    let tokenizer = Tokenizer::from_fragment(xml, 0..xml.len());
763

            
764
    // OPTIMIZED: Use a stack of raw pointers to avoid O(n*d) traversal on every token.
765
    // This is safe because:
766
    // 1. All pointers point into `root_node` which is owned and not moved
767
    // 2. We never hold multiple mutable references simultaneously
768
    // 3. The stack is only used within this function
769
2483
    let mut node_stack: Vec<*mut XmlNode> = vec![&raw mut root_node];
770

            
771
    // Track which hierarchy level is a void element (shouldn't be pushed to hierarchy)
772
2483
    let mut last_was_void = false;
773

            
774
340507
    for token in tokenizer {
775
338083
        let token = token.map_err(|e| XmlError::ParserError(translate_xmlparser_error(e)))?;
776
161255
        match token {
777
106916
            ElementStart { local, .. } => {
778
106916
                let tag_name = local.to_string();
779
106916
                let is_void_element = VOID_ELEMENTS.contains(&tag_name.as_str());
780

            
781
                // HTML5-lite: If last element was a void element (like <img src="...">),
782
                // pop it from hierarchy before processing the new element
783
106916
                if last_was_void {
784
4
                    node_stack.pop();
785
4
                    last_was_void = false;
786
106912
                }
787

            
788
                // HTML5-lite: Check if we need to auto-close the current element
789
106916
                if node_stack.len() > 1 {
790
                    // SAFETY: We only access the last element, which is valid
791
104534
                    let current_element = unsafe { &*node_stack[node_stack.len() - 1] };
792
104534
                    let current_tag = current_element.node_type.as_str();
793

            
794
                    // Check if current element should auto-close when encountering this new tag
795
1044889
                    for (element, closes_on) in AUTO_CLOSE_RULES {
796
940420
                        if current_tag == *element && closes_on.contains(&tag_name.as_str()) {
797
                            // Auto-close the current element
798
65
                            node_stack.pop();
799
65
                            break;
800
940355
                        }
801
                    }
802
2382
                }
803

            
804
                // SAFETY: We access the last element which is valid
805
106916
                if let Some(&current_parent_ptr) = node_stack.last() {
806
106916
                    let current_parent = unsafe { &mut *current_parent_ptr };
807
                    
808
106916
                    current_parent.children.push(XmlNodeChild::Element(XmlNode {
809
106916
                        node_type: tag_name.into(),
810
106916
                        attributes: StringPairVec::new().into(),
811
106916
                        children: Vec::new().into(),
812
106916
                    }));
813

            
814
                    // Get pointer to the newly added child
815
106916
                    let children_len = current_parent.children.len();
816
106916
                    if let Some(XmlNodeChild::Element(ref mut new_child)) = current_parent.children.as_mut().get_mut(children_len - 1) {
817
106916
                        node_stack.push(std::ptr::from_mut::<XmlNode>(new_child));
818
106916
                    }
819
                    
820
106916
                    last_was_void = is_void_element;
821
                }
822
            }
823
            ElementEnd { end: Empty, .. } => {
824
                // Pop hierarchy for all elements (including void elements after their attributes)
825
52394
                if node_stack.len() > 1 {
826
52394
                    node_stack.pop();
827
52394
                }
828
52394
                last_was_void = false;
829
            }
830
            ElementEnd {
831
54349
                end: Close(_, close_value),
832
                ..
833
            } => {
834
                // HTML5-lite: If last element was a void element, pop it first
835
54349
                if last_was_void {
836
38
                    node_stack.pop();
837
38
                    last_was_void = false;
838
54311
                }
839

            
840
                // HTML5-lite: Check if this is a void element - if so, ignore the closing tag
841
54349
                let is_void_element = VOID_ELEMENTS.contains(&close_value.as_str());
842
54349
                if is_void_element {
843
                    // Void elements shouldn't have closing tags, but tolerate them
844
38
                    continue;
845
54311
                }
846

            
847
                // HTML5-lite: Auto-close any elements that should be closed
848
                // Walk up the hierarchy and auto-close elements until we find a match
849
54311
                let close_value_str = close_value.as_str();
850

            
851
                // Find matching element in stack (skip root at index 0)
852
54311
                let mut found_idx = None;
853
54356
                for i in (1..node_stack.len()).rev() {
854
                    // SAFETY: All pointers in stack are valid
855
54352
                    let node = unsafe { &*node_stack[i] };
856
54352
                    if node.node_type.as_str() == close_value_str {
857
54299
                        found_idx = Some(i);
858
54299
                        break;
859
53
                    }
860
                }
861

            
862
54311
                if let Some(idx) = found_idx {
863
54299
                    // Pop all elements from current position to the matching element (inclusive)
864
54299
                    node_stack.truncate(idx);
865
54299
                }
866
                // If no match found, just ignore (lenient HTML parsing)
867

            
868
54311
                last_was_void = false;
869
            }
870
14380
            Attribute { local, value, .. } => {
871
                // SAFETY: Last element in stack is valid
872
14380
                if let Some(&last_ptr) = node_stack.last() {
873
14380
                    let last = unsafe { &mut *last_ptr };
874
14380
                    // NOTE: Only lowercase the key ("local"), not the value!
875
14380
                    // Decode XML entities in attribute values as well
876
14380
                    last.attributes.push(azul_core::window::AzStringPair {
877
14380
                        key: local.to_string().into(),
878
14380
                        value: AzString::from(&*decode_xml_entities(value.as_str())),
879
14380
                    });
880
14380
                }
881
            }
882
55473
            Text { text } => {
883
                // HTML5-lite: If last element was a void element, pop it before adding text
884
55473
                if last_was_void {
885
36
                    node_stack.pop();
886
36
                    last_was_void = false;
887
55437
                }
888

            
889
                // IMPORTANT: Preserve ALL text nodes including whitespace-only nodes.
890
                // Whether whitespace is significant depends on the CSS `white-space` property,
891
                // which is determined during layout, not during parsing.
892
                // 
893
                // For example: <pre><span>    </span></pre> must preserve the 4 spaces.
894
                // 
895
                // We only skip completely EMPTY text nodes (zero-length strings).
896
55473
                let text_str = text.as_str();
897

            
898
55473
                if !text_str.is_empty() {
899
                    // SAFETY: Last element in stack is valid
900
55473
                    if let Some(&current_parent_ptr) = node_stack.last() {
901
55473
                        let current_parent = unsafe { &mut *current_parent_ptr };
902
55473
                        // Decode XML entities (e.g., &lt; -> <, &gt; -> >, etc.)
903
55473
                        let decoded_text = decode_xml_entities(text_str);
904
55473
                        // Add text as a child node
905
55473
                        current_parent
906
55473
                            .children
907
55473
                            .push(XmlNodeChild::Text(AzString::from(&*decoded_text)));
908
55473
                    }
909
                }
910
            }
911
54512
            _ => {}
912
        }
913
    }
914

            
915
    // Clean up: if we ended with a void element, pop it
916
2424
    if last_was_void {
917
5
        node_stack.pop();
918
2419
    }
919

            
920
    // A well-formed document unwinds back to just the root sentinel. If an element was
921
    // left open (e.g. a bare "<svg" with no closing bracket, which the fragment tokenizer
922
    // yields as one ElementStart then cleanly ends), node_stack still holds it — reject
923
    // it instead of returning a "valid" partial tree.
924
2424
    if node_stack.len() != 1 {
925
13
        return Err(XmlError::UnclosedRootNode);
926
2411
    }
927

            
928
2411
    Ok(root_node.children.into())
929
2490
}
930

            
931
#[cfg(feature = "xml")]
932
/// # Errors
933
///
934
/// Returns an `XmlError` if the XML cannot be parsed.
935
5
pub fn parse_xml(s: &str) -> Result<Xml, XmlError> {
936
    Ok(Xml {
937
5
        root: parse_xml_string(s)?.into(),
938
    })
939
5
}
940

            
941
#[cfg(not(feature = "xml"))]
942
pub fn parse_xml(s: &str) -> Result<Xml, XmlError> {
943
    Err(XmlError::NoParserAvailable)
944
}
945

            
946
// to_string(&self) -> String
947

            
948
#[cfg(feature = "xml")]
949
7
#[must_use] pub fn translate_roxmltree_expandedname(
950
7
    e: roxmltree::ExpandedName<'_, '_>,
951
7
) -> XmlQualifiedName {
952
7
    let ns: Option<AzString> = e.namespace().map(|e| e.to_string().into());
953
7
    XmlQualifiedName {
954
7
        local_name: e.name().to_string().into(),
955
7
        namespace: ns.into(),
956
7
    }
957
7
}
958

            
959
#[cfg(feature = "xml")]
960
2
fn translate_roxmltree_attribute(e: roxmltree::Attribute<'_, '_>) -> XmlQualifiedName {
961
    XmlQualifiedName {
962
2
        local_name: e.name().to_string().into(),
963
2
        namespace: e.namespace().map(|e| e.to_string().into()).into(),
964
    }
965
2
}
966

            
967
#[cfg(feature = "xml")]
968
117
fn translate_xmlparser_streamerror(e: xmlparser::StreamError) -> XmlStreamError {
969
117
    match e {
970
8
        xmlparser::StreamError::UnexpectedEndOfStream => XmlStreamError::UnexpectedEndOfStream,
971
59
        xmlparser::StreamError::InvalidName => XmlStreamError::InvalidName,
972
1
        xmlparser::StreamError::InvalidReference => XmlStreamError::InvalidReference,
973
1
        xmlparser::StreamError::InvalidExternalID => XmlStreamError::InvalidExternalID,
974
1
        xmlparser::StreamError::InvalidCommentData => XmlStreamError::InvalidCommentData,
975
1
        xmlparser::StreamError::InvalidCommentEnd => XmlStreamError::InvalidCommentEnd,
976
8
        xmlparser::StreamError::InvalidCharacterData => XmlStreamError::InvalidCharacterData,
977
11
        xmlparser::StreamError::NonXmlChar(c, tp) => XmlStreamError::NonXmlChar(NonXmlCharError {
978
11
            ch: c.into(),
979
11
            pos: translate_xmlparser_textpos(tp),
980
11
        }),
981
1
        xmlparser::StreamError::InvalidChar(a, b, tp) => {
982
1
            XmlStreamError::InvalidChar(InvalidCharError {
983
1
                expected: a,
984
1
                got: b,
985
1
                pos: translate_xmlparser_textpos(tp),
986
1
            })
987
        }
988
1
        xmlparser::StreamError::InvalidCharMultiple(a, b, tp) => {
989
1
            XmlStreamError::InvalidCharMultiple(InvalidCharMultipleError {
990
1
                expected: a,
991
1
                got: b.to_vec().into(),
992
1
                pos: translate_xmlparser_textpos(tp),
993
1
            })
994
        }
995
8
        xmlparser::StreamError::InvalidQuote(a, tp) => {
996
8
            XmlStreamError::InvalidQuote(InvalidQuoteError {
997
8
                got: a,
998
8
                pos: translate_xmlparser_textpos(tp),
999
8
            })
        }
1
        xmlparser::StreamError::InvalidSpace(a, tp) => {
1
            XmlStreamError::InvalidSpace(InvalidSpaceError {
1
                got: a,
1
                pos: translate_xmlparser_textpos(tp),
1
            })
        }
16
        xmlparser::StreamError::InvalidString(a, tp) => {
16
            XmlStreamError::InvalidString(InvalidStringError {
16
                got: a.to_string().into(),
16
                pos: translate_xmlparser_textpos(tp),
16
            })
        }
    }
117
}
#[cfg(feature = "xml")]
113
fn translate_xmlparser_error(e: xmlparser::Error) -> XmlParseError {
113
    match e {
1
        xmlparser::Error::InvalidDeclaration(se, tp) => {
1
            XmlParseError::InvalidDeclaration(XmlTextError {
1
                stream_error: translate_xmlparser_streamerror(se),
1
                pos: translate_xmlparser_textpos(tp),
1
            })
        }
9
        xmlparser::Error::InvalidComment(se, tp) => XmlParseError::InvalidComment(XmlTextError {
9
            stream_error: translate_xmlparser_streamerror(se),
9
            pos: translate_xmlparser_textpos(tp),
9
        }),
5
        xmlparser::Error::InvalidPI(se, tp) => XmlParseError::InvalidPI(XmlTextError {
5
            stream_error: translate_xmlparser_streamerror(se),
5
            pos: translate_xmlparser_textpos(tp),
5
        }),
1
        xmlparser::Error::InvalidDoctype(se, tp) => XmlParseError::InvalidDoctype(XmlTextError {
1
            stream_error: translate_xmlparser_streamerror(se),
1
            pos: translate_xmlparser_textpos(tp),
1
        }),
1
        xmlparser::Error::InvalidEntity(se, tp) => XmlParseError::InvalidEntity(XmlTextError {
1
            stream_error: translate_xmlparser_streamerror(se),
1
            pos: translate_xmlparser_textpos(tp),
1
        }),
52
        xmlparser::Error::InvalidElement(se, tp) => XmlParseError::InvalidElement(XmlTextError {
52
            stream_error: translate_xmlparser_streamerror(se),
52
            pos: translate_xmlparser_textpos(tp),
52
        }),
9
        xmlparser::Error::InvalidAttribute(se, tp) => {
9
            XmlParseError::InvalidAttribute(XmlTextError {
9
                stream_error: translate_xmlparser_streamerror(se),
9
                pos: translate_xmlparser_textpos(tp),
9
            })
        }
8
        xmlparser::Error::InvalidCdata(se, tp) => XmlParseError::InvalidCdata(XmlTextError {
8
            stream_error: translate_xmlparser_streamerror(se),
8
            pos: translate_xmlparser_textpos(tp),
8
        }),
18
        xmlparser::Error::InvalidCharData(se, tp) => XmlParseError::InvalidCharData(XmlTextError {
18
            stream_error: translate_xmlparser_streamerror(se),
18
            pos: translate_xmlparser_textpos(tp),
18
        }),
9
        xmlparser::Error::UnknownToken(tp) => {
9
            XmlParseError::UnknownToken(translate_xmlparser_textpos(tp))
        }
    }
113
}
#[cfg(feature = "xml")]
31
#[must_use] pub fn translate_roxmltree_error(e: roxmltree::Error) -> XmlError {
31
    match e {
1
        roxmltree::Error::InvalidXmlPrefixUri(s) => {
1
            XmlError::InvalidXmlPrefixUri(translate_roxml_textpos(s))
        }
1
        roxmltree::Error::UnexpectedXmlUri(s) => {
1
            XmlError::UnexpectedXmlUri(translate_roxml_textpos(s))
        }
1
        roxmltree::Error::UnexpectedXmlnsUri(s) => {
1
            XmlError::UnexpectedXmlnsUri(translate_roxml_textpos(s))
        }
1
        roxmltree::Error::InvalidElementNamePrefix(s) => {
1
            XmlError::InvalidElementNamePrefix(translate_roxml_textpos(s))
        }
1
        roxmltree::Error::DuplicatedNamespace(s, tp) => {
1
            XmlError::DuplicatedNamespace(DuplicatedNamespaceError {
1
                ns: s.into(),
1
                pos: translate_roxml_textpos(tp),
1
            })
        }
1
        roxmltree::Error::UnknownNamespace(s, tp) => {
1
            XmlError::UnknownNamespace(UnknownNamespaceError {
1
                ns: s.into(),
1
                pos: translate_roxml_textpos(tp),
1
            })
        }
1
        roxmltree::Error::UnexpectedCloseTag(expected, actual, pos) => {
1
            XmlError::UnexpectedCloseTag(UnexpectedCloseTagError {
1
                expected: expected.into(),
1
                actual: actual.into(),
1
                pos: translate_roxml_textpos(pos),
1
            })
        }
1
        roxmltree::Error::UnexpectedEntityCloseTag(s) => {
1
            XmlError::UnexpectedEntityCloseTag(translate_roxml_textpos(s))
        }
1
        roxmltree::Error::UnknownEntityReference(s, tp) => {
1
            XmlError::UnknownEntityReference(UnknownEntityReferenceError {
1
                entity: s.into(),
1
                pos: translate_roxml_textpos(tp),
1
            })
        }
1
        roxmltree::Error::MalformedEntityReference(s) => {
1
            XmlError::MalformedEntityReference(translate_roxml_textpos(s))
        }
1
        roxmltree::Error::EntityReferenceLoop(s) => {
1
            XmlError::EntityReferenceLoop(translate_roxml_textpos(s))
        }
1
        roxmltree::Error::InvalidAttributeValue(s) => {
1
            XmlError::InvalidAttributeValue(translate_roxml_textpos(s))
        }
1
        roxmltree::Error::DuplicatedAttribute(s, tp) => {
1
            XmlError::DuplicatedAttribute(DuplicatedAttributeError {
1
                attribute: s.into(),
1
                pos: translate_roxml_textpos(tp),
1
            })
        }
1
        roxmltree::Error::NoRootNode => XmlError::NoRootNode,
1
        roxmltree::Error::DtdDetected => XmlError::DtdDetected,
1
        roxmltree::Error::UnclosedRootNode => XmlError::UnclosedRootNode,
1
        roxmltree::Error::UnexpectedDeclaration(tp) => {
1
            XmlError::UnexpectedDeclaration(translate_roxml_textpos(tp))
        }
1
        roxmltree::Error::NodesLimitReached => XmlError::NodesLimitReached,
1
        roxmltree::Error::AttributesLimitReached => XmlError::AttributesLimitReached,
1
        roxmltree::Error::NamespacesLimitReached => XmlError::NamespacesLimitReached,
1
        roxmltree::Error::InvalidName(tp) => XmlError::InvalidName(translate_roxml_textpos(tp)),
1
        roxmltree::Error::NonXmlChar(_, tp) => XmlError::NonXmlChar(translate_roxml_textpos(tp)),
1
        roxmltree::Error::InvalidChar(_, _, tp) => {
1
            XmlError::InvalidChar(translate_roxml_textpos(tp))
        }
1
        roxmltree::Error::InvalidChar2(_, _, tp) => {
1
            XmlError::InvalidChar2(translate_roxml_textpos(tp))
        }
1
        roxmltree::Error::InvalidString(_, tp) => {
1
            XmlError::InvalidString(translate_roxml_textpos(tp))
        }
1
        roxmltree::Error::InvalidExternalID(tp) => {
1
            XmlError::InvalidExternalID(translate_roxml_textpos(tp))
        }
1
        roxmltree::Error::InvalidComment(tp) => {
1
            XmlError::InvalidComment(translate_roxml_textpos(tp))
        }
1
        roxmltree::Error::InvalidCharacterData(tp) => {
1
            XmlError::InvalidCharacterData(translate_roxml_textpos(tp))
        }
1
        roxmltree::Error::UnknownToken(tp) => XmlError::UnknownToken(translate_roxml_textpos(tp)),
1
        roxmltree::Error::UnexpectedEndOfStream => XmlError::UnexpectedEndOfStream,
1
        roxmltree::Error::EntityResolver(tp, s) => {
            // New in roxmltree 0.21: EntityResolver error variant
            // For now, treat as a generic entity reference error
1
            XmlError::UnknownEntityReference(UnknownEntityReferenceError {
1
                entity: s.into(),
1
                pos: translate_roxml_textpos(tp),
1
            })
        }
    }
31
}
#[cfg(feature = "xml")]
#[inline]
156
const fn translate_xmlparser_textpos(o: xmlparser::TextPos) -> XmlTextPos {
156
    XmlTextPos {
156
        row: o.row,
156
        col: o.col,
156
    }
156
}
#[cfg(feature = "xml")]
#[inline]
29
const fn translate_roxml_textpos(o: roxmltree::TextPos) -> XmlTextPos {
29
    XmlTextPos {
29
        row: o.row,
29
        col: o.col,
29
    }
29
}
/// Extension trait to add XML parsing capabilities to Dom
///
/// This trait provides methods to parse XML/XHTML strings and convert them
/// into Azul DOM trees. It's implemented as a trait to avoid circular dependencies
/// between azul-core and azul-layout.
#[cfg(feature = "xml")]
pub trait DomXmlExt {
    /// Parse XML/XHTML string into a DOM tree
    ///
    /// This method parses the XML string and converts it to an Azul `StyledDom`.
    /// On error, it returns a `StyledDom` displaying the error message.
    ///
    /// # Arguments
    /// * `xml` - The XML/XHTML string to parse
    ///
    /// # Returns
    /// A `StyledDom` tree representing the parsed XML, or an error DOM on parse failure
    fn from_xml_string<S: AsRef<str>>(xml: S) -> StyledDom;
}
#[cfg(feature = "xml")]
impl DomXmlExt for Dom {
233
    fn from_xml_string<S: AsRef<str>>(xml: S) -> StyledDom {
233
        let component_map = ComponentMap::with_builtin();
233
        let dom_xml = domxml_from_str(xml.as_ref(), &component_map);
233
        dom_xml.parsed_dom
233
    }
}
// ============================================================================
// Adversarial unit tests (autotest). Inline so the private helpers
// (`decode_xml_entities*`, `parse_xml_to_fast_dom_with_css`, `peak_rss_bytes`,
// `translate_*`) are reachable.
// ============================================================================
#[cfg(test)]
mod autotest_generated {
    use azul_core::dom::{FastDom, NodeData, NodeType, TabIndex};
    use super::*;
    // ------------------------------------------------------------------
    // helpers
    // ------------------------------------------------------------------
    /// Element children of an `XmlNodeChild` slice (skips text nodes).
    #[cfg(feature = "xml")]
    fn elements(children: &[XmlNodeChild]) -> Vec<&XmlNode> {
        children
            .iter()
            .filter_map(XmlNodeChild::as_element)
            .collect()
    }
    /// Text children of an `XmlNodeChild` slice (skips element nodes).
    #[cfg(feature = "xml")]
    fn texts(children: &[XmlNodeChild]) -> Vec<&str> {
        children.iter().filter_map(XmlNodeChild::as_text).collect()
    }
    /// `<html><body>…</body></html>` around `body`.
    ///
    /// Every fixture goes through this so the document's first 9 bytes are
    /// ASCII: `parse_xml*` slices `xml[..9]` for the DOCTYPE sniff without a
    /// char-boundary check (see
    /// `parse_entrypoints_do_not_panic_on_short_multibyte_input`).
    fn doc(body: &str) -> String {
        format!("<html><body>{body}</body></html>")
    }
    /// Flat node arena of a `FastDom`.
    fn nodes(dom: &FastDom) -> &[NodeData] {
        dom.node_data.as_ref()
    }
    /// Text content of a `NodeType::Text` node (`None` for every other kind).
    fn text_of(nd: &NodeData) -> Option<String> {
        match nd.get_node_type() {
            NodeType::Text(_) => nd.get_node_type().format(),
            _ => None,
        }
    }
    /// Minimal XML escaper — the inverse of `decode_xml_entities`.
    #[cfg(feature = "xml")]
    fn escape(s: &str) -> String {
        let mut out = String::with_capacity(s.len());
        for c in s.chars() {
            match c {
                '&' => out.push_str("&amp;"),
                '<' => out.push_str("&lt;"),
                '>' => out.push_str("&gt;"),
                '"' => out.push_str("&quot;"),
                '\'' => out.push_str("&apos;"),
                _ => out.push(c),
            }
        }
        out
    }
    /// Non-grammar / hostile fragments. All ASCII on purpose so they exercise
    /// the tokenizer rather than the `xml[..9]` slice.
    const GARBAGE: &[&str] = &[
        "<<<<>>>>",
        "!!!not xml at all!!!",
        "<a b=c>",
        "</>",
        "</div>",
        "<a></a",
        "&&&&&&&&&&&&",
        "]]>",
        "<!--",
        "<![CDATA[",
        "<?",
        "<!DOCTYPE",
        "\u{0}\u{1}\u{2}",
        "<a><<a><<<a>",
        "= = = = = = = = = =",
    ];
    // ------------------------------------------------------------------
    // decode_xml_entities / decode_xml_entities_slow
    // ------------------------------------------------------------------
    #[test]
    fn decode_xml_entities_borrows_when_there_is_no_ampersand() {
        for s in ["", "hello", "  ", "日本語 🙂", "<tag/>", "a;b;c;"] {
            assert!(
                matches!(decode_xml_entities(s), std::borrow::Cow::Borrowed(_)),
                "{s:?} has no '&' and must take the zero-alloc path"
            );
            assert_eq!(&*decode_xml_entities(s), s);
        }
    }
    #[test]
    fn decode_xml_entities_decodes_the_five_named_entities_and_nbsp() {
        assert_eq!(&*decode_xml_entities("&lt;"), "<");
        assert_eq!(&*decode_xml_entities("&gt;"), ">");
        assert_eq!(&*decode_xml_entities("&amp;"), "&");
        assert_eq!(&*decode_xml_entities("&apos;"), "'");
        assert_eq!(&*decode_xml_entities("&quot;"), "\"");
        assert_eq!(&*decode_xml_entities("&nbsp;"), "\u{00A0}");
        assert_eq!(
            &*decode_xml_entities("a&lt;b&gt;c&amp;d&quot;e&apos;f"),
            "a<b>c&d\"e'f"
        );
    }
    #[test]
    fn decode_xml_entities_decodes_numeric_references() {
        // decimal, lowercase hex, uppercase hex marker
        assert_eq!(&*decode_xml_entities("&#60;"), "<");
        assert_eq!(&*decode_xml_entities("&#x3C;"), "<");
        assert_eq!(&*decode_xml_entities("&#X3c;"), "<");
        assert_eq!(&*decode_xml_entities("&#65;"), "A");
        // boundary code points: NUL, BMP max, astral, and the last legal scalar
        assert_eq!(&*decode_xml_entities("&#0;"), "\u{0}");
        assert_eq!(&*decode_xml_entities("&#xFFFF;"), "\u{FFFF}");
        assert_eq!(&*decode_xml_entities("&#65536;"), "\u{10000}");
        assert_eq!(&*decode_xml_entities("&#1114111;"), "\u{10FFFF}");
        assert_eq!(&*decode_xml_entities("&#x10FFFF;"), "\u{10FFFF}");
        // combining marks survive
        assert_eq!(&*decode_xml_entities("e&#x301;"), "e\u{301}");
    }
    #[test]
    fn decode_xml_entities_keeps_out_of_range_and_surrogate_code_points_verbatim() {
        // Every one of these must round-trip to itself: no panic, no
        // replacement char, no silent truncation to a wrong scalar.
        for s in [
            "&#xD800;",      // lone high surrogate
            "&#xDFFF;",      // lone low surrogate
            "&#55296;",      // decimal surrogate
            "&#x110000;",    // one past the last scalar
            "&#1114112;",    // decimal, one past the last scalar
            "&#123456789;",  // entity name is exactly 10 bytes (the length cap)
            "&#4294967296;", // u32::MAX + 1
            "&#99999999999999;",
            "&#x;",
            "&#;",
            "&#xZZ;",
            "&#-1;",
        ] {
            assert_eq!(
                &*decode_xml_entities(s),
                s,
                "{s:?} is not a decodable reference and must be preserved byte-for-byte"
            );
        }
    }
    #[test]
    fn decode_xml_entities_keeps_unterminated_and_unknown_entities_verbatim() {
        for s in [
            "&",
            "&&",
            "&lt",
            "&#",
            "&#x",
            "&foo;",
            "&LT;", // entity table is case-sensitive
            "&Amp;",
            "& lt;",
            "a & b",
            "&;",
        ] {
            assert_eq!(&*decode_xml_entities(s), s, "{s:?} must be preserved");
        }
        // Trailing garbage after a valid entity is still emitted.
        assert_eq!(&*decode_xml_entities("&lt;&"), "<&");
    }
    #[test]
    fn decode_xml_entities_does_not_double_decode() {
        // A single pass only. `&amp;lt;` is the escaped form of the literal
        // text `&lt;` and must NOT collapse to `<` — that would be an
        // injection vector for anything that escapes user text once.
        assert_eq!(&*decode_xml_entities("&amp;lt;"), "&lt;");
        assert_eq!(&*decode_xml_entities("&amp;amp;"), "&amp;");
        assert_eq!(&*decode_xml_entities("&amp;#60;"), "&#60;");
    }
    #[test]
    fn decode_xml_entities_handles_pathological_input_without_panicking() {
        // Entity name far past the 10-byte cap: bails out and preserves input.
        let long_name = format!("&{};", "a".repeat(10_000));
        assert_eq!(&*decode_xml_entities(&long_name), long_name);
        // Unterminated '&' followed by a megabyte of text.
        let long_tail = format!("&{}", "x".repeat(1_000_000));
        assert_eq!(decode_xml_entities(&long_tail).len(), long_tail.len());
        // Multibyte / astral / combining input mixed with entities. The entity
        // scanner uses `char::is_alphanumeric`, so multibyte chars can land in
        // the accumulator — slicing must stay on char boundaries.
        for s in [
            "&\u{1F600}\u{1F600};",
            "&½;",
            "&日本;",
            "&#\u{1F600};",
            "&e\u{301};",
            "🙂&amp;🙂",
        ] {
            let out = decode_xml_entities(s);
            assert!(
                !out.is_empty(),
                "{s:?} decoded to nothing (input was non-empty)"
            );
        }
        // Alternating entities at scale must not go quadratic-and-panic.
        let many = "&lt;".repeat(50_000);
        assert_eq!(decode_xml_entities(&many).chars().count(), 50_000);
    }
    #[test]
    fn decode_xml_entities_slow_matches_the_fast_path() {
        // The fast path is only a `contains('&')` short-circuit: for '&'-free
        // input the slow path must be the identity, and for everything else
        // the two must agree exactly.
        for s in [
            "",
            "plain",
            "日本語 🙂",
            "&lt;",
            "&amp;lt;",
            "&#x1F600;",
            "&unknown;",
            "&",
        ] {
            assert_eq!(
                &*decode_xml_entities(s),
                &*decode_xml_entities_slow(s),
                "fast/slow path disagree on {s:?}"
            );
        }
        for s in ["", "plain", "日本語 🙂", "a;b", "<>"] {
            assert_eq!(&*decode_xml_entities_slow(s), s);
        }
    }
    // ------------------------------------------------------------------
    // KNOWN BUG: unchecked `xml[..9]` slice in the DOCTYPE sniff
    // ------------------------------------------------------------------
    /// `parse_xml_string` (line ~737) and `parse_xml_to_fast_dom_with_css`
    /// (line ~328) both do
    ///
    /// ```ignore
    /// if xml.len() > 9 && xml[..9].to_ascii_lowercase().starts_with("<!doctype")
    /// ```
    ///
    /// `&str[..9]` panics when byte 9 is not a UTF-8 char boundary, so any
    /// input longer than 9 bytes whose third-or-so character is multibyte
    /// aborts the parse with `byte index 9 is not a char boundary` instead of
    /// returning `Err`. `domxml_from_str`, which documents that it "deliberately
    /// never fails", inherits the panic.
    ///
    /// The fix belongs in the source (`xml.is_char_boundary(9)` guard, or
    /// `xml.get(..9)`), so this test asserts the correct invariant and is
    /// expected to be RED until that lands.
    #[cfg(feature = "xml")]
    #[test]
    fn parse_entrypoints_do_not_panic_on_short_multibyte_input() {
        // 3 x 4-byte emoji = 12 bytes; boundaries are 0/4/8/12, so 9 is inside
        // the third character.
        const INPUT: &str = "😀😀😀";
        assert!(INPUT.len() > 9 && !INPUT.is_char_boundary(9));
        let a = std::panic::catch_unwind(|| parse_xml_string(INPUT).is_ok());
        let b = std::panic::catch_unwind(|| parse_xml_to_fast_dom(INPUT).is_ok());
        assert!(
            a.is_ok(),
            "parse_xml_string panicked on {INPUT:?}: the DOCTYPE sniff slices \
             xml[..9] without an is_char_boundary check"
        );
        assert!(
            b.is_ok(),
            "parse_xml_to_fast_dom panicked on {INPUT:?}: same unchecked \
             xml[..9] slice"
        );
    }
    // ------------------------------------------------------------------
    // parse_xml_string
    // ------------------------------------------------------------------
    #[cfg(feature = "xml")]
    #[test]
    fn parse_xml_string_accepts_empty_and_whitespace_only_input() {
        for s in ["", " ", "   ", "\t\n", "\r\n\r\n", "\u{FEFF}", "\u{FEFF}   "] {
            let parsed = parse_xml_string(s)
                .unwrap_or_else(|e| panic!("{s:?} should parse to an empty tree, got {e}"));
            assert!(parsed.is_empty(), "{s:?} produced {} roots", parsed.len());
        }
    }
    #[cfg(feature = "xml")]
    #[test]
    fn parse_xml_string_parses_a_minimal_document() {
        let parsed = parse_xml_string(&doc("<div>hi</div>")).expect("valid document");
        let roots = elements(&parsed);
        assert_eq!(roots.len(), 1);
        assert_eq!(roots[0].node_type.as_str(), "html");
        let body = elements(roots[0].children.as_ref());
        assert_eq!(body.len(), 1);
        assert_eq!(body[0].node_type.as_str(), "body");
        let div = elements(body[0].children.as_ref());
        assert_eq!(div.len(), 1);
        assert_eq!(div[0].node_type.as_str(), "div");
        assert_eq!(texts(div[0].children.as_ref()), vec!["hi"]);
    }
    #[cfg(feature = "xml")]
    #[test]
    fn parse_xml_string_rejects_unclosed_elements() {
        // A well-formed document unwinds to the root sentinel; anything left
        // open must be an error rather than a silently-truncated tree.
        assert!(matches!(
            parse_xml_string("<div>"),
            Err(XmlError::UnclosedRootNode)
        ));
        assert!(matches!(
            parse_xml_string("<html><body><div>"),
            Err(XmlError::UnclosedRootNode)
        ));
        assert!(
            parse_xml_string("<html><body><div>text").is_err(),
            "an unclosed element must not yield a partial 'valid' tree"
        );
    }
    #[cfg(feature = "xml")]
    #[test]
    fn parse_xml_string_rejects_truncated_declaration_and_doctype() {
        assert!(matches!(
            parse_xml_string("<?xml version=\"1.0\""),
            Err(XmlError::MalformedHierarchy(_))
        ));
        assert!(matches!(
            parse_xml_string("<!DOCTYPE html PUBLIC \"x\""),
            Err(XmlError::MalformedHierarchy(_))
        ));
        // ...but the complete forms are stripped and the rest parses.
        for prefix in [
            "<?xml version=\"1.0\"?>",
            "<!DOCTYPE html>",
            "<!doctype HTML>",
            "<!-- leading comment -->",
            "\u{FEFF}",
        ] {
            let src = format!("{prefix}{}", doc("<div/>"));
            let parsed = parse_xml_string(&src)
                .unwrap_or_else(|e| panic!("{prefix:?} prefix should be stripped, got {e}"));
            let roots = elements(&parsed);
            assert_eq!(roots.len(), 1, "{prefix:?} -> {roots:?}");
            assert_eq!(roots[0].node_type.as_str(), "html");
        }
    }
    #[cfg(feature = "xml")]
    #[test]
    fn parse_xml_string_is_deterministic_on_garbage() {
        for g in GARBAGE {
            // The contract is "Err or a tree", never a panic and never a
            // different answer for the same bytes.
            let a = parse_xml_string(g);
            let b = parse_xml_string(g);
            assert_eq!(a.is_ok(), b.is_ok(), "{g:?} parsed non-deterministically");
            assert_eq!(a.ok(), b.ok(), "{g:?} produced two different trees");
        }
    }
    #[cfg(feature = "xml")]
    #[test]
    fn parse_xml_string_trims_leading_and_trailing_whitespace() {
        let padded = format!("  \t\n{}\n\t  ", doc("<div/>"));
        let a = parse_xml_string(&padded).expect("padded document");
        let b = parse_xml_string(&doc("<div/>")).expect("bare document");
        assert_eq!(a, b, "surrounding whitespace must not change the tree");
    }
    #[cfg(feature = "xml")]
    #[test]
    fn parse_xml_string_keeps_trailing_junk_as_text() {
        // Lenient HTML-ish parsing: trailing junk becomes a text node at the
        // root rather than an error or a dropped document.
        let parsed = parse_xml_string(&format!("{};garbage", doc("<div/>"))).expect("lenient parse");
        assert_eq!(elements(&parsed).len(), 1);
        assert_eq!(texts(&parsed), vec![";garbage"]);
    }
    #[cfg(feature = "xml")]
    #[test]
    fn parse_xml_string_round_trips_escaped_text() {
        for raw in [
            "a",
            "<b>bold</b> & \"quotes\" 'apos'",
            "&&&&",
            "  spaced  ",
            "日本語 🙂 combining e\u{301}",
            "1 < 2 > 0 && true",
        ] {
            let src = doc(&escape(raw));
            let parsed = parse_xml_string(&src)
                .unwrap_or_else(|e| panic!("{src:?} should parse, got {e}"));
            let html = elements(&parsed);
            let body = elements(html[0].children.as_ref());
            assert_eq!(
                texts(body[0].children.as_ref()),
                vec![raw],
                "escape -> parse must be the identity for {raw:?}"
            );
        }
    }
    #[cfg(feature = "xml")]
    #[test]
    fn parse_xml_string_decodes_attribute_entities() {
        let parsed = parse_xml_string(&doc(
            r#"<div t="&amp;&lt;&gt;&quot;&apos;x" u="&nosuch;" v="&#x1F600;"></div>"#,
        ))
        .expect("valid document");
        let html = elements(&parsed);
        let body = elements(html[0].children.as_ref());
        let div = elements(body[0].children.as_ref());
        let attrs = &div[0].attributes;
        assert_eq!(attrs.get_key("t").map(AzString::as_str), Some("&<>\"'x"));
        assert_eq!(attrs.get_key("u").map(AzString::as_str), Some("&nosuch;"));
        assert_eq!(attrs.get_key("v").map(AzString::as_str), Some("😀"));
    }
    #[cfg(feature = "xml")]
    #[test]
    fn parse_xml_string_tolerates_extra_and_mismatched_close_tags() {
        let nested = doc("<div></span></div>");
        let paragraphs = doc("<p>one<p>two");
        for src in [
            "<a></a></a>",
            "<a></a></b>",
            nested.as_str(),
            paragraphs.as_str(),
            "<br></br>",
            "<br>",
            "<br><br><br>",
        ] {
            let a = parse_xml_string(src);
            let b = parse_xml_string(src);
            assert_eq!(a.is_ok(), b.is_ok(), "{src:?} is non-deterministic");
            assert_eq!(a.ok(), b.ok(), "{src:?} produced two different trees");
        }
        // A bare void element is a complete document (auto-closed at EOF).
        let parsed = parse_xml_string("<br>").expect("bare void element");
        assert_eq!(elements(&parsed).len(), 1);
        assert_eq!(elements(&parsed)[0].node_type.as_str(), "br");
    }
    #[cfg(feature = "xml")]
    #[test]
    fn parse_xml_string_handles_deep_nesting_without_stack_overflow() {
        // Building is iterative, but dropping the resulting `XmlNode` tree is
        // recursive, so this pins the depth the *whole* lifecycle survives.
        // (The arena path is exercised at 10k in
        // `parse_xml_to_fast_dom_handles_ten_thousand_nested_elements`.)
        const DEPTH: usize = 1_000;
        let mut src = String::with_capacity(DEPTH * 12);
        for _ in 0..DEPTH {
            src.push_str("<a>");
        }
        for _ in 0..DEPTH {
            src.push_str("</a>");
        }
        let parsed = parse_xml_string(&src).expect("balanced nesting is valid");
        let mut depth = 0_usize;
        {
            let mut cursor: Vec<&XmlNode> = elements(&parsed);
            while !cursor.is_empty() {
                depth += 1;
                let node: &XmlNode = cursor[0];
                cursor = elements(node.children.as_ref());
            }
        }
        assert_eq!(depth, DEPTH, "every nesting level must be preserved");
        // Dropping the tree is the recursive half of the lifecycle — a deeper
        // tree would blow the stack here, not during the (iterative) parse.
        drop(parsed);
    }
    #[cfg(feature = "xml")]
    #[test]
    fn parse_xml_string_handles_a_one_million_char_text_node() {
        let payload = "x".repeat(1_000_000);
        let parsed = parse_xml_string(&doc(&payload)).expect("long text is valid");
        let html = elements(&parsed);
        let body = elements(html[0].children.as_ref());
        let t = texts(body[0].children.as_ref());
        assert_eq!(t.len(), 1);
        assert_eq!(t[0].len(), 1_000_000);
    }
    #[cfg(feature = "xml")]
    #[test]
    fn parse_xml_string_handles_many_sibling_elements() {
        const N: usize = 2_000;
        let parsed = parse_xml_string(&doc(&"<i>x</i>".repeat(N))).expect("wide tree is valid");
        let html = elements(&parsed);
        let body = elements(html[0].children.as_ref());
        assert_eq!(elements(body[0].children.as_ref()).len(), N);
    }
    // ------------------------------------------------------------------
    // parse_xml
    // ------------------------------------------------------------------
    #[cfg(feature = "xml")]
    #[test]
    fn parse_xml_agrees_with_parse_xml_string() {
        let one = doc("<div>hi</div>");
        let two = doc("<i/><i/>");
        for src in ["", "   ", one.as_str(), two.as_str()] {
            let via_xml = parse_xml(src).expect("valid");
            let via_string = parse_xml_string(src).expect("valid");
            assert_eq!(
                via_xml.root.as_ref(),
                via_string.as_slice(),
                "parse_xml must be a thin wrapper over parse_xml_string for {src:?}"
            );
        }
        assert!(parse_xml("<div>").is_err());
    }
    #[cfg(not(feature = "xml"))]
    #[test]
    fn parse_xml_without_the_xml_feature_reports_no_parser() {
        for s in ["", "   ", "<div/>", "garbage"] {
            assert!(matches!(parse_xml(s), Err(XmlError::NoParserAvailable)));
        }
    }
    // ------------------------------------------------------------------
    // parse_xml_to_fast_dom / parse_xml_to_fast_dom_with_css
    // ------------------------------------------------------------------
    #[test]
    fn parse_xml_to_fast_dom_accepts_empty_and_whitespace_only_input() {
        for s in ["", " ", "   ", "\t\n", "\u{FEFF}", "\u{FEFF}  \n "] {
            let dom = parse_xml_to_fast_dom(s)
                .unwrap_or_else(|e| panic!("{s:?} should yield an empty arena, got {e}"));
            assert!(nodes(&dom).is_empty(), "{s:?} produced {} nodes", nodes(&dom).len());
            assert_eq!(dom.node_hierarchy.as_ref().len(), nodes(&dom).len());
        }
    }
    #[test]
    fn parse_xml_to_fast_dom_builds_the_expected_arena() {
        let dom = parse_xml_to_fast_dom(&doc("<div>hi</div>")).expect("valid document");
        let n = nodes(&dom);
        assert_eq!(n.len(), 4, "html + body + div + text");
        assert_eq!(
            dom.node_hierarchy.as_ref().len(),
            n.len(),
            "hierarchy and node_data arenas must stay parallel"
        );
        assert!(matches!(n[0].get_node_type(), NodeType::Html));
        assert!(matches!(n[1].get_node_type(), NodeType::Body));
        assert!(matches!(n[2].get_node_type(), NodeType::Div));
        assert_eq!(text_of(&n[3]).as_deref(), Some("hi"));
    }
    #[test]
    fn parse_xml_to_fast_dom_lowercases_tag_names() {
        let dom = parse_xml_to_fast_dom("<HTML><BODY><DiV/></BODY></HTML>").expect("valid");
        let n = nodes(&dom);
        assert_eq!(n.len(), 3);
        assert!(matches!(n[0].get_node_type(), NodeType::Html));
        assert!(matches!(n[1].get_node_type(), NodeType::Body));
        assert!(matches!(n[2].get_node_type(), NodeType::Div));
    }
    #[test]
    fn parse_xml_to_fast_dom_skips_head_but_collects_style_css() {
        let src = "<html><head><title>T</title>\
                   <style>div { width: 10px; }</style></head>\
                   <body>x</body></html>";
        let (dom, css) = parse_xml_to_fast_dom_with_css(src).expect("valid document");
        let n = nodes(&dom);
        assert_eq!(n.len(), 3, "html + body + text; <head> subtree is dropped");
        assert!(
            !n.iter()
                .any(|nd| matches!(nd.get_node_type(), NodeType::Head | NodeType::Title)),
            "no <head>/<title> node may reach the arena"
        );
        assert_eq!(text_of(&n[2]).as_deref(), Some("x"));
        assert_eq!(css.len(), 1, "the <style> body must still be collected");
        assert!(!css[0].rules.as_ref().is_empty(), "the CSS must have parsed");
    }
    #[test]
    fn parse_xml_to_fast_dom_splits_ids_and_classes_on_whitespace() {
        let dom = parse_xml_to_fast_dom(&doc(r#"<div id="a b" class="c  d
        e"></div>"#))
        .expect("valid document");
        let div = &nodes(&dom)[2];
        assert!(div.has_id("a") && div.has_id("b"));
        assert!(div.has_class("c") && div.has_class("d") && div.has_class("e"));
        assert!(!div.has_id("a b"), "the raw joined value must not survive");
        assert_eq!(div.get_ids_and_classes().as_ref().len(), 5);
    }
    /// Reads the tab index of the `<div>` in `doc("<div {attrs}></div>")`.
    fn tab_index_with(attrs: &str) -> Option<TabIndex> {
        let dom = parse_xml_to_fast_dom(&doc(&format!("<div {attrs}></div>")))
            .unwrap_or_else(|e| panic!("{attrs:?} should parse, got {e}"));
        nodes(&dom)[2].get_tab_index()
    }
    #[test]
    fn parse_xml_to_fast_dom_maps_tabindex_boundaries() {
        assert_eq!(tab_index_with(r#"tabindex="0""#), Some(TabIndex::Auto));
        assert_eq!(tab_index_with(r#"tabindex="-0""#), Some(TabIndex::Auto));
        assert_eq!(
            tab_index_with(r#"tabindex="1""#),
            Some(TabIndex::OverrideInParent(1))
        );
        assert_eq!(
            tab_index_with(r#"tabindex="+3""#),
            Some(TabIndex::OverrideInParent(3)),
            "isize::from_str accepts a leading '+'"
        );
        assert_eq!(
            tab_index_with(r#"tabindex="-1""#),
            Some(TabIndex::NoKeyboardFocus)
        );
        assert_eq!(
            tab_index_with(r#"tabindex="-9223372036854775808""#),
            Some(TabIndex::NoKeyboardFocus),
            "i64::MIN is still just 'negative'"
        );
        // NodeFlags packs the override value into bits [27:0].
        const MAX_EXACT: u32 = (1 << 28) - 1;
        assert_eq!(
            tab_index_with(&format!(r#"tabindex="{MAX_EXACT}""#)),
            Some(TabIndex::OverrideInParent(MAX_EXACT))
        );
        // Past that it truncates rather than saturating or panicking. Two
        // lossy steps stack up here: `isize as u32` in the XML parser, then
        // the 28-bit mask in `NodeFlags::set_tab_index`. Pinned as-is because
        // the safety property is "bounded and deterministic", not "exact".
        assert_eq!(
            tab_index_with(r#"tabindex="268435456""#),
            Some(TabIndex::OverrideInParent(0)),
            "1 << 28 truncates to 0"
        );
        assert_eq!(
            tab_index_with(r#"tabindex="9223372036854775807""#),
            Some(TabIndex::OverrideInParent(MAX_EXACT)),
            "i64::MAX -> u32::MAX -> 28-bit mask"
        );
    }
    #[test]
    fn parse_xml_to_fast_dom_ignores_unparseable_tabindex() {
        let baseline = tab_index_with("");
        for junk in [
            r#"tabindex="""#,
            r#"tabindex="NaN""#,
            r#"tabindex="inf""#,
            r#"tabindex="-inf""#,
            r#"tabindex="1.0""#,
            r#"tabindex="1e5""#,
            r#"tabindex=" 3 ""#,
            r#"tabindex="0x10""#,
            r#"tabindex="99999999999999999999999999""#,
            r#"tabindex="-99999999999999999999999999""#,
            r#"tabindex="🙂""#,
        ] {
            assert_eq!(
                tab_index_with(junk),
                baseline,
                "{junk} must leave the tab index untouched"
            );
        }
    }
    #[test]
    fn parse_xml_to_fast_dom_parses_bool_attributes_case_sensitively() {
        assert_eq!(
            tab_index_with(r#"focusable="true""#),
            Some(TabIndex::Auto)
        );
        assert_eq!(
            tab_index_with(r#"focusable="false""#),
            Some(TabIndex::NoKeyboardFocus)
        );
        for junk in [r#"focusable="TRUE""#, r#"focusable="1""#, r#"focusable="yes""#] {
            assert_eq!(
                tab_index_with(junk),
                tab_index_with(""),
                "{junk} is not a bool literal and must be ignored"
            );
        }
        let editable = |v: &str| {
            let dom = parse_xml_to_fast_dom(&doc(&format!(r#"<div contenteditable="{v}"></div>"#)))
                .expect("valid");
            nodes(&dom)[2].is_contenteditable()
        };
        assert!(editable("true"));
        assert!(!editable("false"));
        assert!(!editable("TRUE"));
        assert!(!editable(""));
        assert!(!editable("1"));
    }
    #[test]
    fn parse_xml_to_fast_dom_survives_malformed_style_attributes() {
        let big = "a:b;".repeat(2_000);
        for style in [
            "",
            ";;;;",
            "::::",
            ":",
            "width",
            "width:",
            ":10px",
            "a:b:c",
            ";:;:;:",
            "width:10px",
            "width:10px;;;height:;;",
            "width:not-a-length",
            "🙂:🙂",
            big.as_str(),
        ] {
            let dom = parse_xml_to_fast_dom(&doc(&format!(r#"<div style="{style}"></div>"#)))
                .unwrap_or_else(|e| panic!("style={style:?} should parse, got {e}"));
            assert_eq!(
                nodes(&dom).len(),
                3,
                "style={style:?} must not change the node count"
            );
        }
    }
    #[test]
    fn parse_xml_to_fast_dom_survives_unbalanced_tags() {
        // The interesting case: elements opened inside <head> are pushed onto
        // the tag stack but never opened in the builder, so the EOF unwind
        // calls close_node() more often than open_node() ran. That must be a
        // no-op, not an underflow.
        let dom = parse_xml_to_fast_dom("<html><head><title>").expect("lenient parse");
        assert_eq!(nodes(&dom).len(), 1, "only <html> survives");
        assert!(matches!(nodes(&dom)[0].get_node_type(), NodeType::Html));
        for src in [
            "</div>",
            "</div></div></div>",
            "<a></a></a>",
            "<html><body></body></body></html>",
            "<html><head><head><head>",
            "<html><body><div></span></div></body></html>",
        ] {
            let a = parse_xml_to_fast_dom(src);
            let b = parse_xml_to_fast_dom(src);
            assert_eq!(a.is_ok(), b.is_ok(), "{src:?} is non-deterministic");
            if let (Ok(a), Ok(b)) = (&a, &b) {
                assert_eq!(nodes(a).len(), nodes(b).len(), "{src:?} node count drifted");
            }
        }
    }
    #[test]
    fn parse_xml_to_fast_dom_is_deterministic_on_garbage() {
        for g in GARBAGE {
            let a = parse_xml_to_fast_dom(g);
            let b = parse_xml_to_fast_dom(g);
            assert_eq!(a.is_ok(), b.is_ok(), "{g:?} parsed non-deterministically");
            if let (Ok(a), Ok(b)) = (&a, &b) {
                assert_eq!(nodes(a).len(), nodes(b).len(), "{g:?} node count drifted");
            }
        }
    }
    #[test]
    fn parse_xml_to_fast_dom_handles_ten_thousand_nested_elements() {
        // The arena path is iterative on the way in and flat on the way out,
        // so it should hold a depth the recursive XmlNode tree cannot.
        const DEPTH: usize = 10_000;
        let mut src = String::with_capacity(DEPTH * 12 + 32);
        src.push_str("<html><body>");
        for _ in 0..DEPTH {
            src.push_str("<div>");
        }
        for _ in 0..DEPTH {
            src.push_str("</div>");
        }
        src.push_str("</body></html>");
        let dom = parse_xml_to_fast_dom(&src).expect("balanced nesting is valid");
        assert_eq!(nodes(&dom).len(), DEPTH + 2);
    }
    #[test]
    fn parse_xml_to_fast_dom_handles_a_one_million_char_document() {
        let payload = "x".repeat(1_000_000);
        let dom = parse_xml_to_fast_dom(&doc(&payload)).expect("long text is valid");
        let n = nodes(&dom);
        assert_eq!(n.len(), 3, "html + body + one text node");
        assert_eq!(text_of(&n[2]).map(|s| s.len()), Some(1_000_000));
    }
    #[test]
    fn parse_xml_to_fast_dom_strips_bom_declaration_doctype_and_comments() {
        let expected = nodes(&parse_xml_to_fast_dom(&doc("<div/>")).expect("baseline")).len();
        for prefix in [
            "\u{FEFF}",
            "<?xml version=\"1.0\" encoding=\"utf-8\"?>",
            "<!DOCTYPE html>",
            "<!doctype HTML>",
            "<!DoCtYpE html SYSTEM \"about:legacy-compat\">",
            "<!-- leading comment -->",
        ] {
            let src = format!("{prefix}{}", doc("<div/>"));
            let dom = parse_xml_to_fast_dom(&src)
                .unwrap_or_else(|e| panic!("{prefix:?} should be stripped, got {e}"));
            assert_eq!(nodes(&dom).len(), expected, "{prefix:?} changed the arena");
        }
    }
    #[test]
    fn parse_xml_to_fast_dom_preserves_unicode_text() {
        for payload in [
            "日本語",
            "🙂🙂🙂🙂",
            "e\u{301}\u{302}\u{303}",
            "\u{200B}\u{FEFF}mid-string BOM",
            "ï·º",
        ] {
            let dom = parse_xml_to_fast_dom(&doc(payload))
                .unwrap_or_else(|e| panic!("{payload:?} should parse, got {e}"));
            let n = nodes(&dom);
            assert_eq!(n.len(), 3, "{payload:?}");
            assert_eq!(text_of(&n[2]).as_deref(), Some(payload));
        }
    }
    #[test]
    fn parse_xml_to_fast_dom_treats_numeric_looking_documents_as_text() {
        // Boundary numeric strings are markup content here, not numbers: they
        // must survive verbatim rather than being coerced or rejected.
        for payload in [
            "0",
            "-0",
            "9223372036854775807",
            "-9223372036854775808",
            "18446744073709551616",
            "1e309",
            "-1e-309",
            "NaN",
            "inf",
            "-inf",
        ] {
            let dom = parse_xml_to_fast_dom(&doc(payload))
                .unwrap_or_else(|e| panic!("{payload:?} should parse, got {e}"));
            assert_eq!(text_of(&nodes(&dom)[2]).as_deref(), Some(payload));
        }
    }
    // ------------------------------------------------------------------
    // parse_xml_to_styled_dom
    // ------------------------------------------------------------------
    #[test]
    fn icon_tag_yields_unnamed_icon_nodes_with_spec_text_children() {
        use azul_core::dom::NodeType;
        // The tokenizer stays fully generic: `<icon>spec</icon>` is an
        // un-named Icon node with its spec preserved as a text child.
        // The RESOLVER consumes the spec (see the resolution test below).
        let fast = parse_xml_to_fast_dom(
            "<html><body><icon> content_copy </icon><p>x</p></body></html>",
        )
        .expect("icon markup must parse");
        let icon_names: Vec<&str> = nodes(&fast)
            .iter()
            .filter_map(|nd| match nd.get_node_type() {
                NodeType::Icon(name) => Some(name.as_ref().as_str()),
                _ => None,
            })
            .collect();
        assert_eq!(icon_names, vec![""], "the builder must not interpret the spec");
        let spec_preserved = nodes(&fast).iter().any(|nd| {
            matches!(nd.get_node_type(), NodeType::Text(t) if t.as_ref().as_str().trim() == "content_copy")
        });
        assert!(spec_preserved, "the spec text child must be preserved for the resolver");
    }
    #[test]
    fn icon_resolution_consumes_the_spec_text_like_a_ligature_font() {
        use azul_core::{
            dom::NodeType,
            icon::{resolve_icons_in_styled_dom, IconProviderHandle, SharedIconProvider},
            refany::{OptionRefAny, RefAny},
            styled_dom::StyledDom,
        };
        use azul_css::system::SystemStyle;
        // Marker resolver: registered icons become a Text("RESOLVED") node,
        // unregistered ones become Text("MISSING") — enough to observe both
        // the spec-derived LOOKUP and the replacement without a real font.
        extern "C" fn marker_resolver(
            data: OptionRefAny,
            original: &StyledDom,
            _: &SystemStyle,
        ) -> StyledDom {
            let mut replacement = original.clone();
            let marker = if data.is_some() { "RESOLVED" } else { "MISSING" };
            if let Some(node) = replacement.node_data.as_mut().get_mut(0) {
                node.set_node_type(NodeType::Text(azul_css::css::BoxOrStatic::heap(
                    marker.into(),
                )));
            }
            replacement
        }
        let mut provider = IconProviderHandle::with_resolver(marker_resolver);
        provider.register_icon("testpack", "content_copy", RefAny::new(1u8));
        let provider = SharedIconProvider::from_handle(provider);
        // Both the bare-name spec and the pack-qualified fallback-list spec
        // (`missing:x` first — must fall through to `testpack:content_copy`).
        let mut styled = parse_xml_to_styled_dom(
            "<html><body>\
             <icon> content_copy </icon>\
             <icon>missing:x, testpack:CONTENT_COPY</icon>\
             <icon>unknown_icon</icon>\
             </body></html>",
        )
        .expect("icon markup must cascade");
        resolve_icons_in_styled_dom(&mut styled, &provider, &SystemStyle::default());
        let texts: Vec<String> = styled
            .node_data
            .as_ref()
            .iter()
            .filter_map(|nd| match nd.get_node_type() {
                NodeType::Text(t) => Some(t.as_ref().as_str().to_string()),
                _ => None,
            })
            .collect();
        let resolved = texts.iter().filter(|t| t.as_str() == "RESOLVED").count();
        let missing = texts.iter().filter(|t| t.as_str() == "MISSING").count();
        assert_eq!(resolved, 2, "bare + pack-qualified specs must both resolve: {texts:?}");
        assert_eq!(missing, 1, "the unknown spec resolves to no data: {texts:?}");
        // The spec text was consumed — it must not survive as renderable text.
        assert!(
            !texts.iter().any(|t| t.contains("content_copy") || t.contains("unknown_icon")),
            "spec text children must be cleared after resolution: {texts:?}"
        );
    }
    #[test]
    fn parse_xml_to_styled_dom_accepts_empty_and_whitespace_only_input() {
        for s in ["", "   ", "\t\n", "\u{FEFF}"] {
            let styled = parse_xml_to_styled_dom(s)
                .unwrap_or_else(|e| panic!("{s:?} should cascade cleanly, got {e}"));
            assert!(styled.node_data.as_ref().is_empty(), "{s:?}");
        }
    }
    #[test]
    fn parse_xml_to_styled_dom_keeps_the_fast_dom_node_count() {
        for src in [
            doc("<div>hi</div>"),
            doc("<div><span>a</span><span>b</span></div>"),
            "<html><head><style>div { width: 10px; }</style></head><body><div/></body></html>"
                .to_string(),
        ] {
            let fast = parse_xml_to_fast_dom(&src).expect("fast path");
            let styled = parse_xml_to_styled_dom(&src).expect("styled path");
            assert_eq!(
                styled.node_data.as_ref().len(),
                nodes(&fast).len(),
                "the cascade must not add or drop nodes for {src:?}"
            );
            assert_eq!(
                styled.node_hierarchy.as_ref().len(),
                styled.node_data.as_ref().len()
            );
        }
    }
    #[test]
    fn parse_xml_to_styled_dom_is_deterministic_on_garbage() {
        for g in GARBAGE {
            let a = parse_xml_to_styled_dom(g);
            let b = parse_xml_to_styled_dom(g);
            assert_eq!(a.is_ok(), b.is_ok(), "{g:?} cascaded non-deterministically");
        }
    }
    // ------------------------------------------------------------------
    // dom_from_parsed_xml
    // ------------------------------------------------------------------
    #[test]
    fn dom_from_parsed_xml_reports_errors_instead_of_panicking() {
        // No <html>/<body>: the documented behaviour is an error Dom, not a
        // panic and not an empty tree.
        for root in [
            Vec::new(),
            vec![XmlNodeChild::Text("bare text".into())],
            vec![XmlNodeChild::Element(XmlNode::create("div"))],
            vec![XmlNodeChild::Element(XmlNode::create("html"))],
        ] {
            let dom = dom_from_parsed_xml(Xml { root: root.into() });
            assert!(
                matches!(dom.root.get_node_type(), NodeType::Body),
                "the error Dom is rendered as a <body> with a label"
            );
            assert_eq!(dom.children.as_ref().len(), 1);
        }
    }
    #[test]
    fn dom_from_parsed_xml_builds_a_dom_for_a_minimal_document() {
        let body = XmlNode::create("body")
            .with_children(vec![XmlNodeChild::Element(XmlNode::create("div"))]);
        let html = XmlNode::create("html").with_children(vec![XmlNodeChild::Element(body)]);
        let dom = dom_from_parsed_xml(Xml {
            root: vec![XmlNodeChild::Element(html)].into(),
        });
        assert!(matches!(dom.root.get_node_type(), NodeType::Html));
        assert_eq!(dom.children.as_ref().len(), 1, "the <body> subtree");
    }
    #[test]
    fn dom_from_parsed_xml_caps_recursion_on_deeply_nested_input() {
        // MAX_XML_NESTING_DEPTH is 512; past it the builder drops children
        // instead of blowing the native stack.
        const DEPTH: usize = 550;
        let mut node = XmlNode::create("div");
        for _ in 0..DEPTH {
            node = XmlNode::create("div").with_children(vec![XmlNodeChild::Element(node)]);
        }
        let body = XmlNode::create("body").with_children(vec![XmlNodeChild::Element(node)]);
        let html = XmlNode::create("html").with_children(vec![XmlNodeChild::Element(body)]);
        let dom = dom_from_parsed_xml(Xml {
            root: vec![XmlNodeChild::Element(html)].into(),
        });
        assert!(matches!(dom.root.get_node_type(), NodeType::Html));
    }
    // ------------------------------------------------------------------
    // domxml_from_str / domxml_from_file / DomXmlExt
    // ------------------------------------------------------------------
    #[cfg(feature = "xml")]
    #[test]
    fn domxml_from_str_never_fails() {
        let map = ComponentMap::with_builtin();
        let mut cases: Vec<String> = GARBAGE.iter().map(|s| (*s).to_string()).collect();
        cases.push(String::new());
        cases.push("   ".to_string());
        cases.push("<svg".to_string());
        cases.push("<?xml".to_string());
        cases.push(doc("<div>hi</div>"));
        for src in cases {
            let dom_xml = domxml_from_str(&src, &map);
            assert!(
                !dom_xml.parsed_dom.node_data.as_ref().is_empty(),
                "{src:?} produced an empty StyledDom; errors must render as a label"
            );
        }
    }
    #[cfg(all(feature = "std", feature = "xml"))]
    #[test]
    fn domxml_from_file_renders_io_errors_as_a_dom() {
        let map = ComponentMap::with_builtin();
        for path in [
            "/nonexistent-azul-autotest-dir/definitely-not-here.xml",
            "",
            "/",
            "/proc/self/nonexistent-🙂",
        ] {
            let dom_xml = domxml_from_file(path, &map);
            assert!(
                !dom_xml.parsed_dom.node_data.as_ref().is_empty(),
                "{path:?} must render the io::Error as a label, not fail"
            );
        }
    }
    #[cfg(feature = "xml")]
    #[test]
    fn dom_xml_ext_matches_domxml_from_str() {
        let map = ComponentMap::with_builtin();
        let valid = doc("<div>hi</div>");
        for src in ["", "<svg", valid.as_str()] {
            let via_ext = <Dom as DomXmlExt>::from_xml_string(src);
            let via_fn = domxml_from_str(src, &map).parsed_dom;
            assert_eq!(
                via_ext.node_data.as_ref().len(),
                via_fn.node_data.as_ref().len(),
                "the extension trait must be a pure delegation for {src:?}"
            );
        }
    }
    // ------------------------------------------------------------------
    // peak_rss_bytes
    // ------------------------------------------------------------------
    #[test]
    fn peak_rss_bytes_never_panics_and_never_goes_backwards() {
        let a = peak_rss_bytes();
        let _ballast = "x".repeat(4 * 1024 * 1024);
        let b = peak_rss_bytes();
        #[cfg(all(unix, feature = "probe"))]
        assert!(
            b >= a,
            "ru_maxrss is a high-water mark and must never decrease ({a} -> {b})"
        );
        #[cfg(not(all(unix, feature = "probe")))]
        assert_eq!(
            (a, b),
            (0, 0),
            "without the probe feature the stub must be a constant 0"
        );
    }
    // ------------------------------------------------------------------
    // translate_* (xmlparser / roxmltree -> FFI-stable azul types)
    // ------------------------------------------------------------------
    #[cfg(feature = "xml")]
    #[test]
    fn translate_textpos_round_trips_boundary_values() {
        for (row, col) in [
            (0, 0),
            (1, 1),
            (0, u32::MAX),
            (u32::MAX, 0),
            (u32::MAX, u32::MAX),
        ] {
            let expected = XmlTextPos { row, col };
            assert_eq!(
                translate_xmlparser_textpos(xmlparser::TextPos::new(row, col)),
                expected
            );
            assert_eq!(
                translate_roxml_textpos(roxmltree::TextPos::new(row, col)),
                expected
            );
        }
    }
    #[cfg(feature = "xml")]
    #[test]
    fn translate_roxmltree_expandedname_preserves_name_and_namespace() {
        let plain: roxmltree::ExpandedName<'_, '_> = "rect".into();
        let out = translate_roxmltree_expandedname(plain);
        assert_eq!(out.local_name.as_str(), "rect");
        assert!(out.namespace.as_ref().is_none());
        let ns: roxmltree::ExpandedName<'_, '_> = ("http://www.w3.org/2000/svg", "rect").into();
        let out = translate_roxmltree_expandedname(ns);
        assert_eq!(out.local_name.as_str(), "rect");
        assert_eq!(
            out.namespace.as_ref().map(AzString::as_str),
            Some("http://www.w3.org/2000/svg")
        );
        // Degenerate names must survive untouched, not be normalised away.
        for name in ["", " ", "日本語-🙂", "a:b"] {
            let e: roxmltree::ExpandedName<'_, '_> = name.into();
            assert_eq!(translate_roxmltree_expandedname(e).local_name.as_str(), name);
        }
        let empty_ns: roxmltree::ExpandedName<'_, '_> = ("", "x").into();
        assert_eq!(
            translate_roxmltree_expandedname(empty_ns)
                .namespace
                .as_ref()
                .map(AzString::as_str),
            Some(""),
            "an empty namespace URI is Some(\"\"), not None"
        );
    }
    #[cfg(feature = "xml")]
    #[test]
    fn translate_roxmltree_attribute_preserves_name_and_namespace() {
        let rdoc = roxmltree::Document::parse(r#"<e xmlns:x="urn:x" x:a="1" b="2"/>"#)
            .expect("valid XML");
        let attrs: Vec<XmlQualifiedName> = rdoc
            .root_element()
            .attributes()
            .map(translate_roxmltree_attribute)
            .collect();
        assert_eq!(attrs.len(), 2, "xmlns declarations are not attributes");
        let a = attrs
            .iter()
            .find(|q| q.local_name.as_str() == "a")
            .expect("x:a");
        assert_eq!(a.namespace.as_ref().map(AzString::as_str), Some("urn:x"));
        let b = attrs
            .iter()
            .find(|q| q.local_name.as_str() == "b")
            .expect("b");
        assert!(
            b.namespace.as_ref().is_none(),
            "an unprefixed attribute has no namespace"
        );
    }
    #[cfg(feature = "xml")]
    #[test]
    fn translate_xmlparser_streamerror_maps_every_variant() {
        use xmlparser::StreamError as Se;
        let p = xmlparser::TextPos::new(3, 7);
        let x = XmlTextPos { row: 3, col: 7 };
        assert_eq!(
            translate_xmlparser_streamerror(Se::UnexpectedEndOfStream),
            XmlStreamError::UnexpectedEndOfStream
        );
        assert_eq!(
            translate_xmlparser_streamerror(Se::InvalidName),
            XmlStreamError::InvalidName
        );
        assert_eq!(
            translate_xmlparser_streamerror(Se::InvalidReference),
            XmlStreamError::InvalidReference
        );
        assert_eq!(
            translate_xmlparser_streamerror(Se::InvalidExternalID),
            XmlStreamError::InvalidExternalID
        );
        assert_eq!(
            translate_xmlparser_streamerror(Se::InvalidCommentData),
            XmlStreamError::InvalidCommentData
        );
        assert_eq!(
            translate_xmlparser_streamerror(Se::InvalidCommentEnd),
            XmlStreamError::InvalidCommentEnd
        );
        assert_eq!(
            translate_xmlparser_streamerror(Se::InvalidCharacterData),
            XmlStreamError::InvalidCharacterData
        );
        // Astral char -> u32 (the FFI-stable representation) without loss.
        assert_eq!(
            translate_xmlparser_streamerror(Se::NonXmlChar('\u{1F600}', p)),
            XmlStreamError::NonXmlChar(NonXmlCharError {
                ch: 0x1F600,
                pos: x
            })
        );
        assert_eq!(
            translate_xmlparser_streamerror(Se::InvalidQuote(b'`', p)),
            XmlStreamError::InvalidQuote(InvalidQuoteError { got: b'`', pos: x })
        );
        assert_eq!(
            translate_xmlparser_streamerror(Se::InvalidSpace(b'\t', p)),
            XmlStreamError::InvalidSpace(InvalidSpaceError { got: b'\t', pos: x })
        );
        assert_eq!(
            translate_xmlparser_streamerror(Se::InvalidString("?>", p)),
            XmlStreamError::InvalidString(InvalidStringError {
                got: "?>".into(),
                pos: x
            })
        );
        // NOTE: xmlparser documents InvalidChar/InvalidCharMultiple as
        // (actual, expected, pos), but the translation stores the first field
        // as `expected` and the second as `got` — i.e. the two are swapped.
        // Characterised here rather than "fixed" in the test: it only affects
        // error-message wording, and pinning it makes the swap visible if the
        // mapping is ever corrected.
        assert_eq!(
            translate_xmlparser_streamerror(Se::InvalidChar(b'a', b'b', p)),
            XmlStreamError::InvalidChar(InvalidCharError {
                expected: b'a',
                got: b'b',
                pos: x
            })
        );
        assert_eq!(
            translate_xmlparser_streamerror(Se::InvalidCharMultiple(b'a', &b"xy"[..], p)),
            XmlStreamError::InvalidCharMultiple(InvalidCharMultipleError {
                expected: b'a',
                got: vec![b'x', b'y'].into(),
                pos: x
            })
        );
    }
    #[cfg(feature = "xml")]
    #[test]
    fn translate_xmlparser_error_maps_every_variant() {
        use xmlparser::{Error as Xe, StreamError as Se};
        let p = xmlparser::TextPos::new(9, 4);
        let x = XmlTextPos { row: 9, col: 4 };
        let te = XmlTextError {
            stream_error: XmlStreamError::InvalidName,
            pos: x,
        };
        assert_eq!(
            translate_xmlparser_error(Xe::InvalidDeclaration(Se::InvalidName, p)),
            XmlParseError::InvalidDeclaration(te.clone())
        );
        assert_eq!(
            translate_xmlparser_error(Xe::InvalidComment(Se::InvalidName, p)),
            XmlParseError::InvalidComment(te.clone())
        );
        assert_eq!(
            translate_xmlparser_error(Xe::InvalidPI(Se::InvalidName, p)),
            XmlParseError::InvalidPI(te.clone())
        );
        assert_eq!(
            translate_xmlparser_error(Xe::InvalidDoctype(Se::InvalidName, p)),
            XmlParseError::InvalidDoctype(te.clone())
        );
        assert_eq!(
            translate_xmlparser_error(Xe::InvalidEntity(Se::InvalidName, p)),
            XmlParseError::InvalidEntity(te.clone())
        );
        assert_eq!(
            translate_xmlparser_error(Xe::InvalidElement(Se::InvalidName, p)),
            XmlParseError::InvalidElement(te.clone())
        );
        assert_eq!(
            translate_xmlparser_error(Xe::InvalidAttribute(Se::InvalidName, p)),
            XmlParseError::InvalidAttribute(te.clone())
        );
        assert_eq!(
            translate_xmlparser_error(Xe::InvalidCdata(Se::InvalidName, p)),
            XmlParseError::InvalidCdata(te.clone())
        );
        assert_eq!(
            translate_xmlparser_error(Xe::InvalidCharData(Se::InvalidName, p)),
            XmlParseError::InvalidCharData(te)
        );
        assert_eq!(
            translate_xmlparser_error(Xe::UnknownToken(p)),
            XmlParseError::UnknownToken(x)
        );
    }
    #[cfg(feature = "xml")]
    #[test]
    fn translate_roxmltree_error_maps_every_variant() {
        use roxmltree::Error as Re;
        let p = roxmltree::TextPos::new(2, 5);
        let x = XmlTextPos { row: 2, col: 5 };
        assert_eq!(
            translate_roxmltree_error(Re::InvalidXmlPrefixUri(p)),
            XmlError::InvalidXmlPrefixUri(x)
        );
        assert_eq!(
            translate_roxmltree_error(Re::UnexpectedXmlUri(p)),
            XmlError::UnexpectedXmlUri(x)
        );
        assert_eq!(
            translate_roxmltree_error(Re::UnexpectedXmlnsUri(p)),
            XmlError::UnexpectedXmlnsUri(x)
        );
        assert_eq!(
            translate_roxmltree_error(Re::InvalidElementNamePrefix(p)),
            XmlError::InvalidElementNamePrefix(x)
        );
        assert_eq!(
            translate_roxmltree_error(Re::DuplicatedNamespace(String::from("ns"), p)),
            XmlError::DuplicatedNamespace(DuplicatedNamespaceError {
                ns: "ns".into(),
                pos: x
            })
        );
        assert_eq!(
            translate_roxmltree_error(Re::UnknownNamespace(String::from("ns"), p)),
            XmlError::UnknownNamespace(UnknownNamespaceError {
                ns: "ns".into(),
                pos: x
            })
        );
        assert_eq!(
            translate_roxmltree_error(Re::UnexpectedCloseTag(
                String::from("a"),
                String::from("b"),
                p
            )),
            XmlError::UnexpectedCloseTag(UnexpectedCloseTagError {
                expected: "a".into(),
                actual: "b".into(),
                pos: x
            })
        );
        assert_eq!(
            translate_roxmltree_error(Re::UnexpectedEntityCloseTag(p)),
            XmlError::UnexpectedEntityCloseTag(x)
        );
        assert_eq!(
            translate_roxmltree_error(Re::UnknownEntityReference(String::from("e"), p)),
            XmlError::UnknownEntityReference(UnknownEntityReferenceError {
                entity: "e".into(),
                pos: x
            })
        );
        assert_eq!(
            translate_roxmltree_error(Re::MalformedEntityReference(p)),
            XmlError::MalformedEntityReference(x)
        );
        assert_eq!(
            translate_roxmltree_error(Re::EntityReferenceLoop(p)),
            XmlError::EntityReferenceLoop(x)
        );
        assert_eq!(
            translate_roxmltree_error(Re::InvalidAttributeValue(p)),
            XmlError::InvalidAttributeValue(x)
        );
        assert_eq!(
            translate_roxmltree_error(Re::DuplicatedAttribute(String::from("a"), p)),
            XmlError::DuplicatedAttribute(DuplicatedAttributeError {
                attribute: "a".into(),
                pos: x
            })
        );
        assert_eq!(
            translate_roxmltree_error(Re::NoRootNode),
            XmlError::NoRootNode
        );
        assert_eq!(
            translate_roxmltree_error(Re::DtdDetected),
            XmlError::DtdDetected
        );
        assert_eq!(
            translate_roxmltree_error(Re::UnclosedRootNode),
            XmlError::UnclosedRootNode
        );
        assert_eq!(
            translate_roxmltree_error(Re::UnexpectedDeclaration(p)),
            XmlError::UnexpectedDeclaration(x)
        );
        assert_eq!(
            translate_roxmltree_error(Re::NodesLimitReached),
            XmlError::NodesLimitReached
        );
        assert_eq!(
            translate_roxmltree_error(Re::AttributesLimitReached),
            XmlError::AttributesLimitReached
        );
        assert_eq!(
            translate_roxmltree_error(Re::NamespacesLimitReached),
            XmlError::NamespacesLimitReached
        );
        assert_eq!(
            translate_roxmltree_error(Re::InvalidName(p)),
            XmlError::InvalidName(x)
        );
        assert_eq!(
            translate_roxmltree_error(Re::NonXmlChar('\u{0}', p)),
            XmlError::NonXmlChar(x)
        );
        assert_eq!(
            translate_roxmltree_error(Re::InvalidChar(b'a', b'b', p)),
            XmlError::InvalidChar(x)
        );
        assert_eq!(
            translate_roxmltree_error(Re::InvalidChar2("ab", b'c', p)),
            XmlError::InvalidChar2(x)
        );
        assert_eq!(
            translate_roxmltree_error(Re::InvalidString("s", p)),
            XmlError::InvalidString(x)
        );
        assert_eq!(
            translate_roxmltree_error(Re::InvalidExternalID(p)),
            XmlError::InvalidExternalID(x)
        );
        assert_eq!(
            translate_roxmltree_error(Re::InvalidComment(p)),
            XmlError::InvalidComment(x)
        );
        assert_eq!(
            translate_roxmltree_error(Re::InvalidCharacterData(p)),
            XmlError::InvalidCharacterData(x)
        );
        assert_eq!(
            translate_roxmltree_error(Re::UnknownToken(p)),
            XmlError::UnknownToken(x)
        );
        assert_eq!(
            translate_roxmltree_error(Re::UnexpectedEndOfStream),
            XmlError::UnexpectedEndOfStream
        );
        // roxmltree 0.21's EntityResolver is folded into UnknownEntityReference.
        assert_eq!(
            translate_roxmltree_error(Re::EntityResolver(p, String::from("e"))),
            XmlError::UnknownEntityReference(UnknownEntityReferenceError {
                entity: "e".into(),
                pos: x
            })
        );
    }
}