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 from XML tokens
6
//!   (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
83208
fn decode_xml_entities(s: &str) -> std::borrow::Cow<'_, str> {
29
    // Fast path: if no ampersand, no entities to decode
30
83208
    if !s.contains('&') {
31
83136
        return std::borrow::Cow::Borrowed(s);
32
72
    }
33
72
    decode_xml_entities_slow(s)
34
83208
}
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
#[must_use]
126
2570
pub fn domxml_from_str(xml: &str, component_map: &ComponentMap) -> DomXml {
127
2570
    let error_css = Css::empty();
128

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

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

            
155
2555
    DomXml { parsed_dom }
156
2570
}
157

            
158
/// Creates a `Dom` from an already-parsed `Xml` structure, for use in layout
159
/// callbacks. CSS from `<style>` tags is attached to `Dom.css` and applied
160
/// during the cascade pass.
161
// FFI-exported (api.json fn_body azul_layout::xml::dom_from_parsed_xml(xml)): owned Xml by value.
162
#[allow(clippy::needless_pass_by_value)]
163
#[must_use]
164
208
pub fn dom_from_parsed_xml(xml: Xml) -> Dom {
165
208
    let component_map = ComponentMap::with_builtin();
166
208
    match str_to_dom_unstyled(xml.root.as_ref(), &component_map) {
167
207
        Ok(dom) => dom,
168
1
        Err(e) => {
169
1
            Dom::create_body().with_children(vec![Dom::create_p_with_text(format!("{e}"))].into())
170
        }
171
    }
172
208
}
173

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

            
193
/// `parse_xml_to_styled_dom`, but resolving `<icon>` nodes on the way.
194
///
195
/// Routes through `Dom` (a real tree) rather than `FastDom`, and that is not an
196
/// oversight: an icon resolves to a SUBTREE, and `FastDom` - like `StyledDom` -
197
/// is a flat arena in DFS order, so a subtree cannot be spliced into it without
198
/// inserting mid-arena and shifting every index after it. Resolving on the tree
199
/// and cascading once is what makes an arbitrary `Dom` usable as an icon.
200
///
201
/// Use this whenever an icon provider is available. `parse_xml_to_styled_dom`
202
/// exists for callers that have none, and differs only in that.
203
///
204
/// # Errors
205
///
206
/// Returns an `XmlError` if the XML cannot be parsed.
207
1
pub fn parse_xml_to_styled_dom_resolving_icons(
208
1
    xml: &str,
209
1
    provider: &azul_core::icon::SharedIconProvider,
210
1
    system_style: &azul_css::system::SystemStyle,
211
1
) -> Result<StyledDom, XmlError> {
212
1
    let parsed = parse_xml(xml)?;
213
1
    let dom = dom_from_parsed_xml(parsed);
214
1
    Ok(azul_core::icon::styled_dom_resolving_icons(
215
1
        dom,
216
1
        provider,
217
1
        system_style,
218
1
    ))
219
1
}
220

            
221
/// Parse XML directly into `FastDom` + extracted CSS, ready for `StyledDom`.
222
#[allow(clippy::cast_precision_loss)] // bounded layout/render numeric cast
223
/// # Errors
224
///
225
/// Returns an `XmlError` if the XML cannot be parsed.
226
231
pub fn parse_xml_to_styled_dom(xml: &str) -> Result<StyledDom, XmlError> {
227
    // Optional per-phase RSS/timing breakdown.
228
    // Gated on AZ_PROFILE=memory — prints
229
    //   [XML] tokenize+fast_dom       : +XX MiB in YY ms
230
    //   [XML] css attach              : +XX MiB in YY ms
231
    //   [XML] create_from_fast_dom    : +XX MiB in YY ms
232
    // to locate which sub-phase of the parse-cascade dominates the
233
    // RSS jump seen between `page start` and `xml parsed`.
234
    static MEM_ENABLED: std::sync::OnceLock<bool> = std::sync::OnceLock::new();
235
231
    let mem_on = *MEM_ENABLED.get_or_init(azul_core::profile::memory_enabled);
236

            
237
231
    let rss0 = if mem_on { peak_rss_bytes() } else { 0 };
238
231
    let (mut fast_dom, css) = parse_xml_to_fast_dom_with_css(xml)?;
239
209
    if mem_on {
240
        let rss1 = peak_rss_bytes();
241
        eprintln!(
242
            "[XML] tokenize+fast_dom       : +{:.2} MiB",
243
            (rss1.saturating_sub(rss0)) as f64 / 1024.0 / 1024.0,
244
        );
245
209
    }
246

            
247
209
    let rss1 = if mem_on { peak_rss_bytes() } else { 0 };
248
    // Attach CSS to the FastDom
249
209
    if !css.is_empty() {
250
        // Rules AND keyframes: merging by rules alone silently dropped every
251
        // `@keyframes` block a `<style>` element declared, so
252
        // `-azul-animation-out: shrinkOut 1s` fell back to the default slide
253
        // at runtime while the unit parser tests stayed green.
254
195
        let mut combined_rules = Vec::new();
255
195
        let mut combined_keyframes = Vec::new();
256
390
        for c in css {
257
195
            combined_rules.extend(c.rules.into_library_owned_vec());
258
195
            combined_keyframes.extend(c.keyframes.into_library_owned_vec());
259
195
        }
260
195
        let mut combined_css = Css::new(combined_rules);
261
195
        combined_css.keyframes = combined_keyframes.into();
262
195
        fast_dom.css = vec![azul_core::dom::CssWithNodeId {
263
195
            node_id: 0, // global scope
264
195
            css: combined_css,
265
195
        }]
266
195
        .into();
267
14
    }
268
209
    if mem_on {
269
        let rss2 = peak_rss_bytes();
270
        eprintln!(
271
            "[XML] css attach              : +{:.2} MiB",
272
            (rss2.saturating_sub(rss1)) as f64 / 1024.0 / 1024.0,
273
        );
274
209
    }
275

            
276
    // Hint the allocator to return pages freed by the CSS parser.
277
    // The tokenizer+parser created many small allocations (selectors,
278
    // declarations, strings) that are now packed into FastDom. Purging
279
    // here returns those pages before the cascade allocates more.
280
209
    crate::probe::hint_purge_allocator();
281

            
282
209
    let rss2 = if mem_on { peak_rss_bytes() } else { 0 };
283
209
    let styled = StyledDom::create_from_fast_dom(fast_dom);
284

            
285
    // Major purge point: the cascade just freed ~3 MiB of intermediate
286
    // allocations (build-phase Vecs, CSS selector matching state, pruned
287
    // properties). Tell the allocator to return those pages NOW before
288
    // the layout pass allocates more on top of them.
289
209
    crate::probe::hint_purge_allocator();
290

            
291
209
    if mem_on {
292
        let rss3 = peak_rss_bytes();
293
        eprintln!(
294
            "[XML] create_from_fast_dom    : +{:.2} MiB",
295
            (rss3.saturating_sub(rss2)) as f64 / 1024.0 / 1024.0,
296
        );
297
209
    }
298

            
299
209
    Ok(styled)
300
231
}
301

            
302
/// Resident-set bytes for RSS checkpoints — mirrors servo-shot's
303
/// `peak_rss_bytes()`. Uses `getrusage(RUSAGE_SELF)` via the
304
/// `probe` feature's `libc` dep; returns 0 without it so the
305
/// caller just doesn't emit meaningful deltas.
306
#[cfg(all(unix, feature = "probe"))]
307
2
fn peak_rss_bytes() -> u64 {
308
2
    let mut usage: libc::rusage = unsafe { std::mem::zeroed() };
309
2
    if unsafe { libc::getrusage(libc::RUSAGE_SELF, &raw mut usage) } != 0 {
310
        return 0;
311
2
    }
312
2
    let ru = usage.ru_maxrss as u64;
313
    // macOS reports bytes, Linux reports KiB.
314
    #[cfg(target_os = "macos")]
315
    {
316
        ru
317
    }
318
    #[cfg(not(target_os = "macos"))]
319
    {
320
2
        ru.saturating_mul(1024)
321
    }
322
2
}
323

            
324
#[cfg(not(all(unix, feature = "probe")))]
325
const fn peak_rss_bytes() -> u64 {
326
    0
327
}
328

            
329
/// Internal: parse XML into `FastDom` + collected CSS stylesheets.
330
#[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)] // bounded layout/render numeric cast
331
#[allow(clippy::too_many_lines, clippy::cognitive_complexity)] // large but cohesive: single-purpose
332
                                                               // layout/render/parse routine (one
