sghtmltopdf 0.6.1

HTML to PDF renderer that does not depend on Chromium, WebKit or Gecko
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
//! Selector matching, and ordering the applicable declarations by cascade order
//! (origin, then specificity, then source order).
//!
//! Which declaration wins when several specify the same property (inheritance included) is
//! the computed style's job (the style computation phase). This module goes as far as
//! returning a declaration list ordered so that later entries have higher priority.

use selectors::matching::{
    self, MatchingContext, MatchingForInvalidation, MatchingMode, NeedsSelectorFlags, QuirksMode,
    SelectorCaches,
};
use selectors::parser::SelectorList;

use crate::html::{Dom, NodeId};

use super::custom_properties::{
    compute_custom_properties, resolve_unparsed, uses_variables, CustomProperties,
};
use super::element_ref::ElementRef;
use super::properties::PropertyDeclaration;
use super::selector_impl::{PseudoElement, SgSelectorImpl};
use super::stylesheet::Stylesheet;
use super::values::ContentPart;

#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
enum Origin {
    UserAgent,
    Author,
}

/// The same as [`matching_declarations`], but returning UA-stylesheet declarations and
/// author-CSS declarations separately (each in ascending cascade priority).
///
/// Legacy presentational attributes have to sit "stronger than the UA stylesheet, weaker
/// than author CSS", so this shape exists to let them be spliced in between the two.
pub fn matching_declarations_by_origin<'a>(
    dom: &Dom,
    element: NodeId,
    ua: &'a Stylesheet,
    author: &'a Stylesheet,
) -> (Vec<&'a PropertyDeclaration>, Vec<&'a PropertyDeclaration>) {
    let el = ElementRef::new(dom, element);
    let mut caches = SelectorCaches::default();
    let mut context = MatchingContext::new(
        MatchingMode::Normal,
        None,
        &mut caches,
        QuirksMode::NoQuirks,
        NeedsSelectorFlags::No,
        MatchingForInvalidation::No,
    );

    let mut candidates = Vec::new();
    let mut collect = |sheet: &'a Stylesheet| {
        // Narrow the candidates with the index before matching (the index never drops a
        // match, so the result is the same as scanning every rule).
        sheet.index().candidates(dom, element, &mut candidates);
        let mut matched: Vec<(u32, usize, &'a Vec<PropertyDeclaration>)> = Vec::new();
        for &source_order in &candidates {
            let rule = &sheet.rules[source_order as usize];
            if let Some(specificity) = best_matching_specificity(&rule.selectors, &el, &mut context)
            {
                matched.push((specificity, source_order as usize, &rule.declarations));
            }
        }
        matched.sort_by_key(|(specificity, source_order, _)| (*specificity, *source_order));
        matched
            .into_iter()
            .flat_map(|(_, _, declarations)| declarations.iter())
            .collect::<Vec<_>>()
    };

    // Origin is the primary sort key, so sorting each origin separately and then
    // concatenating gives the same result as sorting everything at once.
    (collect(ua), collect(author))
}

/// Return the declarations that apply to `element` in `dom`, in ascending cascade priority
/// (lowest priority first, highest last).
pub fn matching_declarations<'a>(
    dom: &Dom,
    element: NodeId,
    ua: &'a Stylesheet,
    author: &'a Stylesheet,
) -> Vec<&'a PropertyDeclaration> {
    let (mut ua_declarations, author_declarations) =
        matching_declarations_by_origin(dom, element, ua, author);
    ua_declarations.extend(author_declarations);
    ua_declarations
}

