Skip to main content

azul_layout/xml/
mod.rs

1//! XML/HTML parsing module for the Azul toolkit.
2//!
3//! Provides two parsing paths:
4//! - `parse_xml_string`: builds an `XmlNode` tree (used by `domxml_from_str`)
5//! - `parse_xml_to_fast_dom_with_css`: builds an arena-based `FastDom` directly
6//!   from XML tokens (used by `parse_xml_to_styled_dom`)
7//!
8//! Both paths handle HTML5-lite features: void elements, auto-closing tags,
9//! XML entity decoding, `<style>` CSS extraction, and BOM/DOCTYPE stripping.
10//!
11//! Data types (`XmlNode`, `XmlError`, etc.) live in `azul_core::xml`; this
12//! module provides the parsing implementations.
13
14#![allow(unused_variables)]
15
16use alloc::{boxed::Box, collections::BTreeMap, string::String, vec::Vec};
17use core::fmt;
18#[cfg(feature = "std")]
19use std::path::Path;
20
21#[cfg(feature = "svg")]
22pub 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).
28fn decode_xml_entities(s: &str) -> std::borrow::Cow<'_, str> {
29    // Fast path: if no ampersand, no entities to decode
30    if !s.contains('&') {
31        return std::borrow::Cow::Borrowed(s);
32    }
33    decode_xml_entities_slow(s)
34}
35
36fn decode_xml_entities_slow(s: &str) -> std::borrow::Cow<'_, str> {
37    let mut result = String::with_capacity(s.len());
38    let mut chars = s.chars().peekable();
39    
40    while let Some(c) = chars.next() {
41        if c == '&' {
42            // Collect the entity reference
43            let mut entity = String::new();
44            let mut found_semicolon = false;
45            
46            while let Some(&next) = chars.peek() {
47                if next == ';' {
48                    chars.next();
49                    found_semicolon = true;
50                    break;
51                }
52                if !next.is_alphanumeric() && next != '#' {
53                    break;
54                }
55                entity.push(chars.next().unwrap());
56                if entity.len() > 10 {
57                    // Entity too long, not a valid entity
58                    break;
59                }
60            }
61            
62            if found_semicolon {
63                // Try to decode the entity
64                match entity.as_str() {
65                    "lt" => result.push('<'),
66                    "gt" => result.push('>'),
67                    "amp" => result.push('&'),
68                    "apos" => result.push('\''),
69                    "quot" => result.push('"'),
70                    "nbsp" => result.push('\u{00A0}'),
71                    s if s.starts_with('#') => {
72                        // Numeric character reference
73                        let num_str = &s[1..];
74                        let code_point = if num_str.starts_with('x') || num_str.starts_with('X') {
75                            // Hexadecimal
76                            u32::from_str_radix(&num_str[1..], 16).ok()
77                        } else {
78                            // Decimal
79                            num_str.parse::<u32>().ok()
80                        };
81                        if let Some(cp) = code_point {
82                            if let Some(ch) = char::from_u32(cp) {
83                                result.push(ch);
84                            } else {
85                                // Invalid code point, keep original
86                                result.push('&');
87                                result.push_str(&entity);
88                                result.push(';');
89                            }
90                        } else {
91                            // Parse failed, keep original
92                            result.push('&');
93                            result.push_str(&entity);
94                            result.push(';');
95                        }
96                    }
97                    _ => {
98                        // Unknown entity, keep original
99                        result.push('&');
100                        result.push_str(&entity);
101                        result.push(';');
102                    }
103                }
104            } else {
105                // No semicolon found, not a valid entity reference
106                result.push('&');
107                result.push_str(&entity);
108            }
109        } else {
110            result.push(c);
111        }
112    }
113    
114    std::borrow::Cow::Owned(result)
115}
116
117pub use azul_core::xml::*;
118use azul_core::{dom::Dom, impl_from, styled_dom::StyledDom, window::StringPairVec};
119#[cfg(feature = "parser")]
120use azul_css::parser2::CssParseError;
121use azul_css::{css::Css, AzString, OptionString, U8Vec};
122use xmlparser::Tokenizer;
123
124#[cfg(feature = "xml")]
125#[must_use] pub fn domxml_from_str(xml: &str, component_map: &ComponentMap) -> DomXml {
126    let error_css = Css::empty();
127
128    let parsed = match parse_xml_string(xml) {
129        Ok(parsed) => parsed,
130        Err(e) => {
131            return DomXml {
132                parsed_dom: {
133                    let mut dom = Dom::create_body()
134                        .with_children(vec![Dom::create_text(format!("{e}"))].into());
135                    StyledDom::create(&mut dom, error_css)
136                },
137            };
138        }
139    };
140
141    let parsed_dom = match str_to_dom(parsed.as_ref(), component_map, None) {
142        Ok(o) => o,
143        Err(e) => {
144            return DomXml {
145                parsed_dom: {
146                    let mut dom = Dom::create_body()
147                        .with_children(vec![Dom::create_text(format!("{e}"))].into());
148                    StyledDom::create(&mut dom, error_css)
149                },
150            };
151        }
152    };
153
154    DomXml { parsed_dom }
155}
156
157/// Create a Dom (with CSS attached but not applied) from an already-parsed Xml structure.
158///
159/// Returns an unstyled `Dom` suitable for use in layout callbacks (which return `Dom`,
160/// not `StyledDom`). The CSS from `<style>` tags is attached to the `Dom.css` field
161/// and will be applied during the cascade pass.
162// FFI-exported (api.json fn_body azul_layout::xml::dom_from_parsed_xml(xml)): owned Xml by value.
163#[allow(clippy::needless_pass_by_value)]
164#[must_use] pub fn dom_from_parsed_xml(xml: Xml) -> Dom {
165    let component_map = ComponentMap::with_builtin();
166    match str_to_dom_unstyled(xml.root.as_ref(), &component_map) {
167        Ok(dom) => dom,
168        Err(e) => Dom::create_body().with_children(vec![Dom::create_text(format!("{e}"))].into()),
169    }
170}
171
172/// Fastest path: parse XML string directly into `FastDom` without intermediate `XmlNode` tree.
173///
174/// Feeds XML tokenizer events directly into `CompactDomBuilder`, skipping both the
175/// `XmlNode` tree construction AND the Dom tree construction.
176/// Parse XML string directly into a `FastDom` (arena-based DOM) in a single pass.
177///
178/// Also extracts `<style>` tag content as CSS. Returns both the `FastDom` and
179/// collected CSS stylesheets. No intermediate `XmlNode` tree is built.
180///
181/// This is the fastest XML→DOM path: XML tokens feed directly into
182/// `CompactDomBuilder`, and `<style>` text is collected inline.
183/// # Errors
184///
185/// Returns an `XmlError` if the XML cannot be parsed.
186pub fn parse_xml_to_fast_dom(xml: &str) -> Result<azul_core::dom::FastDom, XmlError> {
187    let (fast_dom, _css) = parse_xml_to_fast_dom_with_css(xml)?;
188    Ok(fast_dom)
189}
190
191/// Parse XML directly into `FastDom` + extracted CSS, ready for `StyledDom`.
192#[allow(clippy::cast_precision_loss)] // bounded layout/render numeric cast
193/// # Errors
194///
195/// Returns an `XmlError` if the XML cannot be parsed.
196pub fn parse_xml_to_styled_dom(xml: &str) -> Result<StyledDom, XmlError> {
197    // Optional per-phase RSS/timing breakdown.
198    // Gated on AZ_MEM_BREAKDOWN=1 — prints
199    //   [XML] tokenize+fast_dom       : +XX MiB in YY ms
200    //   [XML] css attach              : +XX MiB in YY ms
201    //   [XML] create_from_fast_dom    : +XX MiB in YY ms
202    // to locate which sub-phase of the parse-cascade dominates the
203    // RSS jump seen between `page start` and `xml parsed`.
204    static MEM_ENABLED: std::sync::OnceLock<bool> = std::sync::OnceLock::new();
205    let mem_on = *MEM_ENABLED.get_or_init(azul_core::profile::memory_enabled);
206
207    let rss0 = if mem_on { peak_rss_bytes() } else { 0 };
208    let (mut fast_dom, css) = parse_xml_to_fast_dom_with_css(xml)?;
209    if mem_on {
210        let rss1 = peak_rss_bytes();
211        eprintln!(
212            "[XML] tokenize+fast_dom       : +{:.2} MiB",
213            (rss1.saturating_sub(rss0)) as f64 / 1024.0 / 1024.0,
214        );
215    }
216
217    let rss1 = if mem_on { peak_rss_bytes() } else { 0 };
218    // Attach CSS to the FastDom
219    if !css.is_empty() {
220        let combined_css = Css::new(css.into_iter()
221            .flat_map(|c| c.rules.into_library_owned_vec())
222            .collect());
223        fast_dom.css = vec![azul_core::dom::CssWithNodeId {
224            node_id: 0, // global scope
225            css: combined_css,
226        }].into();
227    }
228    if mem_on {
229        let rss2 = peak_rss_bytes();
230        eprintln!(
231            "[XML] css attach              : +{:.2} MiB",
232            (rss2.saturating_sub(rss1)) as f64 / 1024.0 / 1024.0,
233        );
234    }
235
236    // Hint the allocator to return pages freed by the CSS parser.
237    // The tokenizer+parser created many small allocations (selectors,
238    // declarations, strings) that are now packed into FastDom. Purging
239    // here returns those pages before the cascade allocates more.
240    crate::probe::hint_purge_allocator();
241
242    let rss2 = if mem_on { peak_rss_bytes() } else { 0 };
243    let styled = StyledDom::create_from_fast_dom(fast_dom);
244
245    // Major purge point: the cascade just freed ~3 MiB of intermediate
246    // allocations (build-phase Vecs, CSS selector matching state, pruned
247    // properties). Tell the allocator to return those pages NOW before
248    // the layout pass allocates more on top of them.
249    crate::probe::hint_purge_allocator();
250
251    if mem_on {
252        let rss3 = peak_rss_bytes();
253        eprintln!(
254            "[XML] create_from_fast_dom    : +{:.2} MiB",
255            (rss3.saturating_sub(rss2)) as f64 / 1024.0 / 1024.0,
256        );
257    }
258
259    Ok(styled)
260}
261
262/// Resident-set bytes for RSS checkpoints — mirrors servo-shot's
263/// `peak_rss_bytes()`. Uses `getrusage(RUSAGE_SELF)` via the
264/// `probe` feature's `libc` dep; returns 0 without it so the
265/// caller just doesn't emit meaningful deltas.
266#[cfg(all(unix, feature = "probe"))]
267fn peak_rss_bytes() -> u64 {
268    let mut usage: libc::rusage = unsafe { std::mem::zeroed() };
269    if unsafe { libc::getrusage(libc::RUSAGE_SELF, &mut usage) } != 0 {
270        return 0;
271    }
272    let ru = usage.ru_maxrss as u64;
273    // macOS reports bytes, Linux reports KiB.
274    #[cfg(target_os = "macos")]
275    { ru }
276    #[cfg(not(target_os = "macos"))]
277    { ru.saturating_mul(1024) }
278}
279
280#[cfg(not(all(unix, feature = "probe")))]
281const fn peak_rss_bytes() -> u64 {
282    0
283}
284
285/// Internal: parse XML into `FastDom` + collected CSS stylesheets.
286#[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)] // bounded layout/render numeric cast
287#[allow(clippy::too_many_lines, clippy::cognitive_complexity)] // large but cohesive: single-purpose layout/render/parse routine (one branch per case)
288fn parse_xml_to_fast_dom_with_css(xml: &str) -> Result<(azul_core::dom::FastDom, Vec<Css>), XmlError> {
289    use xmlparser::{ElementEnd::{Open, Empty, Close}, Token::{ElementStart, Attribute, ElementEnd, Text}, Tokenizer};
290    use azul_core::dom::{NodeData, NodeType, IdOrClass, TabIndex};
291    use azul_core::xml::CompactDomBuilder;
292
293    const ESTIMATED_BYTES_PER_NODE: usize = 20;
294
295    const VOID_ELEMENTS: &[&str] = &[
296        "area", "base", "br", "col", "embed", "hr", "img", "input", "link", "meta",
297        "param", "source", "track", "wbr",
298    ];
299
300    // Lowercase `src` into `dst`, reusing `dst`'s existing capacity.
301    // Zero-alloc when dst's capacity is already ≥ src.len() AND no uppercase
302    // conversion is needed (the happy path for HTML5 where tags are lowercase).
303    fn lowercase_into(dst: &mut String, src: &str) {
304        dst.clear();
305        if src.bytes().all(|b| !b.is_ascii_uppercase()) {
306            dst.push_str(src);
307        } else {
308            dst.reserve(src.len());
309            for b in src.bytes() {
310                dst.push(b.to_ascii_lowercase() as char);
311            }
312        }
313    }
314
315    // Strip BOM
316    let xml = xml.strip_prefix('\u{FEFF}').unwrap_or(xml);
317    let mut xml = xml.trim();
318
319    // Skip <?xml ... ?>
320    if xml.starts_with("<?") {
321        if let Some(pos) = xml.find("?>") {
322            xml = &xml[(pos + 2)..];
323        }
324    }
325
326    // Skip <!DOCTYPE ...>
327    let mut xml = xml.trim();
328    if xml.len() > 9 && xml[..9].to_ascii_lowercase().starts_with("<!doctype") {
329        if let Some(pos) = xml.find('>') {
330            xml = &xml[(pos + 1)..];
331        }
332    } else if xml.starts_with("<!--") {
333        if let Some(end) = xml.find("-->") {
334            xml = &xml[(end + 3)..];
335            xml = xml.trim();
336        }
337    }
338
339    let tokenizer = Tokenizer::from_fragment(xml, 0..xml.len());
340
341    let estimated_nodes = xml.len() / ESTIMATED_BYTES_PER_NODE;
342    let mut builder = CompactDomBuilder::with_capacity(estimated_nodes);
343    let mut collected_css: Vec<Css> = Vec::new();
344    let mut inside_style_tag = false;
345    let mut style_text = String::new();
346    // Track <head> depth: skip DOM nodes inside <head> (still collect <style> CSS).
347    // This ensures the FastDom contains only <html><body>... as the layout engine expects.
348    let mut head_depth: usize = 0;
349
350    // Temporary storage for current element's attributes
351    let mut current_tag: String = String::new();
352    let mut current_attrs: Vec<(String, String)> = Vec::new();
353    let mut pending_open = false;
354
355    // Pre-compute the CSS key map once (used for style= attribute parsing)
356    let css_key_map = azul_css::props::property::get_css_key_map();
357
358    // One bump arena for every AzString produced during this parse —
359    // id/class tokens, text nodes, etc. Replaces ~1k small heap allocs
360    // with a handful of 64 KiB chunks. Each AzString carries its own
361    // Arc reference to the arena, so the arena survives until the last
362    // string is dropped (typically when the StyledDom is dropped).
363    let mut str_arena = azul_css::corety::StringArena::new();
364
365    // Finalize the pending open element: create NodeData from tag + attrs, push to builder
366    // tag is already lowercase
367    let finalize_open = |
368        builder: &mut CompactDomBuilder,
369        str_arena: &mut azul_css::corety::StringArena,
370        tag: &str,
371        attrs: &[(String, String)],
372        css_key_map: &azul_css::props::property::CssKeyMap,
373    | {
374        let node_type = tag_to_node_type(tag);
375        let mut nd = NodeData::create_node(node_type);
376
377        // Apply attributes — build AttributeTypeVec directly (avoids the
378        // clone + retain dance in set_ids_and_classes for fresh NodeData).
379        let mut attr_vec: Vec<azul_core::dom::AttributeType> = Vec::new();
380        for (key, value) in attrs {
381            match key.as_str() {
382                "id" => {
383                    for id in value.split_whitespace() {
384                        attr_vec.push(azul_core::dom::AttributeType::Id(str_arena.intern(id)));
385                    }
386                }
387                "class" => {
388                    for class in value.split_whitespace() {
389                        attr_vec.push(azul_core::dom::AttributeType::Class(str_arena.intern(class)));
390                    }
391                }
392                "focusable" => {
393                    if let Some(f) = parse_bool(value.as_str()) {
394                        nd.set_tab_index(if f { TabIndex::Auto } else { TabIndex::NoKeyboardFocus });
395                    }
396                }
397                "tabindex" => {
398                    if let Ok(ti) = value.parse::<isize>() {
399                        match ti {
400                            0 => nd.set_tab_index(TabIndex::Auto),
401                            i if i > 0 => nd.set_tab_index(TabIndex::OverrideInParent(i as u32)),
402                            _ => nd.set_tab_index(TabIndex::NoKeyboardFocus),
403                        }
404                    }
405                }
406                "style" => {
407                    let mut css_attrs = Vec::new();
408                    for s in value.split(';') {
409                        let mut s = s.split(':');
410                        let Some(key) = s.next() else { continue };
411                        let Some(val) = s.next() else { continue };
412                        // Called for its side effect (writes parsed props into
413                        // `css_attrs`); the returned value is intentionally discarded.
414                        drop(azul_css::parser2::parse_css_declaration(
415                            key.trim(), val.trim(),
416                            azul_css::parser2::ErrorLocationRange::default(),
417                            css_key_map, &mut Vec::new(), &mut css_attrs,
418                        ));
419                    }
420                    let props = css_attrs.into_iter().filter_map(|s| {
421                        use azul_css::css::CssDeclaration;
422                        use azul_css::dynamic_selector::CssPropertyWithConditions;
423                        match s {
424                            CssDeclaration::Static(s) => Some(CssPropertyWithConditions::simple(s)),
425                            CssDeclaration::Dynamic(_) => None,
426                        }
427                    }).collect::<Vec<_>>();
428                    if !props.is_empty() {
429                        nd.set_css_props(props.into());
430                    }
431                }
432                "contenteditable" => {
433                    if parse_bool(value.as_str()).unwrap_or(false) {
434                        nd.set_contenteditable(true);
435                    }
436                }
437                _ => {}
438            }
439        }
440        if !attr_vec.is_empty() {
441            nd.set_attributes(attr_vec.into());
442        }
443
444        builder.open_node(nd);
445    };
446
447    let mut last_was_void = false;
448    let mut tag_stack: Vec<String> = Vec::new(); // for matching close tags
449
450    for token in tokenizer {
451        let token = token.map_err(|e| XmlError::ParserError(translate_xmlparser_error(e)))?;
452        match token {
453            ElementStart { local, .. } => {
454                // Flush any pending open element
455                if pending_open {
456                    let is_void = VOID_ELEMENTS.contains(&current_tag.as_str());
457                    if current_tag == "head" { head_depth += 1; }
458                    if head_depth == 0 {
459                        finalize_open(&mut builder, &mut str_arena, &current_tag, &current_attrs, &css_key_map);
460                        if is_void { builder.close_node(); }
461                    }
462                    if !is_void {
463                        tag_stack.push(core::mem::take(&mut current_tag));
464                    }
465                }
466
467                // Reuse the current_tag buffer — avoids ~1023 fresh String
468                // allocations per parse (one per ElementStart).
469                lowercase_into(&mut current_tag, local.as_str());
470                current_attrs.clear();
471                pending_open = true;
472                last_was_void = VOID_ELEMENTS.contains(&current_tag.as_str());
473            }
474            Attribute { local, value, .. } => {
475                // decode_xml_entities returns Cow::Borrowed when no entities
476                // are present (the common case), so `.into_owned()` is the
477                // only fresh allocation here. The key is copied via
478                // `to_string()` because we can't hold a borrow across token
479                // iterations. TODO: when we switch current_attrs to
480                // Vec<(&str, Cow<str>)> this becomes zero-alloc for the key.
481                current_attrs.push((local.to_string(), decode_xml_entities(value.as_str()).into_owned()));
482            }
483            ElementEnd { end: Open, .. } => {
484                if pending_open {
485                    let is_void = VOID_ELEMENTS.contains(&current_tag.as_str());
486                    if current_tag == "style" {
487                        inside_style_tag = true;
488                        style_text.clear();
489                    }
490                    if current_tag == "head" { head_depth += 1; }
491                    if head_depth == 0 {
492                        finalize_open(&mut builder, &mut str_arena, &current_tag, &current_attrs, &css_key_map);
493                        if is_void { builder.close_node(); }
494                    }
495                    if !is_void {
496                        // Use take() instead of clone() — after pending_open=false,
497                        // current_tag is not read again until the next ElementStart
498                        // reassigns it via lowercase_into.
499                        tag_stack.push(core::mem::take(&mut current_tag));
500                    }
501                    pending_open = false;
502                }
503            }
504            ElementEnd { end: Empty, .. } => {
505                // Self-closing element: open + immediately close
506                if pending_open {
507                    if current_tag == "head" { head_depth += 1; }
508                    if head_depth == 0 {
509                        finalize_open(&mut builder, &mut str_arena, &current_tag, &current_attrs, &css_key_map);
510                        builder.close_node();
511                    }
512                    if current_tag == "head" && head_depth > 0 { head_depth -= 1; }
513                    pending_open = false;
514                }
515            }
516            ElementEnd { end: Close(_, close_value), .. } => {
517                if pending_open {
518                    let is_void = VOID_ELEMENTS.contains(&current_tag.as_str());
519                    if current_tag == "head" { head_depth += 1; }
520                    if head_depth == 0 {
521                        finalize_open(&mut builder, &mut str_arena, &current_tag, &current_attrs, &css_key_map);
522                        if is_void { builder.close_node(); }
523                    }
524                    if !is_void {
525                        tag_stack.push(core::mem::take(&mut current_tag));
526                    }
527                    pending_open = false;
528                }
529
530                let close_lower = close_value.as_str().to_ascii_lowercase();
531                let close_str = close_lower.as_str();
532                if VOID_ELEMENTS.contains(&close_str) {
533                    continue;
534                }
535
536                // If closing a <style> tag, parse collected CSS
537                if close_str == "style" && inside_style_tag {
538                    if !style_text.is_empty() {
539                        let parsed_css = Css::from_string(core::mem::take(&mut style_text).into());
540                        collected_css.push(parsed_css);
541                    }
542                    inside_style_tag = false;
543                }
544
545                // Pop until we find matching tag
546                while let Some(top) = tag_stack.last() {
547                    let is_match = top == close_str;
548                    let was_head = top == "head";
549                    // Pop this tag (unconditionally auto-close mismatched tags)
550                    let popped = tag_stack.pop().unwrap();
551                    if popped == "head" && head_depth > 0 { head_depth -= 1; }
552                    if head_depth == 0 && !was_head {
553                        builder.close_node();
554                    }
555                    if is_match { break; }
556                }
557            }
558            Text { text } => {
559                if pending_open {
560                    let is_void = VOID_ELEMENTS.contains(&current_tag.as_str());
561                    if current_tag == "style" {
562                        inside_style_tag = true;
563                        style_text.clear();
564                    }
565                    if current_tag == "head" { head_depth += 1; }
566                    if head_depth == 0 {
567                        finalize_open(&mut builder, &mut str_arena, &current_tag, &current_attrs, &css_key_map);
568                        if is_void { builder.close_node(); }
569                    }
570                    if !is_void {
571                        tag_stack.push(current_tag.clone());
572                    }
573                    pending_open = false;
574                }
575
576                let text_str = text.as_str();
577                if !text_str.is_empty() {
578                    if inside_style_tag {
579                        style_text.push_str(text_str);
580                    } else if head_depth == 0 {
581                        // Skip whitespace-only text at <html> level (between </head> and <body>)
582                        // but keep whitespace inside <body> (it's significant for inline layout)
583                        let inside_body = tag_stack.iter().any(|t| t == "body");
584                        if inside_body || !text_str.trim().is_empty() {
585                            let decoded = decode_xml_entities(text_str);
586                            builder.add_leaf(NodeData::create_text(str_arena.intern(&decoded)));
587                        }
588                    }
589                }
590            }
591            _ => {}
592        }
593    }
594
595    // Close any remaining open elements
596    if pending_open {
597        finalize_open(&mut builder, &mut str_arena, &current_tag, &current_attrs, &css_key_map);
598    }
599    while tag_stack.pop().is_some() {
600        builder.close_node();
601    }
602
603    // Drop the arena handle explicitly. AzStrings already embedded in
604    // the FastDom keep the backing bytes alive via their cloned Arc refs.
605    drop(str_arena);
606
607    Ok((builder.finish(), collected_css))
608}
609
610/// Loads, parses and builds a DOM from an XML file
611///
612/// **Warning**: The file is reloaded from disk on every function call - do not
613/// use this in release builds! This function deliberately never fails: In an error case,
614/// the error gets rendered as a `NodeType::Label`.
615#[cfg(all(feature = "std", feature = "xml"))]
616pub fn domxml_from_file<I: AsRef<Path>>(
617    file_path: I,
618    component_map: &ComponentMap,
619) -> DomXml {
620    use std::fs;
621
622    let error_css = Css::empty();
623
624    let xml = match fs::read_to_string(file_path.as_ref()) {
625        Ok(xml) => xml,
626        Err(e) => {
627            return DomXml {
628                parsed_dom: {
629                    let mut dom = Dom::create_body()
630                        .with_children(
631                            vec![Dom::create_text(format!(
632                                "Error reading: \"{}\": {}",
633                                file_path.as_ref().to_string_lossy(),
634                                e
635                            ))]
636                            .into(),
637                        );
638                    StyledDom::create(&mut dom, error_css)
639                },
640            };
641        }
642    };
643
644    domxml_from_str(&xml, component_map)
645}
646
647/// Parses the XML string into an XML tree, returns
648/// the root `<app></app>` node, with the children attached to it.
649///
650/// Since the XML allows multiple root nodes, this function returns
651/// a `Vec<XmlNode>` - which are the "root" nodes, containing all their
652/// children recursively.
653#[cfg(feature = "xml")]
654#[allow(clippy::too_many_lines)] // large but cohesive: single-purpose layout/render/parse routine (one branch per case)
655/// # Errors
656///
657/// Returns an `XmlError` if the XML cannot be parsed.
658pub fn parse_xml_string(xml: &str) -> Result<Vec<XmlNodeChild>, XmlError> {
659    use xmlparser::{ElementEnd::{Empty, Close}, Token::{ElementStart, ElementEnd, Attribute, Text}, Tokenizer};
660
661    use self::XmlParseError::*;
662
663    // HTML5-lite parser: List of void elements that should auto-close
664    // See: https://developer.mozilla.org/en-US/docs/Glossary/Void_element
665    const VOID_ELEMENTS: &[&str] = &[
666        "area", "base", "br", "col", "embed", "hr", "img", "input", "link", "meta", "param",
667        "source", "track", "wbr",
668    ];
669
670    // HTML5-lite parser: Elements that auto-close when certain other elements are encountered
671    // Format: (element_name, closes_when_encountering)
672    const AUTO_CLOSE_RULES: &[(&str, &[&str])] = &[
673        // List items close when encountering another list item or when parent closes
674        ("li", &["li"]),
675        // Table cells/rows have complex closing rules
676        ("td", &["td", "th", "tr"]),
677        ("th", &["td", "th", "tr"]),
678        ("tr", &["tr"]),
679        // Paragraphs close on block-level elements
680        (
681            "p",
682            &[
683                "address",
684                "article",
685                "aside",
686                "blockquote",
687                "div",
688                "dl",
689                "fieldset",
690                "footer",
691                "form",
692                "h1",
693                "h2",
694                "h3",
695                "h4",
696                "h5",
697                "h6",
698                "header",
699                "hr",
700                "main",
701                "nav",
702                "ol",
703                "p",
704                "pre",
705                "section",
706                "table",
707                "ul",
708            ],
709        ),
710        // Option closes on another option or optgroup
711        ("option", &["option", "optgroup"]),
712        ("optgroup", &["optgroup"]),
713        // DD/DT close on each other
714        ("dd", &["dd", "dt"]),
715        ("dt", &["dd", "dt"]),
716    ];
717
718    let mut root_node = XmlNode::default();
719
720    // Strip UTF-8 BOM if present (some W3C test files have it)
721    let xml = xml.strip_prefix('\u{FEFF}').unwrap_or(xml);
722
723    // Search for "<?xml" and "?>" tags and delete them from the XML
724    let mut xml = xml.trim();
725    if xml.starts_with("<?") {
726        let pos = xml.find("?>").ok_or(XmlError::MalformedHierarchy(
727            MalformedHierarchyError {
728                expected: "<?xml".into(),
729                got: "?>".into(),
730            },
731        ))?;
732        xml = &xml[(pos + 2)..];
733    }
734
735    // Delete <!DOCTYPE ...> if necessary (case-insensitive)
736    let mut xml = xml.trim();
737    if xml.len() > 9 && xml[..9].to_ascii_lowercase().starts_with("<!doctype") {
738        let pos = xml.find('>').ok_or(XmlError::MalformedHierarchy(
739            MalformedHierarchyError {
740                expected: "<!DOCTYPE".into(),
741                got: ">".into(),
742            },
743        ))?;
744        xml = &xml[(pos + 1)..];
745    } else if xml.starts_with("<!--") {
746        // Skip HTML comments at the start
747        if let Some(end) = xml.find("-->") {
748            xml = &xml[(end + 3)..];
749            xml = xml.trim();
750        }
751    }
752
753    let tokenizer = Tokenizer::from_fragment(xml, 0..xml.len());
754
755    // OPTIMIZED: Use a stack of raw pointers to avoid O(n*d) traversal on every token.
756    // This is safe because:
757    // 1. All pointers point into `root_node` which is owned and not moved
758    // 2. We never hold multiple mutable references simultaneously
759    // 3. The stack is only used within this function
760    let mut node_stack: Vec<*mut XmlNode> = vec![&raw mut root_node];
761
762    // Track which hierarchy level is a void element (shouldn't be pushed to hierarchy)
763    let mut last_was_void = false;
764
765    for token in tokenizer {
766        let token = token.map_err(|e| XmlError::ParserError(translate_xmlparser_error(e)))?;
767        match token {
768            ElementStart { local, .. } => {
769                let tag_name = local.to_string();
770                let is_void_element = VOID_ELEMENTS.contains(&tag_name.as_str());
771
772                // HTML5-lite: If last element was a void element (like <img src="...">),
773                // pop it from hierarchy before processing the new element
774                if last_was_void {
775                    node_stack.pop();
776                    last_was_void = false;
777                }
778
779                // HTML5-lite: Check if we need to auto-close the current element
780                if node_stack.len() > 1 {
781                    // SAFETY: We only access the last element, which is valid
782                    let current_element = unsafe { &*node_stack[node_stack.len() - 1] };
783                    let current_tag = current_element.node_type.as_str();
784
785                    // Check if current element should auto-close when encountering this new tag
786                    for (element, closes_on) in AUTO_CLOSE_RULES {
787                        if current_tag == *element && closes_on.contains(&tag_name.as_str()) {
788                            // Auto-close the current element
789                            node_stack.pop();
790                            break;
791                        }
792                    }
793                }
794
795                // SAFETY: We access the last element which is valid
796                if let Some(&current_parent_ptr) = node_stack.last() {
797                    let current_parent = unsafe { &mut *current_parent_ptr };
798                    
799                    current_parent.children.push(XmlNodeChild::Element(XmlNode {
800                        node_type: tag_name.into(),
801                        attributes: StringPairVec::new().into(),
802                        children: Vec::new().into(),
803                    }));
804
805                    // Get pointer to the newly added child
806                    let children_len = current_parent.children.len();
807                    if let Some(XmlNodeChild::Element(ref mut new_child)) = current_parent.children.as_mut().get_mut(children_len - 1) {
808                        node_stack.push(std::ptr::from_mut::<XmlNode>(new_child));
809                    }
810                    
811                    last_was_void = is_void_element;
812                }
813            }
814            ElementEnd { end: Empty, .. } => {
815                // Pop hierarchy for all elements (including void elements after their attributes)
816                if node_stack.len() > 1 {
817                    node_stack.pop();
818                }
819                last_was_void = false;
820            }
821            ElementEnd {
822                end: Close(_, close_value),
823                ..
824            } => {
825                // HTML5-lite: If last element was a void element, pop it first
826                if last_was_void {
827                    node_stack.pop();
828                    last_was_void = false;
829                }
830
831                // HTML5-lite: Check if this is a void element - if so, ignore the closing tag
832                let is_void_element = VOID_ELEMENTS.contains(&close_value.as_str());
833                if is_void_element {
834                    // Void elements shouldn't have closing tags, but tolerate them
835                    continue;
836                }
837
838                // HTML5-lite: Auto-close any elements that should be closed
839                // Walk up the hierarchy and auto-close elements until we find a match
840                let close_value_str = close_value.as_str();
841
842                // Find matching element in stack (skip root at index 0)
843                let mut found_idx = None;
844                for i in (1..node_stack.len()).rev() {
845                    // SAFETY: All pointers in stack are valid
846                    let node = unsafe { &*node_stack[i] };
847                    if node.node_type.as_str() == close_value_str {
848                        found_idx = Some(i);
849                        break;
850                    }
851                }
852
853                if let Some(idx) = found_idx {
854                    // Pop all elements from current position to the matching element (inclusive)
855                    node_stack.truncate(idx);
856                }
857                // If no match found, just ignore (lenient HTML parsing)
858
859                last_was_void = false;
860            }
861            Attribute { local, value, .. } => {
862                // SAFETY: Last element in stack is valid
863                if let Some(&last_ptr) = node_stack.last() {
864                    let last = unsafe { &mut *last_ptr };
865                    // NOTE: Only lowercase the key ("local"), not the value!
866                    // Decode XML entities in attribute values as well
867                    last.attributes.push(azul_core::window::AzStringPair {
868                        key: local.to_string().into(),
869                        value: AzString::from(&*decode_xml_entities(value.as_str())),
870                    });
871                }
872            }
873            Text { text } => {
874                // HTML5-lite: If last element was a void element, pop it before adding text
875                if last_was_void {
876                    node_stack.pop();
877                    last_was_void = false;
878                }
879
880                // IMPORTANT: Preserve ALL text nodes including whitespace-only nodes.
881                // Whether whitespace is significant depends on the CSS `white-space` property,
882                // which is determined during layout, not during parsing.
883                // 
884                // For example: <pre><span>    </span></pre> must preserve the 4 spaces.
885                // 
886                // We only skip completely EMPTY text nodes (zero-length strings).
887                let text_str = text.as_str();
888
889                if !text_str.is_empty() {
890                    // SAFETY: Last element in stack is valid
891                    if let Some(&current_parent_ptr) = node_stack.last() {
892                        let current_parent = unsafe { &mut *current_parent_ptr };
893                        // Decode XML entities (e.g., &lt; -> <, &gt; -> >, etc.)
894                        let decoded_text = decode_xml_entities(text_str);
895                        // Add text as a child node
896                        current_parent
897                            .children
898                            .push(XmlNodeChild::Text(AzString::from(&*decoded_text)));
899                    }
900                }
901            }
902            _ => {}
903        }
904    }
905
906    // Clean up: if we ended with a void element, pop it
907    if last_was_void {
908        node_stack.pop();
909    }
910
911    Ok(root_node.children.into())
912}
913
914#[cfg(feature = "xml")]
915/// # Errors
916///
917/// Returns an `XmlError` if the XML cannot be parsed.
918pub fn parse_xml(s: &str) -> Result<Xml, XmlError> {
919    Ok(Xml {
920        root: parse_xml_string(s)?.into(),
921    })
922}
923
924#[cfg(not(feature = "xml"))]
925pub fn parse_xml(s: &str) -> Result<Xml, XmlError> {
926    Err(XmlError::NoParserAvailable)
927}
928
929// to_string(&self) -> String
930
931#[cfg(feature = "xml")]
932#[must_use] pub fn translate_roxmltree_expandedname(
933    e: roxmltree::ExpandedName<'_, '_>,
934) -> XmlQualifiedName {
935    let ns: Option<AzString> = e.namespace().map(|e| e.to_string().into());
936    XmlQualifiedName {
937        local_name: e.name().to_string().into(),
938        namespace: ns.into(),
939    }
940}
941
942#[cfg(feature = "xml")]
943fn translate_roxmltree_attribute(e: roxmltree::Attribute<'_, '_>) -> XmlQualifiedName {
944    XmlQualifiedName {
945        local_name: e.name().to_string().into(),
946        namespace: e.namespace().map(|e| e.to_string().into()).into(),
947    }
948}
949
950#[cfg(feature = "xml")]
951fn translate_xmlparser_streamerror(e: xmlparser::StreamError) -> XmlStreamError {
952    match e {
953        xmlparser::StreamError::UnexpectedEndOfStream => XmlStreamError::UnexpectedEndOfStream,
954        xmlparser::StreamError::InvalidName => XmlStreamError::InvalidName,
955        xmlparser::StreamError::InvalidReference => XmlStreamError::InvalidReference,
956        xmlparser::StreamError::InvalidExternalID => XmlStreamError::InvalidExternalID,
957        xmlparser::StreamError::InvalidCommentData => XmlStreamError::InvalidCommentData,
958        xmlparser::StreamError::InvalidCommentEnd => XmlStreamError::InvalidCommentEnd,
959        xmlparser::StreamError::InvalidCharacterData => XmlStreamError::InvalidCharacterData,
960        xmlparser::StreamError::NonXmlChar(c, tp) => XmlStreamError::NonXmlChar(NonXmlCharError {
961            ch: c.into(),
962            pos: translate_xmlparser_textpos(tp),
963        }),
964        xmlparser::StreamError::InvalidChar(a, b, tp) => {
965            XmlStreamError::InvalidChar(InvalidCharError {
966                expected: a,
967                got: b,
968                pos: translate_xmlparser_textpos(tp),
969            })
970        }
971        xmlparser::StreamError::InvalidCharMultiple(a, b, tp) => {
972            XmlStreamError::InvalidCharMultiple(InvalidCharMultipleError {
973                expected: a,
974                got: b.to_vec().into(),
975                pos: translate_xmlparser_textpos(tp),
976            })
977        }
978        xmlparser::StreamError::InvalidQuote(a, tp) => {
979            XmlStreamError::InvalidQuote(InvalidQuoteError {
980                got: a,
981                pos: translate_xmlparser_textpos(tp),
982            })
983        }
984        xmlparser::StreamError::InvalidSpace(a, tp) => {
985            XmlStreamError::InvalidSpace(InvalidSpaceError {
986                got: a,
987                pos: translate_xmlparser_textpos(tp),
988            })
989        }
990        xmlparser::StreamError::InvalidString(a, tp) => {
991            XmlStreamError::InvalidString(InvalidStringError {
992                got: a.to_string().into(),
993                pos: translate_xmlparser_textpos(tp),
994            })
995        }
996    }
997}
998
999#[cfg(feature = "xml")]
1000fn translate_xmlparser_error(e: xmlparser::Error) -> XmlParseError {
1001    match e {
1002        xmlparser::Error::InvalidDeclaration(se, tp) => {
1003            XmlParseError::InvalidDeclaration(XmlTextError {
1004                stream_error: translate_xmlparser_streamerror(se),
1005                pos: translate_xmlparser_textpos(tp),
1006            })
1007        }
1008        xmlparser::Error::InvalidComment(se, tp) => XmlParseError::InvalidComment(XmlTextError {
1009            stream_error: translate_xmlparser_streamerror(se),
1010            pos: translate_xmlparser_textpos(tp),
1011        }),
1012        xmlparser::Error::InvalidPI(se, tp) => XmlParseError::InvalidPI(XmlTextError {
1013            stream_error: translate_xmlparser_streamerror(se),
1014            pos: translate_xmlparser_textpos(tp),
1015        }),
1016        xmlparser::Error::InvalidDoctype(se, tp) => XmlParseError::InvalidDoctype(XmlTextError {
1017            stream_error: translate_xmlparser_streamerror(se),
1018            pos: translate_xmlparser_textpos(tp),
1019        }),
1020        xmlparser::Error::InvalidEntity(se, tp) => XmlParseError::InvalidEntity(XmlTextError {
1021            stream_error: translate_xmlparser_streamerror(se),
1022            pos: translate_xmlparser_textpos(tp),
1023        }),
1024        xmlparser::Error::InvalidElement(se, tp) => XmlParseError::InvalidElement(XmlTextError {
1025            stream_error: translate_xmlparser_streamerror(se),
1026            pos: translate_xmlparser_textpos(tp),
1027        }),
1028        xmlparser::Error::InvalidAttribute(se, tp) => {
1029            XmlParseError::InvalidAttribute(XmlTextError {
1030                stream_error: translate_xmlparser_streamerror(se),
1031                pos: translate_xmlparser_textpos(tp),
1032            })
1033        }
1034        xmlparser::Error::InvalidCdata(se, tp) => XmlParseError::InvalidCdata(XmlTextError {
1035            stream_error: translate_xmlparser_streamerror(se),
1036            pos: translate_xmlparser_textpos(tp),
1037        }),
1038        xmlparser::Error::InvalidCharData(se, tp) => XmlParseError::InvalidCharData(XmlTextError {
1039            stream_error: translate_xmlparser_streamerror(se),
1040            pos: translate_xmlparser_textpos(tp),
1041        }),
1042        xmlparser::Error::UnknownToken(tp) => {
1043            XmlParseError::UnknownToken(translate_xmlparser_textpos(tp))
1044        }
1045    }
1046}
1047
1048#[cfg(feature = "xml")]
1049#[must_use] pub fn translate_roxmltree_error(e: roxmltree::Error) -> XmlError {
1050    match e {
1051        roxmltree::Error::InvalidXmlPrefixUri(s) => {
1052            XmlError::InvalidXmlPrefixUri(translate_roxml_textpos(s))
1053        }
1054        roxmltree::Error::UnexpectedXmlUri(s) => {
1055            XmlError::UnexpectedXmlUri(translate_roxml_textpos(s))
1056        }
1057        roxmltree::Error::UnexpectedXmlnsUri(s) => {
1058            XmlError::UnexpectedXmlnsUri(translate_roxml_textpos(s))
1059        }
1060        roxmltree::Error::InvalidElementNamePrefix(s) => {
1061            XmlError::InvalidElementNamePrefix(translate_roxml_textpos(s))
1062        }
1063        roxmltree::Error::DuplicatedNamespace(s, tp) => {
1064            XmlError::DuplicatedNamespace(DuplicatedNamespaceError {
1065                ns: s.into(),
1066                pos: translate_roxml_textpos(tp),
1067            })
1068        }
1069        roxmltree::Error::UnknownNamespace(s, tp) => {
1070            XmlError::UnknownNamespace(UnknownNamespaceError {
1071                ns: s.into(),
1072                pos: translate_roxml_textpos(tp),
1073            })
1074        }
1075        roxmltree::Error::UnexpectedCloseTag(expected, actual, pos) => {
1076            XmlError::UnexpectedCloseTag(UnexpectedCloseTagError {
1077                expected: expected.into(),
1078                actual: actual.into(),
1079                pos: translate_roxml_textpos(pos),
1080            })
1081        }
1082        roxmltree::Error::UnexpectedEntityCloseTag(s) => {
1083            XmlError::UnexpectedEntityCloseTag(translate_roxml_textpos(s))
1084        }
1085        roxmltree::Error::UnknownEntityReference(s, tp) => {
1086            XmlError::UnknownEntityReference(UnknownEntityReferenceError {
1087                entity: s.into(),
1088                pos: translate_roxml_textpos(tp),
1089            })
1090        }
1091        roxmltree::Error::MalformedEntityReference(s) => {
1092            XmlError::MalformedEntityReference(translate_roxml_textpos(s))
1093        }
1094        roxmltree::Error::EntityReferenceLoop(s) => {
1095            XmlError::EntityReferenceLoop(translate_roxml_textpos(s))
1096        }
1097        roxmltree::Error::InvalidAttributeValue(s) => {
1098            XmlError::InvalidAttributeValue(translate_roxml_textpos(s))
1099        }
1100        roxmltree::Error::DuplicatedAttribute(s, tp) => {
1101            XmlError::DuplicatedAttribute(DuplicatedAttributeError {
1102                attribute: s.into(),
1103                pos: translate_roxml_textpos(tp),
1104            })
1105        }
1106        roxmltree::Error::NoRootNode => XmlError::NoRootNode,
1107        roxmltree::Error::DtdDetected => XmlError::DtdDetected,
1108        roxmltree::Error::UnclosedRootNode => XmlError::UnclosedRootNode,
1109        roxmltree::Error::UnexpectedDeclaration(tp) => {
1110            XmlError::UnexpectedDeclaration(translate_roxml_textpos(tp))
1111        }
1112        roxmltree::Error::NodesLimitReached => XmlError::NodesLimitReached,
1113        roxmltree::Error::AttributesLimitReached => XmlError::AttributesLimitReached,
1114        roxmltree::Error::NamespacesLimitReached => XmlError::NamespacesLimitReached,
1115        roxmltree::Error::InvalidName(tp) => XmlError::InvalidName(translate_roxml_textpos(tp)),
1116        roxmltree::Error::NonXmlChar(_, tp) => XmlError::NonXmlChar(translate_roxml_textpos(tp)),
1117        roxmltree::Error::InvalidChar(_, _, tp) => {
1118            XmlError::InvalidChar(translate_roxml_textpos(tp))
1119        }
1120        roxmltree::Error::InvalidChar2(_, _, tp) => {
1121            XmlError::InvalidChar2(translate_roxml_textpos(tp))
1122        }
1123        roxmltree::Error::InvalidString(_, tp) => {
1124            XmlError::InvalidString(translate_roxml_textpos(tp))
1125        }
1126        roxmltree::Error::InvalidExternalID(tp) => {
1127            XmlError::InvalidExternalID(translate_roxml_textpos(tp))
1128        }
1129        roxmltree::Error::InvalidComment(tp) => {
1130            XmlError::InvalidComment(translate_roxml_textpos(tp))
1131        }
1132        roxmltree::Error::InvalidCharacterData(tp) => {
1133            XmlError::InvalidCharacterData(translate_roxml_textpos(tp))
1134        }
1135        roxmltree::Error::UnknownToken(tp) => XmlError::UnknownToken(translate_roxml_textpos(tp)),
1136        roxmltree::Error::UnexpectedEndOfStream => XmlError::UnexpectedEndOfStream,
1137        roxmltree::Error::EntityResolver(tp, s) => {
1138            // New in roxmltree 0.21: EntityResolver error variant
1139            // For now, treat as a generic entity reference error
1140            XmlError::UnknownEntityReference(UnknownEntityReferenceError {
1141                entity: s.into(),
1142                pos: translate_roxml_textpos(tp),
1143            })
1144        }
1145    }
1146}
1147
1148#[cfg(feature = "xml")]
1149#[inline]
1150const fn translate_xmlparser_textpos(o: xmlparser::TextPos) -> XmlTextPos {
1151    XmlTextPos {
1152        row: o.row,
1153        col: o.col,
1154    }
1155}
1156
1157#[cfg(feature = "xml")]
1158#[inline]
1159const fn translate_roxml_textpos(o: roxmltree::TextPos) -> XmlTextPos {
1160    XmlTextPos {
1161        row: o.row,
1162        col: o.col,
1163    }
1164}
1165
1166/// Extension trait to add XML parsing capabilities to Dom
1167///
1168/// This trait provides methods to parse XML/XHTML strings and convert them
1169/// into Azul DOM trees. It's implemented as a trait to avoid circular dependencies
1170/// between azul-core and azul-layout.
1171#[cfg(feature = "xml")]
1172pub trait DomXmlExt {
1173    /// Parse XML/XHTML string into a DOM tree
1174    ///
1175    /// This method parses the XML string and converts it to an Azul `StyledDom`.
1176    /// On error, it returns a `StyledDom` displaying the error message.
1177    ///
1178    /// # Arguments
1179    /// * `xml` - The XML/XHTML string to parse
1180    ///
1181    /// # Returns
1182    /// A `StyledDom` tree representing the parsed XML, or an error DOM on parse failure
1183    fn from_xml_string<S: AsRef<str>>(xml: S) -> StyledDom;
1184}
1185
1186#[cfg(feature = "xml")]
1187impl DomXmlExt for Dom {
1188    fn from_xml_string<S: AsRef<str>>(xml: S) -> StyledDom {
1189        let component_map = ComponentMap::with_builtin();
1190        let dom_xml = domxml_from_str(xml.as_ref(), &component_map);
1191        dom_xml.parsed_dom
1192    }
1193}