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