/// Return the declarations matching `pseudo` (`::before`/`::after`/`::first-letter`) on
/// `element` in `dom`, in ascending cascade priority (lowest priority first, highest last).
/// The pseudo-element counterpart of `matching_declarations`.
///
/// Matching uses the `selectors` crate's `MatchingMode::ForStatelessPseudoElement`. That
/// assumes the selector ends in `pseudo`, consumes the pseudo-element part, and matches the
/// remaining compound selector against `element` (the real element) as usual, since no DOM
/// node corresponds to the pseudo-element itself.
pub(super) fn matching_pseudo_declarations<'a>(
    dom: &Dom,
    element: NodeId,
    pseudo: PseudoElement,
    ua: &'a Stylesheet,
    author: &'a Stylesheet,
) -> Vec<&'a PropertyDeclaration> {
    let el = ElementRef::new(dom, element);
    let mut caches = SelectorCaches::default();
    let mut context = MatchingContext::new(
        MatchingMode::ForStatelessPseudoElement,
        None,
        &mut caches,
        QuirksMode::NoQuirks,
        NeedsSelectorFlags::No,
        MatchingForInvalidation::No,
    );
    let matches_pseudo = |p: &PseudoElement| *p == pseudo;
    context.pseudo_element_matching_fn = Some(&matches_pseudo);

    let mut matched: Vec<(Origin, u32, usize, &'a Vec<PropertyDeclaration>)> = Vec::new();
    for (origin, sheet) in [(Origin::UserAgent, ua), (Origin::Author, author)] {
        // Only rules carrying a pseudo-element selector are relevant.
        let index = sheet.index();
        for &source_order in index.pseudo_candidates() {
            let source_order = source_order as usize;
            let rule = &sheet.rules[source_order];
            let specificity = rule
                .selectors
                .slice()
                .iter()
                .filter(|selector| selector.pseudo_element() == Some(&pseudo))
                .filter(|selector| matching::matches_selector(selector, 0, None, &el, &mut context))
                .map(|selector| selector.specificity())
                .max();
            if let Some(specificity) = specificity {
                matched.push((origin, specificity, source_order, &rule.declarations));
            }
        }
    }

    matched.sort_by_key(|(origin, specificity, source_order, _)| {
        (*origin, *specificity, *source_order)
    });

    matched
        .into_iter()
        .flat_map(|(_, _, _, declarations)| declarations.iter())
        .collect()
}

/// Resolve, according to the cascade, the generated-content parts of `pseudo`
/// (`::before`/`::after`) on `element` in `dom`. Returns `None` if the matched declarations
/// contain no valid `content` declaration at all (that is, no generated box, the same as
/// the CSS initial value `normal`).
pub fn matching_pseudo_content(
    dom: &Dom,
    element: NodeId,
    pseudo: PseudoElement,
    ua: &Stylesheet,
    author: &Stylesheet,
    element_properties: &CustomProperties,
) -> Option<Vec<ContentPart>> {
    let declarations = matching_pseudo_declarations(dom, element, pseudo, ua, author);
    // `content: var(--label)` resolves against the element's custom properties plus any
    // declared on the pseudo-element itself.
    let properties = if uses_variables(declarations.iter().copied()) {
        compute_custom_properties(element_properties, declarations.iter().copied())
    } else {
        element_properties.clone()
    };
    declarations
        .into_iter()
        .rev()
        .find_map(|decl| match decl {
            PropertyDeclaration::Content(content) => Some(content.clone()),
            // `content: initial | inherit | unset` all compute to `normal` on a
            // pseudo-element (the originating element's own `content` is `normal`).
            PropertyDeclaration::CssWide(wide)
                if wide
                    .longhands
                    .iter()
                    .any(|d| matches!(d, PropertyDeclaration::Content(_))) =>
            {
                Some(None)
            }
            PropertyDeclaration::Unparsed(unparsed)
                if unparsed.name.eq_ignore_ascii_case("content") =>
            {
                // Invalid at computed-value time: `content` becomes its initial value (`normal`).
                Some(
                    match resolve_unparsed(unparsed, &properties) {
                        (resolved, false) => resolved.into_iter().find_map(|d| match d {
                            PropertyDeclaration::Content(content) => Some(content),
                            _ => None,
                        }),
                        _ => None,
                    }
                    .flatten(),
                )
            }
            _ => None,
        })
        .flatten()
}

