Skip to main content

vexy_vsvg/
stringifier.rs

1// this_file: crates/vexy-vsvg/src/stringifier.rs
2
3//! Convert AST back to SVG string with performance optimizations
4//!
5//! This module provides efficient stringification of SVG documents with:
6//! - Pre-allocated string buffers to minimize reallocations
7//! - Optimized escape functions that avoid unnecessary allocations
8//! - Configurable formatting options (pretty print, minified)
9//! - Streaming output support for large documents
10
11use std::fmt::Write;
12
13use crate::ast::{Document, Element, Node};
14use crate::error::VexyError;
15
16/// Configuration options for stringification
17#[derive(Debug, Clone)]
18pub struct StringifyConfig {
19    /// Whether to pretty-print with indentation
20    pub pretty: bool,
21    /// Indentation string (e.g., "  " or "    ")
22    pub indent: String,
23    /// Whether to add newlines
24    pub newlines: bool,
25    /// Whether to quote attribute values (always true for SVG)
26    pub quote_attrs: bool,
27    /// Whether to self-close empty elements
28    pub self_close: bool,
29    /// Initial buffer capacity for better performance
30    pub initial_capacity: usize,
31}
32
33impl Default for StringifyConfig {
34    fn default() -> Self {
35        Self {
36            pretty: false,
37            indent: "  ".to_string(),
38            newlines: false,
39            quote_attrs: true,
40            self_close: true,
41            initial_capacity: 4096, // 4KB initial buffer
42        }
43    }
44}
45
46impl StringifyConfig {
47    /// Create a minified configuration (default)
48    pub fn minified() -> Self {
49        Self::default()
50    }
51
52    /// Create a pretty-print configuration
53    pub fn pretty() -> Self {
54        Self {
55            pretty: true,
56            newlines: true,
57            ..Default::default()
58        }
59    }
60}
61
62/// Convert a Document to an SVG string with default settings
63pub fn stringify(document: &Document) -> Result<String, VexyError> {
64    stringify_with_config(document, &StringifyConfig::default())
65}
66
67/// Convert a Document to an SVG string with custom configuration
68pub fn stringify_with_config(
69    document: &Document,
70    config: &StringifyConfig,
71) -> Result<String, VexyError> {
72    let estimated_size = estimate_document_size(document);
73    let mut output = String::with_capacity(estimated_size.max(config.initial_capacity));
74    stringify_into_buffer(document, config, &mut output)?;
75    Ok(output)
76}
77
78/// Write a Document into an existing String buffer, clearing it first.
79///
80/// Reuses the buffer's heap allocation across multipass iterations,
81/// avoiding repeated large allocations for multi-MB documents.
82pub fn stringify_into_buffer(
83    document: &Document,
84    config: &StringifyConfig,
85    output: &mut String,
86) -> Result<(), VexyError> {
87    output.clear();
88
89    // Ensure capacity matches estimated size (only grows, never shrinks)
90    let estimated_size = estimate_document_size(document);
91    let needed = estimated_size.max(config.initial_capacity);
92    if output.capacity() < needed {
93        output.reserve(needed - output.len());
94    }
95
96    // Write XML declaration if present
97    if let Some(ref version) = document.metadata.version {
98        write!(output, "<?xml version=\"{version}\"")?;
99        if let Some(ref encoding) = document.metadata.encoding {
100            write!(output, " encoding=\"{encoding}\"")?;
101        }
102        if let Some(ref standalone) = document.metadata.standalone {
103            write!(output, " standalone=\"{standalone}\"")?;
104        }
105        output.push_str("?>");
106        if config.newlines {
107            output.push('\n');
108        }
109    }
110
111    // Write prologue nodes
112    for node in &document.prologue {
113        stringify_node(node, &mut *output, config, 0)?;
114        if config.newlines && !matches!(node, Node::Text(_)) {
115            output.push('\n');
116        }
117    }
118
119    // Write root element
120    stringify_element(&document.root, &mut *output, config, 0)?;
121
122    // Write epilogue nodes
123    if !document.epilogue.is_empty() && config.newlines {
124        output.push('\n');
125    }
126    for node in &document.epilogue {
127        stringify_node(node, &mut *output, config, 0)?;
128        if config.newlines && !matches!(node, Node::Text(_)) {
129            output.push('\n');
130        }
131    }
132
133    Ok(())
134}
135
136/// Estimate the size of the document for pre-allocation
137fn estimate_document_size(document: &Document) -> usize {
138    // Basic estimation: element overhead + attributes + text content
139    estimate_element_size(&document.root)
140        + document
141            .prologue
142            .iter()
143            .map(estimate_node_size)
144            .sum::<usize>()
145        + document
146            .epilogue
147            .iter()
148            .map(estimate_node_size)
149            .sum::<usize>()
150}
151
152fn estimate_element_size(element: &Element) -> usize {
153    let mut size = element.name.len() * 2 + 5; // <name> + </name>
154
155    // Attributes
156    for (name, value) in &element.attributes {
157        size += name.len() + value.len() + 4; // name="value"
158    }
159
160    // Namespaces
161    for (prefix, uri) in &element.namespaces {
162        if prefix.is_empty() {
163            size += 7 + uri.len(); // xmlns="uri"
164        } else {
165            size += 7 + prefix.len() + uri.len(); // xmlns:prefix="uri"
166        }
167    }
168
169    // Children
170    size += element
171        .children
172        .iter()
173        .map(estimate_node_size)
174        .sum::<usize>();
175
176    size
177}
178
179fn estimate_node_size(node: &Node) -> usize {
180    match node {
181        Node::Element(e) => estimate_element_size(e),
182        Node::Text(t) => t.len(),
183        Node::Comment(c) => c.len() + 7, // <!-- -->
184        Node::CData(c) => c.len() + 12,  // <![CDATA[]]>
185        Node::ProcessingInstruction { target, data } => target.len() + data.len() + 5, // <? ?>
186        Node::DocType(d) => d.len() + 11, // <!DOCTYPE >
187    }
188}
189
190/// Stringify an element with optimized performance
191fn stringify_element(
192    element: &Element,
193    output: &mut String,
194    config: &StringifyConfig,
195    depth: usize,
196) -> Result<(), VexyError> {
197    // Indentation (skip when indentation already exists in preserved whitespace)
198    if config.pretty && depth > 0 {
199        let line_start = output.rfind('\n').map_or(0, |index| index + 1);
200        let line_prefix = &output[line_start..];
201        let has_existing_indent = !line_prefix.is_empty() && line_prefix.chars().all(|c| c == ' ');
202
203        if !has_existing_indent {
204            for _ in 0..depth {
205                output.push_str(&config.indent);
206            }
207        }
208    }
209
210    // Opening tag
211    output.push('<');
212    output.push_str(&element.name);
213
214    // Attributes
215    for (name, value) in &element.attributes {
216        output.push(' ');
217        output.push_str(name);
218        if value.is_empty() {
219            output.push('=');
220            if config.quote_attrs {
221                output.push_str("\"\"");
222            }
223            continue;
224        }
225        output.push('=');
226        if value.starts_with('{') && value.ends_with('}') {
227            output.push_str(value);
228        } else if config.quote_attrs {
229            output.push('"');
230            output.push_str(value);
231            output.push('"');
232        } else {
233            output.push_str(value);
234        }
235    }
236
237    // Emit namespaces tracked outside attributes (e.g. programmatic AST construction).
238    for (prefix, uri) in &element.namespaces {
239        let xmlns_key = if prefix.is_empty() {
240            "xmlns".to_string()
241        } else {
242            format!("xmlns:{prefix}")
243        };
244
245        if element.attributes.contains_key(xmlns_key.as_str()) {
246            continue;
247        }
248
249        output.push(' ');
250        output.push_str(&xmlns_key);
251        output.push('=');
252        if config.quote_attrs {
253            output.push('"');
254            output.push_str(uri);
255            output.push('"');
256        } else {
257            output.push_str(uri);
258        }
259    }
260
261    if element.children.is_empty() && config.self_close {
262        // Self-closing tag
263        output.push_str("/>");
264    } else {
265        // Opening tag close
266        output.push('>');
267
268        let has_element_children = element
269            .children
270            .iter()
271            .any(|n| matches!(n, Node::Element(_)));
272        let has_non_empty_text = element.children.iter().any(|n| match n {
273            Node::Text(t) => !t.trim().is_empty(),
274            _ => false,
275        });
276        let starts_with_formatting_text = matches!(
277            element.children.first(),
278            Some(Node::Text(text)) if text.trim().is_empty() && text.contains('\n')
279        );
280        let ends_with_formatting_text = matches!(
281            element.children.last(),
282            Some(Node::Text(text)) if text.trim().is_empty() && text.contains('\n')
283        );
284        let inline_text_element = element.name == "text"
285            && !has_element_children
286            && element.children.len() == 1
287            && matches!(
288                element.children.first(),
289                Some(Node::Text(t)) if !t.trim().is_empty() && !t.contains('\n') && t.as_ref() == t.trim()
290            );
291        let has_content_children = has_element_children || has_non_empty_text;
292        let is_formatting_text =
293            |node: &Node| matches!(node, Node::Text(text) if text.trim().is_empty());
294
295        if has_content_children
296            && config.newlines
297            && !inline_text_element
298            && !starts_with_formatting_text
299        {
300            output.push('\n');
301        }
302
303        // Children
304        for (i, child) in element.children.iter().enumerate() {
305            match child {
306                Node::Element(_) => {
307                    stringify_node(child, output, config, depth + 1)?;
308                    let next_is_formatting = matches!(
309                        element.children.get(i + 1),
310                        Some(next_child) if is_formatting_text(next_child)
311                    );
312                    if config.newlines && i < element.children.len() - 1 && !next_is_formatting {
313                        output.push('\n');
314                    }
315                }
316                Node::Text(t) if !t.trim().is_empty() => {
317                    let text_depth = if inline_text_element {
318                        0
319                    } else if element.name == "textPath" {
320                        depth
321                    } else {
322                        depth + 1
323                    };
324                    stringify_node(child, output, config, text_depth)?;
325                    let next_is_formatting = matches!(
326                        element.children.get(i + 1),
327                        Some(next_child) if is_formatting_text(next_child)
328                    );
329                    if config.newlines
330                        && !inline_text_element
331                        && i < element.children.len() - 1
332                        && !next_is_formatting
333                    {
334                        output.push('\n');
335                    }
336                }
337                Node::Text(t) => {
338                    if config.pretty && config.newlines {
339                        output.push_str(t);
340                    }
341                }
342                _ => {
343                    stringify_node(child, output, config, depth + 1)?;
344                }
345            }
346        }
347
348        if has_content_children
349            && config.newlines
350            && !inline_text_element
351            && !ends_with_formatting_text
352        {
353            output.push('\n');
354            if config.pretty {
355                for _ in 0..depth {
356                    output.push_str(&config.indent);
357                }
358            }
359        }
360
361        // Closing tag
362        output.push_str("</");
363        output.push_str(&element.name);
364        output.push('>');
365    }
366
367    Ok(())
368}
369
370/// Stringify a node
371fn stringify_node(
372    node: &Node,
373    output: &mut String,
374    config: &StringifyConfig,
375    depth: usize,
376) -> Result<(), VexyError> {
377    match node {
378        Node::Element(e) => stringify_element(e, output, config, depth),
379        Node::Text(t) => {
380            // Check if text should be indented
381            let should_indent = config.pretty && depth > 0 && !t.trim().is_empty();
382            if should_indent {
383                for _ in 0..depth {
384                    output.push_str(&config.indent);
385                }
386            }
387            let text = if config.pretty { t.trim() } else { t.as_ref() };
388            escape_text_to(text, output);
389            Ok(())
390        }
391        Node::Comment(c) => {
392            if config.pretty && depth > 0 {
393                for _ in 0..depth {
394                    output.push_str(&config.indent);
395                }
396            }
397            output.push_str("<!--");
398            output.push_str(c);
399            output.push_str("-->");
400            Ok(())
401        }
402        Node::CData(c) => {
403            output.push_str("<![CDATA[");
404            output.push_str(c);
405            output.push_str("]]>");
406            Ok(())
407        }
408        Node::ProcessingInstruction { target, data } => {
409            output.push_str("<?");
410            output.push_str(target);
411            if !data.is_empty() {
412                output.push(' ');
413                output.push_str(data);
414            }
415            output.push_str("?>");
416            Ok(())
417        }
418        Node::DocType(doctype) => {
419            output.push_str("<!DOCTYPE ");
420            output.push_str(doctype);
421            output.push('>');
422            Ok(())
423        }
424    }
425}
426
427/// Escape text content, writing directly to output to avoid allocation
428fn escape_text_to(s: &str, output: &mut String) {
429    // Fast path: if no special characters, append directly
430    if !s.contains(&['&', '<', '>', '"', '\''][..]) {
431        output.push_str(s);
432        return;
433    }
434
435    // Reserve additional capacity for escapes
436    output.reserve(s.len() + 10);
437
438    for ch in s.chars() {
439        match ch {
440            '&' => output.push_str("&amp;"),
441            '<' => output.push_str("&lt;"),
442            '>' => output.push_str("&gt;"),
443            '"' => output.push_str("&quot;"),
444            '\'' => output.push_str("&apos;"),
445            _ => output.push(ch),
446        }
447    }
448}
449
450/// Escape attribute content, writing directly to output to avoid allocation
451#[cfg(test)]
452fn escape_attribute_to(s: &str, output: &mut String) {
453    // Fast path: if no special characters, append directly
454    if !s.contains(&['&', '<', '>', '"', '\''][..]) {
455        output.push_str(s);
456        return;
457    }
458
459    // Reserve additional capacity for escapes
460    output.reserve(s.len() + 10);
461
462    for ch in s.chars() {
463        match ch {
464            '&' => output.push_str("&amp;"),
465            '<' => output.push_str("&lt;"),
466            '>' => output.push_str("&gt;"),
467            '"' => output.push_str("&quot;"),
468            '\'' => output.push_str("&apos;"),
469            _ => output.push(ch),
470        }
471    }
472}
473
474/// Streaming stringifier for very large documents
475pub struct StreamingStringifier<W: std::io::Write> {
476    writer: W,
477    config: StringifyConfig,
478}
479
480impl<W: std::io::Write> StreamingStringifier<W> {
481    /// Create a new streaming stringifier
482    pub fn new(writer: W, config: StringifyConfig) -> Self {
483        Self { writer, config }
484    }
485
486    /// Stringify a document to the writer
487    pub fn stringify(&mut self, document: &Document) -> Result<(), VexyError> {
488        // Write XML declaration
489        if let Some(ref version) = document.metadata.version {
490            write!(self.writer, "<?xml version=\"{version}\"")?;
491            if let Some(ref encoding) = document.metadata.encoding {
492                write!(self.writer, " encoding=\"{encoding}\"")?;
493            }
494            if let Some(ref standalone) = document.metadata.standalone {
495                write!(self.writer, " standalone=\"{standalone}\"")?;
496            }
497            self.writer.write_all(b"?>")?;
498            if self.config.newlines {
499                self.writer.write_all(b"\n")?;
500            }
501        }
502
503        // Write prologue
504        for node in &document.prologue {
505            self.stringify_node(node, 0)?;
506            if self.config.newlines && !matches!(node, Node::Text(_)) {
507                self.writer.write_all(b"\n")?;
508            }
509        }
510
511        // Write root element
512        self.stringify_element(&document.root, 0)?;
513
514        // Write epilogue
515        if !document.epilogue.is_empty() && self.config.newlines {
516            self.writer.write_all(b"\n")?;
517        }
518        for node in &document.epilogue {
519            self.stringify_node(node, 0)?;
520            if self.config.newlines && !matches!(node, Node::Text(_)) {
521                self.writer.write_all(b"\n")?;
522            }
523        }
524
525        self.writer.flush()?;
526        Ok(())
527    }
528
529    fn write_indent(&mut self, depth: usize) -> Result<(), VexyError> {
530        if self.config.pretty {
531            for _ in 0..depth {
532                self.writer.write_all(self.config.indent.as_bytes())?;
533            }
534        }
535        Ok(())
536    }
537
538    fn write_escaped_text(&mut self, s: &str) -> Result<(), VexyError> {
539        if !s.contains(&['&', '<', '>', '"', '\''][..]) {
540            self.writer.write_all(s.as_bytes())?;
541            return Ok(());
542        }
543
544        for ch in s.chars() {
545            match ch {
546                '&' => self.writer.write_all(b"&amp;")?,
547                '<' => self.writer.write_all(b"&lt;")?,
548                '>' => self.writer.write_all(b"&gt;")?,
549                '"' => self.writer.write_all(b"&quot;")?,
550                '\'' => self.writer.write_all(b"&apos;")?,
551                _ => {
552                    let mut buf = [0u8; 4];
553                    let encoded = ch.encode_utf8(&mut buf);
554                    self.writer.write_all(encoded.as_bytes())?;
555                }
556            }
557        }
558
559        Ok(())
560    }
561
562    fn stringify_element(&mut self, element: &Element, depth: usize) -> Result<(), VexyError> {
563        if self.config.pretty && depth > 0 {
564            self.write_indent(depth)?;
565        }
566
567        self.writer.write_all(b"<")?;
568        self.writer.write_all(element.name.as_bytes())?;
569
570        for (name, value) in &element.attributes {
571            self.writer.write_all(b" ")?;
572            self.writer.write_all(name.as_bytes())?;
573            self.writer.write_all(b"=")?;
574
575            if self.config.quote_attrs {
576                self.writer.write_all(b"\"")?;
577                self.writer.write_all(value.as_bytes())?;
578                self.writer.write_all(b"\"")?;
579            } else {
580                self.writer.write_all(value.as_bytes())?;
581            }
582        }
583
584        for (prefix, uri) in &element.namespaces {
585            let xmlns_key = if prefix.is_empty() {
586                "xmlns".to_string()
587            } else {
588                format!("xmlns:{prefix}")
589            };
590
591            if element.attributes.contains_key(xmlns_key.as_str()) {
592                continue;
593            }
594
595            self.writer.write_all(b" ")?;
596            self.writer.write_all(xmlns_key.as_bytes())?;
597            self.writer.write_all(b"=")?;
598            if self.config.quote_attrs {
599                self.writer.write_all(b"\"")?;
600                self.writer.write_all(uri.as_bytes())?;
601                self.writer.write_all(b"\"")?;
602            } else {
603                self.writer.write_all(uri.as_bytes())?;
604            }
605        }
606
607        if element.children.is_empty() && self.config.self_close {
608            self.writer.write_all(b"/>")?;
609            return Ok(());
610        }
611
612        self.writer.write_all(b">")?;
613
614        let has_element_children = element
615            .children
616            .iter()
617            .any(|n| matches!(n, Node::Element(_)));
618        let has_non_empty_text = element.children.iter().any(|n| match n {
619            Node::Text(t) => !t.trim().is_empty(),
620            _ => false,
621        });
622        let starts_with_formatting_text = matches!(
623            element.children.first(),
624            Some(Node::Text(text)) if text.trim().is_empty() && text.contains('\n')
625        );
626        let ends_with_formatting_text = matches!(
627            element.children.last(),
628            Some(Node::Text(text)) if text.trim().is_empty() && text.contains('\n')
629        );
630        let inline_text_element = element.name == "text"
631            && !has_element_children
632            && element.children.len() == 1
633            && matches!(
634                element.children.first(),
635                Some(Node::Text(t)) if !t.trim().is_empty() && !t.contains('\n') && t.as_ref() == t.trim()
636            );
637        let has_content_children = has_element_children || has_non_empty_text;
638        let is_formatting_text =
639            |node: &Node| matches!(node, Node::Text(text) if text.trim().is_empty());
640
641        if has_content_children
642            && self.config.newlines
643            && !inline_text_element
644            && !starts_with_formatting_text
645        {
646            self.writer.write_all(b"\n")?;
647        }
648
649        for (i, child) in element.children.iter().enumerate() {
650            match child {
651                Node::Element(_) => {
652                    self.stringify_node(child, depth + 1)?;
653                    let next_is_formatting = matches!(
654                        element.children.get(i + 1),
655                        Some(next_child) if is_formatting_text(next_child)
656                    );
657                    if self.config.newlines && i < element.children.len() - 1 && !next_is_formatting
658                    {
659                        self.writer.write_all(b"\n")?;
660                    }
661                }
662                Node::Text(t) if !t.trim().is_empty() => {
663                    let text_depth = if inline_text_element {
664                        0
665                    } else if element.name == "textPath" {
666                        depth
667                    } else {
668                        depth + 1
669                    };
670                    self.stringify_node(child, text_depth)?;
671                    let next_is_formatting = matches!(
672                        element.children.get(i + 1),
673                        Some(next_child) if is_formatting_text(next_child)
674                    );
675                    if self.config.newlines
676                        && !inline_text_element
677                        && i < element.children.len() - 1
678                        && !next_is_formatting
679                    {
680                        self.writer.write_all(b"\n")?;
681                    }
682                }
683                Node::Text(t) => {
684                    if self.config.pretty && self.config.newlines {
685                        self.writer.write_all(t.as_bytes())?;
686                    }
687                }
688                _ => self.stringify_node(child, depth + 1)?,
689            }
690        }
691
692        if has_content_children
693            && self.config.newlines
694            && !inline_text_element
695            && !ends_with_formatting_text
696        {
697            self.writer.write_all(b"\n")?;
698            if self.config.pretty {
699                self.write_indent(depth)?;
700            }
701        }
702
703        self.writer.write_all(b"</")?;
704        self.writer.write_all(element.name.as_bytes())?;
705        self.writer.write_all(b">")?;
706
707        Ok(())
708    }
709
710    fn stringify_node(&mut self, node: &Node, depth: usize) -> Result<(), VexyError> {
711        match node {
712            Node::Element(e) => self.stringify_element(e, depth),
713            Node::Text(t) => {
714                let should_indent = self.config.pretty && depth > 0 && !t.trim().is_empty();
715                if should_indent {
716                    self.write_indent(depth)?;
717                }
718                let text = if self.config.pretty {
719                    t.trim()
720                } else {
721                    t.as_ref()
722                };
723                self.write_escaped_text(text)
724            }
725            Node::Comment(c) => {
726                if self.config.pretty && depth > 0 {
727                    self.write_indent(depth)?;
728                }
729                self.writer.write_all(b"<!--")?;
730                self.writer.write_all(c.as_bytes())?;
731                self.writer.write_all(b"-->")?;
732                Ok(())
733            }
734            Node::CData(c) => {
735                self.writer.write_all(b"<![CDATA[")?;
736                self.writer.write_all(c.as_bytes())?;
737                self.writer.write_all(b"]]>")?;
738                Ok(())
739            }
740            Node::ProcessingInstruction { target, data } => {
741                self.writer.write_all(b"<?")?;
742                self.writer.write_all(target.as_bytes())?;
743                if !data.is_empty() {
744                    self.writer.write_all(b" ")?;
745                    self.writer.write_all(data.as_bytes())?;
746                }
747                self.writer.write_all(b"?>")?;
748                Ok(())
749            }
750            Node::DocType(doctype) => {
751                self.writer.write_all(b"<!DOCTYPE ")?;
752                self.writer.write_all(doctype.as_bytes())?;
753                self.writer.write_all(b">")?;
754                Ok(())
755            }
756        }
757    }
758}
759
760#[cfg(test)]
761mod tests {
762    use super::*;
763    use crate::ast::Element;
764
765    #[test]
766    fn test_escape_attribute() {
767        let mut output = String::new();
768        escape_attribute_to("hello", &mut output);
769        assert_eq!(output, "hello");
770
771        output.clear();
772        escape_attribute_to("hello & world", &mut output);
773        assert_eq!(output, "hello &amp; world");
774
775        output.clear();
776        escape_attribute_to("\"quoted\"", &mut output);
777        assert_eq!(output, "&quot;quoted&quot;");
778
779        output.clear();
780        escape_attribute_to("<tag>", &mut output);
781        assert_eq!(output, "&lt;tag&gt;");
782    }
783
784    #[test]
785    fn test_escape_text() {
786        let mut output = String::new();
787        escape_text_to("hello", &mut output);
788        assert_eq!(output, "hello");
789
790        output.clear();
791        escape_text_to("hello & world", &mut output);
792        assert_eq!(output, "hello &amp; world");
793
794        output.clear();
795        escape_text_to("<tag>", &mut output);
796        assert_eq!(output, "&lt;tag&gt;");
797
798        output.clear();
799        escape_text_to("\"quoted\"", &mut output);
800        assert_eq!(output, "&quot;quoted&quot;");
801
802        output.clear();
803        escape_text_to("it's", &mut output);
804        assert_eq!(output, "it&apos;s");
805    }
806
807    #[test]
808    fn test_stringify_simple() {
809        let mut doc = Document::new();
810        doc.root.set_attr("width", "100");
811        doc.root.set_attr("height", "100");
812
813        let result = stringify(&doc).unwrap();
814        assert!(result.contains("<svg width=\"100\" height=\"100\"/>"));
815    }
816
817    #[test]
818    fn test_stringify_with_children() {
819        let mut doc = Document::new();
820        let mut rect = Element::new("rect");
821        rect.set_attr("x", "10");
822        rect.set_attr("y", "10");
823        doc.root.add_child(Node::Element(rect));
824
825        let result = stringify(&doc).unwrap();
826        assert!(result.contains("<svg><rect x=\"10\" y=\"10\"/></svg>"));
827    }
828
829    #[test]
830    fn test_stringify_pretty() {
831        let mut doc = Document::new();
832        let mut g = Element::new("g");
833        let mut rect = Element::new("rect");
834        rect.set_attr("x", "10");
835        g.add_child(Node::Element(rect));
836        doc.root.add_child(Node::Element(g));
837
838        let config = StringifyConfig::pretty();
839        let result = stringify_with_config(&doc, &config).unwrap();
840
841        assert!(result.contains("\n"));
842        assert!(result.contains("  <g>"));
843        assert!(result.contains("    <rect"));
844    }
845
846    #[test]
847    fn test_escape_performance() {
848        // Test that fast path works
849        let no_escape = "simple text without special chars";
850        let mut output = String::new();
851        escape_text_to(no_escape, &mut output);
852        assert_eq!(output, no_escape);
853
854        // Test escaping
855        output.clear();
856        escape_text_to("text & <tag>", &mut output);
857        assert_eq!(output, "text &amp; &lt;tag&gt;");
858    }
859
860    #[test]
861    fn test_size_estimation() {
862        let mut element = Element::new("rect");
863        element.set_attr("x", "10");
864        element.set_attr("y", "20");
865        element.add_child(Node::Text("content".into()));
866
867        let estimated = estimate_element_size(&element);
868        assert!(estimated > 20); // Should be reasonable estimate
869    }
870
871    #[test]
872    fn test_namespace_output() {
873        let mut doc = Document::new();
874        doc.root
875            .namespaces
876            .insert("".into(), "http://www.w3.org/2000/svg".into());
877        doc.root
878            .namespaces
879            .insert("xlink".into(), "http://www.w3.org/1999/xlink".into());
880
881        let result = stringify(&doc).unwrap();
882        assert!(result.contains("xmlns=\"http://www.w3.org/2000/svg\""));
883        assert!(result.contains("xmlns:xlink=\"http://www.w3.org/1999/xlink\""));
884    }
885
886    #[test]
887    fn test_streaming_stringifier_matches_stringify_minified() {
888        let mut doc = Document::new();
889        doc.metadata.version = Some("1.0".into());
890        doc.metadata.encoding = Some("UTF-8".into());
891        doc.metadata.standalone = Some("no".into());
892        doc.root.set_attr("width", "100");
893        doc.root.set_attr("height", "100");
894        doc.root
895            .namespaces
896            .insert("".into(), "http://www.w3.org/2000/svg".into());
897
898        let mut g = Element::new("g");
899        let mut rect = Element::new("rect");
900        rect.set_attr("x", "10");
901        rect.set_attr("y", "10");
902        rect.set_attr("fill", "red");
903        g.add_child(Node::Element(rect));
904        g.add_child(Node::Comment("note".into()));
905        doc.root.add_child(Node::Element(g));
906        doc.root
907            .add_child(Node::Text("it's \"ok\" & <safe>".into()));
908
909        let expected = stringify(&doc).unwrap();
910
911        let mut out = Vec::<u8>::new();
912        let mut streaming = StreamingStringifier::new(&mut out, StringifyConfig::default());
913        streaming.stringify(&doc).unwrap();
914        let actual = String::from_utf8(out).unwrap();
915
916        assert_eq!(actual, expected);
917    }
918
919    #[test]
920    fn test_streaming_stringifier_matches_stringify_pretty() {
921        let mut doc = Document::new();
922        let mut g = Element::new("g");
923        let mut circle = Element::new("circle");
924        circle.set_attr("cx", "5");
925        circle.set_attr("cy", "6");
926        g.add_child(Node::Element(circle));
927        doc.root.add_child(Node::Element(g));
928
929        let config = StringifyConfig::pretty();
930        let expected = stringify_with_config(&doc, &config).unwrap();
931
932        let mut out = Vec::<u8>::new();
933        let mut streaming = StreamingStringifier::new(&mut out, config);
934        streaming.stringify(&doc).unwrap();
935        let actual = String::from_utf8(out).unwrap();
936
937        assert_eq!(actual, expected);
938    }
939}