Skip to main content

blitz_dom/
util.rs

1use crate::node::{Node, NodeData};
2use color::{AlphaColor, Srgb};
3use keyboard_types::Modifiers;
4use std::borrow::Cow;
5use style::color::AbsoluteColor;
6
7#[cfg(target_os = "macos")]
8pub(crate) const ACTION_MOD: Modifiers = Modifiers::SUPER;
9#[cfg(not(target_os = "macos"))]
10pub(crate) const ACTION_MOD: Modifiers = Modifiers::CONTROL;
11
12/// Clipboard shortcuts accept Control as well as the platform action modifier.
13///
14/// macOS normally uses Command, but applications may expose Control+C/X/V as
15/// explicit shortcuts and Blitz must not drop those when a text control owns
16/// focus. Cursor movement continues to use `ACTION_MOD`, preserving the native
17/// macOS Control-key editing bindings.
18pub(crate) fn has_clipboard_modifier(modifiers: Modifiers) -> bool {
19    modifiers.contains(ACTION_MOD) || modifiers.contains(Modifiers::CONTROL)
20}
21
22#[cfg(test)]
23mod shortcut_tests {
24    use super::*;
25
26    #[test]
27    fn clipboard_accepts_control_and_the_platform_action_modifier() {
28        assert!(has_clipboard_modifier(Modifiers::CONTROL));
29        assert!(has_clipboard_modifier(ACTION_MOD));
30        assert!(!has_clipboard_modifier(Modifiers::SHIFT));
31    }
32}
33
34pub type Color = AlphaColor<Srgb>;
35
36/// Decode raw font bytes, decompressing WOFF/WOFF2 if the `woff` feature is enabled.
37/// Returns the original slice unchanged for TTF/OTF input, and also on decompression
38/// failure. With the `woff` feature disabled, all input passes through unchanged.
39pub fn decode_font_bytes(bytes: &[u8]) -> Cow<'_, [u8]> {
40    if bytes.len() < 4 {
41        return Cow::Borrowed(bytes);
42    }
43    match &bytes[0..4] {
44        #[cfg(feature = "woff")]
45        b"wOFF" => wuff::decompress_woff1(bytes)
46            .map(Cow::Owned)
47            .unwrap_or_else(|_| {
48                #[cfg(feature = "tracing")]
49                tracing::warn!("Failed to decompress woff1 font");
50                Cow::Borrowed(bytes)
51            }),
52        #[cfg(feature = "woff")]
53        b"wOF2" => wuff::decompress_woff2(bytes)
54            .map(Cow::Owned)
55            .unwrap_or_else(|_| {
56                #[cfg(feature = "tracing")]
57                tracing::warn!("Failed to decompress woff2 font");
58                Cow::Borrowed(bytes)
59            }),
60        _ => Cow::Borrowed(bytes),
61    }
62}
63
64#[cfg(feature = "svg")]
65use std::sync::{Arc, LazyLock};
66#[cfg(feature = "svg")]
67use usvg::fontdb;
68#[cfg(feature = "svg")]
69pub(crate) static FONT_DB: LazyLock<Arc<fontdb::Database>> = LazyLock::new(|| {
70    let mut db = fontdb::Database::new();
71    db.load_system_fonts();
72    Arc::new(db)
73});
74
75/// Which kind of CSS image layer list (`background-image` or `mask-image`) to
76/// flush from style to dedicated storage on the node.
77#[derive(Clone, Copy, Debug)]
78pub enum ImageLayerKind {
79    Background,
80    Mask,
81}
82
83impl ImageLayerKind {
84    pub fn image_type(self, idx: usize) -> ImageType {
85        match self {
86            Self::Background => ImageType::Background(idx),
87            Self::Mask => ImageType::Mask(idx),
88        }
89    }
90}
91
92#[derive(Clone, Copy, Debug)]
93pub enum ImageType {
94    Image,
95    Background(usize),
96    Mask(usize),
97}
98
99/// A point
100#[derive(Clone, Debug, Copy, Eq, PartialEq)]
101pub struct Point<T> {
102    /// The x coordinate
103    pub x: T,
104    /// The y coordinate
105    pub y: T,
106}
107
108impl Point<f64> {
109    pub const ZERO: Self = Point { x: 0.0, y: 0.0 };
110}
111
112// Debug print an RcDom
113pub fn walk_tree(indent: usize, node: &Node) {
114    // Skip all-whitespace text nodes entirely
115    if let NodeData::Text(data) = &node.data {
116        if data.content.chars().all(|c| c.is_ascii_whitespace()) {
117            return;
118        }
119    }
120
121    print!("{}", " ".repeat(indent));
122    let id = node.id;
123    match &node.data {
124        NodeData::Document(_) => println!("#Document {id}"),
125
126        NodeData::DocumentFragment => println!("#DocumentFragment {id}"),
127
128        NodeData::Text(data) => {
129            if data.content.chars().all(|c| c.is_ascii_whitespace()) {
130                println!("{id} #text: <whitespace>");
131            } else {
132                let content = data.content.trim();
133                if content.len() > 10 {
134                    println!(
135                        "#text {id}: {}...",
136                        content
137                            .split_at(content.char_indices().take(10).last().unwrap().0)
138                            .0
139                            .escape_default()
140                    )
141                } else {
142                    println!("#text {id}: {}", data.content.trim().escape_default())
143                }
144            }
145        }
146
147        NodeData::Comment { .. } => println!("<!-- COMMENT {id} -->"),
148
149        NodeData::ShadowRoot(data) => println!("{id} #shadow-root ({:?})", data.mode),
150
151        NodeData::AnonymousBlock(_) => println!("{id} AnonymousBlock"),
152
153        NodeData::Element(data) => {
154            print!("<{} {id}", data.name.local);
155            for attr in data.attrs.iter() {
156                print!(" {}=\"{}\"", attr.name.local, attr.value);
157            }
158            if !node.children.is_empty() {
159                println!(">");
160            } else {
161                println!("/>");
162            }
163        } // NodeData::Doctype {
164          //     ref name,
165          //     ref public_id,
166          //     ref system_id,
167          // } => println!("<!DOCTYPE {} \"{}\" \"{}\">", name, public_id, system_id),
168          // NodeData::ProcessingInstruction { .. } => unreachable!(),
169    }
170
171    if !node.children.is_empty() {
172        for child_id in node.children.iter() {
173            walk_tree(indent + 2, node.with(*child_id));
174        }
175
176        if let NodeData::Element(data) = &node.data {
177            println!("{}</{}>", " ".repeat(indent), data.name.local);
178        }
179    }
180}
181
182/// Parse an SVG image.
183#[cfg(feature = "svg")]
184pub(crate) fn parse_svg_image(source: &[u8]) -> Result<crate::node::SvgImageData, usvg::Error> {
185    let options = usvg::Options {
186        fontdb: Arc::clone(&*FONT_DB),
187        ..Default::default()
188    };
189    let tree = usvg::Tree::from_data(source, &options)?;
190    Ok(crate::node::SvgImageData {
191        tree: Arc::new(tree),
192    })
193}
194
195pub trait ToColorColor {
196    /// Converts a color into the `AlphaColor<Srgb>` type from the `color` crate
197    fn as_color_color(&self) -> Color;
198}
199impl ToColorColor for AbsoluteColor {
200    fn as_color_color(&self) -> Color {
201        Color::new(
202            *self
203                .to_color_space(style::color::ColorSpace::Srgb)
204                .raw_components(),
205        )
206    }
207}
208
209/// Serialize a computed CSS color in the legacy sRGB syntax accepted by SVG
210/// preprocessors such as usvg. Stylo preserves modern author color spaces such
211/// as `oklab()`, but those must not leak into the self-contained SVG source.
212pub(crate) fn absolute_color_to_svg_css(color: &AbsoluteColor) -> String {
213    let [red, green, blue, alpha] = color.as_color_color().components;
214    let channel = |value: f32| (value.clamp(0.0, 1.0) * 255.0).round() as u8;
215    format!(
216        "rgba({}, {}, {}, {})",
217        channel(red),
218        channel(green),
219        channel(blue),
220        alpha.clamp(0.0, 1.0)
221    )
222}
223
224#[cfg(all(test, feature = "svg"))]
225mod svg_tests {
226    use super::parse_svg_image;
227
228    #[test]
229    fn missing_height_is_computed_from_width_and_viewbox_ratio() {
230        let src = br#"<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100" width="200"><rect width="100%" height="100%" fill="green"/></svg>"#;
231        let svg = parse_svg_image(src).unwrap();
232        assert_eq!(svg.intrinsic_width(), Some(200.0));
233        assert_eq!(svg.intrinsic_height(), None);
234        assert_eq!(svg.viewbox_aspect_ratio(), Some(1.0));
235        assert_eq!(svg.tree.size().width(), 200.0);
236        assert_eq!(svg.tree.size().height(), 200.0);
237        assert_eq!(svg.intrinsic_size(), (200.0, 200.0));
238    }
239
240    #[test]
241    fn viewbox_only_has_no_intrinsic_dimensions() {
242        let src = br#"<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 485 58"></svg>"#;
243        let svg = parse_svg_image(src).unwrap();
244        assert_eq!(svg.intrinsic_width(), None);
245        assert_eq!(svg.intrinsic_height(), None);
246        // The aspect ratio is still available from the viewBox.
247        assert!((svg.aspect_ratio() - (485.0 / 58.0)).abs() < 1e-3);
248    }
249
250    #[test]
251    fn absolute_dimensions_are_intrinsic() {
252        let src = br#"<svg xmlns="http://www.w3.org/2000/svg" width="24" height="16" viewBox="0 0 48 32"></svg>"#;
253        let svg = parse_svg_image(src).unwrap();
254        assert_eq!(svg.intrinsic_width(), Some(24.0));
255        assert_eq!(svg.intrinsic_height(), Some(16.0));
256    }
257
258    #[test]
259    fn percentage_dimensions_are_not_intrinsic() {
260        let src = br#"<svg xmlns="http://www.w3.org/2000/svg" width="100%" height="50%" viewBox="0 0 200 100"></svg>"#;
261        let svg = parse_svg_image(src).unwrap();
262        assert_eq!(svg.intrinsic_width(), None);
263        assert_eq!(svg.intrinsic_height(), None);
264    }
265
266    #[test]
267    fn unit_lengths_are_intrinsic() {
268        let src = br#"<svg xmlns="http://www.w3.org/2000/svg" width="24px" height="1.5em" viewBox="0 0 48 32"></svg>"#;
269        let svg = parse_svg_image(src).unwrap();
270        assert!(svg.intrinsic_width().is_some());
271        assert!(svg.intrinsic_height().is_some());
272    }
273
274    #[test]
275    fn non_numeric_dimensions_are_not_intrinsic() {
276        let src = br#"<svg xmlns="http://www.w3.org/2000/svg" width="auto" height="foo" viewBox="0 0 200 100"></svg>"#;
277        let svg = parse_svg_image(src).unwrap();
278        assert_eq!(svg.intrinsic_width(), None);
279        assert_eq!(svg.intrinsic_height(), None);
280    }
281}
282
283/// Creates an markup5ever::QualName.
284/// Given a local name and an optional namespace
285#[macro_export]
286macro_rules! qual_name {
287    ($local:tt $(, $ns:ident)?) => {
288        $crate::QualName {
289            prefix: None,
290            ns: $crate::ns!($($ns)?),
291            local: $crate::local_name!($local),
292        }
293    };
294}
295
296/// Restore the camelCase spelling of SVG attributes.
297///
298/// The HTML serialiser lowercases attribute names, which is right for HTML but
299/// wrong for SVG: its attributes are case-sensitive, so `viewBox` written as
300/// `viewbox` is simply ignored. usvg then falls back to the bounding box of the
301/// path geometry, producing an intrinsic size with the wrong aspect ratio, and
302/// `width: auto` resolves against it.
303#[cfg(feature = "svg")]
304pub(crate) fn restore_svg_attribute_case(markup: &str) -> String {
305    // The SVG 1.1/2 attributes whose names carry capitals. Anything not listed
306    // is genuinely lowercase in the spec.
307    const CAMEL_CASED: &[&str] = &[
308        "viewBox",
309        "preserveAspectRatio",
310        "baseProfile",
311        "clipPath",
312        "clipPathUnits",
313        "diffuseConstant",
314        "edgeMode",
315        "filterUnits",
316        "glyphRef",
317        "gradientTransform",
318        "gradientUnits",
319        "kernelMatrix",
320        "kernelUnitLength",
321        "keyPoints",
322        "keySplines",
323        "keyTimes",
324        "lengthAdjust",
325        "limitingConeAngle",
326        "markerHeight",
327        "markerUnits",
328        "markerWidth",
329        "maskContentUnits",
330        "maskUnits",
331        "numOctaves",
332        "pathLength",
333        "patternContentUnits",
334        "patternTransform",
335        "patternUnits",
336        "pointsAtX",
337        "pointsAtY",
338        "pointsAtZ",
339        "primitiveUnits",
340        "refX",
341        "refY",
342        "repeatCount",
343        "repeatDur",
344        "requiredExtensions",
345        "requiredFeatures",
346        "specularConstant",
347        "specularExponent",
348        "spreadMethod",
349        "startOffset",
350        "stdDeviation",
351        "stitchTiles",
352        "surfaceScale",
353        "systemLanguage",
354        "tableValues",
355        "targetX",
356        "targetY",
357        "textLength",
358        "xChannelSelector",
359        "yChannelSelector",
360        "zoomAndPan",
361    ];
362
363    let mut output = markup.to_owned();
364    for name in CAMEL_CASED {
365        let lowered = name.to_ascii_lowercase();
366        if lowered == *name {
367            continue;
368        }
369        // Only rewrite what is unambiguously an attribute position: a space
370        // before the name and an `=` after it, so element text is untouched.
371        let needle = format!(" {lowered}=");
372        if output.contains(&needle) {
373            output = output.replace(&needle, &format!(" {name}="));
374        }
375    }
376    output
377}
378
379#[cfg(all(test, feature = "svg"))]
380mod svg_attribute_case_tests {
381    use super::{parse_svg_image, restore_svg_attribute_case};
382
383    /// The HTML serialiser lowercases attribute names. For SVG that silently
384    /// changes the geometry, because the attributes are case sensitive.
385    #[test]
386    fn a_lowercased_view_box_loses_the_declared_aspect_ratio() {
387        let lowered = r#"<svg viewbox="0 0 744 221" xmlns="http://www.w3.org/2000/svg"><path d="M0 0h10v10H0z"/></svg>"#;
388        let parsed = parse_svg_image(lowered.as_bytes()).unwrap();
389        let ratio = parsed.tree.size().width() / parsed.tree.size().height();
390        assert!(
391            (ratio - 3.367).abs() > 0.1,
392            "lowercased viewbox unexpectedly honoured: {ratio}"
393        );
394    }
395
396    #[test]
397    fn restoring_the_case_recovers_the_view_box() {
398        let lowered = r#"<svg viewbox="0 0 744 221" preserveaspectratio="xMidYMid meet" xmlns="http://www.w3.org/2000/svg"><path d="M0 0h10v10H0z"/></svg>"#;
399        let restored = restore_svg_attribute_case(lowered);
400        assert!(restored.contains("viewBox="));
401        assert!(restored.contains("preserveAspectRatio="));
402
403        let parsed = parse_svg_image(restored.as_bytes()).unwrap();
404        let size = parsed.tree.size();
405        assert_eq!(size.width().round(), 744.0);
406        assert_eq!(size.height().round(), 221.0);
407    }
408
409    #[test]
410    fn attribute_names_without_capitals_are_left_alone() {
411        let markup = r#"<svg width="10" height="10" xmlns="http://www.w3.org/2000/svg"/>"#;
412        assert_eq!(restore_svg_attribute_case(markup), markup);
413    }
414}