/// Return the highest specificity among the selectors in the list that actually matched the
/// element (for a selector group such as `h1, h2 { ... }`, the specificity of the one that matched).
fn best_matching_specificity(
    selectors: &SelectorList<SgSelectorImpl>,
    element: &ElementRef,
    context: &mut MatchingContext<SgSelectorImpl>,
) -> Option<u32> {
    selectors
        .slice()
        .iter()
        .filter(|selector| matching::matches_selector(selector, 0, None, element, context))
        .map(|selector| selector.specificity())
        .max()
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::html::{self, NodeData};
    use crate::style::{parse_stylesheet, Color, Display};

    fn find(dom: &Dom, id: NodeId, tag: &str) -> Option<NodeId> {
        if let NodeData::Element { name, .. } = &dom.node(id).data {
            if &*name.local == tag {
                return Some(id);
            }
        }
        dom.children(id).find_map(|child| find(dom, child, tag))
    }

    fn last_color(decls: &[&PropertyDeclaration]) -> Option<Color> {
        decls.iter().rev().find_map(|d| match d {
            PropertyDeclaration::Color(c) => Some(*c),
            _ => None,
        })
    }

    fn last_display(decls: &[&PropertyDeclaration]) -> Option<Display> {
        decls.iter().rev().find_map(|d| match d {
            PropertyDeclaration::Display(display) => Some(*display),
            _ => None,
        })
    }

    fn rgb(v: u8) -> Color {
        Color::Rgba {
            red: v,
            green: v,
            blue: v,
            alpha: 1.0,
        }
    }

    #[test]
    fn specificity_beats_source_order() {
        let dom = html::parse(br#"<div id="x" class="c">t</div>"#);
        let div = find(&dom, dom.document(), "div").expect("div not found");

        // Even with the more specific #x rule written first in the source, specificity is
        // what wins, so it should end up last.
        let author = parse_stylesheet(
            "#x { color: rgb(2, 2, 2); } .c { color: rgb(1, 1, 1); } div { color: rgb(0, 0, 0); }",
        );
        let ua = Stylesheet::default();

        let decls = matching_declarations(&dom, div, &ua, &author);
        assert_eq!(last_color(&decls), Some(rgb(2)));
    }

    #[test]
    fn later_source_order_wins_on_specificity_tie() {
        let dom = html::parse(br#"<div>t</div>"#);
        let div = find(&dom, dom.document(), "div").expect("div not found");

        let author = parse_stylesheet("div { color: rgb(9, 9, 9); } div { color: rgb(8, 8, 8); }");
        let ua = Stylesheet::default();

        let decls = matching_declarations(&dom, div, &ua, &author);
        assert_eq!(last_color(&decls), Some(rgb(8)));
    }

    #[test]
    fn author_origin_beats_user_agent_on_specificity_tie() {
        let dom = html::parse(br#"<div>t</div>"#);
        let div = find(&dom, dom.document(), "div").expect("div not found");

        let ua = parse_stylesheet("div { display: block; }");
        let author = parse_stylesheet("div { display: inline; }");

        let decls = matching_declarations(&dom, div, &ua, &author);
        assert_eq!(last_display(&decls), Some(Display::Inline));
    }

    #[test]
    fn descendant_combinator_matches_nested_element() {
        let dom = html::parse(br#"<div><p>inner</p></div><p>outer</p>"#);
        let ps: Vec<_> = {
            let mut out = Vec::new();
            fn collect(dom: &Dom, id: NodeId, out: &mut Vec<NodeId>) {
                if let NodeData::Element { name, .. } = &dom.node(id).data {
                    if &*name.local == "p" {
                        out.push(id);
                    }
                }
                for child in dom.children(id) {
                    collect(dom, child, out);
                }
            }
            collect(&dom, dom.document(), &mut out);
            out
        };
        assert_eq!(ps.len(), 2, "expected both <p> elements to be found");

        let author = parse_stylesheet("div p { color: rgb(3, 3, 3); }");
        let ua = Stylesheet::default();

        let inner_decls = matching_declarations(&dom, ps[0], &ua, &author);
        let outer_decls = matching_declarations(&dom, ps[1], &ua, &author);

        assert_eq!(last_color(&inner_decls), Some(rgb(3)));
        assert_eq!(last_color(&outer_decls), None);
    }

    #[test]
    fn hover_pseudo_class_never_matches_but_does_not_break_the_rest_of_the_selector_list() {
        let dom = html::parse(br#"<div class="foo">t</div>"#);
        let div = find(&dom, dom.document(), "div").expect("div not found");

        // If `.bar:hover` merely fails to match (rather than failing to parse), the
        // comma-separated `.foo` should survive and apply.
        let author = parse_stylesheet(".foo, .bar:hover { color: rgb(6, 6, 6); }");
        let ua = Stylesheet::default();

        let decls = matching_declarations(&dom, div, &ua, &author);
        assert_eq!(last_color(&decls), Some(rgb(6)));
    }

    #[test]
    fn hover_alone_never_matches() {
        let dom = html::parse(br#"<div>t</div>"#);
        let div = find(&dom, dom.document(), "div").expect("div not found");

        let author = parse_stylesheet("div:hover { color: rgb(6, 6, 6); }");
        let ua = Stylesheet::default();

        let decls = matching_declarations(&dom, div, &ua, &author);
        assert_eq!(last_color(&decls), None);
    }

    /// Pseudo-elements that can never have a box in a static PDF parse but never match, so
    /// a selector list naming one keeps its other items (Tailwind v4's preflight reset).
    const NEVER_MATCHING: [&str; 5] = [
        "::backdrop",
        "::file-selector-button",
        "::placeholder",
        "::selection",
        "::marker",
    ];

    #[test]
    fn never_matching_pseudo_elements_do_not_drop_the_rest_of_the_selector_list() {
        let dom = html::parse(br#"<div class="foo">t</div>"#);
        let div = find(&dom, dom.document(), "div").expect("div not found");
        let ua = Stylesheet::default();

        for pseudo in NEVER_MATCHING {
            let author = parse_stylesheet(&format!(".foo, {pseudo} {{ color: rgb(6, 6, 6); }}"));
            let decls = matching_declarations(&dom, div, &ua, &author);
            assert_eq!(last_color(&decls), Some(rgb(6)), "{pseudo}");
        }

        // The Tailwind v4 preflight selector.
        let author = parse_stylesheet(
            "*, ::after, ::before, ::backdrop, ::file-selector-button { color: rgb(6, 6, 6); }",
        );
        let decls = matching_declarations(&dom, div, &ua, &author);
        assert_eq!(last_color(&decls), Some(rgb(6)));
    }

    #[test]
    fn never_matching_pseudo_elements_do_not_match_the_host_element() {
        let dom = html::parse(br#"<div class="foo">t</div>"#);
        let div = find(&dom, dom.document(), "div").expect("div not found");
        let ua = Stylesheet::default();

        for pseudo in NEVER_MATCHING {
            let author = parse_stylesheet(&format!("div{pseudo}, .foo{pseudo} {{ color: red; }}"));
            let decls = matching_declarations(&dom, div, &ua, &author);
            assert_eq!(last_color(&decls), None, "{pseudo}");
            // ... and they are not mistaken for ::before/::after/::first-letter either.
            for p in [
                PseudoElement::Before,
                PseudoElement::After,
                PseudoElement::FirstLetter,
            ] {
                assert!(matching_pseudo_declarations(&dom, div, p, &ua, &author).is_empty());
            }
        }
    }

    #[test]
    fn never_matching_pseudo_element_inside_is_and_where_is_forgiven() {
        let dom = html::parse(br#"<div class="foo">t</div>"#);
        let div = find(&dom, dom.document(), "div").expect("div not found");
        let ua = Stylesheet::default();

        // A pseudo-element is not valid inside :is()/:where(); the forgiving list drops
        // just that item, as it does for any other unsupported argument.
        let author = parse_stylesheet(":is(.foo, ::placeholder) { color: rgb(6, 6, 6); }");
        let decls = matching_declarations(&dom, div, &ua, &author);
        assert_eq!(last_color(&decls), Some(rgb(6)));
        let author = parse_stylesheet(":where(::placeholder) { color: red; }");
        let decls = matching_declarations(&dom, div, &ua, &author);
        assert_eq!(last_color(&decls), None);
    }

    /// `:host` and `:host()` only match a shadow host from inside its shadow tree. There are
    /// no shadow trees here, so they parse but never match.
    const HOST_SELECTORS: [&str; 3] = [":host", ":host(.foo)", ":host(div)"];

    #[test]
    fn host_does_not_drop_the_rest_of_the_selector_list() {
        let dom = html::parse(br#"<div class="foo">t</div>"#);
        let div = find(&dom, dom.document(), "div").expect("div not found");
        let html_el = find(&dom, dom.document(), "html").expect("html not found");
        let ua = Stylesheet::default();

        for host in HOST_SELECTORS {
            let author = parse_stylesheet(&format!(".foo, {host} {{ color: rgb(6, 6, 6); }}"));
            let decls = matching_declarations(&dom, div, &ua, &author);
            assert_eq!(last_color(&decls), Some(rgb(6)), "{host}");
        }

        // Tailwind v4's theme and preflight selectors.
        for selector in [":root, :host", "html, :host"] {
            let author = parse_stylesheet(&format!("{selector} {{ color: rgb(6, 6, 6); }}"));
            let decls = matching_declarations(&dom, html_el, &ua, &author);
            assert_eq!(last_color(&decls), Some(rgb(6)), "{selector}");
        }
        let author = parse_stylesheet("@layer theme { :root, :host { color: rgb(6, 6, 6); } }");
        let decls = matching_declarations(&dom, html_el, &ua, &author);
        assert_eq!(last_color(&decls), Some(rgb(6)));
    }

    #[test]
    fn host_never_matches() {
        let dom = html::parse(br#"<div class="foo">t</div>"#);
        let ua = Stylesheet::default();

        for tag in ["html", "body", "div"] {
            let element = find(&dom, dom.document(), tag).expect("element not found");
            for host in HOST_SELECTORS {
                for selector in [
                    host.to_string(),
                    format!("{host} div"),
                    format!("{host} > *"),
                ] {
                    let author = parse_stylesheet(&format!("{selector} {{ color: red; }}"));
                    let decls = matching_declarations(&dom, element, &ua, &author);
                    assert_eq!(last_color(&decls), None, "{selector} on <{tag}>");
                }
            }
        }
    }
}