Skip to main content

rusty_xml_writer/
lib.rs

1//! xmlsave + xmlTextWriter matching libxml2 `xmlsave.h` / `xmlwriter.h` for M2.
2
3#![forbid(unsafe_code)]
4
5use rusty_xml_tree::{NodeId, NodeKind, XmlDoc};
6
7/// libxml2 `xmlSaveOption` bits.
8pub const XML_SAVE_FORMAT: i32 = 1 << 0;
9pub const XML_SAVE_NO_DECL: i32 = 1 << 1;
10pub const XML_SAVE_NO_EMPTY: i32 = 1 << 2;
11pub const XML_SAVE_NO_XHTML: i32 = 1 << 3;
12pub const XML_SAVE_XHTML: i32 = 1 << 4;
13pub const XML_SAVE_AS_XML: i32 = 1 << 5;
14pub const XML_SAVE_AS_HTML: i32 = 1 << 6;
15pub const XML_SAVE_WSNONSIG: i32 = 1 << 7;
16pub const XML_SAVE_EMPTY: i32 = 1 << 8;
17pub const XML_SAVE_NO_INDENT: i32 = 1 << 9;
18pub const XML_SAVE_INDENT: i32 = 1 << 10;
19
20fn hex_ref(c: u32) -> String {
21    format!("&#x{c:X};")
22}
23
24fn escape_text(s: &str, attr: bool, non_ascii: bool) -> String {
25    escape_as(s, attr, non_ascii, false)
26}
27
28/// `html` passes C0 control characters through untouched.
29///
30/// XML forbids them, so in an XML document they can no longer reach the writer
31/// at all -- the parser rejects them now. HTML permits them and C writes them
32/// out verbatim. Substituting U+FFFD here meant an HTML document came back
33/// holding a character it never contained, and the substitution was not even
34/// idempotent: the first save escaped it, the second emitted it raw.
35fn escape_as(s: &str, attr: bool, non_ascii: bool, html: bool) -> String {
36    let mut out = String::new();
37    for c in s.chars() {
38        let u = c as u32;
39        if non_ascii && u >= 0x80 {
40            out.push_str(&hex_ref(u));
41            continue;
42        }
43        match c {
44            '&' => out.push_str("&amp;"),
45            '<' => out.push_str("&lt;"),
46            '>' => out.push_str("&gt;"),
47            '"' if attr => out.push_str("&quot;"),
48            '\r' => out.push_str("&#13;"),
49            '\t' if attr => out.push_str("&#9;"),
50            '\n' if attr => out.push_str("&#10;"),
51            c if u < 0x20 && c != '\t' && c != '\n' => {
52                if html {
53                    out.push(c);
54                } else {
55                    out.push_str(&hex_ref(0xfffd));
56                }
57            }
58            c => out.push(c),
59        }
60    }
61    out
62}
63
64/// Serialize with HTML rules. Private: set by `xml_save_doc` from the document
65/// kind, never by a caller, and deliberately above the public XML_SAVE_* bits.
66const SAVE_AS_HTML: i32 = 1 << 29;
67
68/// Serialize empty elements as `<br />`. Private, set from the DOCTYPE.
69const SAVE_XHTML_EMPTY: i32 = 1 << 28;
70
71/// Elements that never take an end tag in HTML. C writes `<br>`, not `<br/>`
72/// and not `<br></br>`.
73const VOID_ELEMENTS: &[&str] = &[
74    "area", "base", "basefont", "br", "col", "embed", "frame", "hr", "img",
75    "input", "isindex", "link", "meta", "param", "source", "track", "wbr",
76];
77
78fn is_void(name: &str) -> bool {
79    VOID_ELEMENTS.iter().any(|v| name.eq_ignore_ascii_case(v))
80}
81
82fn qname(prefix: Option<&str>, local: &str) -> String {
83    match prefix {
84        Some(p) if !p.is_empty() => format!("{p}:{local}"),
85        _ => local.to_string(),
86    }
87}
88
89fn indent_unit() -> String {
90    std::env::var("XMLLINT_INDENT").unwrap_or_else(|_| "  ".into())
91}
92
93/// libxml2 keeps a fixed 60-character indent buffer and writes
94/// `min(level * indent_size, 60)` of it, so indentation stops growing at 60
95/// columns however deep the document goes. We grew forever, which diverged
96/// from C at level 31 with the default two-space unit.
97const MAX_INDENT_CHARS: usize = 60;
98
99fn write_indent(out: &mut String, level: i32) {
100    let unit = indent_unit();
101    if unit.is_empty() || level <= 0 {
102        return;
103    }
104    let want = (level as usize).saturating_mul(unit.chars().count());
105    let mut written = 0;
106    while written < want.min(MAX_INDENT_CHARS) {
107        for c in unit.chars() {
108            if written >= MAX_INDENT_CHARS {
109                break;
110            }
111            out.push(c);
112            written += 1;
113        }
114    }
115}
116
117/// One unit of serialization work, replacing recursion into children.
118enum Step {
119    /// Emit this node, scheduling its children and its closing tag.
120    Open(NodeId, i32, bool),
121    /// Emit the closing tag of an element whose children are done.
122    Close(NodeId, i32, bool),
123    Indent(i32),
124    Newline,
125}
126
127/// Serialize a subtree.
128///
129/// Driven by an explicit stack rather than recursion. The recursive form cost a
130/// frame per level of document nesting, so a 2000-deep tree -- well inside the
131/// parser's own limit of 5000 -- overflowed the stack while being SAVED.
132/// Accepting a document the writer cannot serialize just moves the cliff.
133fn write_node(doc: &XmlDoc, id: NodeId, out: &mut String, opts: i32, level: i32, format: bool) {
134    let mut stack: Vec<Step> = vec![Step::Open(id, level, format)];
135    while let Some(step) = stack.pop() {
136        match step {
137            Step::Indent(l) => write_indent(out, l),
138            Step::Newline => out.push('\n'),
139            Step::Close(id, level, child_format) => {
140                if child_format {
141                    write_indent(out, level);
142                }
143                out.push_str("</");
144                out.push_str(&qname(doc.prefix(id), doc.name(id)));
145                out.push('>');
146            }
147            Step::Open(id, level, format) => {
148                write_one(doc, id, out, opts, level, format, &mut stack)
149            }
150        }
151    }
152}
153
154/// Emit a single node, pushing whatever work its children require.
155fn write_one(
156    doc: &XmlDoc,
157    id: NodeId,
158    out: &mut String,
159    opts: i32,
160    level: i32,
161    format: bool,
162    stack: &mut Vec<Step>,
163) {
164    match doc.kind(id) {
165        NodeKind::Element => {
166            out.push('<');
167            out.push_str(&qname(doc.prefix(id), doc.name(id)));
168            for (pre, href) in doc.ns_defs(id) {
169                out.push_str(" xmlns");
170                if let Some(p) = pre {
171                    out.push(':');
172                    out.push_str(p);
173                }
174                let non_ascii = doc.encoding.is_none();
175                out.push_str("=\"");
176                out.push_str(&escape_text(href, true, non_ascii));
177                out.push('"');
178            }
179            for a in doc.attrs(id) {
180                out.push(' ');
181                out.push_str(&qname(doc.prefix(a), doc.name(a)));
182                out.push_str("=\"");
183                out.push_str(&escape_as(
184                    doc.content(a),
185                    true,
186                    doc.encoding.is_none(),
187                    (opts & SAVE_AS_HTML) != 0,
188                ));
189                out.push('"');
190            }
191            let html = (opts & SAVE_AS_HTML) != 0;
192            if html && is_void(doc.name(id)) {
193                // <br>, not <br/>: a stray slash is not an HTML end tag, and
194                // re-parsing "<br/>" made every following node a CHILD of the
195                // br rather than a sibling. That is what broke the HTML round
196                // trip -- content moved on every pass.
197                out.push('>');
198                return;
199            }
200            let has_kids = doc.first_child(id).is_some();
201            if !has_kids {
202                // HTML has no empty-element syntax: a non-void element always
203                // gets its end tag, exactly as XML_SAVE_NO_EMPTY does.
204                if (opts & XML_SAVE_NO_EMPTY) == 0 && !html {
205                    if (opts & SAVE_XHTML_EMPTY) != 0 {
206                        out.push(' ');
207                    }
208                    out.push_str("/>");
209                } else {
210                    out.push_str("></");
211                    out.push_str(&qname(doc.prefix(id), doc.name(id)));
212                    out.push('>');
213                }
214                return;
215            }
216            let mixed = {
217                let mut c = doc.first_child(id);
218                let mut m = false;
219                while let Some(ch) = c {
220                    if matches!(doc.kind(ch), NodeKind::Text | NodeKind::CData) {
221                        m = true;
222                        break;
223                    }
224                    c = doc.next_sibling(ch);
225                }
226                m
227            };
228            out.push('>');
229            let child_format = format && !mixed;
230            if child_format {
231                out.push('\n');
232            }
233            // Pushed in reverse so they pop in document order. Walking the
234            // children backwards avoids collecting them into a Vec per element.
235            stack.push(Step::Close(id, level, child_format));
236            let mut c = doc.last_child(id);
237            while let Some(ch) = c {
238                if child_format {
239                    stack.push(Step::Newline);
240                }
241                stack.push(Step::Open(ch, level + 1, child_format));
242                if child_format {
243                    stack.push(Step::Indent(level + 1));
244                }
245                c = doc.prev_sibling(ch);
246            }
247        }
248        NodeKind::Text => {
249            out.push_str(&escape_as(
250                doc.content(id),
251                false,
252                doc.encoding.is_none(),
253                (opts & SAVE_AS_HTML) != 0,
254            ));
255        }
256        NodeKind::CData => {
257            let content = doc.content(id);
258            if content.is_empty() {
259                out.push_str("<![CDATA[]]>");
260            } else {
261                // Split on ]]> like C.
262                let bytes = content.as_bytes();
263                let mut start = 0usize;
264                let mut i = 0usize;
265                while i + 2 < bytes.len() {
266                    if bytes[i] == b']' && bytes[i + 1] == b']' && bytes[i + 2] == b'>' {
267                        out.push_str("<![CDATA[");
268                        out.push_str(&content[start..=i + 1]);
269                        out.push_str("]]>");
270                        start = i + 2;
271                        i += 2;
272                    }
273                    i += 1;
274                }
275                if start < content.len() {
276                    out.push_str("<![CDATA[");
277                    out.push_str(&content[start..]);
278                    out.push_str("]]>");
279                }
280            }
281        }
282        NodeKind::Comment => {
283            let _ = (format, level);
284            out.push_str("<!--");
285            out.push_str(doc.content(id));
286            out.push_str("-->");
287        }
288        NodeKind::Pi => {
289            let _ = (format, level);
290            out.push_str("<?");
291            out.push_str(doc.name(id));
292            if !doc.content(id).is_empty() {
293                out.push(' ');
294                out.push_str(doc.content(id));
295            }
296            out.push_str("?>");
297        }
298        _ => {}
299    }
300}
301
302/// `xmlSaveDoc` / `xmlDocDumpMemory` with `xmlSaveOption` bits.
303#[doc(alias = "xmlSaveDoc")]
304pub fn xml_save_doc(doc: &XmlDoc, options: i32) -> Vec<u8> {
305    let mut out = String::new();
306    // An HTML document is serialized by HTML rules. It used to go out through
307    // the XML writer, which emitted <?xml version="1.0" encoding="HTML"?> --
308    // and re-parsing that as HTML turned the declaration into a text node, so
309    // the round trip was not stable. C emits the doctype.
310    if doc.kind(rusty_xml_tree::NodeId::DOCUMENT) == NodeKind::HtmlDocument {
311        return save_html_doc(doc, options | SAVE_AS_HTML);
312    }
313    if (options & XML_SAVE_NO_DECL) == 0 {
314        out.push_str("<?xml version=\"");
315        out.push_str(if doc.version.is_empty() {
316            "1.0"
317        } else {
318            &doc.version
319        });
320        out.push('"');
321        if let Some(enc) = &doc.encoding {
322            out.push_str(" encoding=\"");
323            out.push_str(enc);
324            out.push('"');
325        }
326        match doc.standalone {
327            Some(true) => out.push_str(" standalone=\"yes\""),
328            Some(false) => out.push_str(" standalone=\"no\""),
329            None => {}
330        }
331        out.push_str("?>\n");
332    }
333    write_doctype(doc, &mut out);
334    let mut options = options;
335    if is_xhtml(doc) {
336        options |= SAVE_XHTML_EMPTY;
337    }
338    let format = (options & XML_SAVE_FORMAT) != 0;
339    let mut child = doc.first_child(rusty_xml_tree::NodeId::DOCUMENT);
340    while let Some(id) = child {
341        write_node(doc, id, &mut out, options, 0, format);
342        out.push('\n');
343        child = doc.next_sibling(id);
344    }
345    out.into_bytes()
346}
347
348/// Write the document type declaration.
349///
350/// This was not written at all: a document with a DTD lost it on save, so any
351/// read-modify-write silently dropped every entity and ATTLIST default it
352/// declared. The internal subset is emitted VERBATIM, exactly as it was read.
353/// libxml2 re-serializes it from its parsed form instead, which reorders the
354/// declarations and respaces the content models -- faithful to the meaning but
355/// not to the document. Preserving the bytes is the better answer for a
356/// round trip, and it is the difference the corpus records.
357fn write_doctype(doc: &XmlDoc, out: &mut String) {
358    let Some(dtd) = doc.dtd.as_ref() else { return };
359    let Some(name) = dtd.name.as_deref() else { return };
360    out.push_str("<!DOCTYPE ");
361    out.push_str(name);
362    match (dtd.public_id.as_deref(), dtd.system_id.as_deref()) {
363        (Some(p), Some(s)) => {
364            out.push_str(" PUBLIC \"");
365            out.push_str(p);
366            out.push_str("\" \"");
367            out.push_str(s);
368            out.push('"');
369        }
370        (Some(p), None) => {
371            out.push_str(" PUBLIC \"");
372            out.push_str(p);
373            out.push('"');
374        }
375        (None, Some(s)) => {
376            out.push_str(" SYSTEM \"");
377            out.push_str(s);
378            out.push('"');
379        }
380        (None, None) => {}
381    }
382    if let Some(sub) = dtd.int_subset.as_deref() {
383        if !sub.trim().is_empty() {
384            out.push_str(" [");
385            out.push_str(sub);
386            out.push(']');
387        }
388    }
389    out.push_str(">\n");
390}
391
392/// XHTML is serialized with a space before the empty-element slash --
393/// `<br />`, not `<br/>` -- so that HTML parsers that predate XML do not read
394/// the slash as part of the tag name. libxml2 switches on the DOCTYPE.
395fn is_xhtml(doc: &XmlDoc) -> bool {
396    let Some(dtd) = doc.dtd.as_ref() else {
397        return false;
398    };
399    let pubid = dtd.public_id.as_deref().unwrap_or("");
400    let sysid = dtd.system_id.as_deref().unwrap_or("");
401    pubid.starts_with("-//W3C//DTD XHTML") || sysid.contains("xhtml1")
402}
403
404/// `xmlDocDumpFormatMemory`.
405#[doc(alias = "xmlDocDumpFormatMemory")]
406pub fn xml_doc_dump_format_memory(doc: &XmlDoc, format: bool) -> Vec<u8> {
407    xml_save_doc(doc, if format { XML_SAVE_FORMAT } else { 0 })
408}
409
410/// `xmlDocDumpMemory`.
411#[doc(alias = "xmlDocDumpMemory")]
412pub fn xml_doc_dump_memory(doc: &XmlDoc) -> Vec<u8> {
413    xml_save_doc(doc, 0)
414}
415
416/// `xmlNodeDump` of a subtree (no XML declaration).
417#[doc(alias = "xmlNodeDump")]
418pub fn xml_node_dump(doc: &XmlDoc, node: NodeId, options: i32) -> Vec<u8> {
419    let mut out = String::new();
420    write_node(doc, node, &mut out, options, 0, (options & XML_SAVE_FORMAT) != 0);
421    out.into_bytes()
422}
423
424#[derive(Clone, Copy, PartialEq, Eq)]
425enum FrameKind {
426    Document,
427    Element,
428}
429
430struct Frame {
431    kind: FrameKind,
432    name: String,
433    prefix: Option<String>,
434    open: bool,
435    has_content: bool,
436}
437
438/// `xmlTextWriter` writing into an in-memory buffer.
439pub struct XmlTextWriter {
440    buf: String,
441    stack: Vec<Frame>,
442    indent: bool,
443    indent_unit: String,
444    started: bool,
445}
446
447impl Default for XmlTextWriter {
448    fn default() -> Self {
449        Self::xml_new_text_writer_memory()
450    }
451}
452
453impl XmlTextWriter {
454    /// `xmlNewTextWriterMemory`.
455    #[doc(alias = "xmlNewTextWriterMemory")]
456    pub fn xml_new_text_writer_memory() -> Self {
457        Self {
458            buf: String::new(),
459            stack: Vec::new(),
460            indent: false,
461            indent_unit: indent_unit(),
462            started: false,
463        }
464    }
465
466    pub fn set_indent(&mut self, indent: bool) {
467        self.indent = indent;
468    }
469
470    fn close_start_tag(&mut self) {
471        if let Some(f) = self.stack.last_mut() {
472            if f.kind == FrameKind::Element && !f.open {
473                self.buf.push('>');
474                f.open = true;
475                f.has_content = true;
476            }
477        }
478    }
479
480    /// `xmlTextWriterStartDocument`.
481    #[doc(alias = "xmlTextWriterStartDocument")]
482    pub fn start_document(
483        &mut self,
484        version: Option<&str>,
485        encoding: Option<&str>,
486        standalone: Option<&str>,
487    ) -> Result<(), String> {
488        self.buf.push_str("<?xml version=\"");
489        self.buf.push_str(version.unwrap_or("1.0"));
490        self.buf.push('"');
491        if let Some(e) = encoding {
492            self.buf.push_str(" encoding=\"");
493            self.buf.push_str(e);
494            self.buf.push('"');
495        }
496        if let Some(s) = standalone {
497            self.buf.push_str(" standalone=\"");
498            self.buf.push_str(s);
499            self.buf.push('"');
500        }
501        self.buf.push_str("?>\n");
502        self.stack.push(Frame {
503            kind: FrameKind::Document,
504            name: "#document".into(),
505            prefix: None,
506            open: true,
507            has_content: true,
508        });
509        self.started = true;
510        Ok(())
511    }
512
513    /// `xmlTextWriterStartElement`.
514    #[doc(alias = "xmlTextWriterStartElement")]
515    pub fn start_element(&mut self, name: &str) -> Result<(), String> {
516        self.start_element_ns(None, name, None)
517    }
518
519    /// `xmlTextWriterStartElementNS`.
520    #[doc(alias = "xmlTextWriterStartElementNS")]
521    pub fn start_element_ns(
522        &mut self,
523        prefix: Option<&str>,
524        name: &str,
525        ns_uri: Option<&str>,
526    ) -> Result<(), String> {
527        self.close_start_tag();
528        if self.indent && !self.buf.is_empty() && !self.buf.ends_with('\n') {
529            self.buf.push('\n');
530        }
531        if self.indent {
532            let depth = self.stack.iter().filter(|f| f.kind == FrameKind::Element).count();
533            for _ in 0..depth {
534                self.buf.push_str(&self.indent_unit);
535            }
536        }
537        self.buf.push('<');
538        self.buf.push_str(&qname(prefix, name));
539        if let Some(uri) = ns_uri {
540            if let Some(p) = prefix {
541                self.buf.push_str(" xmlns:");
542                self.buf.push_str(p);
543            } else {
544                self.buf.push_str(" xmlns");
545            }
546            self.buf.push_str("=\"");
547            self.buf.push_str(&escape_text(uri, true, false));
548            self.buf.push('"');
549        }
550        self.stack.push(Frame {
551            kind: FrameKind::Element,
552            name: name.to_string(),
553            prefix: prefix.map(str::to_string),
554            open: false,
555            has_content: false,
556        });
557        Ok(())
558    }
559
560    /// `xmlTextWriterWriteAttribute`.
561    #[doc(alias = "xmlTextWriterWriteAttribute")]
562    pub fn write_attribute(&mut self, name: &str, value: &str) -> Result<(), String> {
563        let top = self.stack.last().ok_or_else(|| "no open element".to_string())?;
564        if top.kind != FrameKind::Element || top.open {
565            return Err("attribute after element content".into());
566        }
567        self.buf.push(' ');
568        self.buf.push_str(name);
569        self.buf.push_str("=\"");
570        self.buf.push_str(&escape_text(value, true, false));
571        self.buf.push('"');
572        Ok(())
573    }
574
575    /// `xmlTextWriterWriteAttributeNS`.
576    #[doc(alias = "xmlTextWriterWriteAttributeNS")]
577    pub fn write_attribute_ns(
578        &mut self,
579        prefix: Option<&str>,
580        name: &str,
581        _ns_uri: Option<&str>,
582        value: &str,
583    ) -> Result<(), String> {
584        self.write_attribute(&qname(prefix, name), value)
585    }
586
587    /// `xmlTextWriterWriteString`.
588    #[doc(alias = "xmlTextWriterWriteString")]
589    pub fn write_string(&mut self, content: &str) -> Result<(), String> {
590        self.close_start_tag();
591        self.buf.push_str(&escape_text(content, false, false));
592        Ok(())
593    }
594
595    /// `xmlTextWriterWriteComment`.
596    #[doc(alias = "xmlTextWriterWriteComment")]
597    pub fn write_comment(&mut self, content: &str) -> Result<(), String> {
598        self.close_start_tag();
599        self.buf.push_str("<!--");
600        self.buf.push_str(content);
601        self.buf.push_str("-->");
602        Ok(())
603    }
604
605    /// `xmlTextWriterWritePI`.
606    #[doc(alias = "xmlTextWriterWritePI")]
607    pub fn write_pi(&mut self, target: &str, data: Option<&str>) -> Result<(), String> {
608        self.close_start_tag();
609        self.buf.push_str("<?");
610        self.buf.push_str(target);
611        if let Some(d) = data {
612            self.buf.push(' ');
613            self.buf.push_str(d);
614        }
615        self.buf.push_str("?>");
616        Ok(())
617    }
618
619    /// `xmlTextWriterWriteCDATA`.
620    #[doc(alias = "xmlTextWriterWriteCDATA")]
621    pub fn write_cdata(&mut self, content: &str) -> Result<(), String> {
622        self.close_start_tag();
623        self.buf.push_str("<![CDATA[");
624        self.buf.push_str(content);
625        self.buf.push_str("]]>");
626        Ok(())
627    }
628
629    /// `xmlTextWriterWriteRaw`.
630    #[doc(alias = "xmlTextWriterWriteRaw")]
631    pub fn write_raw(&mut self, content: &str) -> Result<(), String> {
632        self.close_start_tag();
633        self.buf.push_str(content);
634        Ok(())
635    }
636
637    /// `xmlTextWriterEndElement`.
638    #[doc(alias = "xmlTextWriterEndElement")]
639    pub fn end_element(&mut self) -> Result<(), String> {
640        let f = self.stack.pop().ok_or_else(|| "no open element".to_string())?;
641        if f.kind != FrameKind::Element {
642            return Err("end_element on document".into());
643        }
644        if !f.open {
645            self.buf.push_str("/>");
646        } else {
647            if self.indent && f.has_content {
648                // keep compact unless we wrote nested elements with indent
649            }
650            self.buf.push_str("</");
651            self.buf.push_str(&qname(f.prefix.as_deref(), &f.name));
652            self.buf.push('>');
653        }
654        Ok(())
655    }
656
657    /// `xmlTextWriterEndDocument`.
658    #[doc(alias = "xmlTextWriterEndDocument")]
659    pub fn end_document(&mut self) -> Result<(), String> {
660        while self
661            .stack
662            .last()
663            .map(|f| f.kind == FrameKind::Element)
664            .unwrap_or(false)
665        {
666            self.end_element()?;
667        }
668        if !self.buf.ends_with('\n') {
669            self.buf.push('\n');
670        }
671        self.stack.clear();
672        Ok(())
673    }
674
675    pub fn into_bytes(self) -> Vec<u8> {
676        self.buf.into_bytes()
677    }
678
679    pub fn as_str(&self) -> &str {
680        &self.buf
681    }
682}
683
684/// `xmlNewTextWriterMemory`.
685#[doc(alias = "xmlNewTextWriterMemory")]
686pub fn xml_new_text_writer_memory() -> XmlTextWriter {
687    XmlTextWriter::xml_new_text_writer_memory()
688}
689
690/// Serialize an HTML document: doctype, then the children, no XML declaration.
691///
692/// C synthesizes the HTML 4.0 Transitional doctype when the source had none,
693/// and round-trips whatever doctype the source did carry.
694fn save_html_doc(doc: &XmlDoc, options: i32) -> Vec<u8> {
695    let mut out = String::new();
696    if (options & XML_SAVE_NO_DECL) == 0 {
697        out.push_str("<!DOCTYPE ");
698        let dtd = doc.dtd.as_ref();
699        out.push_str(dtd.and_then(|d| d.name.as_deref()).unwrap_or("html"));
700        match (
701            dtd.and_then(|d| d.public_id.as_deref()),
702            dtd.and_then(|d| d.system_id.as_deref()),
703        ) {
704            (Some(p), sys) => {
705                out.push_str(" PUBLIC \"");
706                out.push_str(p);
707                out.push('"');
708                if let Some(s) = sys {
709                    out.push_str(" \"");
710                    out.push_str(s);
711                    out.push('"');
712                }
713            }
714            (None, Some(s)) => {
715                out.push_str(" SYSTEM \"");
716                out.push_str(s);
717                out.push('"');
718            }
719            (None, None) if dtd.is_none() => {
720                // No doctype in the source: C supplies this one.
721                out.push_str(
722                    " PUBLIC \"-//W3C//DTD HTML 4.0 Transitional//EN\" \
723                     \"http://www.w3.org/TR/REC-html40/loose.dtd\"",
724                );
725            }
726            (None, None) => {}
727        }
728        out.push_str(">\n");
729    }
730    let format = (options & XML_SAVE_FORMAT) != 0;
731    let mut child = doc.first_child(rusty_xml_tree::NodeId::DOCUMENT);
732    while let Some(id) = child {
733        write_node(doc, id, &mut out, options, 0, format);
734        out.push('\n');
735        child = doc.next_sibling(id);
736    }
737    out.into_bytes()
738}