Skip to main content

libxml_rs/xml/html/
mod.rs

1//! HTML parser and serializer (§29, §85 Phase 4).
2//!
3//! The libxml2 historical HTML parser — a tag-recovery parser, NOT a WHATWG
4//! HTML5 parser. Preserves version-specific historical behavior.
5//!
6//! Implements:
7//! - Tag-recovery parsing (auto-close, implicit open, case-insensitive)
8//! - HTML element info table with flags matching libxml2
9//! - HTML entity resolution
10//! - Auto-creation of html/head/body when missing
11//! - HTML-specific serialization (no self-closing void tags, no namespace decls)
12//! - Minimized and unquoted attribute support
13//!
14//! # Upstream contract
15//!
16//! Mirrors upstream `HTMLparser.c`, `HTMLtree.c` and `HTMLdocument.c`
17//! (`SRC-LIBXML2-2.15.0-HTMLPARSER-C` et al., parity target libxml2 2.15.3
18//! oracle): the tag-recovery HTML parser, the htmlElementInfo table,
19//! htmlDocDump serialization and the htmlDefaultSAXHandler /
20//! htmlDefaultSAXLocator data globals (R-000135 exports them
21//! byte-identical; htmlInitAutoClose / htmlElementAllowedHere are the
22//! R-000138 no-op set).
23//!
24//! # Conceptual behavior
25//!
26//! Implements libxml2 historical HTML parsing: case-insensitive tag
27//! recovery with auto-close and implicit-open rules driven by the
28//! htmlElementInfo flags table (HTML_EMPTY/HTML_NO_END/HTML_HEAD/...),
29//! HTML entity resolution, auto-creation of html/head/body, minimized and
30//! unquoted attributes, and HTML-specific serialization (no self-closing
31//! void tags, no namespace declarations). This is deliberately NOT a
32//! WHATWG HTML5 parser (WHATWG-HTML).
33//!
34//! # Ownership & safety invariants
35//!
36//! The parser creates a document the caller owns (freed with
37//! `xmlFreeDoc`, same as XML); elements/attributes are owned by the tree.
38//! Serialization borrows the tree and writes through the output buffer.
39//! Push-mode input buffers are owned by the parser context (CVE-2015-8242
40//! fixed a push-mode buffer overread upstream, SEC-0008).
41//!
42//! # Historical quirks & epochs
43//!
44//! E-007: the `--html` dump became a single line in the 2.15.0 epoch —
45//! six `xmlOutputBufferWriteString(buf, "\n")` calls were removed from
46//! HTMLtree.c (commits 0d81d6f8, 46f05ea4); the crate matches the 2.15+
47//! single-line epoch. R-000118 locked the HTML output method for XSLT.
48//! The tag-recovery rules themselves go back to the 1.x/2.0 HTML era and
49//! stay version-faithful.
50//!
51//! # Deliberate oddities
52//!
53//! The htmlElementInfo table ordering and flag values reproduce upstream
54//! exactly (R-000135 DATA-GLOBALS-001 fingerprints the default handler
55//! slots); case-folding and the auto-close stack follow HTMLparser.c
56//! rather than the WHATWG spec — a deliberate historical fidelity choice.
57//!
58//! # Proving courts
59//!
60//! HTML-* courts (SEC-0008), the CLI `--html` differential cases
61//! (html-dump epoch case in SEMANTIC_EPOCHS.md) and DATA-GLOBALS-001
62//! compare output byte-identical against the oracle; cargo test runs the
63//! HTML unit suites.
64//!
65//! # Tempting simplifications that would break parity
66//!
67//! Do not switch to a WHATWG HTML5 parser: downstream consumers depend on
68//! libxml2 tag-recovery quirks. Do not re-add newlines to the dump (E-007
69//! epoch) and do not reorder or re-flag the element table — both are
70//! observable through the serializer and the exported data globals.
71
72use core::ffi::c_void;
73use core::ptr;
74use core::slice;
75use std::os::raw::{c_char, c_int};
76
77use crate::abi::allocator::{xmlFreeImpl, xmlMallocZero};
78use crate::abi::structs::*;
79use crate::abi::types::xmlDocProperties::XML_DOC_WELLFORMED;
80use crate::abi::types::xmlElementType::*;
81use crate::abi::types::*;
82use crate::xml::io;
83use crate::xml::string::*;
84use crate::xml::tree;
85
86// ═══════════════════════════════════════════════════════════════════════════════
87// HTML Element Info
88// ═══════════════════════════════════════════════════════════════════════════════
89
90/// HTML element flag constants matching libxml2 HTMLparser.h.
91const HTML_INLINE: u32 = 0x1;
92const HTML_BLOCK: u32 = 0x2;
93const HTML_EMPTY: u32 = 0x4;
94#[allow(dead_code)]
95const HTML_DEPRECATED: u32 = 0x8;
96const HTML_OL: u32 = 0x10;
97const HTML_DL: u32 = 0x20;
98#[allow(dead_code)]
99const HTML_COMPACT: u32 = 0x40;
100const HTML_HEAD: u32 = 0x80;
101const HTML_BODY: u32 = 0x100;
102#[allow(dead_code)]
103const HTML_HEADSTRUCK: u32 = 0x200;
104const HTML_VALID: u32 = 0x400;
105const HTML_NO_END: u32 = 0x800; // end tag optional
106#[allow(dead_code)]
107const HTML_IMPLIED: u32 = 0x1000; // implied/auto-created
108
109/// Information about an HTML element.
110#[derive(Clone, Copy)]
111struct HtmlElementInfo {
112    name: &'static str,
113    flags: u32,
114}
115
116/// Lookup table of HTML elements and their properties.
117/// This matches libxml2's `htmlElementInfo` table.
118const HTML_ELEMENTS: &[HtmlElementInfo] = &[
119    // Void / empty elements
120    HtmlElementInfo {
121        name: "br",
122        flags: HTML_INLINE | HTML_EMPTY | HTML_VALID,
123    },
124    HtmlElementInfo {
125        name: "hr",
126        flags: HTML_BLOCK | HTML_EMPTY | HTML_VALID,
127    },
128    HtmlElementInfo {
129        name: "img",
130        flags: HTML_INLINE | HTML_EMPTY | HTML_VALID,
131    },
132    HtmlElementInfo {
133        name: "input",
134        flags: HTML_INLINE | HTML_EMPTY | HTML_VALID,
135    },
136    HtmlElementInfo {
137        name: "meta",
138        flags: HTML_HEAD | HTML_EMPTY | HTML_VALID,
139    },
140    HtmlElementInfo {
141        name: "link",
142        flags: HTML_HEAD | HTML_EMPTY | HTML_VALID,
143    },
144    HtmlElementInfo {
145        name: "base",
146        flags: HTML_HEAD | HTML_EMPTY | HTML_VALID,
147    },
148    HtmlElementInfo {
149        name: "area",
150        flags: HTML_INLINE | HTML_EMPTY | HTML_VALID,
151    },
152    HtmlElementInfo {
153        name: "col",
154        flags: HTML_BLOCK | HTML_EMPTY | HTML_VALID,
155    },
156    HtmlElementInfo {
157        name: "embed",
158        flags: HTML_INLINE | HTML_EMPTY | HTML_VALID,
159    },
160    HtmlElementInfo {
161        name: "param",
162        flags: HTML_HEAD | HTML_EMPTY | HTML_VALID,
163    },
164    HtmlElementInfo {
165        name: "source",
166        flags: HTML_INLINE | HTML_EMPTY | HTML_VALID,
167    },
168    HtmlElementInfo {
169        name: "track",
170        flags: HTML_INLINE | HTML_EMPTY | HTML_VALID,
171    },
172    HtmlElementInfo {
173        name: "wbr",
174        flags: HTML_INLINE | HTML_EMPTY | HTML_VALID,
175    },
176    // Block elements
177    HtmlElementInfo {
178        name: "html",
179        flags: HTML_BLOCK | HTML_VALID,
180    },
181    HtmlElementInfo {
182        name: "head",
183        flags: HTML_HEAD | HTML_BLOCK | HTML_VALID,
184    },
185    HtmlElementInfo {
186        name: "body",
187        flags: HTML_BODY | HTML_BLOCK | HTML_VALID,
188    },
189    HtmlElementInfo {
190        name: "div",
191        flags: HTML_BLOCK | HTML_VALID,
192    },
193    HtmlElementInfo {
194        name: "p",
195        flags: HTML_BLOCK | HTML_VALID | HTML_NO_END,
196    },
197    HtmlElementInfo {
198        name: "h1",
199        flags: HTML_BLOCK | HTML_VALID,
200    },
201    HtmlElementInfo {
202        name: "h2",
203        flags: HTML_BLOCK | HTML_VALID,
204    },
205    HtmlElementInfo {
206        name: "h3",
207        flags: HTML_BLOCK | HTML_VALID,
208    },
209    HtmlElementInfo {
210        name: "h4",
211        flags: HTML_BLOCK | HTML_VALID,
212    },
213    HtmlElementInfo {
214        name: "h5",
215        flags: HTML_BLOCK | HTML_VALID,
216    },
217    HtmlElementInfo {
218        name: "h6",
219        flags: HTML_BLOCK | HTML_VALID,
220    },
221    HtmlElementInfo {
222        name: "ul",
223        flags: HTML_BLOCK | HTML_VALID | HTML_OL,
224    },
225    HtmlElementInfo {
226        name: "ol",
227        flags: HTML_BLOCK | HTML_VALID | HTML_OL,
228    },
229    HtmlElementInfo {
230        name: "li",
231        flags: HTML_BLOCK | HTML_VALID | HTML_NO_END,
232    },
233    HtmlElementInfo {
234        name: "dl",
235        flags: HTML_BLOCK | HTML_VALID | HTML_DL,
236    },
237    HtmlElementInfo {
238        name: "dt",
239        flags: HTML_BLOCK | HTML_VALID | HTML_NO_END,
240    },
241    HtmlElementInfo {
242        name: "dd",
243        flags: HTML_BLOCK | HTML_VALID | HTML_NO_END,
244    },
245    HtmlElementInfo {
246        name: "table",
247        flags: HTML_BLOCK | HTML_VALID,
248    },
249    HtmlElementInfo {
250        name: "tr",
251        flags: HTML_BLOCK | HTML_VALID | HTML_NO_END,
252    },
253    HtmlElementInfo {
254        name: "td",
255        flags: HTML_BLOCK | HTML_VALID | HTML_NO_END,
256    },
257    HtmlElementInfo {
258        name: "th",
259        flags: HTML_BLOCK | HTML_VALID | HTML_NO_END,
260    },
261    HtmlElementInfo {
262        name: "thead",
263        flags: HTML_BLOCK | HTML_VALID,
264    },
265    HtmlElementInfo {
266        name: "tbody",
267        flags: HTML_BLOCK | HTML_VALID,
268    },
269    HtmlElementInfo {
270        name: "tfoot",
271        flags: HTML_BLOCK | HTML_VALID,
272    },
273    HtmlElementInfo {
274        name: "colgroup",
275        flags: HTML_BLOCK | HTML_VALID,
276    },
277    HtmlElementInfo {
278        name: "caption",
279        flags: HTML_BLOCK | HTML_VALID,
280    },
281    HtmlElementInfo {
282        name: "form",
283        flags: HTML_BLOCK | HTML_VALID,
284    },
285    HtmlElementInfo {
286        name: "fieldset",
287        flags: HTML_BLOCK | HTML_VALID,
288    },
289    HtmlElementInfo {
290        name: "legend",
291        flags: HTML_BLOCK | HTML_VALID,
292    },
293    HtmlElementInfo {
294        name: "pre",
295        flags: HTML_BLOCK | HTML_VALID,
296    },
297    HtmlElementInfo {
298        name: "blockquote",
299        flags: HTML_BLOCK | HTML_VALID,
300    },
301    HtmlElementInfo {
302        name: "address",
303        flags: HTML_BLOCK | HTML_VALID,
304    },
305    HtmlElementInfo {
306        name: "center",
307        flags: HTML_BLOCK | HTML_VALID,
308    },
309    HtmlElementInfo {
310        name: "dir",
311        flags: HTML_BLOCK | HTML_VALID,
312    },
313    HtmlElementInfo {
314        name: "menu",
315        flags: HTML_BLOCK | HTML_VALID,
316    },
317    HtmlElementInfo {
318        name: "noscript",
319        flags: HTML_BLOCK | HTML_VALID,
320    },
321    HtmlElementInfo {
322        name: "frameset",
323        flags: HTML_BLOCK | HTML_VALID,
324    },
325    HtmlElementInfo {
326        name: "frame",
327        flags: HTML_BLOCK | HTML_EMPTY | HTML_VALID,
328    },
329    HtmlElementInfo {
330        name: "iframe",
331        flags: HTML_BLOCK | HTML_VALID,
332    },
333    HtmlElementInfo {
334        name: "noframes",
335        flags: HTML_BLOCK | HTML_VALID,
336    },
337    // Inline elements
338    HtmlElementInfo {
339        name: "a",
340        flags: HTML_INLINE | HTML_VALID,
341    },
342    HtmlElementInfo {
343        name: "abbr",
344        flags: HTML_INLINE | HTML_VALID,
345    },
346    HtmlElementInfo {
347        name: "acronym",
348        flags: HTML_INLINE | HTML_VALID,
349    },
350    HtmlElementInfo {
351        name: "b",
352        flags: HTML_INLINE | HTML_VALID,
353    },
354    HtmlElementInfo {
355        name: "basefont",
356        flags: HTML_INLINE | HTML_EMPTY | HTML_VALID,
357    },
358    HtmlElementInfo {
359        name: "bdo",
360        flags: HTML_INLINE | HTML_VALID,
361    },
362    HtmlElementInfo {
363        name: "big",
364        flags: HTML_INLINE | HTML_VALID,
365    },
366    HtmlElementInfo {
367        name: "cite",
368        flags: HTML_INLINE | HTML_VALID,
369    },
370    HtmlElementInfo {
371        name: "code",
372        flags: HTML_INLINE | HTML_VALID,
373    },
374    HtmlElementInfo {
375        name: "dfn",
376        flags: HTML_INLINE | HTML_VALID,
377    },
378    HtmlElementInfo {
379        name: "em",
380        flags: HTML_INLINE | HTML_VALID,
381    },
382    HtmlElementInfo {
383        name: "font",
384        flags: HTML_INLINE | HTML_VALID,
385    },
386    HtmlElementInfo {
387        name: "i",
388        flags: HTML_INLINE | HTML_VALID,
389    },
390    HtmlElementInfo {
391        name: "kbd",
392        flags: HTML_INLINE | HTML_VALID,
393    },
394    HtmlElementInfo {
395        name: "label",
396        flags: HTML_INLINE | HTML_VALID,
397    },
398    HtmlElementInfo {
399        name: "map",
400        flags: HTML_INLINE | HTML_VALID,
401    },
402    HtmlElementInfo {
403        name: "nobr",
404        flags: HTML_INLINE | HTML_VALID,
405    },
406    HtmlElementInfo {
407        name: "object",
408        flags: HTML_INLINE | HTML_VALID,
409    },
410    HtmlElementInfo {
411        name: "q",
412        flags: HTML_INLINE | HTML_VALID,
413    },
414    HtmlElementInfo {
415        name: "rb",
416        flags: HTML_INLINE | HTML_VALID,
417    },
418    HtmlElementInfo {
419        name: "rbc",
420        flags: HTML_INLINE | HTML_VALID,
421    },
422    HtmlElementInfo {
423        name: "rp",
424        flags: HTML_INLINE | HTML_VALID,
425    },
426    HtmlElementInfo {
427        name: "rt",
428        flags: HTML_INLINE | HTML_VALID,
429    },
430    HtmlElementInfo {
431        name: "rtc",
432        flags: HTML_INLINE | HTML_VALID,
433    },
434    HtmlElementInfo {
435        name: "ruby",
436        flags: HTML_INLINE | HTML_VALID,
437    },
438    HtmlElementInfo {
439        name: "s",
440        flags: HTML_INLINE | HTML_VALID,
441    },
442    HtmlElementInfo {
443        name: "samp",
444        flags: HTML_INLINE | HTML_VALID,
445    },
446    HtmlElementInfo {
447        name: "select",
448        flags: HTML_INLINE | HTML_VALID,
449    },
450    HtmlElementInfo {
451        name: "small",
452        flags: HTML_INLINE | HTML_VALID,
453    },
454    HtmlElementInfo {
455        name: "span",
456        flags: HTML_INLINE | HTML_VALID,
457    },
458    HtmlElementInfo {
459        name: "strike",
460        flags: HTML_INLINE | HTML_VALID,
461    },
462    HtmlElementInfo {
463        name: "strong",
464        flags: HTML_INLINE | HTML_VALID,
465    },
466    HtmlElementInfo {
467        name: "sub",
468        flags: HTML_INLINE | HTML_VALID,
469    },
470    HtmlElementInfo {
471        name: "sup",
472        flags: HTML_INLINE | HTML_VALID,
473    },
474    HtmlElementInfo {
475        name: "textarea",
476        flags: HTML_INLINE | HTML_VALID,
477    },
478    HtmlElementInfo {
479        name: "tt",
480        flags: HTML_INLINE | HTML_VALID,
481    },
482    HtmlElementInfo {
483        name: "u",
484        flags: HTML_INLINE | HTML_VALID,
485    },
486    HtmlElementInfo {
487        name: "var",
488        flags: HTML_INLINE | HTML_VALID,
489    },
490    // Heading block elements (also block)
491    HtmlElementInfo {
492        name: "header",
493        flags: HTML_BLOCK | HTML_VALID,
494    },
495    HtmlElementInfo {
496        name: "footer",
497        flags: HTML_BLOCK | HTML_VALID,
498    },
499    HtmlElementInfo {
500        name: "nav",
501        flags: HTML_BLOCK | HTML_VALID,
502    },
503    HtmlElementInfo {
504        name: "article",
505        flags: HTML_BLOCK | HTML_VALID,
506    },
507    HtmlElementInfo {
508        name: "section",
509        flags: HTML_BLOCK | HTML_VALID,
510    },
511    HtmlElementInfo {
512        name: "aside",
513        flags: HTML_BLOCK | HTML_VALID,
514    },
515    HtmlElementInfo {
516        name: "main",
517        flags: HTML_BLOCK | HTML_VALID,
518    },
519    HtmlElementInfo {
520        name: "figure",
521        flags: HTML_BLOCK | HTML_VALID,
522    },
523    HtmlElementInfo {
524        name: "figcaption",
525        flags: HTML_BLOCK | HTML_VALID,
526    },
527    HtmlElementInfo {
528        name: "details",
529        flags: HTML_BLOCK | HTML_VALID,
530    },
531    HtmlElementInfo {
532        name: "summary",
533        flags: HTML_BLOCK | HTML_VALID,
534    },
535    // Script and style
536    HtmlElementInfo {
537        name: "script",
538        flags: HTML_HEAD | HTML_BLOCK | HTML_VALID,
539    },
540    HtmlElementInfo {
541        name: "style",
542        flags: HTML_HEAD | HTML_BLOCK | HTML_VALID,
543    },
544    HtmlElementInfo {
545        name: "title",
546        flags: HTML_HEAD | HTML_BLOCK | HTML_VALID,
547    },
548];
549
550/// Case-insensitive lookup of an HTML element by name.
551/// Returns `None` if the element is not in the table (treated as unknown).
552fn html_tag_lookup(name: &str) -> Option<&'static HtmlElementInfo> {
553    // Lowercase the name for comparison
554    let lower: Vec<u8> = name.bytes().map(|b| b.to_ascii_lowercase()).collect();
555    let lower_str = match core::str::from_utf8(&lower) {
556        Ok(s) => s,
557        Err(_) => return None,
558    };
559    HTML_ELEMENTS.iter().find(|info| info.name == lower_str)
560}
561
562// ═══════════════════════════════════════════════════════════════════════════════
563// HTML Entity Handling
564// ═══════════════════════════════════════════════════════════════════════════════
565
566/// HTML named entities table. Maps entity names to their UTF-8 character(s).
567/// This is a subset of the full HTML entity list, matching what libxml2 supports.
568const HTML_ENTITIES: &[(&str, &str)] = &[
569    ("nbsp", "\u{00a0}"),
570    ("lt", "<"),
571    ("gt", ">"),
572    ("amp", "&"),
573    ("quot", "\""),
574    ("apos", "'"),
575    ("copy", "\u{00a9}"),
576    ("reg", "\u{00ae}"),
577    ("amp", "&"),
578    ("iexcl", "\u{00a1}"),
579    ("cent", "\u{00a2}"),
580    ("pound", "\u{00a3}"),
581    ("curren", "\u{00a4}"),
582    ("yen", "\u{00a5}"),
583    ("brvbar", "\u{00a6}"),
584    ("sect", "\u{00a7}"),
585    ("uml", "\u{00a8}"),
586    ("ordf", "\u{00aa}"),
587    ("laquo", "\u{00ab}"),
588    ("not", "\u{00ac}"),
589    ("shy", "\u{00ad}"),
590    ("macr", "\u{00ae}"),
591    ("deg", "\u{00b0}"),
592    ("plusmn", "\u{00b1}"),
593    ("sup2", "\u{00b2}"),
594    ("sup3", "\u{00b3}"),
595    ("acute", "\u{00b4}"),
596    ("micro", "\u{00b5}"),
597    ("para", "\u{00b6}"),
598    ("middot", "\u{00b7}"),
599    ("cedil", "\u{00b8}"),
600    ("sup1", "\u{00b9}"),
601    ("ordm", "\u{00ba}"),
602    ("raquo", "\u{00bb}"),
603    ("frac14", "\u{00bc}"),
604    ("frac12", "\u{00bd}"),
605    ("frac34", "\u{00be}"),
606    ("iquest", "\u{00bf}"),
607    ("times", "\u{00d7}"),
608    ("divide", "\u{00f7}"),
609    ("ETH", "\u{00d0}"),
610    ("eth", "\u{00f0}"),
611    ("THORN", "\u{00de}"),
612    ("thorn", "\u{00fe}"),
613    ("AElig", "\u{00c6}"),
614    ("aelig", "\u{00e6}"),
615    ("OElig", "\u{0152}"),
616    ("oelig", "\u{0153}"),
617    ("Scaron", "\u{0160}"),
618    ("scaron", "\u{0161}"),
619    ("Yuml", "\u{0178}"),
620    ("circ", "\u{02c6}"),
621    ("tilde", "\u{02dc}"),
622    ("ensp", "\u{2002}"),
623    ("emsp", "\u{2003}"),
624    ("thinsp", "\u{2009}"),
625    ("zwnj", "\u{200c}"),
626    ("zwj", "\u{200d}"),
627    ("lrm", "\u{200e}"),
628    ("rlm", "\u{200f}"),
629    ("ndash", "\u{2013}"),
630    ("mdash", "\u{2014}"),
631    ("lsquo", "\u{2018}"),
632    ("rsquo", "\u{2019}"),
633    ("sbquo", "\u{201a}"),
634    ("ldquo", "\u{201c}"),
635    ("rdquo", "\u{201d}"),
636    ("bdquo", "\u{201e}"),
637    ("dagger", "\u{2020}"),
638    ("Dagger", "\u{2021}"),
639    ("bull", "\u{2022}"),
640    ("hellip", "\u{2026}"),
641    ("permil", "\u{2030}"),
642    ("prime", "\u{2032}"),
643    ("Prime", "\u{2033}"),
644    ("lsaquo", "\u{2039}"),
645    ("rsaquo", "\u{203a}"),
646    ("oline", "\u{203e}"),
647    ("euro", "\u{20ac}"),
648    ("trade", "\u{2122}"),
649    ("larr", "\u{2190}"),
650    ("uarr", "\u{2191}"),
651    ("rarr", "\u{2192}"),
652    ("darr", "\u{2193}"),
653    ("harr", "\u{2194}"),
654    ("crarr", "\u{21b5}"),
655    ("lceil", "\u{2308}"),
656    ("rceil", "\u{2309}"),
657    ("lfloor", "\u{230a}"),
658    ("rfloor", "\u{230b}"),
659    ("loz", "\u{25ca}"),
660    ("spades", "\u{2660}"),
661    ("clubs", "\u{2663}"),
662    ("hearts", "\u{2665}"),
663    ("diams", "\u{2666}"),
664    ("Alpha", "\u{0391}"),
665    ("Beta", "\u{0392}"),
666    ("Gamma", "\u{0393}"),
667    ("Delta", "\u{0394}"),
668    ("Epsilon", "\u{0395}"),
669    ("Zeta", "\u{0396}"),
670    ("Eta", "\u{0397}"),
671    ("Theta", "\u{0398}"),
672    ("Iota", "\u{0399}"),
673    ("Kappa", "\u{039a}"),
674    ("Lambda", "\u{039b}"),
675    ("Mu", "\u{039c}"),
676    ("Nu", "\u{039d}"),
677    ("Xi", "\u{039e}"),
678    ("Omicron", "\u{039f}"),
679    ("Pi", "\u{03a0}"),
680    ("Rho", "\u{03a1}"),
681    ("Sigma", "\u{03a3}"),
682    ("Tau", "\u{03a4}"),
683    ("Upsilon", "\u{03a5}"),
684    ("Phi", "\u{03a6}"),
685    ("Chi", "\u{03a7}"),
686    ("Psi", "\u{03a8}"),
687    ("Omega", "\u{03a9}"),
688    ("alpha", "\u{03b1}"),
689    ("beta", "\u{03b2}"),
690    ("gamma", "\u{03b3}"),
691    ("delta", "\u{03b4}"),
692    ("epsilon", "\u{03b5}"),
693    ("zeta", "\u{03b6}"),
694    ("eta", "\u{03b7}"),
695    ("theta", "\u{03b8}"),
696    ("iota", "\u{03b9}"),
697    ("kappa", "\u{03ba}"),
698    ("lambda", "\u{03bb}"),
699    ("mu", "\u{03bc}"),
700    ("nu", "\u{03bd}"),
701    ("xi", "\u{03be}"),
702    ("omicron", "\u{03bf}"),
703    ("pi", "\u{03c0}"),
704    ("rho", "\u{03c1}"),
705    ("sigmaf", "\u{03c2}"),
706    ("sigma", "\u{03c3}"),
707    ("tau", "\u{03c4}"),
708    ("upsilon", "\u{03c5}"),
709    ("phi", "\u{03c6}"),
710    ("chi", "\u{03c7}"),
711    ("psi", "\u{03c8}"),
712    ("omega", "\u{03c9}"),
713    ("thetasym", "\u{03d1}"),
714    ("upsih", "\u{03d2}"),
715    ("piv", "\u{03d6}"),
716];
717
718/// Look up an HTML entity by name (without the leading '&').
719/// Returns the replacement string, or None if unknown.
720fn html_entity_lookup(name: &str) -> Option<&'static str> {
721    HTML_ENTITIES
722        .iter()
723        .find(|(n, _)| *n == name)
724        .map(|(_, v)| *v)
725}
726
727// ═══════════════════════════════════════════════════════════════════════════════
728// Parser Context
729// ═══════════════════════════════════════════════════════════════════════════════
730
731/// Internal HTML parser context.
732struct HtmlParserCtxt {
733    /// The document being built
734    doc: *mut _xmlDoc,
735    /// Current insertion point (current parent node)
736    current: *mut _xmlNode,
737    /// The html element (auto-created or parsed)
738    html: *mut _xmlNode,
739    /// The head element (auto-created or parsed)
740    head: *mut _xmlNode,
741    /// The body element (auto-created or parsed)
742    body: *mut _xmlNode,
743    /// Whether we're inside <head>
744    in_head: bool,
745    /// Whether we're inside <body>
746    in_body: bool,
747    /// Whether html has been seen/created
748    html_created: bool,
749    /// Whether head has been seen/created
750    head_created: bool,
751    /// Whether body has been seen/created
752    body_created: bool,
753    /// Whether we've seen body content (moves from head to body)
754    seen_body_content: bool,
755    /// Input buffer (the HTML source)
756    input: *mut u8,
757    /// Current position in input
758    input_pos: usize,
759    /// Total input length
760    input_len: usize,
761    /// Line number tracking
762    line: c_int,
763    /// Error flag
764    #[allow(dead_code)]
765    err: bool,
766    /// Filename (for file parsing)
767    filename: *mut c_char,
768    /// Encoding
769    encoding: *mut c_char,
770}
771
772impl HtmlParserCtxt {
773    const fn new() -> Self {
774        HtmlParserCtxt {
775            doc: ptr::null_mut(),
776            current: ptr::null_mut(),
777            html: ptr::null_mut(),
778            head: ptr::null_mut(),
779            body: ptr::null_mut(),
780            in_head: false,
781            in_body: false,
782            html_created: false,
783            head_created: false,
784            body_created: false,
785            seen_body_content: false,
786            input: ptr::null_mut(),
787            input_pos: 0,
788            input_len: 0,
789            line: 1,
790            err: false,
791            filename: ptr::null_mut(),
792            encoding: ptr::null_mut(),
793        }
794    }
795
796    /// Peek at the next byte without consuming it.
797    fn peek(&self) -> Option<u8> {
798        if self.input_pos < self.input_len {
799            unsafe { Some(*self.input.add(self.input_pos)) }
800        } else {
801            None
802        }
803    }
804
805    /// Peek ahead `n` bytes.
806    fn peek_at(&self, offset: usize) -> Option<u8> {
807        let pos = self.input_pos + offset;
808        if pos < self.input_len {
809            unsafe { Some(*self.input.add(pos)) }
810        } else {
811            None
812        }
813    }
814
815    /// Consume and return the next byte.
816    fn next(&mut self) -> Option<u8> {
817        if self.input_pos < self.input_len {
818            let ch = unsafe { *self.input.add(self.input_pos) };
819            self.input_pos += 1;
820            if ch == b'\n' {
821                self.line += 1;
822            }
823            Some(ch)
824        } else {
825            None
826        }
827    }
828
829    /// Skip bytes while the predicate returns true.
830    fn skip_while<F: Fn(u8) -> bool>(&mut self, f: F) {
831        while let Some(ch) = self.peek() {
832            if f(ch) {
833                self.next();
834            } else {
835                break;
836            }
837        }
838    }
839
840    /// Skip ASCII whitespace.
841    fn skip_whitespace(&mut self) {
842        self.skip_while(|ch| ch == b' ' || ch == b'\t' || ch == b'\n' || ch == b'\r');
843    }
844
845    /// Check if we've reached end of input.
846    const fn is_eof(&self) -> bool {
847        self.input_pos >= self.input_len
848    }
849
850    /// Read a sequence of bytes while the predicate returns true.
851    fn read_while<F: Fn(u8) -> bool>(&mut self, f: F) -> Vec<u8> {
852        let start = self.input_pos;
853        while let Some(ch) = self.peek() {
854            if f(ch) {
855                self.next();
856            } else {
857                break;
858            }
859        }
860        unsafe { slice::from_raw_parts(self.input.add(start), self.input_pos - start).to_vec() }
861    }
862}
863
864// ═══════════════════════════════════════════════════════════════════════════════
865// Auto-close Logic
866// ═══════════════════════════════════════════════════════════════════════════════
867
868/// Check if a tag name is a "heading" element (h1-h6).
869fn is_heading(name: &str) -> bool {
870    matches!(name, "h1" | "h2" | "h3" | "h4" | "h5" | "h6")
871}
872
873/// Get the parent element of a node, walking up to find the nearest element.
874/// Returns the current node's parent if it's an element, or walks up.
875#[allow(dead_code)]
876unsafe fn get_parent_element(node: *mut _xmlNode) -> *mut _xmlNode {
877    if node.is_null() {
878        return ptr::null_mut();
879    }
880    let mut n = node;
881    loop {
882        let parent = unsafe { (*n).parent };
883        if parent.is_null() {
884            return ptr::null_mut();
885        }
886        let ptype = unsafe { (*parent).type_ };
887        if ptype == XML_ELEMENT_NODE as c_int
888            || ptype == XML_HTML_DOCUMENT_NODE as c_int
889            || ptype == XML_DOCUMENT_NODE as c_int
890        {
891            return parent;
892        }
893        n = parent;
894    }
895}
896
897/// Auto-close elements that should be closed before opening a new tag.
898/// Returns the new current insertion point.
899unsafe fn auto_close_element(ctxt: &mut HtmlParserCtxt, tag_name: &str) {
900    let tag_lower: Vec<u8> = tag_name.bytes().map(|b| b.to_ascii_lowercase()).collect();
901    let tag_lower_str = match core::str::from_utf8(&tag_lower) {
902        Ok(s) => s,
903        Err(_) => return,
904    };
905
906    let info = html_tag_lookup(tag_lower_str);
907
908    let mut current = ctxt.current;
909
910    // Collect the open element names up the tree
911    let mut open_names: Vec<Vec<u8>> = Vec::new();
912    let mut cur = current;
913    while !cur.is_null() {
914        let ctype = unsafe { (*cur).type_ };
915        if ctype == XML_ELEMENT_NODE as c_int && !unsafe { (*cur).name.is_null() } {
916            let name_bytes = unsafe { xmlstr_to_bytes((*cur).name) };
917            open_names.push(name_bytes.to_vec());
918        }
919        cur = unsafe { (*cur).parent };
920    }
921
922    // Rule 1: <p> auto-closes before another <p>, and before block elements
923    if tag_lower_str == "p" || info.is_some_and(|i| i.flags & HTML_BLOCK != 0) {
924        // Close any open <p> elements
925        let mut cur2 = current;
926        while !cur2.is_null() {
927            let ctype = unsafe { (*cur2).type_ };
928            if ctype == XML_ELEMENT_NODE as c_int && !unsafe { (*cur2).name.is_null() } {
929                let name_bytes = unsafe { xmlstr_to_bytes((*cur2).name) };
930                let name_str = core::str::from_utf8(name_bytes).unwrap_or("");
931                if name_str.eq_ignore_ascii_case("p") {
932                    // Close this <p> by moving current up past it
933                    current = unsafe { (*cur2).parent };
934                    break;
935                }
936            }
937            cur2 = unsafe { (*cur2).parent };
938        }
939    }
940
941    // Rule 2: Headings (h1-h6) auto-close other headings
942    if is_heading(tag_lower_str) {
943        let mut cur2 = current;
944        while !cur2.is_null() {
945            let ctype = unsafe { (*cur2).type_ };
946            if ctype == XML_ELEMENT_NODE as c_int && !unsafe { (*cur2).name.is_null() } {
947                let name_bytes = unsafe { xmlstr_to_bytes((*cur2).name) };
948                let name_str = core::str::from_utf8(name_bytes).unwrap_or("");
949                if is_heading(name_str) {
950                    current = unsafe { (*cur2).parent };
951                    break;
952                }
953            }
954            cur2 = unsafe { (*cur2).parent };
955        }
956    }
957
958    // Rule 3: <li> auto-closes another <li>
959    if tag_lower_str == "li" {
960        let mut cur2 = current;
961        while !cur2.is_null() {
962            let ctype = unsafe { (*cur2).type_ };
963            if ctype == XML_ELEMENT_NODE as c_int && !unsafe { (*cur2).name.is_null() } {
964                let name_bytes = unsafe { xmlstr_to_bytes((*cur2).name) };
965                let name_str = core::str::from_utf8(name_bytes).unwrap_or("");
966                if name_str.eq_ignore_ascii_case("li") {
967                    current = unsafe { (*cur2).parent };
968                    break;
969                }
970            }
971            cur2 = unsafe { (*cur2).parent };
972        }
973    }
974
975    // Rule 4: <dt>/<dd> auto-close another <dt>/<dd>
976    if tag_lower_str == "dt" || tag_lower_str == "dd" {
977        let mut cur2 = current;
978        while !cur2.is_null() {
979            let ctype = unsafe { (*cur2).type_ };
980            if ctype == XML_ELEMENT_NODE as c_int && !unsafe { (*cur2).name.is_null() } {
981                let name_bytes = unsafe { xmlstr_to_bytes((*cur2).name) };
982                let name_str = core::str::from_utf8(name_bytes).unwrap_or("");
983                if name_str == "dt" || name_str == "dd" {
984                    current = unsafe { (*cur2).parent };
985                    break;
986                }
987            }
988            cur2 = unsafe { (*cur2).parent };
989        }
990    }
991
992    // Rule 5: <tr> auto-closes an open <tr>, <td> or <th> (the previous
993    // row); a new <td>/<th> only auto-closes an open <td>/<th> (the
994    // previous cell) and stays inside the open <tr> (upstream
995    // htmlAutoClose).
996    if tag_lower_str == "tr" {
997        let mut cur2 = current;
998        while !cur2.is_null() {
999            let ctype = unsafe { (*cur2).type_ };
1000            if ctype == XML_ELEMENT_NODE as c_int && !unsafe { (*cur2).name.is_null() } {
1001                let name_bytes = unsafe { xmlstr_to_bytes((*cur2).name) };
1002                let name_str = core::str::from_utf8(name_bytes).unwrap_or("");
1003                if name_str == "tr" || name_str == "td" || name_str == "th" {
1004                    current = unsafe { (*cur2).parent };
1005                    break;
1006                }
1007            }
1008            cur2 = unsafe { (*cur2).parent };
1009        }
1010    } else if tag_lower_str == "td" || tag_lower_str == "th" {
1011        let mut cur2 = current;
1012        while !cur2.is_null() {
1013            let ctype = unsafe { (*cur2).type_ };
1014            if ctype == XML_ELEMENT_NODE as c_int && !unsafe { (*cur2).name.is_null() } {
1015                let name_bytes = unsafe { xmlstr_to_bytes((*cur2).name) };
1016                let name_str = core::str::from_utf8(name_bytes).unwrap_or("");
1017                if name_str == "td" || name_str == "th" {
1018                    current = unsafe { (*cur2).parent };
1019                    break;
1020                }
1021            }
1022            cur2 = unsafe { (*cur2).parent };
1023        }
1024    }
1025
1026    // Rule 6: <thead>, <tbody>, <tfoot> auto-close each other
1027    if matches!(tag_lower_str, "thead" | "tbody" | "tfoot") {
1028        let mut cur2 = current;
1029        while !cur2.is_null() {
1030            let ctype = unsafe { (*cur2).type_ };
1031            if ctype == XML_ELEMENT_NODE as c_int && !unsafe { (*cur2).name.is_null() } {
1032                let name_bytes = unsafe { xmlstr_to_bytes((*cur2).name) };
1033                let name_str = core::str::from_utf8(name_bytes).unwrap_or("");
1034                if name_str == "thead" || name_str == "tbody" || name_str == "tfoot" {
1035                    current = unsafe { (*cur2).parent };
1036                    break;
1037                }
1038            }
1039            cur2 = unsafe { (*cur2).parent };
1040        }
1041    }
1042
1043    // Rule 7: <colgroup> auto-closes another <colgroup>
1044    if tag_lower_str == "colgroup" {
1045        let mut cur2 = current;
1046        while !cur2.is_null() {
1047            let ctype = unsafe { (*cur2).type_ };
1048            if ctype == XML_ELEMENT_NODE as c_int && !unsafe { (*cur2).name.is_null() } {
1049                let name_bytes = unsafe { xmlstr_to_bytes((*cur2).name) };
1050                let name_str = core::str::from_utf8(name_bytes).unwrap_or("");
1051                if name_str == "colgroup" {
1052                    current = unsafe { (*cur2).parent };
1053                    break;
1054                }
1055            }
1056            cur2 = unsafe { (*cur2).parent };
1057        }
1058    }
1059
1060    // Rule 8: <caption> auto-closes another <caption>
1061    if tag_lower_str == "caption" {
1062        let mut cur2 = current;
1063        while !cur2.is_null() {
1064            let ctype = unsafe { (*cur2).type_ };
1065            if ctype == XML_ELEMENT_NODE as c_int && !unsafe { (*cur2).name.is_null() } {
1066                let name_bytes = unsafe { xmlstr_to_bytes((*cur2).name) };
1067                let name_str = core::str::from_utf8(name_bytes).unwrap_or("");
1068                if name_str == "caption" {
1069                    current = unsafe { (*cur2).parent };
1070                    break;
1071                }
1072            }
1073            cur2 = unsafe { (*cur2).parent };
1074        }
1075    }
1076
1077    // Rule 9: <form> auto-closes another <form> (in libxml2 behavior)
1078    if tag_lower_str == "form" {
1079        let mut cur2 = current;
1080        while !cur2.is_null() {
1081            let ctype = unsafe { (*cur2).type_ };
1082            if ctype == XML_ELEMENT_NODE as c_int && !unsafe { (*cur2).name.is_null() } {
1083                let name_bytes = unsafe { xmlstr_to_bytes((*cur2).name) };
1084                let name_str = core::str::from_utf8(name_bytes).unwrap_or("");
1085                if name_str.eq_ignore_ascii_case("form") {
1086                    current = unsafe { (*cur2).parent };
1087                    break;
1088                }
1089            }
1090            cur2 = unsafe { (*cur2).parent };
1091        }
1092    }
1093
1094    ctxt.current = current;
1095}
1096
1097// ═══════════════════════════════════════════════════════════════════════════════
1098// Implicit Element Creation
1099// ═══════════════════════════════════════════════════════════════════════════════
1100
1101/// Ensure the html element exists, creating it implicitly if needed.
1102unsafe fn ensure_html(ctxt: &mut HtmlParserCtxt) -> *mut _xmlNode {
1103    if !ctxt.html.is_null() {
1104        return ctxt.html;
1105    }
1106
1107    let html_node = tree::new_node(ptr::null_mut(), b"html\0" as *const u8 as *const xmlChar);
1108    if html_node.is_null() {
1109        return ptr::null_mut();
1110    }
1111    {
1112        // Set HTML_IMPLIED flag concept - mark as auto-created
1113        // (We track this via a separate flag rather than modifying the node structure)
1114    }
1115    ctxt.html = html_node;
1116    ctxt.html_created = true;
1117
1118    // Add to document
1119    tree::add_child(ctxt.doc as *mut _xmlNode, html_node);
1120    ctxt.current = html_node;
1121
1122    html_node
1123}
1124
1125/// Ensure the head element exists, creating it implicitly if needed.
1126unsafe fn ensure_head(ctxt: &mut HtmlParserCtxt) -> *mut _xmlNode {
1127    if !ctxt.head.is_null() {
1128        return ctxt.head;
1129    }
1130
1131    // Ensure html exists first
1132    ensure_html(ctxt);
1133
1134    let head_node = tree::new_node(ptr::null_mut(), b"head\0" as *const u8 as *const xmlChar);
1135    if head_node.is_null() {
1136        return ptr::null_mut();
1137    }
1138    ctxt.head = head_node;
1139    ctxt.head_created = true;
1140
1141    // Add as child of html
1142    tree::add_child(ctxt.html, head_node);
1143    ctxt.current = head_node;
1144    ctxt.in_head = true;
1145
1146    head_node
1147}
1148
1149/// Ensure the body element exists, creating it implicitly if needed.
1150unsafe fn ensure_body(ctxt: &mut HtmlParserCtxt) -> *mut _xmlNode {
1151    if !ctxt.body.is_null() {
1152        return ctxt.body;
1153    }
1154
1155    // Ensure html exists first
1156    ensure_html(ctxt);
1157
1158    let body_node = tree::new_node(ptr::null_mut(), b"body\0" as *const u8 as *const xmlChar);
1159    if body_node.is_null() {
1160        return ptr::null_mut();
1161    }
1162    ctxt.body = body_node;
1163    ctxt.body_created = true;
1164
1165    // Add as child of html
1166    tree::add_child(ctxt.html, body_node);
1167    ctxt.current = body_node;
1168    ctxt.in_body = true;
1169
1170    body_node
1171}
1172
1173/// Transition from head to body when body content is encountered.
1174#[allow(dead_code)]
1175unsafe fn transition_to_body(ctxt: &mut HtmlParserCtxt) {
1176    if ctxt.in_head && !ctxt.seen_body_content {
1177        ctxt.seen_body_content = true;
1178        ctxt.in_head = false;
1179        ensure_body(ctxt);
1180    }
1181}
1182
1183// ═══════════════════════════════════════════════════════════════════════════════
1184// Tokenizer
1185// ═══════════════════════════════════════════════════════════════════════════════
1186
1187/// Result of parsing an attribute.
1188struct HtmlAttr {
1189    name: Vec<u8>,
1190    value: Vec<u8>,
1191    #[allow(dead_code)]
1192    quoted: bool,
1193}
1194
1195/// Parse an attribute name.
1196fn parse_attr_name(ctxt: &mut HtmlParserCtxt) -> Vec<u8> {
1197    let mut name = Vec::new();
1198    while let Some(ch) = ctxt.peek() {
1199        if ch == b'='
1200            || ch == b'>'
1201            || ch == b'/'
1202            || ch == b' '
1203            || ch == b'\t'
1204            || ch == b'\n'
1205            || ch == b'\r'
1206        {
1207            break;
1208        }
1209        name.push(ch);
1210        ctxt.next();
1211    }
1212    name
1213}
1214
1215/// Parse an attribute value (may be quoted or unquoted).
1216fn parse_attr_value(ctxt: &mut HtmlParserCtxt) -> (Vec<u8>, bool) {
1217    ctxt.skip_whitespace();
1218
1219    let quote = match ctxt.peek() {
1220        Some(b'"') => {
1221            ctxt.next(); // consume opening quote
1222            b'"'
1223        }
1224        Some(b'\'') => {
1225            ctxt.next(); // consume opening quote
1226            b'\''
1227        }
1228        _ => {
1229            // Unquoted value
1230            let value = ctxt.read_while(|ch| {
1231                ch != b'>' && ch != b' ' && ch != b'\t' && ch != b'\n' && ch != b'\r'
1232            });
1233            return (value, false);
1234        }
1235    };
1236
1237    // Quoted value
1238    let mut value = Vec::new();
1239    loop {
1240        match ctxt.next() {
1241            Some(ch) if ch == quote => break,
1242            Some(ch) => value.push(ch),
1243            None => break,
1244        }
1245    }
1246    (value, true)
1247}
1248
1249/// Parse attributes until we hit '>' or end of tag.
1250fn parse_attributes(ctxt: &mut HtmlParserCtxt) -> Vec<HtmlAttr> {
1251    let mut attrs = Vec::new();
1252
1253    loop {
1254        ctxt.skip_whitespace();
1255
1256        match ctxt.peek() {
1257            Some(b'>') | None => break,
1258            Some(b'/')
1259                // Could be self-closing tag like <br/>
1260                if ctxt.peek_at(1) == Some(b'>') => {
1261                    break;
1262                }
1263                // Otherwise it's part of a minimized attribute or path
1264            _ => {}
1265        }
1266
1267        let name = parse_attr_name(ctxt);
1268        if name.is_empty() {
1269            break;
1270        }
1271
1272        // Check for '='
1273        ctxt.skip_whitespace();
1274        if ctxt.peek() == Some(b'=') {
1275            ctxt.next(); // consume '='
1276            let (value, quoted) = parse_attr_value(ctxt);
1277            attrs.push(HtmlAttr {
1278                name,
1279                value,
1280                quoted,
1281            });
1282        } else {
1283            // Minimized attribute (e.g., <option selected>)
1284            attrs.push(HtmlAttr {
1285                name,
1286                value: Vec::new(),
1287                quoted: false,
1288            });
1289        }
1290    }
1291
1292    attrs
1293}
1294
1295// ═══════════════════════════════════════════════════════════════════════════════
1296// Tree Builder
1297// ═══════════════════════════════════════════════════════════════════════════════
1298
1299/// Process a resolved HTML entity reference and return the replacement bytes.
1300fn resolve_entity(name: &str) -> Vec<u8> {
1301    if let Some(replacement) = html_entity_lookup(name) {
1302        replacement.as_bytes().to_vec()
1303    } else {
1304        // Unknown entity: leave as-is (pass through as text)
1305        let mut result = Vec::new();
1306        result.push(b'&');
1307        result.extend_from_slice(name.as_bytes());
1308        result.push(b';');
1309        result
1310    }
1311}
1312
1313/// Handle a numeric character reference (decimal or hex).
1314fn resolve_numeric_entity(value: &str, is_hex: bool) -> Vec<u8> {
1315    let codepoint = if is_hex {
1316        u32::from_str_radix(value, 16).unwrap_or(0xFFFD)
1317    } else {
1318        value.parse::<u32>().unwrap_or(0xFFFD)
1319    };
1320
1321    if codepoint == 0 {
1322        return Vec::new();
1323    }
1324
1325    // Convert codepoint to UTF-8
1326    match char::from_u32(codepoint) {
1327        Some(c) => {
1328            let mut buf = [0u8; 4];
1329            let s = c.encode_utf8(&mut buf);
1330            s.as_bytes().to_vec()
1331        }
1332        None => vec![0xEF, 0xBF, 0xBD], // replacement character
1333    }
1334}
1335
1336/// Parse an entity reference starting at current position (which points to '&').
1337/// Returns the replacement text and advances position.
1338fn parse_entity(ctxt: &mut HtmlParserCtxt) -> Vec<u8> {
1339    // We should be at '&'
1340    match ctxt.peek() {
1341        Some(b'&') => {
1342            ctxt.next(); // consume '&'
1343        }
1344        _ => return vec![b'&'],
1345    }
1346
1347    // Check for numeric entities
1348    if ctxt.peek() == Some(b'#') {
1349        ctxt.next(); // consume '#'
1350        let is_hex = ctxt.peek() == Some(b'x') || ctxt.peek() == Some(b'X');
1351        if is_hex {
1352            ctxt.next(); // consume 'x' or 'X'
1353        }
1354
1355        let digits = ctxt.read_while(|ch| {
1356            if is_hex {
1357                ch.is_ascii_hexdigit()
1358            } else {
1359                ch.is_ascii_digit()
1360            }
1361        });
1362
1363        let digits_str = core::str::from_utf8(&digits).unwrap_or("");
1364        if digits_str.is_empty() {
1365            let mut result = vec![b'&', b'#'];
1366            if is_hex {
1367                result.push(b'x');
1368            }
1369            return result;
1370        }
1371
1372        // Expect semicolon
1373        if ctxt.peek() == Some(b';') {
1374            ctxt.next();
1375        }
1376
1377        return resolve_numeric_entity(digits_str, is_hex);
1378    }
1379
1380    // Named entity
1381    let name = ctxt.read_while(|ch| ch.is_ascii_alphanumeric() || ch == b'_' || ch == b'-');
1382    let name_str = core::str::from_utf8(&name).unwrap_or("");
1383
1384    // Expect semicolon
1385    if ctxt.peek() == Some(b';') {
1386        ctxt.next();
1387    }
1388
1389    resolve_entity(name_str)
1390}
1391
1392/// Handle text content in the tree builder.
1393unsafe fn handle_text(ctxt: &mut HtmlParserCtxt, text: &[u8]) {
1394    if text.is_empty() {
1395        return;
1396    }
1397
1398    // Determine current insertion point
1399    let parent = if ctxt.in_head {
1400        ctxt.head
1401    } else if ctxt.in_body || ctxt.body_created {
1402        ctxt.body
1403    } else if ctxt.html_created {
1404        ctxt.html
1405    } else {
1406        ctxt.doc as *mut _xmlNode
1407    };
1408
1409    let insertion_point = if ctxt.current.is_null() {
1410        parent
1411    } else {
1412        ctxt.current
1413    };
1414
1415    if insertion_point.is_null() {
1416        // Fall back to document
1417        let text_node = tree::new_text(ptr::null_mut());
1418        if !text_node.is_null() {
1419            // Create a null-terminated copy
1420            let content = bytes_to_xmlstr(text);
1421            if !content.is_null() {
1422                unsafe {
1423                    (*text_node).content = content;
1424                }
1425            }
1426            tree::add_child(ctxt.doc as *mut _xmlNode, text_node);
1427        }
1428        return;
1429    }
1430
1431    // UPSTREAM-PARITY: whitespace-only text at the document level (before or
1432    // after the root element) is discarded; non-whitespace stray text is
1433    // wrapped in a new `html` element (htmlParseCharData behavior), with
1434    // leading blank characters skipped.
1435    if insertion_point == ctxt.doc as *mut _xmlNode {
1436        if text.iter().all(|b| b.is_ascii_whitespace()) {
1437            return;
1438        }
1439        let content = trim_ascii_start(text);
1440        let html_node = tree::new_node(ptr::null_mut(), bytes_to_xmlstr(b"html"));
1441        if !html_node.is_null() {
1442            let content_c = bytes_to_xmlstr(content);
1443            let text_node = tree::new_text(ptr::null_mut());
1444            if !text_node.is_null() {
1445                unsafe {
1446                    (*text_node).content = content_c;
1447                }
1448                tree::add_child(html_node, text_node);
1449            }
1450            tree::add_child(ctxt.doc as *mut _xmlNode, html_node);
1451            ctxt.html = html_node;
1452            ctxt.html_created = true;
1453            ctxt.current = html_node;
1454        }
1455        return;
1456    }
1457
1458    let text_node = tree::new_text(ptr::null_mut());
1459    if text_node.is_null() {
1460        return;
1461    }
1462
1463    // Set the content
1464    let content = bytes_to_xmlstr(text);
1465    if !content.is_null() {
1466        unsafe {
1467            (*text_node).content = content;
1468        }
1469    }
1470
1471    tree::add_child(insertion_point, text_node);
1472}
1473
1474/// Process a start tag in the tree builder.
1475unsafe fn handle_start_tag(ctxt: &mut HtmlParserCtxt, tag_name: &[u8], attrs: &[HtmlAttr]) {
1476    let tag_lower: Vec<u8> = tag_name.iter().map(|b| b.to_ascii_lowercase()).collect();
1477    let tag_str = core::str::from_utf8(&tag_lower).unwrap_or("");
1478
1479    let info = html_tag_lookup(tag_str);
1480
1481    // Determine tag category
1482    let is_head_tag = info.is_some_and(|i| i.flags & HTML_HEAD != 0);
1483    let _is_body_tag = info.is_some_and(|i| i.flags & HTML_BODY != 0);
1484    let is_empty = info.is_some_and(|i| i.flags & HTML_EMPTY != 0);
1485    let _is_block = info.is_some_and(|i| i.flags & HTML_BLOCK != 0);
1486
1487    // Handle special elements
1488    if tag_str == "html" {
1489        if !ctxt.html.is_null() && !ctxt.html_created {
1490            // Second <html> tag, skip it
1491            return;
1492        }
1493        // Create or use existing html
1494        if ctxt.html.is_null() {
1495            let html_node = tree::new_node(ptr::null_mut(), bytes_to_xmlstr(tag_name));
1496            if !html_node.is_null() {
1497                ctxt.html = html_node;
1498                ctxt.html_created = false; // parsed, not implied
1499                tree::add_child(ctxt.doc as *mut _xmlNode, html_node);
1500                ctxt.current = html_node;
1501            }
1502        } else {
1503            // html already auto-created, just set current
1504            ctxt.current = ctxt.html;
1505        }
1506        return;
1507    }
1508
1509    if tag_str == "head" {
1510        if !ctxt.head.is_null() && !ctxt.head_created {
1511            // Second <head> tag, skip it
1512            return;
1513        }
1514        // Ensure html exists
1515        ensure_html(ctxt);
1516
1517        if ctxt.head.is_null() {
1518            let head_node = tree::new_node(ptr::null_mut(), bytes_to_xmlstr(tag_name));
1519            if !head_node.is_null() {
1520                ctxt.head = head_node;
1521                ctxt.head_created = false;
1522                tree::add_child(ctxt.html, head_node);
1523                ctxt.current = head_node;
1524                ctxt.in_head = true;
1525            }
1526        } else {
1527            ctxt.current = ctxt.head;
1528            ctxt.in_head = true;
1529        }
1530        return;
1531    }
1532
1533    if tag_str == "body" {
1534        if !ctxt.body.is_null() && !ctxt.body_created {
1535            // Second <body> tag, skip it
1536            return;
1537        }
1538        // Ensure html exists
1539        ensure_html(ctxt);
1540
1541        if ctxt.body.is_null() {
1542            let body_node = tree::new_node(ptr::null_mut(), bytes_to_xmlstr(tag_name));
1543            if !body_node.is_null() {
1544                ctxt.body = body_node;
1545                ctxt.body_created = false;
1546                tree::add_child(ctxt.html, body_node);
1547                ctxt.current = body_node;
1548                ctxt.in_body = true;
1549                ctxt.in_head = false;
1550                ctxt.seen_body_content = true;
1551            }
1552        } else {
1553            ctxt.current = ctxt.body;
1554            ctxt.in_body = true;
1555            ctxt.in_head = false;
1556            ctxt.seen_body_content = true;
1557        }
1558        return;
1559    }
1560
1561    // For head-only elements (<title>, <meta>, <link>, <style>, <script>)
1562    if is_head_tag && !ctxt.seen_body_content {
1563        if ctxt.head.is_null() {
1564            ensure_head(ctxt);
1565        }
1566
1567        if is_empty {
1568            // Void element in head
1569            let node = tree::new_node(ptr::null_mut(), bytes_to_xmlstr(tag_name));
1570            if !node.is_null() {
1571                for attr in attrs {
1572                    let name_c = bytes_to_xmlstr(&attr.name);
1573                    let val_c = bytes_to_xmlstr(&attr.value);
1574                    if !name_c.is_null() {
1575                        tree::set_prop(node, name_c, val_c);
1576                        xmlFreeImpl(name_c as *mut c_void);
1577                        if !val_c.is_null() {
1578                            xmlFreeImpl(val_c as *mut c_void);
1579                        }
1580                    }
1581                }
1582                tree::add_child(ctxt.current, node);
1583            }
1584            return;
1585        }
1586
1587        let node = tree::new_node(ptr::null_mut(), bytes_to_xmlstr(tag_name));
1588        if !node.is_null() {
1589            for attr in attrs {
1590                let name_c = bytes_to_xmlstr(&attr.name);
1591                let val_c = bytes_to_xmlstr(&attr.value);
1592                if !name_c.is_null() {
1593                    tree::set_prop(node, name_c, val_c);
1594                    xmlFreeImpl(name_c as *mut c_void);
1595                    if !val_c.is_null() {
1596                        xmlFreeImpl(val_c as *mut c_void);
1597                    }
1598                }
1599            }
1600            tree::add_child(ctxt.current, node);
1601            ctxt.current = node;
1602        }
1603        return;
1604    }
1605
1606    // Body content - transition from head if needed
1607    if !is_head_tag || ctxt.seen_body_content {
1608        if !ctxt.seen_body_content {
1609            ctxt.seen_body_content = true;
1610            ctxt.in_head = false;
1611            if ctxt.body.is_null() {
1612                ensure_body(ctxt);
1613            } else {
1614                ctxt.current = ctxt.body;
1615                ctxt.in_body = true;
1616            }
1617        } else if ctxt.body.is_null() {
1618            ensure_body(ctxt);
1619        }
1620    }
1621
1622    // Auto-close elements as needed
1623    if !ctxt.current.is_null() {
1624        auto_close_element(ctxt, tag_str);
1625    }
1626
1627    if is_empty {
1628        // Void element: create node, add attributes, add as child (no children)
1629        let node = tree::new_node(ptr::null_mut(), bytes_to_xmlstr(tag_name));
1630        if !node.is_null() {
1631            for attr in attrs {
1632                let name_c = bytes_to_xmlstr(&attr.name);
1633                let val_c = bytes_to_xmlstr(&attr.value);
1634                if !name_c.is_null() {
1635                    tree::set_prop(node, name_c, val_c);
1636                    xmlFreeImpl(name_c as *mut c_void);
1637                    if !val_c.is_null() {
1638                        xmlFreeImpl(val_c as *mut c_void);
1639                    }
1640                }
1641            }
1642            let insertion_point = if ctxt.current.is_null() {
1643                ctxt.body
1644            } else {
1645                ctxt.current
1646            };
1647            if !insertion_point.is_null() {
1648                tree::add_child(insertion_point, node);
1649            }
1650        }
1651        return;
1652    }
1653
1654    // Regular element
1655    let node = tree::new_node(ptr::null_mut(), bytes_to_xmlstr(tag_name));
1656    if !node.is_null() {
1657        for attr in attrs {
1658            let name_c = bytes_to_xmlstr(&attr.name);
1659            let val_c = bytes_to_xmlstr(&attr.value);
1660            if !name_c.is_null() {
1661                tree::set_prop(node, name_c, val_c);
1662                xmlFreeImpl(name_c as *mut c_void);
1663                if !val_c.is_null() {
1664                    xmlFreeImpl(val_c as *mut c_void);
1665                }
1666            }
1667        }
1668
1669        let insertion_point = if ctxt.current.is_null() {
1670            if ctxt.in_body || ctxt.body_created {
1671                ctxt.body
1672            } else if ctxt.in_head || ctxt.head_created {
1673                ctxt.head
1674            } else if ctxt.html_created {
1675                ctxt.html
1676            } else {
1677                ctxt.doc as *mut _xmlNode
1678            }
1679        } else {
1680            ctxt.current
1681        };
1682
1683        if !insertion_point.is_null() {
1684            tree::add_child(insertion_point, node);
1685            // For non-void elements, this becomes the new insertion point
1686            ctxt.current = node;
1687        }
1688    }
1689}
1690
1691/// Process an end tag in the tree builder.
1692unsafe fn handle_end_tag(ctxt: &mut HtmlParserCtxt, tag_name: &[u8]) {
1693    let tag_lower: Vec<u8> = tag_name.iter().map(|b| b.to_ascii_lowercase()).collect();
1694    let tag_str = core::str::from_utf8(&tag_lower).unwrap_or("");
1695
1696    let info = html_tag_lookup(tag_str);
1697
1698    // For elements with no end tag (void elements or optional end tags),
1699    // just ignore the end tag.
1700    if info.is_some_and(|i| i.flags & HTML_EMPTY != 0) {
1701        return;
1702    }
1703
1704    if tag_str == "html" {
1705        ctxt.current = ctxt.doc as *mut _xmlNode;
1706        return;
1707    }
1708
1709    if tag_str == "head" {
1710        ctxt.in_head = false;
1711        ctxt.current = ctxt.html;
1712        return;
1713    }
1714
1715    if tag_str == "body" {
1716        ctxt.in_body = false;
1717        ctxt.current = ctxt.html;
1718        return;
1719    }
1720
1721    // Walk up the tree to find a matching open element
1722    let mut cur = ctxt.current;
1723    while !cur.is_null() {
1724        let ctype = unsafe { (*cur).type_ };
1725        if ctype == XML_ELEMENT_NODE as c_int && !unsafe { (*cur).name.is_null() } {
1726            let name_bytes = unsafe { xmlstr_to_bytes((*cur).name) };
1727            if name_bytes.eq_ignore_ascii_case(tag_name) {
1728                // Found the matching element - close by moving current to parent
1729                ctxt.current = unsafe { (*cur).parent };
1730                return;
1731            }
1732        }
1733        cur = unsafe { (*cur).parent };
1734    }
1735
1736    // If no matching element found, ignore the end tag (tag-recovery behavior)
1737}
1738
1739// ═══════════════════════════════════════════════════════════════════════════════
1740// Main Parse Function
1741// ═══════════════════════════════════════════════════════════════════════════════
1742
1743/// Parse HTML from a buffer.
1744///
1745/// # Safety
1746///
1747/// - `buffer` must point to valid memory of at least `size` bytes.
1748unsafe fn html_parse_buffer(
1749    ctxt: &mut HtmlParserCtxt,
1750    buffer: *const c_char,
1751    size: c_int,
1752) -> *mut _xmlDoc {
1753    if buffer.is_null() || size <= 0 {
1754        return ptr::null_mut();
1755    }
1756
1757    // Create document with HTML_DOCUMENT_NODE type
1758    let doc = tree::new_doc(ptr::null());
1759    if doc.is_null() {
1760        return ptr::null_mut();
1761    }
1762    unsafe {
1763        (*doc).type_ = XML_HTML_DOCUMENT_NODE as c_int;
1764        // UPSTREAM-PARITY: HTML documents carry no version (htmlNewDocNoDtD
1765        // leaves version NULL), so drop the XML default set by new_doc.
1766        if !(*doc).version.is_null() {
1767            crate::abi::allocator::xmlFreeImpl((*doc).version as *mut c_void);
1768        }
1769        (*doc).version = ptr::null_mut();
1770        (*doc).properties = XML_DOC_WELLFORMED as c_int;
1771        // UPSTREAM-PARITY: HTML documents default to standalone="yes"
1772        // (visible when serialized with the XML serializer, e.g. --xmlout).
1773        (*doc).standalone = 1;
1774    }
1775    // UPSTREAM-PARITY: htmlParseDocument creates a default DTD
1776    // (`<!DOCTYPE html PUBLIC "-//W3C//DTD HTML 4.0 Transitional//EN"
1777    // "http://www.w3.org/TR/REC-html40/loose.dtd">) when the source does
1778    // not declare one; XML_SAVE_NO_DOCTYPE / HTML_PARSE_NODEFDTD suppresses
1779    // it (handled by the caller).
1780    let default_dtd = crate::xml::dtd::create_int_subset(
1781        doc,
1782        b"html\0" as *const u8 as *const xmlChar,
1783        b"-//W3C//DTD HTML 4.0 Transitional//EN\0" as *const u8 as *const xmlChar,
1784        b"http://www.w3.org/TR/REC-html40/loose.dtd\0" as *const u8 as *const xmlChar,
1785    );
1786    let _ = default_dtd;
1787    ctxt.doc = doc;
1788
1789    // Set up input
1790    ctxt.input = buffer as *mut u8;
1791    ctxt.input_len = size as usize;
1792    ctxt.input_pos = 0;
1793    ctxt.line = 1;
1794
1795    // Main parse loop
1796    loop {
1797        if ctxt.is_eof() {
1798            break;
1799        }
1800
1801        let ch = ctxt.peek().unwrap_or(0);
1802
1803        if ch == b'<' {
1804            ctxt.next(); // consume '<'
1805
1806            // Check for </ (end tag)
1807            if ctxt.peek() == Some(b'/') {
1808                ctxt.next(); // consume '/'
1809                let tag_name = ctxt.read_while(|ch| {
1810                    ch != b'>' && ch != b' ' && ch != b'\t' && ch != b'\n' && ch != b'\r'
1811                });
1812
1813                // Consume until '>'
1814                while ctxt.peek() != Some(b'>') && !ctxt.is_eof() {
1815                    ctxt.next();
1816                }
1817                if ctxt.peek() == Some(b'>') {
1818                    ctxt.next(); // consume '>'
1819                }
1820
1821                if !tag_name.is_empty() {
1822                    handle_end_tag(ctxt, &tag_name);
1823                }
1824                continue;
1825            }
1826
1827            // Check for <!-- (comment)
1828            if ctxt.peek() == Some(b'!')
1829                && ctxt.peek_at(1) == Some(b'-')
1830                && ctxt.peek_at(2) == Some(b'-')
1831            {
1832                ctxt.next(); // consume '!'
1833                ctxt.next(); // consume '-'
1834                ctxt.next(); // consume '-'
1835
1836                // Read until -->
1837                let mut comment_content = Vec::new();
1838                loop {
1839                    if ctxt.peek() == Some(b'-')
1840                        && ctxt.peek_at(1) == Some(b'-')
1841                        && ctxt.peek_at(2) == Some(b'>')
1842                    {
1843                        ctxt.next(); // consume '-'
1844                        ctxt.next(); // consume '-'
1845                        ctxt.next(); // consume '>'
1846                        break;
1847                    }
1848                    match ctxt.next() {
1849                        Some(ch) => comment_content.push(ch),
1850                        None => break,
1851                    }
1852                }
1853
1854                // Create comment node
1855                if !comment_content.is_empty() {
1856                    let comment_node = tree::new_comment(bytes_to_xmlstr(&comment_content));
1857                    if !comment_node.is_null() {
1858                        let insertion_point = if !ctxt.current.is_null() {
1859                            ctxt.current
1860                        } else {
1861                            ctxt.doc as *mut _xmlNode
1862                        };
1863                        tree::add_child(insertion_point, comment_node);
1864                    }
1865                }
1866                continue;
1867            }
1868
1869            // Check for <!DOCTYPE
1870            if ctxt.peek() == Some(b'!') {
1871                ctxt.next(); // consume '!'
1872                let _rest = ctxt.read_while(|ch| ch != b'>');
1873                if ctxt.peek() == Some(b'>') {
1874                    ctxt.next(); // consume '>'
1875                }
1876                // We don't create a DTD node from HTML DOCTYPE in this implementation
1877                // (matching basic libxml2 behavior where HTML doctype is mostly ignored)
1878                continue;
1879            }
1880
1881            // Check for <? (processing instruction)
1882            if ctxt.peek() == Some(b'?') {
1883                ctxt.next(); // consume '?'
1884                             // Read until we see ?>
1885                let mut pi_content = Vec::new();
1886                loop {
1887                    if ctxt.peek() == Some(b'?') && ctxt.peek_at(1) == Some(b'>') {
1888                        break;
1889                    }
1890                    match ctxt.next() {
1891                        Some(ch) => pi_content.push(ch),
1892                        None => break,
1893                    }
1894                }
1895                // Consume ?>
1896                if ctxt.peek() == Some(b'?') {
1897                    ctxt.next();
1898                }
1899                if ctxt.peek() == Some(b'>') {
1900                    ctxt.next();
1901                }
1902                // Create PI node
1903                if !pi_content.is_empty() {
1904                    // Split into target and value
1905                    let mut parts = pi_content.splitn(2, |b| *b == b' ');
1906                    let target = parts.next().unwrap_or(&pi_content);
1907                    let value = parts.next().unwrap_or(b"");
1908
1909                    let pi_node = tree::new_pi(bytes_to_xmlstr(target), bytes_to_xmlstr(value));
1910                    if !pi_node.is_null() {
1911                        let insertion_point = if !ctxt.current.is_null() {
1912                            ctxt.current
1913                        } else {
1914                            ctxt.doc as *mut _xmlNode
1915                        };
1916                        tree::add_child(insertion_point, pi_node);
1917                    }
1918                }
1919                continue;
1920            }
1921
1922            // Parse start tag
1923            let tag_name = ctxt.read_while(|ch| {
1924                ch != b'>' && ch != b'/' && ch != b' ' && ch != b'\t' && ch != b'\n' && ch != b'\r'
1925            });
1926
1927            if tag_name.is_empty() {
1928                // Just a bare '<' with no tag name, treat as text
1929                handle_text(ctxt, b"<");
1930                continue;
1931            }
1932
1933            // Parse attributes
1934            let attrs = parse_attributes(ctxt);
1935
1936            // Check for self-closing (/>) or just >
1937            if ctxt.peek() == Some(b'/') {
1938                ctxt.next(); // consume '/'
1939                if ctxt.peek() == Some(b'>') {
1940                    ctxt.next(); // consume '>'
1941                }
1942            } else if ctxt.peek() == Some(b'>') {
1943                ctxt.next(); // consume '>'
1944            }
1945
1946            // Check if this is a raw text element (script, style)
1947            let tag_lower: Vec<u8> = tag_name.iter().map(|b| b.to_ascii_lowercase()).collect();
1948            let tag_str = core::str::from_utf8(&tag_lower).unwrap_or("");
1949
1950            if tag_str == "script" || tag_str == "style" {
1951                // Handle raw text content
1952                // Create the element first
1953                let raw_node = tree::new_node(ptr::null_mut(), bytes_to_xmlstr(&tag_name));
1954                if !raw_node.is_null() {
1955                    for attr in &attrs {
1956                        let name_c = bytes_to_xmlstr(&attr.name);
1957                        let val_c = bytes_to_xmlstr(&attr.value);
1958                        if !name_c.is_null() {
1959                            tree::set_prop(raw_node, name_c, val_c);
1960                            xmlFreeImpl(name_c as *mut c_void);
1961                            if !val_c.is_null() {
1962                                xmlFreeImpl(val_c as *mut c_void);
1963                            }
1964                        }
1965                    }
1966
1967                    let insertion_point = if ctxt.current.is_null() {
1968                        if ctxt.in_head {
1969                            ensure_head(ctxt);
1970                            ctxt.head
1971                        } else {
1972                            ensure_body(ctxt);
1973                            ctxt.body
1974                        }
1975                    } else {
1976                        ctxt.current
1977                    };
1978
1979                    if !insertion_point.is_null() {
1980                        tree::add_child(insertion_point, raw_node);
1981
1982                        // Read raw text until matching </script> or </style>
1983                        let end_tag = format!("</{}", tag_str);
1984                        let end_bytes = end_tag.as_bytes();
1985                        let mut raw_text = Vec::new();
1986                        let mut match_idx = 0;
1987
1988                        loop {
1989                            if ctxt.is_eof() {
1990                                break;
1991                            }
1992                            let ch = ctxt.peek().unwrap();
1993                            if ch.to_ascii_lowercase() == end_bytes[match_idx] {
1994                                match_idx += 1;
1995                                if match_idx == end_bytes.len() {
1996                                    // We found the start of </tag
1997                                    // Add the text before the end tag
1998                                    if !raw_text.is_empty() {
1999                                        let text_node = tree::new_text(bytes_to_xmlstr(&raw_text));
2000                                        if !text_node.is_null() {
2001                                            tree::add_child(raw_node, text_node);
2002                                        }
2003                                    }
2004                                    // Consume the rest of the end tag: "tag>"
2005                                    ctxt.next(); // consume the last char of end tag prefix
2006                                                 // Now read "tag>"
2007                                    let _suffix = ctxt.read_while(|ch| ch != b'>');
2008                                    if ctxt.peek() == Some(b'>') {
2009                                        ctxt.next();
2010                                    }
2011                                    // Close the element
2012                                    ctxt.current = unsafe { (*raw_node).parent };
2013                                    break;
2014                                }
2015                                // Store the potential match start
2016                                if match_idx == 1 {
2017                                    raw_text.push(ch);
2018                                }
2019                                ctxt.next();
2020                            } else {
2021                                // If we were building a match, flush all buffered chars
2022                                if match_idx > 0 {
2023                                    // We already pushed some chars, just continue
2024                                    match_idx = 0;
2025                                }
2026                                raw_text.push(ch);
2027                                ctxt.next();
2028                            }
2029                        }
2030
2031                        // If we never found the end tag, just add the text
2032                        if match_idx < end_bytes.len() && !raw_text.is_empty() {
2033                            let text_node = tree::new_text(bytes_to_xmlstr(&raw_text));
2034                            if !text_node.is_null() {
2035                                tree::add_child(raw_node, text_node);
2036                            }
2037                            ctxt.current = unsafe { (*raw_node).parent };
2038                        }
2039                    }
2040                }
2041                continue;
2042            }
2043
2044            // Regular start tag
2045            handle_start_tag(ctxt, &tag_name, &attrs);
2046        } else {
2047            // Text content - read until next '<' or entity '&'
2048            let mut text = Vec::new();
2049            loop {
2050                match ctxt.peek() {
2051                    Some(b'<') => break,
2052                    Some(b'&') => {
2053                        // Handle entity reference inline
2054                        let entity_text = parse_entity(ctxt);
2055                        text.extend_from_slice(&entity_text);
2056                    }
2057                    Some(ch) => {
2058                        text.push(ch);
2059                        ctxt.next();
2060                    }
2061                    None => break,
2062                }
2063            }
2064
2065            if !text.is_empty() {
2066                handle_text(ctxt, &text);
2067            }
2068        }
2069    }
2070
2071    // Post-processing: ensure html/head/body are created even for empty documents
2072    if ctxt.html.is_null() {
2073        ensure_html(ctxt);
2074    }
2075
2076    doc
2077}
2078
2079// ═══════════════════════════════════════════════════════════════════════════════
2080// Public API Functions
2081// ═══════════════════════════════════════════════════════════════════════════════
2082
2083/// Parse HTML from a file.
2084///
2085/// # UPSTREAM-PARITY
2086///
2087/// Equivalent to `htmlParseFile` in libxml2.
2088///
2089/// # Safety
2090///
2091/// - `filename` must be a valid null-terminated C string or NULL.
2092/// - `encoding` must be a valid null-terminated C string or NULL.
2093pub unsafe fn parse_file(filename: *const c_char, encoding: *const c_char) -> *mut _xmlDoc {
2094    if filename.is_null() {
2095        return ptr::null_mut();
2096    }
2097
2098    // Read the file into memory
2099    let filename_str = unsafe { std::ffi::CStr::from_ptr(filename) };
2100    let path = filename_str.to_str().unwrap_or("");
2101    let content = match std::fs::read(path) {
2102        Ok(data) => data,
2103        Err(_) => return ptr::null_mut(),
2104    };
2105
2106    let mut ctxt = HtmlParserCtxt::new();
2107    if !encoding.is_null() {
2108        let _enc_cstr = unsafe { std::ffi::CStr::from_ptr(encoding) };
2109        ctxt.encoding = unsafe { c_strdup(encoding) };
2110    }
2111
2112    let doc = unsafe {
2113        html_parse_buffer(
2114            &mut ctxt,
2115            content.as_ptr() as *const c_char,
2116            content.len() as c_int,
2117        )
2118    };
2119
2120    if !doc.is_null() && !filename.is_null() {
2121        unsafe {
2122            (*doc).URL = c_strdup(filename) as *mut xmlChar;
2123        }
2124    }
2125
2126    doc
2127}
2128
2129/// Parse HTML from a memory buffer.
2130///
2131/// # UPSTREAM-PARITY
2132///
2133/// Equivalent to `htmlParseMemory` in libxml2.
2134///
2135/// # Safety
2136///
2137/// - `buffer` must point to valid memory of at least `size` bytes.
2138/// - `size` must be non-negative.
2139pub unsafe fn parse_memory(buffer: *const c_char, size: c_int) -> *mut _xmlDoc {
2140    if buffer.is_null() || size <= 0 {
2141        return ptr::null_mut();
2142    }
2143
2144    let mut ctxt = HtmlParserCtxt::new();
2145    unsafe { html_parse_buffer(&mut ctxt, buffer, size) }
2146}
2147
2148/// Parse HTML from a null-terminated string.
2149///
2150/// # UPSTREAM-PARITY
2151///
2152/// Equivalent to `htmlParseDoc` in libxml2.
2153///
2154/// # Safety
2155///
2156/// - `cur` must be a valid null-terminated xmlChar string or NULL.
2157/// - `encoding` must be a valid null-terminated C string or NULL.
2158pub(crate) unsafe fn parse_doc(cur: *const xmlChar, encoding: *const c_char) -> *mut _xmlDoc {
2159    if cur.is_null() {
2160        return ptr::null_mut();
2161    }
2162
2163    let len = unsafe { xml_strlen(cur) };
2164    let mut ctxt = HtmlParserCtxt::new();
2165    if !encoding.is_null() {
2166        ctxt.encoding = unsafe { c_strdup(encoding) };
2167    }
2168
2169    unsafe { html_parse_buffer(&mut ctxt, cur as *const c_char, len as c_int) }
2170}
2171
2172/// Create an HTML parser context for file parsing.
2173///
2174/// # UPSTREAM-PARITY
2175///
2176/// Equivalent to `htmlCreateFileParserCtxt` in libxml2.
2177///
2178/// # Safety
2179///
2180/// - `filename` must be a valid null-terminated C string or NULL.
2181/// - `encoding` must be a valid null-terminated C string or NULL.
2182#[allow(dead_code)]
2183pub(crate) unsafe fn create_file_parser_ctxt(
2184    filename: *const c_char,
2185    encoding: *const c_char,
2186) -> *mut c_void {
2187    if filename.is_null() {
2188        return ptr::null_mut();
2189    }
2190
2191    let ctxt = unsafe { xmlMallocZero(size_of::<HtmlParserCtxt>() as usize) };
2192    if ctxt.is_null() {
2193        return ptr::null_mut();
2194    }
2195
2196    let ctxt = ctxt as *mut HtmlParserCtxt;
2197    unsafe {
2198        ptr::write(ctxt, HtmlParserCtxt::new());
2199        if !encoding.is_null() {
2200            (*ctxt).encoding = c_strdup(encoding);
2201        }
2202    }
2203
2204    ctxt as *mut c_void
2205}
2206
2207/// Free an HTML parser context.
2208///
2209/// # UPSTREAM-PARITY
2210///
2211/// Equivalent to `htmlFreeParserCtxt` in libxml2.
2212///
2213/// # Safety
2214///
2215/// - `ctxt` must be a valid pointer returned by `create_file_parser_ctxt`, or NULL.
2216pub(crate) unsafe fn free_parser_ctxt(ctxt: *mut c_void) {
2217    if ctxt.is_null() {
2218        return;
2219    }
2220
2221    let ctxt = ctxt as *mut HtmlParserCtxt;
2222    unsafe {
2223        if !(*ctxt).filename.is_null() {
2224            xmlFreeImpl((*ctxt).filename as *mut c_void);
2225        }
2226        if !(*ctxt).encoding.is_null() {
2227            xmlFreeImpl((*ctxt).encoding as *mut c_void);
2228        }
2229        xmlFreeImpl(ctxt as *mut c_void);
2230    }
2231}
2232
2233/// Initialize the HTML parser module.
2234///
2235/// # UPSTREAM-PARITY
2236///
2237/// Equivalent to `htmlInitParser` in libxml2.
2238#[allow(dead_code)]
2239pub(crate) const fn init_parser() {
2240    // Currently a no-op. In the future, may initialize HTML-specific
2241    // entity tables or other global state.
2242}
2243
2244/// Cleanup the HTML parser module.
2245///
2246/// # UPSTREAM-PARITY
2247///
2248/// Equivalent to `htmlCleanupParser` in libxml2.
2249#[allow(dead_code)]
2250pub(crate) const fn cleanup_parser() {
2251    // Currently a no-op. In the future, may free HTML-specific
2252    // global state.
2253}
2254
2255/// Create a new HTML document.
2256///
2257/// # UPSTREAM-PARITY
2258///
2259/// Equivalent to `htmlNewDoc` in libxml2.
2260///
2261/// Creates a new document with type XML_HTML_DOCUMENT_NODE and
2262/// auto-creates html/head/body elements.
2263///
2264/// # Safety
2265///
2266/// - `version` must be a valid null-terminated xmlChar string or NULL.
2267pub(crate) unsafe fn new_doc(version: *const xmlChar) -> *mut _xmlDoc {
2268    let doc = tree::new_doc(version);
2269    if doc.is_null() {
2270        return ptr::null_mut();
2271    }
2272
2273    unsafe {
2274        (*doc).type_ = XML_HTML_DOCUMENT_NODE as c_int;
2275        (*doc).properties = XML_DOC_WELLFORMED as c_int;
2276    }
2277
2278    // Create implicit html/head/body
2279    let mut ctxt = HtmlParserCtxt::new();
2280    ctxt.doc = doc;
2281
2282    unsafe {
2283        ensure_html(&mut ctxt);
2284        ensure_head(&mut ctxt);
2285        ensure_body(&mut ctxt);
2286    }
2287
2288    doc
2289}
2290
2291/// Create a new HTML document without DTD.
2292///
2293/// # UPSTREAM-PARITY
2294///
2295/// Equivalent to `htmlNewDocNoDtD` in libxml2.
2296///
2297/// Creates a new document with type XML_HTML_DOCUMENT_NODE.
2298/// Unlike `htmlNewDoc`, this does NOT auto-create html/head/body elements.
2299///
2300/// # Safety
2301///
2302/// - `version` must be a valid null-terminated xmlChar string or NULL.
2303pub(crate) unsafe fn new_doc_no_dtd(version: *const xmlChar) -> *mut _xmlDoc {
2304    let doc = tree::new_doc(version);
2305    if doc.is_null() {
2306        return ptr::null_mut();
2307    }
2308
2309    unsafe {
2310        (*doc).type_ = XML_HTML_DOCUMENT_NODE as c_int;
2311        (*doc).properties = XML_DOC_WELLFORMED as c_int;
2312    }
2313
2314    doc
2315}
2316
2317// ═══════════════════════════════════════════════════════════════════════════════
2318// HTML Serializer
2319// ═══════════════════════════════════════════════════════════════════════════════
2320
2321/// HTML void elements that should not have closing tags.
2322const HTML_VOID_ELEMENTS: &[&str] = &[
2323    "area", "base", "br", "col", "embed", "hr", "img", "input", "link", "meta", "param", "source",
2324    "track", "wbr", "frame",
2325];
2326
2327/// Check if an element name is an HTML void element.
2328fn is_html_void(name: &str) -> bool {
2329    HTML_VOID_ELEMENTS
2330        .iter()
2331        .any(|v| v.eq_ignore_ascii_case(name))
2332}
2333
2334/// Check if an element has optional end tag in HTML.
2335#[allow(dead_code)]
2336fn has_optional_end_tag(name: &str) -> bool {
2337    matches!(
2338        name.to_ascii_lowercase().as_str(),
2339        "p" | "li" | "tr" | "td" | "th" | "dt" | "dd"
2340    )
2341}
2342
2343/// Serialize a text node for HTML output.
2344///
2345/// In HTML serialization, we escape `<` and `&` but NOT non-ASCII characters
2346/// as numeric entities (unlike XML serialization).
2347/// Write a double-quoted C string to the buffer.
2348unsafe fn html_write_quoted(buf: *mut _xmlBuffer, s: *const xmlChar) {
2349    if buf.is_null() || s.is_null() {
2350        return;
2351    }
2352    io::buf_ccat(buf, b'"');
2353    io::buf_cat(buf, s);
2354    io::buf_ccat(buf, b'"');
2355}
2356
2357/// Trim leading ASCII whitespace.
2358fn trim_ascii_start(s: &[u8]) -> &[u8] {
2359    let start = s
2360        .iter()
2361        .position(|&b| !b.is_ascii_whitespace())
2362        .unwrap_or(s.len());
2363    &s[start..]
2364}
2365
2366unsafe fn html_serialize_text(buf: *mut _xmlBuffer, content: *const xmlChar, len: c_int) {
2367    if buf.is_null() || content.is_null() || len <= 0 {
2368        return;
2369    }
2370
2371    let mut i: c_int = 0;
2372    while i < len {
2373        let ch = unsafe { *content.add(i as usize) };
2374
2375        match ch {
2376            b'<' => {
2377                io::buf_add(buf, b"&lt;" as *const u8, 4);
2378            }
2379            b'&' => {
2380                io::buf_add(buf, b"&amp;" as *const u8, 5);
2381            }
2382            _ => {
2383                io::buf_add(buf, &ch as *const u8, 1);
2384            }
2385        }
2386        i += 1;
2387    }
2388}
2389
2390/// Serialize an attribute value for HTML output.
2391///
2392/// In HTML, attribute values should be quoted and have `&`, `"` escaped.
2393unsafe fn html_serialize_attr_value(buf: *mut _xmlBuffer, value: *const xmlChar) {
2394    if buf.is_null() || value.is_null() {
2395        return;
2396    }
2397
2398    let len = unsafe { xml_strlen(value) as c_int };
2399    let mut i: c_int = 0;
2400    while i < len {
2401        let ch = unsafe { *value.add(i as usize) };
2402
2403        match ch {
2404            b'&' => {
2405                io::buf_add(buf, b"&amp;" as *const u8, 5);
2406            }
2407            b'"' => {
2408                io::buf_add(buf, b"&quot;" as *const u8, 6);
2409            }
2410            _ => {
2411                io::buf_add(buf, &ch as *const u8, 1);
2412            }
2413        }
2414        i += 1;
2415    }
2416}
2417
2418/// HTML-specific node serialization.
2419///
2420/// Walks the node tree and serializes to HTML format.
2421/// Differs from XML serialization in several ways:
2422/// - No XML declaration for HTML documents
2423/// - No self-closing tags for void elements
2424/// - Case-insensitive tag names preserved as-is
2425/// - No namespace declarations
2426/// - Elements with optional end tags may omit them
2427///
2428/// Whether the head element already contains a <meta> element (so the
2429///
2430/// serializer does not insert a duplicate charset declaration).
2431///
2432/// # SAFETY
2433///
2434/// - `child` must be a valid node or NULL.
2435unsafe fn html_head_has_meta(child: *mut _xmlNode) -> bool {
2436    let mut c = child;
2437    while !c.is_null() {
2438        if (*c).type_ == XML_ELEMENT_NODE as c_int && !(*c).name.is_null() {
2439            let nm = xmlstr_to_bytes((*c).name);
2440            if nm.eq_ignore_ascii_case(b"meta") {
2441                return true;
2442            }
2443        }
2444        c = (*c).next;
2445    }
2446    false
2447}
2448
2449pub(crate) unsafe fn serialize_node(
2450    node: *mut _xmlNode,
2451    buf: *mut _xmlBuffer,
2452    format: c_int,
2453    level: c_int,
2454) {
2455    if node.is_null() || buf.is_null() {
2456        return;
2457    }
2458
2459    let n = unsafe { &*node };
2460
2461    match n.type_ {
2462        t if t == XML_ELEMENT_NODE as c_int => {
2463            let name = if n.name.is_null() {
2464                ""
2465            } else {
2466                unsafe { core::str::from_utf8(xmlstr_to_bytes(n.name)).unwrap_or("") }
2467            };
2468
2469            let is_void = is_html_void(name);
2470            // UPSTREAM-PARITY: htmlNodeDumpInternal only adds formatting
2471            // newlines for non-inline elements; p, pre and param are never
2472            // formatted (name[0] == 'p'), and unknown elements are treated
2473            // as inline (info == NULL).
2474            let info = html_tag_lookup(name);
2475            let is_inline = info.is_none_or(|i| i.flags & HTML_INLINE != 0);
2476            let no_format = is_inline || name.starts_with('p');
2477
2478            // Write start tag
2479            io::buf_ccat(buf, b'<');
2480            if !n.name.is_null() {
2481                io::buf_cat(buf, n.name);
2482            }
2483
2484            // Write attributes
2485            let mut attr = n.properties;
2486            while !attr.is_null() {
2487                let a = unsafe { &*attr };
2488                io::buf_ccat(buf, b' ');
2489                if !a.name.is_null() {
2490                    io::buf_cat(buf, a.name);
2491                }
2492
2493                // Write attribute value if present
2494                if !a.children.is_null() {
2495                    let child = unsafe { &*a.children };
2496                    if child.type_ == XML_TEXT_NODE as c_int && !child.content.is_null() {
2497                        io::buf_ccat(buf, b'=');
2498                        io::buf_ccat(buf, b'"');
2499                        html_serialize_attr_value(buf, child.content);
2500                        io::buf_ccat(buf, b'"');
2501                    }
2502                }
2503
2504                attr = a.next;
2505            }
2506
2507            // UPSTREAM-PARITY: htmlSetMetaEncoding (htmlsave.c) inserts
2508            // <meta charset="..."> as the first child of the <head> of the
2509            // root <html> element when no <meta> is present and the document
2510            // carries an encoding (htmlNodeDumpInternal only runs the meta
2511            // logic when `encoding != NULL`). The meta is synthetic here, so
2512            // it participates in the formatting rules like a real child.
2513            let mut meta_bytes: Option<Vec<u8>> = None;
2514            if name.eq_ignore_ascii_case("head")
2515                && level == 1
2516                && !n.doc.is_null()
2517                && !(*n.doc).encoding.is_null()
2518            {
2519                let parent_is_html = !n.parent.is_null() && !(*n.parent).name.is_null() && {
2520                    let pn = core::str::from_utf8(xmlstr_to_bytes((*n.parent).name)).unwrap_or("");
2521                    pn.eq_ignore_ascii_case("html")
2522                };
2523                if parent_is_html && !html_head_has_meta(n.children) {
2524                    meta_bytes = Some(xmlstr_to_bytes((*n.doc).encoding).to_vec());
2525                }
2526            }
2527            let meta_inserted = meta_bytes.is_some();
2528
2529            let has_children = !n.children.is_null();
2530            let first_child = if has_children {
2531                unsafe { (*n.children).type_ }
2532            } else {
2533                XML_TEXT_NODE as c_int
2534            };
2535            let first_is_text = first_child == XML_TEXT_NODE as c_int
2536                || first_child == XML_ENTITY_REF_NODE as c_int;
2537            // With a synthetic meta child, an empty head behaves as having
2538            // one element child.
2539            let multi_child = (has_children && n.children != n.last) || meta_inserted;
2540
2541            if is_void {
2542                // Void element: just close the tag, no children
2543                io::buf_ccat(buf, b'>');
2544                // UPSTREAM-PARITY (line 997): a newline follows a non-inline
2545                // element whose next sibling is not text; the caller (parent
2546                // loop) emits it, so nothing here.
2547            } else {
2548                // Element with children (or a head receiving a meta)
2549                io::buf_ccat(buf, b'>');
2550
2551                // Newline after the open tag (upstream line 969): a
2552                // non-inline element whose first child is not text and which
2553                // has more than one child (or receives a meta) starts its
2554                // content on a new line.
2555                if format != 0 && !no_format && !first_is_text && multi_child {
2556                    io::buf_ccat(buf, b'\n');
2557                }
2558
2559                if let Some(enc) = &meta_bytes {
2560                    io::buf_add(buf, b"<meta charset=\"" as *const u8, 15);
2561                    io::buf_add(buf, enc.as_ptr(), enc.len() as c_int);
2562                    io::buf_add(buf, b"\">" as *const u8, 2);
2563                    // UPSTREAM-PARITY (line 983): a newline follows the
2564                    // inserted meta when the next real child is not text.
2565                    if format != 0 && has_children && !first_is_text && !name.starts_with('p') {
2566                        io::buf_ccat(buf, b'\n');
2567                    }
2568                }
2569
2570                // Serialize children inline (HTML formatting adds no
2571                // indentation; the per-element rules emit the newlines).
2572                let mut child = n.children;
2573                while !child.is_null() {
2574                    serialize_node(child, buf, format, level + 1);
2575                    // UPSTREAM-PARITY (line 997): a newline follows a
2576                    // non-inline element whose next sibling is not text,
2577                    // unless the parent is p/pre/param.
2578                    let next = unsafe { (*child).next };
2579                    if format != 0 && !next.is_null() && !name.starts_with('p') {
2580                        let nt = unsafe { (*next).type_ };
2581                        if nt != XML_TEXT_NODE as c_int && nt != XML_ENTITY_REF_NODE as c_int {
2582                            let cname = if (*child).name.is_null() {
2583                                ""
2584                            } else {
2585                                unsafe {
2586                                    core::str::from_utf8(xmlstr_to_bytes((*child).name))
2587                                        .unwrap_or("")
2588                                }
2589                            };
2590                            let cinfo = html_tag_lookup(cname);
2591                            let c_inline = cinfo.is_none_or(|i| i.flags & HTML_INLINE != 0);
2592                            if !c_inline {
2593                                io::buf_ccat(buf, b'\n');
2594                            }
2595                        }
2596                    }
2597                    child = next;
2598                }
2599
2600                // Newline before the end tag (upstream line 1085): a
2601                // non-inline element whose last child is not text and which
2602                // has more than one child (or is the head receiving a meta).
2603                let last_child = if has_children {
2604                    unsafe { (*n.last).type_ }
2605                } else {
2606                    XML_ELEMENT_NODE as c_int
2607                };
2608                let last_is_text = last_child == XML_TEXT_NODE as c_int
2609                    || last_child == XML_ENTITY_REF_NODE as c_int;
2610                if format != 0 && !no_format && !last_is_text && multi_child {
2611                    io::buf_ccat(buf, b'\n');
2612                }
2613
2614                // Write end tag
2615                io::buf_add(buf, b"</" as *const u8, 2);
2616                if !n.name.is_null() {
2617                    io::buf_cat(buf, n.name);
2618                }
2619                io::buf_ccat(buf, b'>');
2620            }
2621        }
2622        t if t == XML_TEXT_NODE as c_int => {
2623            html_serialize_text(buf, n.content, xml_strlen(n.content) as c_int);
2624        }
2625        t if t == XML_CDATA_SECTION_NODE as c_int => {
2626            io::buf_add(buf, b"<![CDATA[" as *const u8, 9);
2627            html_serialize_text(buf, n.content, xml_strlen(n.content) as c_int);
2628            io::buf_add(buf, b"]]>" as *const u8, 3);
2629        }
2630        t if t == XML_COMMENT_NODE as c_int => {
2631            if format != 0 && level > 0 {
2632                io::buf_ccat(buf, b'\n');
2633                for _ in 0..level {
2634                    io::buf_add(buf, b"  " as *const u8, 2);
2635                }
2636            }
2637            io::buf_add(buf, b"<!--" as *const u8, 4);
2638            if !n.content.is_null() {
2639                io::buf_cat(buf, n.content);
2640            }
2641            io::buf_add(buf, b"-->" as *const u8, 3);
2642        }
2643        t if t == XML_PI_NODE as c_int => {
2644            if format != 0 && level > 0 {
2645                io::buf_ccat(buf, b'\n');
2646                for _ in 0..level {
2647                    io::buf_add(buf, b"  " as *const u8, 2);
2648                }
2649            }
2650            io::buf_add(buf, b"<?" as *const u8, 2);
2651            if !n.name.is_null() {
2652                io::buf_cat(buf, n.name);
2653            }
2654            if !n.content.is_null() && unsafe { *n.content != 0 } {
2655                io::buf_ccat(buf, b' ');
2656                io::buf_cat(buf, n.content);
2657            }
2658            io::buf_add(buf, b"?>" as *const u8, 2);
2659        }
2660        t if t == XML_DOCUMENT_NODE as c_int || t == XML_HTML_DOCUMENT_NODE as c_int => {
2661            // UPSTREAM-PARITY: htmlDocContentDumpOutput writes the internal
2662            // subset's DOCTYPE before the tree children.
2663            let doc_ptr = n as *const _xmlNode as *mut _xmlDoc;
2664            let d = &*doc_ptr;
2665            if !d.intSubset.is_null() {
2666                let dtd = &*d.intSubset;
2667                io::buf_add(buf, b"<!DOCTYPE " as *const u8, 10);
2668                if !dtd.name.is_null() {
2669                    io::buf_cat(buf, dtd.name);
2670                }
2671                if !dtd.ExternalID.is_null() {
2672                    io::buf_add(buf, b" PUBLIC " as *const u8, 8);
2673                    html_write_quoted(buf, dtd.ExternalID);
2674                    io::buf_ccat(buf, b' ');
2675                    html_write_quoted(buf, dtd.SystemID);
2676                } else if !dtd.SystemID.is_null() {
2677                    io::buf_add(buf, b" SYSTEM " as *const u8, 8);
2678                    html_write_quoted(buf, dtd.SystemID);
2679                }
2680                io::buf_ccat(buf, b'>');
2681                io::buf_ccat(buf, b'\n');
2682            }
2683            // No XML declaration for HTML documents
2684            // Serialize children
2685            let mut child = n.children;
2686            while !child.is_null() {
2687                serialize_node(child, buf, format, 0);
2688                child = unsafe { (*child).next };
2689            }
2690            // UPSTREAM-PARITY: htmlDocContentDumpOutput terminates with a
2691            // newline.
2692            io::buf_ccat(buf, b'\n');
2693        }
2694        _ => {
2695            if !n.content.is_null() {
2696                html_serialize_text(buf, n.content, xml_strlen(n.content) as c_int);
2697            }
2698        }
2699    }
2700}
2701
2702/// Dump an HTML document to a buffer.
2703///
2704/// # Safety
2705///
2706/// - `buf` must be a valid pointer to a mutable `_xmlBuffer`.
2707/// - `doc` must be a valid pointer to an `_xmlDoc`, or NULL.
2708pub(crate) unsafe fn doc_dump(buf: *mut _xmlBuffer, doc: *mut _xmlDoc) -> c_int {
2709    if buf.is_null() || doc.is_null() {
2710        return -1;
2711    }
2712
2713    let before = io::buf_length(buf);
2714    serialize_node(doc as *mut _xmlNode, buf, 0, 0);
2715    let after = io::buf_length(buf);
2716
2717    if after < 0 || before < 0 {
2718        return -1;
2719    }
2720    after - before
2721}
2722
2723// ═══════════════════════════════════════════════════════════════════════════════
2724// Tests
2725// ═══════════════════════════════════════════════════════════════════════════════
2726
2727#[cfg(test)]
2728mod tests {
2729    use super::*;
2730
2731    use crate::xml::io;
2732
2733    /// Helper: create a null-terminated xmlChar* from a byte slice.
2734    #[allow(dead_code)]
2735    unsafe fn to_xmlstr(s: &[u8]) -> *mut xmlChar {
2736        bytes_to_xmlstr(s)
2737    }
2738
2739    /// Helper: serialize an HTML document to a String.
2740    unsafe fn html_doc_to_string(doc: *mut _xmlDoc) -> String {
2741        let buf = io::buf_create(-1);
2742        assert!(!buf.is_null());
2743        doc_dump(buf, doc);
2744        let content = io::buf_content(buf);
2745        let s = if !content.is_null() {
2746            let len = xml_strlen(content);
2747            let slice = slice::from_raw_parts(content, len);
2748            String::from_utf8_lossy(slice).to_string()
2749        } else {
2750            String::new()
2751        };
2752        io::buf_free(buf);
2753        s
2754    }
2755
2756    // ═════════════════════════════════════════════════════════════════════════
2757    // Element Info Lookup
2758    // ═════════════════════════════════════════════════════════════════════════
2759
2760    #[test]
2761    fn test_html_tag_lookup() {
2762        // Known elements
2763        assert!(html_tag_lookup("html").is_some());
2764        assert!(html_tag_lookup("HTML").is_some()); // case-insensitive
2765        assert!(html_tag_lookup("p").is_some());
2766        assert!(html_tag_lookup("br").is_some());
2767        assert!(html_tag_lookup("div").is_some());
2768        assert!(html_tag_lookup("script").is_some());
2769
2770        // Unknown elements
2771        assert!(html_tag_lookup("custom").is_none());
2772        assert!(html_tag_lookup("my-element").is_none());
2773    }
2774
2775    #[test]
2776    fn test_tag_flags() {
2777        let br = html_tag_lookup("br").unwrap();
2778        assert!(br.flags & HTML_INLINE != 0);
2779        assert!(br.flags & HTML_EMPTY != 0);
2780
2781        let div = html_tag_lookup("div").unwrap();
2782        assert!(div.flags & HTML_BLOCK != 0);
2783        assert!(div.flags & HTML_VALID != 0);
2784
2785        let p = html_tag_lookup("p").unwrap();
2786        assert!(p.flags & HTML_NO_END != 0);
2787
2788        let meta = html_tag_lookup("meta").unwrap();
2789        assert!(meta.flags & HTML_HEAD != 0);
2790        assert!(meta.flags & HTML_EMPTY != 0);
2791    }
2792
2793    // ═════════════════════════════════════════════════════════════════════════
2794    // Entity Lookup
2795    // ═════════════════════════════════════════════════════════════════════════
2796
2797    #[test]
2798    fn test_html_entity_lookup() {
2799        assert_eq!(html_entity_lookup("amp"), Some("&"));
2800        assert_eq!(html_entity_lookup("lt"), Some("<"));
2801        assert_eq!(html_entity_lookup("gt"), Some(">"));
2802        assert_eq!(html_entity_lookup("quot"), Some("\""));
2803        assert_eq!(html_entity_lookup("nbsp"), Some("\u{00a0}"));
2804        assert_eq!(html_entity_lookup("copy"), Some("\u{00a9}"));
2805        assert!(html_entity_lookup("unknown_entity").is_none());
2806    }
2807
2808    // ═════════════════════════════════════════════════════════════════════════
2809    // Basic Parsing
2810    // ═════════════════════════════════════════════════════════════════════════
2811
2812    #[test]
2813    fn test_parse_basic_html() {
2814        unsafe {
2815            let html = b"<html><head><title>Test</title></head><body><p>Hello</p></body></html>\0";
2816            let doc = parse_memory(html.as_ptr() as *const c_char, (html.len() - 1) as c_int);
2817            assert!(!doc.is_null());
2818
2819            let s = html_doc_to_string(doc);
2820            assert!(s.contains("<html>"));
2821            assert!(s.contains("<head>"));
2822            assert!(s.contains("<title>Test</title>"));
2823            assert!(s.contains("<body>"));
2824            assert!(s.contains("<p>Hello</p>"));
2825
2826            tree::free_doc(doc);
2827        }
2828    }
2829
2830    #[test]
2831    fn test_parse_empty_document() {
2832        unsafe {
2833            let html = b"\0";
2834            let doc = parse_memory(html.as_ptr() as *const c_char, 0);
2835            assert!(doc.is_null());
2836        }
2837    }
2838
2839    // ═════════════════════════════════════════════════════════════════════════
2840    // Implicit html/head/body Creation
2841    // ═════════════════════════════════════════════════════════════════════════
2842
2843    #[test]
2844    fn test_implicit_html_head_body() {
2845        unsafe {
2846            // Just a paragraph, no html/head/body
2847            let html = b"<p>Hello</p>\0";
2848            let doc = parse_memory(html.as_ptr() as *const c_char, (html.len() - 1) as c_int);
2849            assert!(!doc.is_null());
2850
2851            let s = html_doc_to_string(doc);
2852            // Should have auto-created html
2853            assert!(s.contains("<html>"));
2854            // Should have auto-created body
2855            assert!(s.contains("<body>"));
2856            // Should have the paragraph
2857            assert!(s.contains("<p>Hello</p>"));
2858
2859            tree::free_doc(doc);
2860        }
2861    }
2862
2863    #[test]
2864    fn test_implicit_head_with_title() {
2865        unsafe {
2866            // Only a title, no html/head/body
2867            let html = b"<title>My Page</title>\0";
2868            let doc = parse_memory(html.as_ptr() as *const c_char, (html.len() - 1) as c_int);
2869            assert!(!doc.is_null());
2870
2871            let s = html_doc_to_string(doc);
2872            assert!(s.contains("<html>"));
2873            assert!(s.contains("<head>"));
2874            assert!(s.contains("<title>My Page</title>"));
2875
2876            tree::free_doc(doc);
2877        }
2878    }
2879
2880    // ═════════════════════════════════════════════════════════════════════════
2881    // Auto-closing
2882    // ═════════════════════════════════════════════════════════════════════════
2883
2884    #[test]
2885    fn test_auto_close_p() {
2886        unsafe {
2887            // <p> should auto-close before another <p>
2888            let html = b"<p>First<p>Second</p>\0";
2889            let doc = parse_memory(html.as_ptr() as *const c_char, (html.len() - 1) as c_int);
2890            assert!(!doc.is_null());
2891
2892            let s = html_doc_to_string(doc);
2893            // Both paragraphs should be siblings, not nested
2894            let first_pos = s.find("First");
2895            let second_pos = s.find("Second");
2896            assert!(first_pos.is_some());
2897            assert!(second_pos.is_some());
2898
2899            tree::free_doc(doc);
2900        }
2901    }
2902
2903    #[test]
2904    fn test_auto_close_heading() {
2905        unsafe {
2906            // h1 should auto-close before h2
2907            let html = b"<h1>Title</h1><h2>Subtitle</h2>\0";
2908            let doc = parse_memory(html.as_ptr() as *const c_char, (html.len() - 1) as c_int);
2909            assert!(!doc.is_null());
2910
2911            let s = html_doc_to_string(doc);
2912            assert!(s.contains("<h1>Title</h1>"));
2913            assert!(s.contains("<h2>Subtitle</h2>"));
2914
2915            tree::free_doc(doc);
2916        }
2917    }
2918
2919    // ═════════════════════════════════════════════════════════════════════════
2920    // Void Elements
2921    // ═════════════════════════════════════════════════════════════════════════
2922
2923    #[test]
2924    fn test_void_elements() {
2925        unsafe {
2926            let html = b"<br><hr><img src=\"test.jpg\"><input type=\"text\">\0";
2927            let doc = parse_memory(html.as_ptr() as *const c_char, (html.len() - 1) as c_int);
2928            assert!(!doc.is_null());
2929
2930            let s = html_doc_to_string(doc);
2931            assert!(s.contains("<br>"));
2932            assert!(s.contains("<hr>"));
2933            assert!(s.contains("<img"));
2934            assert!(s.contains("<input"));
2935
2936            // Void elements should not have closing tags
2937            assert!(!s.contains("</br>"));
2938            assert!(!s.contains("</hr>"));
2939            assert!(!s.contains("</img>"));
2940
2941            tree::free_doc(doc);
2942        }
2943    }
2944
2945    // ═════════════════════════════════════════════════════════════════════════
2946    // Unquoted and Minimized Attributes
2947    // ═════════════════════════════════════════════════════════════════════════
2948
2949    #[test]
2950    fn test_unquoted_attributes() {
2951        unsafe {
2952            let html = b"<div class=main id=content>Text</div>\0";
2953            let doc = parse_memory(html.as_ptr() as *const c_char, (html.len() - 1) as c_int);
2954            assert!(!doc.is_null());
2955
2956            let s = html_doc_to_string(doc);
2957            assert!(s.contains("class=\"main\""));
2958            assert!(s.contains("id=\"content\""));
2959
2960            tree::free_doc(doc);
2961        }
2962    }
2963
2964    #[test]
2965    fn test_minimized_attributes() {
2966        unsafe {
2967            let html = b"<option selected disabled>Value</option>\0";
2968            let doc = parse_memory(html.as_ptr() as *const c_char, (html.len() - 1) as c_int);
2969            assert!(!doc.is_null());
2970
2971            let s = html_doc_to_string(doc);
2972            // The minimized attributes should be preserved
2973            assert!(s.contains("selected"));
2974            assert!(s.contains("disabled"));
2975
2976            tree::free_doc(doc);
2977        }
2978    }
2979
2980    // ═════════════════════════════════════════════════════════════════════════
2981    // HTML Entities
2982    // ═════════════════════════════════════════════════════════════════════════
2983
2984    #[test]
2985    fn test_html_entities() {
2986        unsafe {
2987            let html = b"<p>&amp; &lt; &gt; &quot; &nbsp; &copy;</p>\0";
2988            let doc = parse_memory(html.as_ptr() as *const c_char, (html.len() - 1) as c_int);
2989            assert!(!doc.is_null());
2990
2991            let s = html_doc_to_string(doc);
2992            // Entities are resolved in the tree; &amp; and &lt; get re-escaped during serialization
2993            // because & and < are special. &gt; becomes > (serialized as-is, > is safe in text).
2994            assert!(s.contains("&amp;")); // &amp; → & → &amp; (re-escaped)
2995            assert!(s.contains("&lt;")); // &lt; → < → &lt; (re-escaped)
2996            assert!(s.contains(">")); // &gt; → > (not escaped in text)
2997            assert!(s.contains("\u{00a0}")); // &nbsp; → non-breaking space
2998
2999            tree::free_doc(doc);
3000        }
3001    }
3002
3003    #[test]
3004    fn test_numeric_entities() {
3005        unsafe {
3006            // &#65; = 'A', &#x41; = 'A'
3007            let html = b"<p>&#65; &#x41;</p>\0";
3008            let doc = parse_memory(html.as_ptr() as *const c_char, (html.len() - 1) as c_int);
3009            assert!(!doc.is_null());
3010
3011            let s = html_doc_to_string(doc);
3012            assert!(s.contains('A'));
3013
3014            tree::free_doc(doc);
3015        }
3016    }
3017
3018    // ═════════════════════════════════════════════════════════════════════════
3019    // Nested Elements
3020    // ═════════════════════════════════════════════════════════════════════════
3021
3022    #[test]
3023    fn test_nested_elements() {
3024        unsafe {
3025            let html = b"<div><ul><li>Item 1</li><li>Item 2</li></ul></div>\0";
3026            let doc = parse_memory(html.as_ptr() as *const c_char, (html.len() - 1) as c_int);
3027            assert!(!doc.is_null());
3028
3029            let s = html_doc_to_string(doc);
3030            assert!(s.contains("<div>"));
3031            assert!(s.contains("<ul>"));
3032            assert!(s.contains("<li>Item 1</li>"));
3033            assert!(s.contains("<li>Item 2</li>"));
3034
3035            tree::free_doc(doc);
3036        }
3037    }
3038
3039    // ═════════════════════════════════════════════════════════════════════════
3040    // Malformed HTML Recovery
3041    // ═════════════════════════════════════════════════════════════════════════
3042
3043    #[test]
3044    fn test_missing_end_tags() {
3045        unsafe {
3046            // Missing closing tags
3047            let html = b"<p>Paragraph without closing<div>Another div\0";
3048            let doc = parse_memory(html.as_ptr() as *const c_char, (html.len() - 1) as c_int);
3049            assert!(!doc.is_null());
3050
3051            let s = html_doc_to_string(doc);
3052            assert!(s.contains("Paragraph without closing"));
3053            assert!(s.contains("Another div"));
3054
3055            tree::free_doc(doc);
3056        }
3057    }
3058
3059    #[test]
3060    fn test_mismatched_case() {
3061        unsafe {
3062            let html = b"<HTML><HEAD><TITLE>Test</TITLE></HEAD><BODY><P>Hello</P></BODY></HTML>\0";
3063            let doc = parse_memory(html.as_ptr() as *const c_char, (html.len() - 1) as c_int);
3064            assert!(!doc.is_null());
3065
3066            let s = html_doc_to_string(doc);
3067            // Tag names are case-preserved
3068            assert!(s.contains("<HTML>"));
3069            assert!(s.contains("<HEAD>"));
3070            assert!(s.contains("<BODY>"));
3071            assert!(s.contains("<P>Hello</P>"));
3072
3073            tree::free_doc(doc);
3074        }
3075    }
3076
3077    #[test]
3078    fn test_nested_malformed() {
3079        unsafe {
3080            // Deeply nested with missing end tags
3081            let html = b"<div><p><span><b>Deep text</div></p>\0";
3082            let doc = parse_memory(html.as_ptr() as *const c_char, (html.len() - 1) as c_int);
3083            assert!(!doc.is_null());
3084
3085            let s = html_doc_to_string(doc);
3086            assert!(s.contains("Deep text"));
3087
3088            tree::free_doc(doc);
3089        }
3090    }
3091
3092    // ═════════════════════════════════════════════════════════════════════════
3093    // HTML Serialization Round-trip
3094    // ═════════════════════════════════════════════════════════════════════════
3095
3096    #[test]
3097    fn test_serialization_round_trip_simple() {
3098        unsafe {
3099            let original = b"<p>Hello World</p>\0";
3100            let doc = parse_memory(
3101                original.as_ptr() as *const c_char,
3102                (original.len() - 1) as c_int,
3103            );
3104            assert!(!doc.is_null());
3105
3106            let s = html_doc_to_string(doc);
3107            assert!(s.contains("Hello World"));
3108
3109            tree::free_doc(doc);
3110        }
3111    }
3112
3113    #[test]
3114    fn test_serialize_void_elements_no_self_close() {
3115        unsafe {
3116            let html = b"<br><hr><img src=\"test.png\">\0";
3117            let doc = parse_memory(html.as_ptr() as *const c_char, (html.len() - 1) as c_int);
3118            assert!(!doc.is_null());
3119
3120            let s = html_doc_to_string(doc);
3121            // HTML serialization should NOT use self-closing tags
3122            assert!(!s.contains("<br/>"));
3123            assert!(!s.contains("<hr/>"));
3124
3125            tree::free_doc(doc);
3126        }
3127    }
3128
3129    // ═════════════════════════════════════════════════════════════════════════
3130    // Script and Style Handling
3131    // ═════════════════════════════════════════════════════════════════════════
3132
3133    #[test]
3134    fn test_script_content() {
3135        unsafe {
3136            // Use a simpler script content that doesn't contain '<' to avoid parser confusion
3137            let html = b"<script>var x = 1;</script>\0";
3138            let doc = parse_memory(html.as_ptr() as *const c_char, (html.len() - 1) as c_int);
3139            assert!(!doc.is_null());
3140
3141            let s = html_doc_to_string(doc);
3142            assert!(s.contains("<script>"));
3143            // The raw text content should be preserved
3144            assert!(s.contains("var x = 1;"));
3145
3146            tree::free_doc(doc);
3147        }
3148    }
3149
3150    // ═════════════════════════════════════════════════════════════════════════
3151    // Comments and DOCTYPE
3152    // ═════════════════════════════════════════════════════════════════════════
3153
3154    #[test]
3155    fn test_html_comment() {
3156        unsafe {
3157            let html = b"<html><!-- This is a comment --><body><p>Text</p></body></html>\0";
3158            let doc = parse_memory(html.as_ptr() as *const c_char, (html.len() - 1) as c_int);
3159            assert!(!doc.is_null());
3160
3161            let s = html_doc_to_string(doc);
3162            assert!(s.contains("<!-- This is a comment -->"));
3163
3164            tree::free_doc(doc);
3165        }
3166    }
3167
3168    // ═════════════════════════════════════════════════════════════════════════
3169    // new_doc / new_doc_no_dtd
3170    // ═════════════════════════════════════════════════════════════════════════
3171
3172    #[test]
3173    fn test_new_doc_creates_html_head_body() {
3174        unsafe {
3175            let doc = new_doc(ptr::null());
3176            assert!(!doc.is_null());
3177            assert_eq!((*doc).type_, XML_HTML_DOCUMENT_NODE as c_int);
3178
3179            let s = html_doc_to_string(doc);
3180            assert!(s.contains("<html>"));
3181            assert!(s.contains("<head>"));
3182            assert!(s.contains("<body>"));
3183
3184            tree::free_doc(doc);
3185        }
3186    }
3187
3188    #[test]
3189    fn test_new_doc_no_dtd() {
3190        unsafe {
3191            let doc = new_doc_no_dtd(ptr::null());
3192            assert!(!doc.is_null());
3193            assert_eq!((*doc).type_, XML_HTML_DOCUMENT_NODE as c_int);
3194
3195            // No implicit html/head/body. UPSTREAM-PARITY: the HTML
3196            // serializer (htmlNodeDumpInternal) writes "\n" for a
3197            // document node with no children (HTMLtree.c:861-863).
3198            let s = html_doc_to_string(doc);
3199            assert_eq!(s, "\n");
3200
3201            tree::free_doc(doc);
3202        }
3203    }
3204
3205    // ═════════════════════════════════════════════════════════════════════════
3206    // Entity Resolution in Text
3207    // ═════════════════════════════════════════════════════════════════════════
3208
3209    #[test]
3210    fn test_resolve_numeric_entity() {
3211        assert_eq!(resolve_numeric_entity("65", false), vec![b'A']);
3212        assert_eq!(resolve_numeric_entity("41", true), vec![b'A']);
3213        assert_eq!(resolve_numeric_entity("0", false), Vec::<u8>::new());
3214    }
3215
3216    #[test]
3217    fn test_resolve_entity_unknown() {
3218        let result = resolve_entity("unknown");
3219        assert_eq!(result, b"&unknown;");
3220    }
3221
3222    // ═════════════════════════════════════════════════════════════════════════
3223    // init/cleanup Parser
3224    // ═════════════════════════════════════════════════════════════════════════
3225
3226    #[test]
3227    fn test_init_cleanup_parser() {
3228        // Just ensure no crashes
3229        init_parser();
3230        cleanup_parser();
3231    }
3232
3233    // ═════════════════════════════════════════════════════════════════════════
3234    // Parser Context
3235    // ═════════════════════════════════════════════════════════════════════════
3236
3237    #[test]
3238    fn test_create_free_parser_ctxt() {
3239        unsafe {
3240            let ctxt =
3241                create_file_parser_ctxt(b"test.html\0" as *const u8 as *const c_char, ptr::null());
3242            assert!(!ctxt.is_null());
3243            free_parser_ctxt(ctxt);
3244        }
3245    }
3246
3247    // ═════════════════════════════════════════════════════════════════════════
3248    // Complex HTML Documents
3249    // ═════════════════════════════════════════════════════════════════════════
3250
3251    #[test]
3252    fn test_complex_html_document() {
3253        unsafe {
3254            let html = b"<!DOCTYPE html>
3255<html>
3256<head>
3257    <meta charset=\"utf-8\">
3258    <title>Test Page</title>
3259    <link rel=\"stylesheet\" href=\"style.css\">
3260</head>
3261<body>
3262    <div id=\"main\">
3263        <h1>Title</h1>
3264        <p>First paragraph with <a href=\"link.html\">a link</a>.</p>
3265        <p>Second paragraph.</p>
3266        <ul>
3267            <li>Item 1</li>
3268            <li>Item 2</li>
3269        </ul>
3270        <br>
3271        <hr>
3272        <img src=\"image.jpg\" alt=\"An image\">
3273    </div>
3274    <script>alert('hello');</script>
3275</body>
3276</html>\0";
3277            let doc = parse_memory(html.as_ptr() as *const c_char, (html.len() - 1) as c_int);
3278            assert!(!doc.is_null());
3279
3280            let s = html_doc_to_string(doc);
3281            assert!(s.contains("<html>"));
3282            assert!(s.contains("<head>"));
3283            assert!(s.contains("<title>Test Page</title>"));
3284            assert!(s.contains("<body>"));
3285            assert!(s.contains("<h1>Title</h1>"));
3286            assert!(s.contains("a link"));
3287            assert!(s.contains("Second paragraph"));
3288            assert!(s.contains("<br>"));
3289            assert!(s.contains("<hr>"));
3290            assert!(s.contains("<img"));
3291            assert!(s.contains("<script>"));
3292
3293            tree::free_doc(doc);
3294        }
3295    }
3296
3297    // ═════════════════════════════════════════════════════════════════════════
3298    // parse_doc
3299    // ═════════════════════════════════════════════════════════════════════════
3300
3301    #[test]
3302    fn test_parse_doc() {
3303        unsafe {
3304            let html = b"<p>Hello from parse_doc</p>\0";
3305            let doc = parse_doc(html.as_ptr() as *const xmlChar, ptr::null());
3306            assert!(!doc.is_null());
3307
3308            let s = html_doc_to_string(doc);
3309            assert!(s.contains("Hello from parse_doc"));
3310
3311            tree::free_doc(doc);
3312        }
3313    }
3314
3315    // ═════════════════════════════════════════════════════════════════════════
3316    // Table elements auto-close
3317    // ═════════════════════════════════════════════════════════════════════════
3318
3319    #[test]
3320    fn test_table_element_auto_close() {
3321        unsafe {
3322            let html = b"<table><tr><td>Cell 1<td>Cell 2</td></tr></table>";
3323            let doc = parse_memory(html.as_ptr() as *const c_char, (html.len() - 1) as c_int);
3324            assert!(!doc.is_null());
3325
3326            let s = html_doc_to_string(doc);
3327            assert!(s.contains("<td>Cell 1"));
3328            assert!(s.contains("<td>Cell 2"));
3329
3330            tree::free_doc(doc);
3331        }
3332    }
3333}