333
                                                               // branch per case)
334
367
fn parse_xml_to_fast_dom_with_css(
335
367
    xml: &str,
336
367
) -> Result<(azul_core::dom::FastDom, Vec<Css>), XmlError> {
337
    use azul_core::{
338
        dom::{IdOrClass, NodeData, NodeType, TabIndex},
339
        xml::CompactDomBuilder,
340
    };
341
    use xmlparser::{
342
        ElementEnd::{Close, Empty, Open},
343
        Token::{Attribute, ElementEnd, ElementStart, Text},
344
        Tokenizer,
345
    };
346

            
347
    const ESTIMATED_BYTES_PER_NODE: usize = 20;
348

            
349
    const VOID_ELEMENTS: &[&str] = &[
350
        "area", "base", "br", "col", "embed", "hr", "img", "input", "link", "meta", "param",
351
        "source", "track", "wbr",
352
    ];
353

            
354
    // Lowercase `src` into `dst`, reusing `dst`'s existing capacity.
355
    // Zero-alloc when dst's capacity is already ≥ src.len() AND no uppercase
356
    // conversion is needed (the happy path for HTML5 where tags are lowercase).
357
11763
    fn lowercase_into(dst: &mut String, src: &str) {
358
11763
        dst.clear();
359
36327
        if src.bytes().all(|b| !b.is_ascii_uppercase()) {
360
11760
            dst.push_str(src);
361
11760
        } else {
362
3
            dst.reserve(src.len());
363
11
            for b in src.bytes() {
364
11
                dst.push(b.to_ascii_lowercase() as char);
365
11
            }
366
        }
367
11763
    }
368

            
369
    // Strip BOM
370
367
    let xml = xml.strip_prefix('\u{FEFF}').unwrap_or(xml);
371
367
    let mut xml = xml.trim();
372

            
373
    // Skip <?xml ... ?>
374
367
    if xml.starts_with("<?") {
375
5
        if let Some(pos) = xml.find("?>") {
376
1
            xml = &xml[(pos + 2)..];
377
4
        }
378
362
    }
379

            
380
    // Skip <!DOCTYPE ...>
381
367
    let mut xml = xml.trim();
382
367
    if xml.len() > 9
383
311
        && xml.is_char_boundary(9)
384
310
        && xml[..9].to_ascii_lowercase().starts_with("<!doctype")
385
    {
386
3
        if let Some(pos) = xml.find('>') {
387
3
            xml = &xml[(pos + 1)..];
388
3
        }
389
364
    } else if xml.starts_with("<!--") {
390
5
        if let Some(end) = xml.find("-->") {
391
1
            xml = &xml[(end + 3)..];
392
1
            xml = xml.trim();
393
4
        }
394
359
    }
395

            
396
367
    let tokenizer = Tokenizer::from_fragment(xml, 0..xml.len());
397

            
398
367
    let estimated_nodes = xml.len() / ESTIMATED_BYTES_PER_NODE;
399
367
    let mut builder = CompactDomBuilder::with_capacity(estimated_nodes);
400
367
    let mut collected_css: Vec<Css> = Vec::new();
401
367
    let mut inside_style_tag = false;
402
367
    let mut style_text = String::new();
403
    // Track <head> depth: skip DOM nodes inside <head> (still collect <style> CSS).
404
    // This ensures the FastDom contains only <html><body>... as the layout engine expects.
405
367
    let mut head_depth: usize = 0;
406

            
407
    // Temporary storage for current element's attributes
408
367
    let mut current_tag: String = String::new();
409
367
    let mut current_attrs: Vec<(String, String)> = Vec::new();
410
367
    let mut pending_open = false;
411

            
412
    // Pre-compute the CSS key map once (used for style= attribute parsing)
413
367
    let css_key_map = azul_css::props::property::get_css_key_map();
414

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

            
422
    // Finalize the pending open element: create NodeData from tag + attrs, push to builder
423
    // tag is already lowercase
424
367
    let finalize_open =
425
        |builder: &mut CompactDomBuilder,
426
         str_arena: &mut azul_css::corety::StringArena,
427
         tag: &str,
428
         attrs: &[(String, String)],
429
11356
         css_key_map: &azul_css::props::property::CssKeyMap| {
430
11356
            let node_type = tag_to_node_type(tag);
431
11356
            let mut nd = NodeData::create_node(node_type);
432

            
433
            // `<transient-window open="true" anchor="bottom" …>`: the config rides
434
            // INSIDE the NodeType, so its attributes are applied onto that payload
435
            // rather than stored as generic attributes. Done before the generic
436
            // loop so the keys it consumes never reach `attr_vec`.
437
11356
            let mut transient_cfg = match nd.get_node_type() {
438
2
                NodeType::TransientWindow(c) => Some(*c),
439
11354
                _ => None,
440
            };
441

            
442
            // Apply attributes — build AttributeTypeVec directly (avoids the
443
            // clone + retain dance in set_ids_and_classes for fresh NodeData).
444
11356
            let mut attr_vec: Vec<azul_core::dom::AttributeType> = Vec::new();
445
12481
            for (key, value) in attrs {
446
1125
                if let Some(cfg) = transient_cfg.as_mut() {
447
5
                    if cfg.apply_attr(key.as_str(), value.as_str()) {
448
                        // `tearoff="zone:<selector>"`: the MODE rides in the
449
                        // config (it is `Copy`), the selector - a string - stays
450
                        // on the node as its `tearoff-zone` attribute, where the
451
                        // engine's drop handling reads it.
452
4
                        if key == "tearoff" {
453
                            if let Some(selector) = value.trim().strip_prefix("zone:") {
454
                                attr_vec.push(azul_core::dom::AttributeType::Custom(
455
                                    azul_core::dom::AttributeNameValue {
456
                                        attr_name: str_arena.intern("tearoff-zone"),
457
                                        value: str_arena.intern(selector.trim()),
458
                                    },
459
                                ));
460
                            }
461
4
                        }
462
4
                        continue;
463
1
                    }
464
1120
                }
465
1121
                match key.as_str() {
466
1121
                    "id" => {
467
654
                        for id in value.split_whitespace() {
468
654
                            attr_vec.push(azul_core::dom::AttributeType::Id(str_arena.intern(id)));
469
654
                        }
470
                    }
471
468
                    "class" => {
472
316
                        for class in value.split_whitespace() {
473
316
                            attr_vec.push(azul_core::dom::AttributeType::Class(
474
316
                                str_arena.intern(class),
475
316
                            ));
476
316
                        }
477
                    }
478
154
                    "focusable" => {
479
5
                        if let Some(f) = parse_bool(value.as_str()) {
480
2
                            nd.set_tab_index(if f {
481
1
                                TabIndex::Auto
482
                            } else {
483
1
                                TabIndex::NoKeyboardFocus
484
                            });
485
3
                        }
486
                    }
487
149
                    "tabindex" => {
488
98
                        if let Ok(ti) = value.parse::<isize>() {
489
7
                            match ti {
490
80
                                0 => nd.set_tab_index(TabIndex::Auto),
491
7
                                i if i > 0 => {
492
5
                                    nd.set_tab_index(TabIndex::OverrideInParent(i as u32));
493
5
                                }
494
2
                                _ => nd.set_tab_index(TabIndex::NoKeyboardFocus),
495
                            }
496
11
                        }
497
                    }
498
51
                    "style" => {
499
14
                        let mut css_attrs = Vec::new();
500
2026
                        for s in value.split(';') {
501
2026
                            let mut s = s.split(':');
502
2026
                            let Some(key) = s.next() else { continue };
503
2026
                            let Some(val) = s.next() else { continue };
504
                            // Called for its side effect (writes parsed props into
505
                            // `css_attrs`); the returned value is intentionally discarded.
506
2013
                            drop(azul_css::parser2::parse_css_declaration(
507
2013
                                key.trim(),
508
2013
                                val.trim(),
509
2013
                                azul_css::parser2::ErrorLocationRange::default(),
510
2013
                                css_key_map,
511
2013
                                &mut Vec::new(),
512
2013
                                &mut css_attrs,
513
                            ));
514
                        }
515
14
                        let props = css_attrs
516
14
                            .into_iter()
517
14
                            .filter_map(|s| {
518
                                use azul_css::{
519
                                    css::CssDeclaration,
520
                                    dynamic_selector::CssPropertyWithConditions,
521
                                };
522
2
                                match s {
523
2
                                    CssDeclaration::Static(s) => {
524
2
                                        Some(CssPropertyWithConditions::simple(s))
525
                                    }
526
                                    CssDeclaration::Dynamic(_) => None,
527
                                }
528
2
                            })
529
14
                            .collect::<Vec<_>>();
530
14
                        if !props.is_empty() {
531
2
                            nd.set_css_props(props.into());
532
12
                        }
533
                    }
534
                    // Boolean attribute: presence is the value, as in HTML.
535
37
                    "autofocus" => attr_vec.push(azul_core::dom::AttributeType::Autofocus),
536
37
                    "placeholder" => attr_vec.push(azul_core::dom::AttributeType::Placeholder(
537
                        value.clone().into(),
538
                    )),
539
37
                    "contenteditable" => {
540
37
                        match parse_bool(value.as_str()) {
541
30
                            Some(true) => nd.set_contenteditable(true),
542
                            // An explicit `false` is NOT "no attribute": inside an
543
                            // editable host it walls its subtree off (HTML's
544
                            // inheritance rule, `is_node_contenteditable_inherited`)
545
                            // and keeps that subtree out of the host's edit buffer
546
                            // and out of the block the edit is shaped into. Dropped
547
                            // here, a mounted `<p contenteditable="false">` island
548
                            // behaved like any other child — the Rust API's
549
                            // `with_attribute(ContentEditable(false))` and the HTML
550
                            // loader disagreed on the same document.
551
2
                            Some(false) => {
552
2
                                attr_vec
553
2
                                    .push(azul_core::dom::AttributeType::ContentEditable(false));
554
2
                            }
555
5
                            None => {}
556
                        }
557
                    }
558
                    _ => {}
559
                }
560
            }
561
11356
            if !attr_vec.is_empty() {
562
680
                nd.set_attributes(attr_vec.into());
563
10958
            }
564
            // Write the parsed popup config back into the node's payload.
565
11356
            if let Some(cfg) = transient_cfg {
566
2
                nd.set_node_type(NodeType::TransientWindow(cfg));
567
11354
            }
568

            
569
11356
            builder.open_node(nd);
570
11356
        };
571

            
572
367
    let mut last_was_void = false;
573
367
    let mut tag_stack: Vec<String> = Vec::new(); // for matching close tags
574

            
575
39151
    for token in tokenizer {
576
38828
        let token = token.map_err(|e| XmlError::ParserError(translate_xmlparser_error(e)))?;
577
23504
        match token {
578
11763
            ElementStart { local, .. } => {
579
                // Flush any pending open element
580
11763
                if pending_open {
581
                    let is_void = VOID_ELEMENTS.contains(&current_tag.as_str());
582
                    if current_tag == "head" {
583
                        head_depth += 1;
584
                    }
585
                    if head_depth == 0 {
586
                        finalize_open(
587
                            &mut builder,
588
                            &mut str_arena,
589
                            &current_tag,
590
                            &current_attrs,
591
                            &css_key_map,
592
                        );
593
                        if is_void {
594
                            builder.close_node();
595
                        }
596
                    }
597
                    if !is_void {
598
                        tag_stack.push(core::mem::take(&mut current_tag));
599
                    }
600
11763
                }
601

            
602
                // Reuse the current_tag buffer — avoids ~1023 fresh String
603
                // allocations per parse (one per ElementStart).
604
11763
                lowercase_into(&mut current_tag, local.as_str());
605
11763
                current_attrs.clear();
606
11763
                pending_open = true;
607
11763
                last_was_void = VOID_ELEMENTS.contains(&current_tag.as_str());
608
            }
609
1135
            Attribute { local, value, .. } => {
610
1135
                // decode_xml_entities returns Cow::Borrowed when no entities
611
1135
                // are present (the common case), so `.into_owned()` is the
612
1135
                // only fresh allocation here. The key is copied via
613
1135
                // `to_string()` because we can't hold a borrow across token
614
1135
                // iterations. TODO: when we switch current_attrs to
615
1135
                // Vec<(&str, Cow<str>)> this becomes zero-alloc for the key.
616
1135
                current_attrs.push((
617
1135
                    local.to_string(),
618
1135
                    decode_xml_entities(value.as_str()).into_owned(),
619
1135
                ));
620
1135
            }
621
            ElementEnd { end: Open, .. } => {
622
11746
                if pending_open {
623
11746
                    let is_void = VOID_ELEMENTS.contains(&current_tag.as_str());
624
11746
                    if current_tag == "style" {
625
197
                        inside_style_tag = true;
626
197
                        style_text.clear();
627
11549
                    }
628
11746
                    if current_tag == "head" {
629
204
                        head_depth += 1;
630
11542
                    }
631
11746
                    if head_depth == 0 {
632
11343
                        finalize_open(
633
11343
                            &mut builder,
634
11343
                            &mut str_arena,
635
11343
                            &current_tag,
636
11343
                            &current_attrs,
637
11343
                            &css_key_map,
638
11343
                        );
639
11343
                        if is_void {
640
                            builder.close_node();
641
11343
                        }
642
403
                    }
643
11746
                    if !is_void {
644
11746
                        // Use take() instead of clone() — after pending_open=false,
645
11746
                        // current_tag is not read again until the next ElementStart
646
11746
                        // reassigns it via lowercase_into.
647
11746
                        tag_stack.push(core::mem::take(&mut current_tag));
648
11746
                    }
649
11746
                    pending_open = false;
650
                }
651
            }
652
            ElementEnd { end: Empty, .. } => {
653
                // Self-closing element: open + immediately close
654
13
                if pending_open {
655
13
                    if current_tag == "head" {
656
                        head_depth += 1;
657
13
                    }
658
13
                    if head_depth == 0 {
659
13
                        finalize_open(
660
13
                            &mut builder,
661
13
                            &mut str_arena,
662
13
                            &current_tag,
663
13
                            &current_attrs,
664
13
                            &css_key_map,
665
13
                        );
666
13
                        builder.close_node();
667
13
                    }
668
13
                    if current_tag == "head" && head_depth > 0 {
669
                        head_depth -= 1;
670
13
                    }
671
13
                    pending_open = false;
672
                }
673
            }
674
            ElementEnd {
675
11745
                end: Close(_, close_value),
676
                ..
677
            } => {
678
11745
                if pending_open {
679
                    let is_void = VOID_ELEMENTS.contains(&current_tag.as_str());
680
                    if current_tag == "head" {
681
                        head_depth += 1;
682
                    }
683
                    if head_depth == 0 {
684
                        finalize_open(
685
                            &mut builder,
686
                            &mut str_arena,
687
                            &current_tag,
688
                            &current_attrs,
689
                            &css_key_map,
690
                        );
691
                        if is_void {
692
                            builder.close_node();
693
                        }
694
                    }
695
                    if !is_void {
696
                        tag_stack.push(core::mem::take(&mut current_tag));
697
                    }
698
                    pending_open = false;
699
11745
                }
700

            
701
11745
                let close_lower = close_value.as_str().to_ascii_lowercase();
702
11745
                let close_str = close_lower.as_str();
703
11745
                if VOID_ELEMENTS.contains(&close_str) {
704
                    continue;
705
11745
                }
706

            
707
                // If closing a <style> tag, parse collected CSS
708
11745
                if close_str == "style" && inside_style_tag {
709
197
                    if !style_text.is_empty() {
710
197
                        let parsed_css = Css::from_string(core::mem::take(&mut style_text).into());
711
197
                        collected_css.push(parsed_css);
712
197
                    }
713
197
                    inside_style_tag = false;
714
11548
                }
715

            
716
                // Pop until we find matching tag
717
11753
                while let Some(top) = tag_stack.last() {
718
11727
                    let is_match = top == close_str;
719
11727
                    let was_head = top == "head";
720
                    // Pop this tag (unconditionally auto-close mismatched tags)
721
11727
                    let popped = tag_stack.pop().unwrap();
722
11727
                    if popped == "head" && head_depth > 0 {
723
197
                        head_depth -= 1;
724
11530
                    }
725
11727
                    if head_depth == 0 && !was_head {
726
11332
                        builder.close_node();
727
11332
                    }
728
11727
                    if is_match {
729
11719
                        break;
730
8
                    }
731
                }
732
            }
733
2382
            Text { text } => {
734
2382
                if pending_open {
735
                    let is_void = VOID_ELEMENTS.contains(&current_tag.as_str());
736
                    if current_tag == "style" {
737
                        inside_style_tag = true;
738
                        style_text.clear();
739
                    }
740
                    if current_tag == "head" {
741
                        head_depth += 1;
742
                    }
743
                    if head_depth == 0 {
744
                        finalize_open(
745
                            &mut builder,
746
                            &mut str_arena,
747
                            &current_tag,
748
                            &current_attrs,
749
                            &css_key_map,
750
                        );
751
                        if is_void {
752
                            builder.close_node();
753
                        }
754
                    }
755
                    if !is_void {
756
                        tag_stack.push(current_tag.clone());
757
                    }
758
                    pending_open = false;
759
2382
                }
760

            
761
2382
                let text_str = text.as_str();
762
2382
                if !text_str.is_empty() {
763
2382
                    if inside_style_tag {
764
197
                        style_text.push_str(text_str);
765
2185
                    } else if head_depth == 0 {
766
                        // Skip whitespace-only text at <html> level (between </head> and <body>)
767
                        // but keep whitespace inside <body> (it's significant for inline layout)
768
3045
                        let inside_body = tag_stack.iter().any(|t| t == "body");
769
1816
                        if inside_body || !text_str.trim().is_empty() {
770
1254
                            let decoded = decode_xml_entities(text_str);
771
1254
                            builder.add_leaf(
772
1254
                                NodeData::create_text_do_not_use_without_block_level_wrapper(
773
1254
                                    str_arena.intern(&decoded),
774
1254
                                ),
775
1254
                            );
776
1264
                        }
777
369
                    }
778
                }
779
            }
780
            _ => {}
781
        }
782
    }
783

            
784
    // Close any remaining open elements
785
323
    if pending_open {
786
        finalize_open(
787
            &mut builder,
788
            &mut str_arena,
789
            &current_tag,
790
            &current_attrs,
791
            &css_key_map,
792
        );
793
323
    }
794
334
    while tag_stack.pop().is_some() {
795
11
        builder.close_node();
796
11
    }
797

            
798
    // Drop the arena handle explicitly. AzStrings already embedded in
799
    // the FastDom keep the backing bytes alive via their cloned Arc refs.
800
323
    drop(str_arena);
801

            
802
323
    Ok((builder.finish(), collected_css))
803
367
}
804

            
805
/// Loads, parses and builds a DOM from an XML file
806
///
807
/// **Warning**: The file is reloaded from disk on every function call - do not
808
/// use this in release builds! This function deliberately never fails: In an error case,
809
/// the error gets rendered as a `NodeType::Label`.
810
#[cfg(all(feature = "std", feature = "xml"))]
811
4
pub fn domxml_from_file<I: AsRef<Path>>(file_path: I, component_map: &ComponentMap) -> DomXml {
812
    use std::fs;
813

            
814
4
    let error_css = Css::empty();
815

            
816
4
    let xml = match fs::read_to_string(file_path.as_ref()) {
817
        Ok(xml) => xml,
818
4
        Err(e) => {
819
4
            return DomXml {
820
4
                parsed_dom: {
821
4
                    let mut dom = Dom::create_body().with_children(
822
4
                        vec![Dom::create_p_with_text(format!(
823
4
                            "Error reading: \"{}\": {}",
824
4
                            file_path.as_ref().to_string_lossy(),
825
4
                            e
826
4
                        ))]
827
4
                        .into(),
828
4
                    );
829
4
                    StyledDom::create(&mut dom, error_css)
830
4
                },
831
4
            };
832
        }
833
    };
834

            
835
    domxml_from_str(&xml, component_map)
836
4
}
837

            
838
/// Parses the XML string into an XML tree, returns
839
/// the root `<app></app>` node, with the children attached to it.
840
///
841
/// Since the XML allows multiple root nodes, this function returns
842
/// a `Vec<XmlNode>` - which are the "root" nodes, containing all their
843
/// children recursively.
844
#[cfg(feature = "xml")]
845
#[allow(clippy::too_many_lines)] // large but cohesive: single-purpose layout/render/parse routine (one branch per case)
846
/// # Errors
847
///
848
/// Returns an `XmlError` if the XML cannot be parsed.
849
3146
pub fn parse_xml_string(xml: &str) -> Result<Vec<XmlNodeChild>, XmlError> {
850
    use xmlparser::{
851
        ElementEnd::{Close, Empty},
852
        Token::{Attribute, ElementEnd, ElementStart, Text},
853
        Tokenizer,
854
    };
855

            
856
    use self::XmlParseError::*;
857

            
858
    // HTML5-lite parser: List of void elements that should auto-close
859
    // See: https://developer.mozilla.org/en-US/docs/Glossary/Void_element
860
    const VOID_ELEMENTS: &[&str] = &[
861
        "area", "base", "br", "col", "embed", "hr", "img", "input", "link", "meta", "param",
862
        "source", "track", "wbr",
863
    ];
864

            
865
    // HTML5-lite parser: Elements that auto-close when certain other elements are encountered
866
    // Format: (element_name, closes_when_encountering)
867
    const AUTO_CLOSE_RULES: &[(&str, &[&str])] = &[
868
        // List items close when encountering another list item or when parent closes
869
        ("li", &["li"]),
870
        // Table cells/rows have complex closing rules
871
        ("td", &["td", "th", "tr"]),
872
        ("th", &["td", "th", "tr"]),
873
        ("tr", &["tr"]),
874
        // Paragraphs close on block-level elements
875
        (
876
            "p",
877
            &[
878
                "address",
879
                "article",
880
                "aside",
881
                "blockquote",
882
                "div",
883
                "dl",
884
                "fieldset",
885
                "footer",
886
                "form",
887
                "h1",
888
                "h2",
889
                "h3",
890
                "h4",
891
                "h5",
892
                "h6",
893
                "header",
894
                "hr",
895
                "main",
896
                "nav",
897
                "ol",
898
                "p",
899
                "pre",
900
                "section",
901
                "table",
902
                "ul",
903
            ],
904
        ),
905
        // Option closes on another option or optgroup
906
        ("option", &["option", "optgroup"]),
907
        ("optgroup", &["optgroup"]),
908
        // DD/DT close on each other
909
        ("dd", &["dd", "dt"]),
910
        ("dt", &["dd", "dt"]),
911
    ];
912

            
913
3146
    let mut root_node = XmlNode::default();
914

            
915
    // Strip UTF-8 BOM if present (some W3C test files have it)
916
3146
    let xml = xml.strip_prefix('\u{FEFF}').unwrap_or(xml);
917

            
918
    // Search for "<?xml" and "?>" tags and delete them from the XML
919
3146
    let mut xml = xml.trim();
920
3146
    if xml.starts_with("<?") {
921
27
        let pos = xml
922
27
            .find("?>")
923
27
            .ok_or(XmlError::MalformedHierarchy(MalformedHierarchyError {
924
27
                expected: "<?xml".into(),
925
27
                got: "?>".into(),
926
27
            }))?;
927
21
        xml = &xml[(pos + 2)..];
928
3119
    }
929

            
930
    // Delete <!DOCTYPE ...> if necessary (case-insensitive)
931
3140
    let mut xml = xml.trim();
932
3140
    if xml.len() > 9
933
3047
        && xml.is_char_boundary(9)
934
3045
        && xml[..9].to_ascii_lowercase().starts_with("<!doctype")
935
    {
936
3
        let pos = xml
937
3
            .find('>')
938
3
            .ok_or(XmlError::MalformedHierarchy(MalformedHierarchyError {
939
3
                expected: "<!DOCTYPE".into(),
940
3
                got: ">".into(),
941
3
            }))?;
942
2
        xml = &xml[(pos + 1)..];
943
3137
    } else if xml.starts_with("<!--") {
944
        // Skip HTML comments at the start
945
15
        if let Some(end) = xml.find("-->") {
946
11
            xml = &xml[(end + 3)..];
947
11
            xml = xml.trim();
948
14
        }
949
3122
    }
950

            
951
3139
    let tokenizer = Tokenizer::from_fragment(xml, 0..xml.len());
952

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

            
960
    // Track which hierarchy level is a void element (shouldn't be pushed to hierarchy)
961
3139
    let mut last_was_void = false;
962

            
963
370143
    for token in tokenizer {
964
367052
        let token = token.map_err(|e| XmlError::ParserError(translate_xmlparser_error(e)))?;
965
173175
        match token {
966
113083
            ElementStart { local, .. } => {
967
113083
                let tag_name = local.to_string();
968
113083
                let is_void_element = VOID_ELEMENTS.contains(&tag_name.as_str());
969

            
970
                // HTML5-lite: If last element was a void element (like <img src="...">),
971
                // pop it from hierarchy before processing the new element
972
113083
                if last_was_void {
973
4
                    node_stack.pop();
974
4
                    last_was_void = false;
975
113079
                }
976

            
977
                // HTML5-lite: Check if we need to auto-close the current element
978
113083
                if node_stack.len() > 1 {
979
                    // SAFETY: We only access the last element, which is valid
980
109871
                    let current_element = unsafe { &*node_stack[node_stack.len() - 1] };
981
109871
                    let current_tag = current_element.node_type.as_str();
982

            
983
                    // Check if current element should auto-close when encountering this new tag
984
1098210
                    for (element, closes_on) in AUTO_CLOSE_RULES {
985
988411
                        if current_tag == *element && closes_on.contains(&tag_name.as_str()) {
986
                            // Auto-close the current element
987
72
                            node_stack.pop();
988
72
                            break;
989
988339
                        }
990
                    }
991
3212
                }
992

            
993
                // SAFETY: We access the last element which is valid
994
113083
                if let Some(&current_parent_ptr) = node_stack.last() {
995
113083
                    let current_parent = unsafe { &mut *current_parent_ptr };
996

            
997
113083
                    current_parent.children.push(XmlNodeChild::Element(XmlNode {
998
113083
                        node_type: tag_name.into(),
999
113083
                        attributes: StringPairVec::new().into(),
113083
                        children: Vec::new().into(),
113083
                    }));
                    // Get pointer to the newly added child
113083
                    let children_len = current_parent.children.len();
113083
                    if let Some(XmlNodeChild::Element(ref mut new_child)) =
113083
                        current_parent.children.as_mut().get_mut(children_len - 1)
113083
                    {
113083
                        node_stack.push(std::ptr::from_mut::<XmlNode>(new_child));
113083
                    }
113083
                    last_was_void = is_void_element;
                }
            }
            ElementEnd { end: Empty, .. } => {
                // Pop hierarchy for all elements (including void elements after their attributes)
52792
                if node_stack.len() > 1 {
52792
                    node_stack.pop();
52792
                }
52792
                last_was_void = false;
            }
            ElementEnd {
60102
                end: Close(_, close_value),
                ..
            } => {
                // HTML5-lite: If last element was a void element, pop it first
60102
                if last_was_void {
42
                    node_stack.pop();
42
                    last_was_void = false;
60060
                }
                // HTML5-lite: Check if this is a void element - if so, ignore the closing tag
60102
                let is_void_element = VOID_ELEMENTS.contains(&close_value.as_str());
60102
                if is_void_element {
                    // Void elements shouldn't have closing tags, but tolerate them
42
                    continue;
60060
                }
                // HTML5-lite: Auto-close any elements that should be closed
                // Walk up the hierarchy and auto-close elements until we find a match
60060
                let close_value_str = close_value.as_str();
                // Find matching element in stack (skip root at index 0)
60060
                let mut found_idx = None;
60110
                for i in (1..node_stack.len()).rev() {
                    // SAFETY: All pointers in stack are valid
60106
                    let node = unsafe { &*node_stack[i] };
60106
                    if node.node_type.as_str() == close_value_str {
60048
                        found_idx = Some(i);
60048
                        break;
58
                    }
                }
60060
                if let Some(idx) = found_idx {
60048
                    // Pop all elements from current position to the matching element (inclusive)
60048
                    node_stack.truncate(idx);
60048
                }
                // If no match found, just ignore (lenient HTML parsing)
60060
                last_was_void = false;
            }
17318
            Attribute { local, value, .. } => {
                // SAFETY: Last element in stack is valid
17318
                if let Some(&last_ptr) = node_stack.last() {
17318
                    let last = unsafe { &mut *last_ptr };
17318
                    // NOTE: Only lowercase the key ("local"), not the value!
17318
                    // Decode XML entities in attribute values as well
17318
                    last.attributes.push(azul_core::window::AzStringPair {
17318
                        key: local.to_string().into(),
17318
                        value: AzString::from(&*decode_xml_entities(value.as_str())),
17318
                    });
17318
                }
            }
63428
            Text { text } => {
                // HTML5-lite: If last element was a void element, pop it before adding text
63428
                if last_was_void {
40
                    node_stack.pop();
40
                    last_was_void = false;
63388
                }
                // IMPORTANT: Preserve ALL text nodes including whitespace-only nodes.
                // Whether whitespace is significant depends on the CSS `white-space` property,
                // which is determined during layout, not during parsing.
                //
                // For example: <pre><span>    </span></pre> must preserve the 4 spaces.
                //
                // We only skip completely EMPTY text nodes (zero-length strings).
63428
                let text_str = text.as_str();
63428
                if !text_str.is_empty() {
                    // SAFETY: Last element in stack is valid
63428
                    if let Some(&current_parent_ptr) = node_stack.last() {
63428
                        let current_parent = unsafe { &mut *current_parent_ptr };
63428
                        // Decode XML entities (e.g., &lt; -> <, &gt; -> >, etc.)
63428
                        let decoded_text = decode_xml_entities(text_str);
63428
                        // Add text as a child node
63428
                        current_parent
63428
                            .children
63428
                            .push(XmlNodeChild::Text(AzString::from(&*decoded_text)));
63428
                    }
                }
            }
60281
            _ => {}
        }
    }
    // Clean up: if we ended with a void element, pop it
3091
    if last_was_void {
5
        node_stack.pop();
3086
    }
    // A well-formed document unwinds back to just the root sentinel. If an element was
    // left open (e.g. a bare "<svg" with no closing bracket, which the fragment tokenizer
    // yields as one ElementStart then cleanly ends), node_stack still holds it — reject
    // it instead of returning a "valid" partial tree.
3091
    if node_stack.len() != 1 {
13
        return Err(XmlError::UnclosedRootNode);
3078
    }
3078
    Ok(root_node.children.into())
3146
}
#[cfg(feature = "xml")]
/// # Errors
///
/// Returns an `XmlError` if the XML cannot be parsed.
156
pub fn parse_xml(s: &str) -> Result<Xml, XmlError> {
    Ok(Xml {
156
        root: parse_xml_string(s)?.into(),
    })
156
}
#[cfg(not(feature = "xml"))]
pub fn parse_xml(s: &str) -> Result<Xml, XmlError> {
    Err(XmlError::NoParserAvailable)
}
// to_string(&self) -> String
#[cfg(feature = "xml")]
#[must_use]
7
pub fn translate_roxmltree_expandedname(e: roxmltree::ExpandedName<'_, '_>) -> XmlQualifiedName {
7
    let ns: Option<AzString> = e.namespace().map(|e| e.to_string().into());
7
    XmlQualifiedName {
7
        local_name: e.name().to_string().into(),
7
        namespace: ns.into(),
7
    }
7
}
#[cfg(feature = "xml")]
2
fn translate_roxmltree_attribute(e: roxmltree::Attribute<'_, '_>) -> XmlQualifiedName {
    XmlQualifiedName {
2
        local_name: e.name().to_string().into(),
2
        namespace: e.namespace().map(|e| e.to_string().into()).into(),
    }
2
}
#[cfg(feature = "xml")]
106
fn translate_xmlparser_streamerror(e: xmlparser::StreamError) -> XmlStreamError {
106
    match e {
8
        xmlparser::StreamError::UnexpectedEndOfStream => XmlStreamError::UnexpectedEndOfStream,
48
        xmlparser::StreamError::InvalidName => XmlStreamError::InvalidName,
1
        xmlparser::StreamError::InvalidReference => XmlStreamError::InvalidReference,
1
        xmlparser::StreamError::InvalidExternalID => XmlStreamError::InvalidExternalID,
1
        xmlparser::StreamError::InvalidCommentData => XmlStreamError::InvalidCommentData,
1
        xmlparser::StreamError::InvalidCommentEnd => XmlStreamError::InvalidCommentEnd,
8
        xmlparser::StreamError::InvalidCharacterData => XmlStreamError::InvalidCharacterData,
11
        xmlparser::StreamError::NonXmlChar(c, tp) => XmlStreamError::NonXmlChar(NonXmlCharError {
11
            ch: c.into(),
11
            pos: translate_xmlparser_textpos(tp),
11
        }),
1
        xmlparser::StreamError::InvalidChar(a, b, tp) => {
1
            XmlStreamError::InvalidChar(InvalidCharError {
1
                expected: a,
1
                got: b,
1
                pos: translate_xmlparser_textpos(tp),
1
            })
        }
1
        xmlparser::StreamError::InvalidCharMultiple(a, b, tp) => {
1
            XmlStreamError::InvalidCharMultiple(InvalidCharMultipleError {
1
                expected: a,
1
                got: b.to_vec().into(),
1
                pos: translate_xmlparser_textpos(tp),
1
            })
        }
8
        xmlparser::StreamError::InvalidQuote(a, tp) => {
8
            XmlStreamError::InvalidQuote(InvalidQuoteError {
8
                got: a,
8
                pos: translate_xmlparser_textpos(tp),
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
            })
        }
    }
106
}
#[cfg(feature = "xml")]
102
fn translate_xmlparser_error(e: xmlparser::Error) -> XmlParseError {
102
    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
        }),
