Skip to main content

css_inline/html/
serializer.rs

1use super::{
2    attributes::Attributes,
3    document::Document,
4    node::{ElementData, NodeData, NodeId},
5    DocumentStyleMap, InliningMode,
6};
7use crate::{html::ElementStyleMap, parser, InlineError};
8use html5ever::{local_name, ns, tendril::StrTendril, LocalName, QualName};
9use memchr::{memchr3_iter, memchr_iter};
10use smallvec::{smallvec, SmallVec};
11use std::io::Write;
12
13/// Check if an element supports width/height HTML attributes.
14#[inline]
15fn supports_dimension_attrs(name: &LocalName) -> bool {
16    matches!(
17        *name,
18        local_name!("table") | local_name!("td") | local_name!("th") | local_name!("img")
19    )
20}
21
22/// Check if an element is a table element (supports percentage values).
23#[inline]
24fn is_table_element(name: &LocalName) -> bool {
25    matches!(
26        *name,
27        local_name!("table") | local_name!("td") | local_name!("th")
28    )
29}
30
31/// Extracted dimension value for HTML attribute.
32#[derive(Clone, Copy)]
33enum DimensionValue<'a> {
34    /// Numeric value without suffix (for px/unitless)
35    Numeric(&'a str),
36    /// Numeric value that needs % suffix when written
37    Percent(&'a str),
38    /// The "auto" keyword
39    Auto,
40}
41
42impl DimensionValue<'_> {
43    #[inline]
44    fn write_to<W: Write>(&self, writer: &mut W) -> Result<(), InlineError> {
45        match self {
46            DimensionValue::Numeric(n) => writer.write_all(n.as_bytes())?,
47            DimensionValue::Percent(n) => {
48                writer.write_all(n.as_bytes())?;
49                writer.write_all(b"%")?;
50            }
51            DimensionValue::Auto => writer.write_all(b"auto")?,
52        }
53        Ok(())
54    }
55}
56
57/// Extract dimension value for HTML attribute from CSS value.
58#[inline]
59#[allow(clippy::arithmetic_side_effects)]
60fn extract_dimension_value(value: &str, allow_percent: bool) -> Option<DimensionValue<'_>> {
61    let value = value.trim();
62
63    if value.eq_ignore_ascii_case("auto") {
64        return Some(DimensionValue::Auto);
65    }
66
67    // Find where the numeric part ends
68    let bytes = value.as_bytes();
69    let mut end = 0;
70    let mut has_dot = false;
71
72    // Handle optional leading sign
73    if bytes.first() == Some(&b'-') || bytes.first() == Some(&b'+') {
74        end = 1;
75    }
76
77    // Parse digits and optional decimal point
78    while end < bytes.len() {
79        match bytes[end] {
80            b'0'..=b'9' => end += 1,
81            b'.' if !has_dot => {
82                has_dot = true;
83                end += 1;
84            }
85            _ => break,
86        }
87    }
88
89    // Must have at least one digit
90    if end == 0 || (end == 1 && (bytes[0] == b'-' || bytes[0] == b'+')) {
91        return None;
92    }
93
94    let numeric_part = &value[..end];
95    // Trim whitespace between number and unit (e.g., "100 px") for lenient parsing
96    let unit_part = value[end..].trim();
97    // Strip `!important` suffix if present
98    let unit_part = unit_part
99        .strip_suffix("!important")
100        .map_or(unit_part, str::trim);
101
102    match unit_part {
103        // Pixel values - strip the 'px' suffix
104        "" | "px" => Some(DimensionValue::Numeric(numeric_part)),
105        // Percentage - only allowed for table elements
106        "%" if allow_percent => Some(DimensionValue::Percent(numeric_part)),
107        // All other units are not supported
108        _ => None,
109    }
110}
111
112/// Find a style property value from stylesheet rules (not pre-existing inline styles).
113#[inline]
114fn find_style_value<'a>(styles: &'a ElementStyleMap<'_>, property: &str) -> Option<&'a str> {
115    styles
116        .iter()
117        .rev()
118        .find(|(name, _, _)| *name == property)
119        .map(|(_, _, value)| *value)
120}
121
122#[allow(clippy::too_many_arguments, clippy::fn_params_excessive_bools)]
123pub(crate) fn serialize_to<W: Write>(
124    document: &Document,
125    writer: &mut W,
126    styles: DocumentStyleMap<'_>,
127    keep_style_tags: bool,
128    keep_link_tags: bool,
129    minify_css: bool,
130    at_rules: Option<&String>,
131    mode: InliningMode,
132    apply_width_attributes: bool,
133    apply_height_attributes: bool,
134) -> Result<(), InlineError> {
135    let sink = Sink::new(
136        document,
137        NodeId::document_id(),
138        keep_style_tags,
139        keep_link_tags,
140        minify_css,
141        at_rules,
142        mode,
143        apply_width_attributes,
144        apply_height_attributes,
145    );
146    let mut ser = HtmlSerializer::new(writer, styles);
147    sink.serialize(&mut ser)
148}
149
150/// Intermediary structure for serializing an HTML document.
151#[allow(clippy::struct_excessive_bools)]
152struct Sink<'a> {
153    document: &'a Document,
154    node: NodeId,
155    keep_style_tags: bool,
156    keep_link_tags: bool,
157    minify_css: bool,
158    at_rules: Option<&'a String>,
159    inlining_mode: InliningMode,
160    apply_width_attributes: bool,
161    apply_height_attributes: bool,
162}
163
164impl<'a> Sink<'a> {
165    #[allow(clippy::too_many_arguments, clippy::fn_params_excessive_bools)]
166    fn new(
167        document: &'a Document,
168        node: NodeId,
169        keep_style_tags: bool,
170        keep_link_tags: bool,
171        minify_css: bool,
172        at_rules: Option<&'a String>,
173        inlining_mode: InliningMode,
174        apply_width_attributes: bool,
175        apply_height_attributes: bool,
176    ) -> Sink<'a> {
177        Sink {
178            document,
179            node,
180            keep_style_tags,
181            keep_link_tags,
182            minify_css,
183            at_rules,
184            inlining_mode,
185            apply_width_attributes,
186            apply_height_attributes,
187        }
188    }
189    #[inline]
190    fn for_node(&self, node: NodeId) -> Sink<'a> {
191        Sink::new(
192            self.document,
193            node,
194            self.keep_style_tags,
195            self.keep_link_tags,
196            self.minify_css,
197            self.at_rules,
198            self.inlining_mode,
199            self.apply_width_attributes,
200            self.apply_height_attributes,
201        )
202    }
203    #[inline]
204    fn data(&self) -> &NodeData {
205        &self.document[self.node].data
206    }
207    #[inline]
208    fn should_skip_element(&self, element: &ElementData) -> bool {
209        if element.name.local == local_name!("style") {
210            !self.keep_style_tags && element.attributes.get_css_inline() != Some("keep")
211        } else if element.name.local == local_name!("link")
212            && element.attributes.get(local_name!("rel")) == Some("stylesheet")
213        {
214            !self.keep_link_tags
215        } else if element.name.local == local_name!("html") {
216            matches!(self.inlining_mode, InliningMode::Fragment)
217        } else {
218            false
219        }
220    }
221
222    fn serialize_children<W: Write>(
223        &self,
224        serializer: &mut HtmlSerializer<'_, W>,
225    ) -> Result<(), InlineError> {
226        for child in self.document.children(self.node) {
227            self.for_node(child).serialize(serializer)?;
228        }
229        Ok(())
230    }
231
232    fn serialize<W: Write>(
233        &self,
234        serializer: &mut HtmlSerializer<'_, W>,
235    ) -> Result<(), InlineError> {
236        match self.data() {
237            NodeData::Element {
238                element,
239                inlining_ignored,
240            } => {
241                if self.should_skip_element(element) {
242                    return Ok(());
243                }
244
245                let style_node_id = if *inlining_ignored {
246                    None
247                } else {
248                    Some(self.node)
249                };
250
251                serializer.start_elem(
252                    &element.name,
253                    &element.attributes,
254                    style_node_id,
255                    self.minify_css,
256                    self.apply_width_attributes,
257                    self.apply_height_attributes,
258                )?;
259
260                if element.name.local == local_name!("head") {
261                    if let Some(at_rules) = &self.at_rules {
262                        if !at_rules.is_empty() {
263                            serializer.write_at_rules_style(at_rules)?;
264                        }
265                    }
266                }
267
268                self.serialize_children(serializer)?;
269
270                serializer.end_elem(&element.name)?;
271                Ok(())
272            }
273            NodeData::Document => self.serialize_children(serializer),
274            NodeData::Doctype { name } => serializer.write_doctype(name),
275            NodeData::Text { text } => serializer.write_text(text),
276            NodeData::Comment { text } => serializer.write_comment(text),
277            NodeData::ProcessingInstruction { target, data } => {
278                serializer.write_processing_instruction(target, data)
279            }
280        }
281    }
282}
283
284struct ElemInfo {
285    html_name: Option<LocalName>,
286    ignore_children: bool,
287}
288
289/// Inspired by HTML serializer from `html5ever`
290/// Source: <https://github.com/servo/html5ever/blob/98d3c0cd01471af997cd60849a38da45a9414dfd/html5ever/src/serialize/mod.rs#L77>
291struct HtmlSerializer<'a, Wr: Write> {
292    writer: Wr,
293    styles: DocumentStyleMap<'a>,
294    stack: Vec<ElemInfo>,
295    style_buffer: SmallVec<[Vec<u8>; 8]>,
296}
297
298impl<'a, W: Write> HtmlSerializer<'a, W> {
299    fn new(writer: W, styles: DocumentStyleMap<'a>) -> Self {
300        let mut stack = Vec::with_capacity(8);
301        stack.push(ElemInfo {
302            html_name: None,
303            ignore_children: false,
304        });
305        HtmlSerializer {
306            writer,
307            styles,
308            stack,
309            style_buffer: smallvec![],
310        }
311    }
312
313    fn parent(&mut self) -> &mut ElemInfo {
314        self.stack.last_mut().expect("no parent ElemInfo")
315    }
316
317    fn write_escaped(&mut self, text: &str) -> Result<(), InlineError> {
318        let mut last_end = 0;
319        for (start, part) in text.match_indices(['&', '\u{00A0}', '<', '>']) {
320            self.writer.write_all(
321                text.get(last_end..start)
322                    .expect("Invalid substring")
323                    .as_bytes(),
324            )?;
325            // This is slightly faster than matching on `char`
326            // Notably, this approach does not work in `write_attributes` below
327            match (part.as_bytes()[0] & 0b0000_1110) >> 1 {
328                1 => self.writer.write_all(b"&nbsp;")?,
329                3 => self.writer.write_all(b"&amp;")?,
330                6 => self.writer.write_all(b"&lt;")?,
331                7 => self.writer.write_all(b"&gt;")?,
332                _ => unreachable!(),
333            }
334            last_end = start.checked_add(part.len()).expect("Size overflow");
335        }
336        self.writer.write_all(
337            text.get(last_end..text.len())
338                .expect("Invalid substring")
339                .as_bytes(),
340        )?;
341        Ok(())
342    }
343
344    #[allow(clippy::arithmetic_side_effects)]
345    fn write_attributes(&mut self, text: &str) -> Result<(), InlineError> {
346        let bytes = text.as_bytes();
347        let mut last_end = 0;
348
349        // Scan for '&' (0x26), '"' (0x22), and 0xC2 (first byte of \u{00A0})
350        for idx in memchr3_iter(b'&', b'"', 0xC2, bytes) {
351            match bytes[idx] {
352                b'&' => {
353                    self.writer.write_all(&bytes[last_end..idx])?;
354                    self.writer.write_all(b"&amp;")?;
355                    last_end = idx + 1;
356                }
357                b'"' => {
358                    self.writer.write_all(&bytes[last_end..idx])?;
359                    self.writer.write_all(b"&quot;")?;
360                    last_end = idx + 1;
361                }
362                0xC2 if bytes.get(idx + 1) == Some(&0xA0) => {
363                    self.writer.write_all(&bytes[last_end..idx])?;
364                    self.writer.write_all(b"&nbsp;")?;
365                    last_end = idx + 2; // Skip both bytes of \u{00A0}
366                }
367                _ => {} // False positive for 0xC2 not followed by 0xA0
368            }
369        }
370        self.writer.write_all(&bytes[last_end..])?;
371        Ok(())
372    }
373
374    #[allow(clippy::too_many_lines)]
375    fn start_elem(
376        &mut self,
377        name: &QualName,
378        attrs: &Attributes,
379        style_node_id: Option<NodeId>,
380        minify_css: bool,
381        apply_width_attributes: bool,
382        apply_height_attributes: bool,
383    ) -> Result<(), InlineError> {
384        let html_name = match name.ns {
385            ns!(html) => Some(name.local.clone()),
386            _ => None,
387        };
388
389        if self.parent().ignore_children {
390            self.stack.push(ElemInfo {
391                html_name,
392                ignore_children: true,
393            });
394            return Ok(());
395        }
396
397        let mut styles = if let Some(node_id) = style_node_id {
398            self.styles.get_mut(node_id.get()).and_then(|slot| {
399                slot.take().map(|mut styles| {
400                    // Sort by specificity for consistent output order
401                    styles.sort_unstable_by(|a, b| a.1.cmp(&b.1));
402                    styles
403                })
404            })
405        } else {
406            None
407        };
408
409        self.writer.write_all(b"<")?;
410        self.writer.write_all(name.local.as_bytes())?;
411        if let Some(class) = &attrs.class {
412            self.writer.write_all(b" class=\"")?;
413            self.writer.write_all(class.value.as_bytes())?;
414            self.writer.write_all(b"\"")?;
415        }
416
417        // Extract and write width/height HTML attributes before styles is consumed
418        if let Some(ref html_name) = html_name {
419            if supports_dimension_attrs(html_name) {
420                let allow_percent = is_table_element(html_name);
421                if apply_width_attributes && !attrs.contains(local_name!("width")) {
422                    if let Some(dim) = styles
423                        .as_ref()
424                        .and_then(|s| find_style_value(s, "width"))
425                        .and_then(|v| extract_dimension_value(v, allow_percent))
426                    {
427                        self.writer.write_all(b" width=\"")?;
428                        dim.write_to(&mut self.writer)?;
429                        self.writer.write_all(b"\"")?;
430                    }
431                }
432                if apply_height_attributes && !attrs.contains(local_name!("height")) {
433                    if let Some(dim) = styles
434                        .as_ref()
435                        .and_then(|s| find_style_value(s, "height"))
436                        .and_then(|v| extract_dimension_value(v, allow_percent))
437                    {
438                        self.writer.write_all(b" height=\"")?;
439                        dim.write_to(&mut self.writer)?;
440                        self.writer.write_all(b"\"")?;
441                    }
442                }
443            }
444        }
445
446        for attr in &attrs.attributes {
447            self.writer.write_all(b" ")?;
448
449            match attr.name.ns {
450                ns!() => (),
451                ns!(xml) => self.writer.write_all(b"xml:")?,
452                ns!(xmlns) => {
453                    if attr.name.local != local_name!("xmlns") {
454                        self.writer.write_all(b"xmlns:")?;
455                    }
456                }
457                ns!(xlink) => self.writer.write_all(b"xlink:")?,
458                _ => {
459                    self.writer.write_all(b"unknown_namespace:")?;
460                }
461            }
462
463            self.writer.write_all(attr.name.local.as_bytes())?;
464            self.writer.write_all(b"=\"")?;
465            if attr.name.local == local_name!("style") {
466                if let Some(new_styles) = &styles {
467                    merge_styles(
468                        &mut self.writer,
469                        &attr.value,
470                        new_styles,
471                        &mut self.style_buffer,
472                        minify_css,
473                    )?;
474                    styles = None;
475                } else {
476                    self.write_attributes(&attr.value)?;
477                }
478            } else {
479                self.write_attributes(&attr.value)?;
480            }
481            self.writer.write_all(b"\"")?;
482        }
483        if let Some(styles) = styles {
484            self.writer.write_all(b" style=\"")?;
485            if minify_css {
486                let mut it = styles.iter().peekable();
487                while let Some((property, _, value)) = it.next() {
488                    write_declaration(&mut self.writer, property, value, minify_css)?;
489                    if !minify_css || it.peek().is_some() {
490                        self.writer.write_all(b";")?;
491                    }
492                }
493            } else {
494                for (property, _, value) in styles {
495                    write_declaration(&mut self.writer, property, value, minify_css)?;
496                    self.writer.write_all(b";")?;
497                }
498            }
499            self.writer.write_all(b"\"")?;
500        }
501        self.writer.write_all(b">")?;
502
503        let ignore_children = name.ns == ns!(html)
504            && matches!(
505                name.local,
506                local_name!("area")
507                    | local_name!("base")
508                    | local_name!("basefont")
509                    | local_name!("bgsound")
510                    | local_name!("br")
511                    | local_name!("col")
512                    | local_name!("embed")
513                    | local_name!("frame")
514                    | local_name!("hr")
515                    | local_name!("img")
516                    | local_name!("input")
517                    | local_name!("keygen")
518                    | local_name!("link")
519                    | local_name!("meta")
520                    | local_name!("param")
521                    | local_name!("source")
522                    | local_name!("track")
523                    | local_name!("wbr")
524            );
525
526        self.stack.push(ElemInfo {
527            html_name,
528            ignore_children,
529        });
530
531        Ok(())
532    }
533
534    fn end_elem(&mut self, name: &QualName) -> Result<(), InlineError> {
535        let Some(info) = self.stack.pop() else {
536            panic!("no ElemInfo")
537        };
538        if info.ignore_children {
539            return Ok(());
540        }
541
542        self.writer.write_all(b"</")?;
543        self.writer.write_all(name.local.as_bytes())?;
544        self.writer.write_all(b">")?;
545        Ok(())
546    }
547
548    fn write_text(&mut self, text: &str) -> Result<(), InlineError> {
549        let escape = !matches!(
550            self.parent().html_name,
551            Some(
552                local_name!("style")
553                    | local_name!("script")
554                    | local_name!("xmp")
555                    | local_name!("iframe")
556                    | local_name!("noembed")
557                    | local_name!("noframes")
558                    | local_name!("plaintext")
559                    | local_name!("noscript")
560            ),
561        );
562
563        if escape {
564            self.write_escaped(text)?;
565        } else {
566            self.writer.write_all(text.as_bytes())?;
567        }
568        Ok(())
569    }
570
571    fn write_at_rules_style(&mut self, at_rules: &str) -> Result<(), InlineError> {
572        self.writer.write_all(b"<style>")?;
573        self.writer.write_all(at_rules.as_bytes())?;
574        self.writer.write_all(b"</style>")?;
575        Ok(())
576    }
577
578    fn write_comment(&mut self, text: &str) -> Result<(), InlineError> {
579        self.writer.write_all(b"<!--")?;
580        self.writer.write_all(text.as_bytes())?;
581        self.writer.write_all(b"-->")?;
582        Ok(())
583    }
584
585    fn write_doctype(&mut self, name: &str) -> Result<(), InlineError> {
586        self.writer.write_all(b"<!DOCTYPE ")?;
587        self.writer.write_all(name.as_bytes())?;
588        self.writer.write_all(b">")?;
589        Ok(())
590    }
591
592    fn write_processing_instruction(
593        &mut self,
594        target: &str,
595        data: &str,
596    ) -> Result<(), InlineError> {
597        self.writer.write_all(b"<?")?;
598        self.writer.write_all(target.as_bytes())?;
599        self.writer.write_all(b" ")?;
600        self.writer.write_all(data.as_bytes())?;
601        self.writer.write_all(b">")?;
602        Ok(())
603    }
604}
605
606const STYLE_SEPARATOR: &[u8] = b": ";
607const STYLE_SEPARATOR_MIN: &[u8] = b":";
608
609#[inline]
610fn write_declaration<Wr: Write>(
611    writer: &mut Wr,
612    name: &str,
613    value: &str,
614    minify_css: bool,
615) -> Result<(), InlineError> {
616    writer.write_all(name.as_bytes())?;
617    if minify_css {
618        writer.write_all(STYLE_SEPARATOR_MIN)?;
619    } else {
620        writer.write_all(STYLE_SEPARATOR)?;
621    }
622    write_declaration_value(writer, value)
623}
624
625#[inline]
626#[allow(clippy::arithmetic_side_effects)]
627fn write_declaration_value<Wr: Write>(writer: &mut Wr, value: &str) -> Result<(), InlineError> {
628    let value = value.trim();
629    let bytes = value.as_bytes();
630
631    let mut last_end = 0;
632    for idx in memchr_iter(b'"', bytes) {
633        writer.write_all(&bytes[last_end..idx])?;
634        writer.write_all(b"'")?;
635        last_end = idx + 1;
636    }
637    writer.write_all(&bytes[last_end..])?;
638    Ok(())
639}
640
641macro_rules! push_or_update {
642    ($style_buffer:expr, $length:expr, $name: expr, $value:expr, $minify_css:expr) => {{
643        if let Some(style) = $style_buffer.get_mut($length) {
644            style.clear();
645            write_declaration(style, &$name, $value, $minify_css)?;
646        } else {
647            let value = $value.trim();
648            let mut style = Vec::with_capacity(
649                $name
650                    .len()
651                    .saturating_add(STYLE_SEPARATOR.len())
652                    .saturating_add(value.len()),
653            );
654            write_declaration(&mut style, &$name, $value, $minify_css)?;
655            $style_buffer.push(style);
656        };
657        $length = $length.saturating_add(1);
658    }};
659}
660
661/// Merge a new set of styles into an current one, considering the rules of CSS precedence.
662///
663/// The merge process maintains the order of specificity and respects the `!important` rule in CSS.
664fn merge_styles<Wr: Write>(
665    writer: &mut Wr,
666    current_style: &StrTendril,
667    new_styles: &ElementStyleMap<'_>,
668    declarations_buffer: &mut SmallVec<[Vec<u8>; 8]>,
669    minify_css: bool,
670) -> Result<(), InlineError> {
671    // This function is designed with a focus on reusing existing allocations where possible
672    // We start by parsing the current declarations in the "style" attribute
673    let mut parser_input = cssparser::ParserInput::new(current_style);
674    let mut parser = cssparser::Parser::new(&mut parser_input);
675    let mut declaration_parser = parser::CSSDeclarationListParser;
676    let current_declarations = cssparser::RuleBodyParser::new(&mut parser, &mut declaration_parser);
677    // We manually manage the length of our buffer. The buffer may contain slots used
678    // in previous runs, and we want to access only the portion that we build in this iteration
679    let mut parsed_declarations_count: usize = 0;
680    for (idx, declaration) in current_declarations.enumerate() {
681        parsed_declarations_count = parsed_declarations_count.saturating_add(1);
682        let (property, value) = declaration?;
683        let estimated_declaration_size = property
684            .len()
685            .saturating_add(STYLE_SEPARATOR.len())
686            .saturating_add(value.len());
687        // We store the existing style declarations in the buffer for later merging with new styles
688        // If possible, we reuse existing slots in the buffer to avoid additional allocations
689        if let Some(buffer) = declarations_buffer.get_mut(idx) {
690            buffer.clear();
691            buffer.reserve(estimated_declaration_size);
692            write_declaration(buffer, &property, value, minify_css)?;
693        } else {
694            let mut buffer = Vec::with_capacity(estimated_declaration_size);
695            write_declaration(&mut buffer, &property, value, minify_css)?;
696            declarations_buffer.push(buffer);
697        }
698    }
699    // Keep the number of current declarations to write them last as they have the precedence
700    let current_declarations_count = parsed_declarations_count;
701    // Next, we iterate over the new styles and merge them into our existing set
702    // New rules will not override old ones unless they are marked as `!important`
703    let sep = if minify_css {
704        STYLE_SEPARATOR_MIN
705    } else {
706        STYLE_SEPARATOR
707    };
708    for (property, _, value) in new_styles {
709        match (
710            value.trim_end().strip_suffix("!important"),
711            declarations_buffer
712                .iter_mut()
713                .take(parsed_declarations_count)
714                .find(|style| {
715                    style.starts_with(property.as_bytes())
716                        && style.get(property.len()..property.len().saturating_add(sep.len()))
717                            == Some(sep)
718                }),
719        ) {
720            // The new rule is `!important` and there's an existing rule with the same name
721            // Only override if the existing inline rule is NOT `!important`.
722            // Per CSS spec: inline `!important` takes precedence over stylesheet `!important`
723            (Some(value), Some(buffer)) => {
724                if !buffer.ends_with(b"!important") {
725                    buffer.truncate(property.len().saturating_add(sep.len()));
726                    write_declaration_value(buffer, value)?;
727                    buffer.extend_from_slice(b" !important");
728                }
729            }
730            // There's no existing rule with the same name, but the new rule is `!important`
731            (Some(value), None) => {
732                push_or_update!(
733                    declarations_buffer,
734                    parsed_declarations_count,
735                    property,
736                    value,
737                    minify_css
738                );
739                // `push_or_update!` increments `parsed_declarations_count`, so subtract 1
740                // to get the slot that was just written and append the `!important` suffix.
741                if let Some(buf) =
742                    declarations_buffer.get_mut(parsed_declarations_count.saturating_sub(1))
743                {
744                    buf.extend_from_slice(b" !important");
745                }
746            }
747            // There's no existing rule with the same name, and the new rule is not `!important`
748            // In this case, we just add the new rule as-is
749            (None, None) => push_or_update!(
750                declarations_buffer,
751                parsed_declarations_count,
752                property,
753                value,
754                minify_css
755            ),
756            // Rule exists and the new one is not `!important` - leave the existing rule as-is and
757            // ignore the new one.
758            (None, Some(_)) => {}
759        }
760    }
761
762    let mut first = true;
763    for range in [
764        // First, write the new rules
765        current_declarations_count..parsed_declarations_count,
766        // Then, write the current rules
767        0..current_declarations_count,
768    ] {
769        for declaration in &declarations_buffer[range] {
770            if first {
771                first = false;
772            } else {
773                writer.write_all(b";")?;
774            }
775            writer.write_all(declaration)?;
776        }
777    }
778    Ok(())
779}
780
781#[cfg(test)]
782mod tests {
783    use crate::html::InliningMode;
784
785    use super::Document;
786
787    #[test]
788    fn test_serialize() {
789        let doc = Document::parse_with_options(
790            b"<html><head><style>h1 { color:blue; }</style><style>h1 { color:red }</style></head>",
791            0,
792            InliningMode::Document,
793        );
794        let mut buffer = Vec::new();
795        doc.serialize(
796            &mut buffer,
797            vec![None; doc.nodes.len()],
798            true,
799            false,
800            false,
801            None,
802            InliningMode::Document,
803            false,
804            false,
805        )
806        .expect("Should not fail");
807        assert_eq!(buffer, b"<html><head><style>h1 { color:blue; }</style><style>h1 { color:red }</style></head><body></body></html>");
808    }
809
810    #[test]
811    fn test_skip_style_tags() {
812        let doc = Document::parse_with_options(
813            b"<html><head><style>h1 { color:blue; }</style><style>h1 { color:red }</style></head>",
814            0,
815            InliningMode::Document,
816        );
817        let mut buffer = Vec::new();
818        doc.serialize(
819            &mut buffer,
820            vec![None; doc.nodes.len()],
821            false,
822            false,
823            false,
824            None,
825            InliningMode::Document,
826            false,
827            false,
828        )
829        .expect("Should not fail");
830        assert_eq!(buffer, b"<html><head></head><body></body></html>");
831    }
832
833    #[test]
834    fn test_escaped() {
835        let doc = Document::parse_with_options(
836            b"<!DOCTYPE html><html><head><title>& < > \xC2\xA0</title></head><body></body></html>",
837            0,
838            InliningMode::Document,
839        );
840        let mut buffer = Vec::new();
841        doc.serialize(
842            &mut buffer,
843            vec![None; doc.nodes.len()],
844            false,
845            false,
846            false,
847            None,
848            InliningMode::Document,
849            false,
850            false,
851        )
852        .expect("Should not fail");
853        assert_eq!(buffer, b"<!DOCTYPE html><html><head><title>&amp; &lt; &gt; &nbsp;</title></head><body></body></html>");
854    }
855
856    #[test]
857    fn test_untouched_style() {
858        let doc = Document::parse_with_options(
859            b"<html><body><p style=\"color:blue;\"></p></body></html>",
860            0,
861            InliningMode::Document,
862        );
863        let mut buffer = Vec::new();
864        doc.serialize(
865            &mut buffer,
866            vec![None; doc.nodes.len()],
867            false,
868            false,
869            false,
870            None,
871            InliningMode::Document,
872            false,
873            false,
874        )
875        .expect("Should not fail");
876        assert_eq!(
877            buffer,
878            b"<html><head></head><body><p style=\"color:blue;\"></p></body></html>"
879        );
880    }
881
882    #[test]
883    fn test_attributes() {
884        let doc = Document::parse_with_options(
885            b"<!DOCTYPE html><html><head></head><body data-foo='& \xC2\xA0 \"'></body></html>",
886            0,
887            InliningMode::Document,
888        );
889        let mut buffer = Vec::new();
890        doc.serialize(
891            &mut buffer,
892            vec![None; doc.nodes.len()],
893            false,
894            false,
895            false,
896            None,
897            InliningMode::Document,
898            false,
899            false,
900        )
901        .expect("Should not fail");
902        assert_eq!(buffer, b"<!DOCTYPE html><html><head></head><body data-foo=\"&amp; &nbsp; &quot;\"></body></html>");
903    }
904
905    #[test]
906    fn test_keep_at_rules_tags() {
907        let doc = Document::parse_with_options(
908            b"<html><head><style>h1 { color:red }</style></head>",
909            0,
910            InliningMode::Document,
911        );
912        let mut buffer = Vec::new();
913        doc.serialize(
914            &mut buffer,
915            vec![None; doc.nodes.len()],
916            false,
917            false,
918            false,
919            Some(&String::from(
920                "@media (max-width: 600px) { h1 { font-size: 18px; } }",
921            )),
922            InliningMode::Document,
923            false,
924            false,
925        )
926        .expect("Should not fail");
927        assert_eq!(buffer, b"<html><head><style>@media (max-width: 600px) { h1 { font-size: 18px; } }</style></head><body></body></html>");
928    }
929}