41
        xmlparser::Error::InvalidElement(se, tp) => XmlParseError::InvalidElement(XmlTextError {
41
            stream_error: translate_xmlparser_streamerror(se),
41
            pos: translate_xmlparser_textpos(tp),
41
        }),
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))
        }
    }
102
}
#[cfg(feature = "xml")]
#[must_use]
31
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]
145
const fn translate_xmlparser_textpos(o: xmlparser::TextPos) -> XmlTextPos {
145
    XmlTextPos {
145
        row: o.row,
145
        col: o.col,
145
    }
145
}
#[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 {
258
    fn from_xml_string<S: AsRef<str>>(xml: S) -> StyledDom {
258
        let component_map = ComponentMap::with_builtin();
258
        let dom_xml = domxml_from_str(xml.as_ref(), &component_map);
258
        dom_xml.parsed_dom
258
    }
}
// ============================================================================
// 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
    // ------------------------------------------------------------------
    /// `<transient-window>` parses to its NodeType with the attributes applied
    /// onto the inline config — and those attributes do NOT leak into the
    /// generic attribute list, where they would be meaningless.
    #[test]
    fn transient_window_tag_parses_its_attributes_into_the_config() {
        use azul_core::transient::{TransientAnchor, TransientDismiss};
        let dom = parse_xml_to_fast_dom(
            r#"<div><transient-window open="true" anchor="right" dismiss="escape" size="300x200" class="picker"><p>hi</p></transient-window></div>"#,
        )
        .expect("parses");
        let n = nodes(&dom);
        let tw = n
            .iter()
            .find_map(|nd| match nd.get_node_type() {
                NodeType::TransientWindow(c) => Some((*c, nd)),
                _ => None,
            })
            .expect("a TransientWindow node");
        let (cfg, nd) = tw;
        assert!(cfg.open, "open=\"true\" must open it");
        assert_eq!(cfg.anchor, TransientAnchor::Right);
        assert_eq!(cfg.dismiss, TransientDismiss::Escape);
        assert!(
            matches!(cfg.size, azul_core::geom::OptionLogicalSize::Some(s) if s.width == 300.0)
        );
        // `class` is an ordinary attribute and must survive; the popup keys
        // must NOT have been stored as attributes.
        let classes: Vec<String> = nd
            .get_ids_and_classes()
            .iter()
            .filter_map(|ic| match ic {
                azul_core::dom::IdOrClass::Class(c) => Some(c.as_str().to_string()),
                _ => None,
            })
            .collect();
        assert_eq!(classes, vec!["picker".to_string()]);
    }
    /// With no attributes at all the tag is a CLOSED popup — the default must
    /// never open a window by accident.
    #[test]
    fn a_bare_transient_window_tag_is_closed() {
        let dom = parse_xml_to_fast_dom("<div><transient-window/></div>").expect("parses");
        let closed = nodes(&dom)
            .iter()
            .any(|nd| matches!(nd.get_node_type(), NodeType::TransientWindow(c) if !c.open));
        assert!(closed, "a bare <transient-window/> must parse as closed");
    }
    #[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"));
        // `contenteditable="false"` is kept as the attribute the editable
        // inheritance walk and the edit-buffer collector wall a subtree off
        // by; anything that is not the literal `false` is not.
        let walled = |v: &str| {
            let dom = parse_xml_to_fast_dom(&doc(&format!(r#"<div contenteditable="{v}"></div>"#)))
                .expect("valid");
            nodes(&dom)[2]
                .attributes()
                .as_ref()
                .iter()
                .any(|a| matches!(a, azul_core::dom::AttributeType::ContentEditable(false)))
        };
        assert!(walled("false"));
        assert!(!walled("true"));
        assert!(!walled("FALSE"));
        assert!(!walled(""));
    }
    #[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::{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: &azul_core::dom::NodeData,
            _: &SystemStyle,
        ) -> Dom {
            let marker = if data.is_some() {
                "RESOLVED"
            } else {
                "MISSING"
            };
            let mut replacement = Dom::create_div();
            replacement.root = original.clone();
            replacement
                .root
                .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 styled = parse_xml_to_styled_dom_resolving_icons(
            "<html><body><icon> content_copy </icon><icon>missing:x, \
             testpack:CONTENT_COPY</icon><icon>unknown_icon</icon></body></html>",
            &provider,
            &SystemStyle::default(),
        )
        .expect("icon markup must cascade");
        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 a_fragment_becomes_a_document_and_a_broken_one_still_reports() {
        // BROWSER-LIKE: a fragment gets a synthesised `<html><body>` root,
        // so it renders as ITSELF rather than as the text "No <html> node
        // found as the root of the file" - which used to lay out and paint
        // like any other text, so a caller measuring pixels saw an error
        // message it never asked for.
        for root in [
            Vec::new(),
            vec![XmlNodeChild::Text("bare text".into())],
            vec![XmlNodeChild::Element(XmlNode::create("div"))],
            vec![XmlNodeChild::Element(XmlNode::create("svg"))],
        ] {
            let dom = dom_from_parsed_xml(Xml { root: root.into() });
            assert!(
                matches!(dom.root.get_node_type(), NodeType::Html),
                "a real document is rooted at <html>, got {:?} - a <body> root here would mean \
                 the ERROR Dom came back instead",
                dom.root.get_node_type()
            );
        }
        // An EXPLICIT `<html>` with no `<body>` is still an error Dom: the
        // author stated the structure, so a missing body is their mistake and
        // reporting it is more useful than guessing.
        let broken = dom_from_parsed_xml(Xml {
            root: vec![XmlNodeChild::Element(XmlNode::create("html"))].into(),
        });
        assert!(matches!(broken.root.get_node_type(), NodeType::Body));
        assert_eq!(broken.children.as_ref().len(), 1, "one label child");
    }
    #[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");
    }
    /// Runs on a thread with an 8 MiB stack ON PURPOSE - see the twin
    /// `xml_node_to_dom_fast_deep_nesting_ok` in azul-core. libtest hands each
    /// test the platform default (2 MiB on Linux), which is smaller than any
    /// context this code really runs in; a debug-profile frame of the builder
    /// is big enough that the 512 the cap allows clear 2 MiB, so the
    /// dev-profile CI job aborted here with "has overflowed its stack" while
    /// the product itself was fine on its 8 MiB main thread.
    #[test]
    fn dom_from_parsed_xml_caps_recursion_on_deeply_nested_input() {
        std::thread::Builder::new()
            .stack_size(8 * 1024 * 1024)
            .spawn(|| {
                // 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));
            })
            .expect("spawn deep-nesting probe")
            .join()
            .expect("deep DOM build must not overflow the stack");
    }
    // ------------------------------------------------------------------
    // 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
            })
        );
    }
}