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::Text(data) => {
127            if data.content.chars().all(|c| c.is_ascii_whitespace()) {
128                println!("{id} #text: <whitespace>");
129            } else {
130                let content = data.content.trim();
131                if content.len() > 10 {
132                    println!(
133                        "#text {id}: {}...",
134                        content
135                            .split_at(content.char_indices().take(10).last().unwrap().0)
136                            .0
137                            .escape_default()
138                    )
139                } else {
140                    println!("#text {id}: {}", data.content.trim().escape_default())
141                }
142            }
143        }
144
145        NodeData::Comment { .. } => println!("<!-- COMMENT {id} -->"),
146
147        NodeData::ShadowRoot(data) => println!("{id} #shadow-root ({:?})", data.mode),
148
149        NodeData::AnonymousBlock(_) => println!("{id} AnonymousBlock"),
150
151        NodeData::Element(data) => {
152            print!("<{} {id}", data.name.local);
153            for attr in data.attrs.iter() {
154                print!(" {}=\"{}\"", attr.name.local, attr.value);
155            }
156            if !node.children.is_empty() {
157                println!(">");
158            } else {
159                println!("/>");
160            }
161        } // NodeData::Doctype {
162          //     ref name,
163          //     ref public_id,
164          //     ref system_id,
165          // } => println!("<!DOCTYPE {} \"{}\" \"{}\">", name, public_id, system_id),
166          // NodeData::ProcessingInstruction { .. } => unreachable!(),
167    }
168
169    if !node.children.is_empty() {
170        for child_id in node.children.iter() {
171            walk_tree(indent + 2, node.with(*child_id));
172        }
173
174        if let NodeData::Element(data) = &node.data {
175            println!("{}</{}>", " ".repeat(indent), data.name.local);
176        }
177    }
178}
179
180/// Parse an SVG image.
181#[cfg(feature = "svg")]
182pub(crate) fn parse_svg_image(source: &[u8]) -> Result<crate::node::SvgImageData, usvg::Error> {
183    let options = usvg::Options {
184        fontdb: Arc::clone(&*FONT_DB),
185        ..Default::default()
186    };
187    let tree = usvg::Tree::from_data(source, &options)?;
188    Ok(crate::node::SvgImageData {
189        tree: Arc::new(tree),
190    })
191}
192
193pub trait ToColorColor {
194    /// Converts a color into the `AlphaColor<Srgb>` type from the `color` crate
195    fn as_color_color(&self) -> Color;
196}
197impl ToColorColor for AbsoluteColor {
198    fn as_color_color(&self) -> Color {
199        Color::new(
200            *self
201                .to_color_space(style::color::ColorSpace::Srgb)
202                .raw_components(),
203        )
204    }
205}
206
207/// Serialize a computed CSS color in the legacy sRGB syntax accepted by SVG
208/// preprocessors such as usvg. Stylo preserves modern author color spaces such
209/// as `oklab()`, but those must not leak into the self-contained SVG source.
210pub(crate) fn absolute_color_to_svg_css(color: &AbsoluteColor) -> String {
211    let [red, green, blue, alpha] = color.as_color_color().components;
212    let channel = |value: f32| (value.clamp(0.0, 1.0) * 255.0).round() as u8;
213    format!(
214        "rgba({}, {}, {}, {})",
215        channel(red),
216        channel(green),
217        channel(blue),
218        alpha.clamp(0.0, 1.0)
219    )
220}
221
222#[cfg(all(test, feature = "svg"))]
223mod svg_tests {
224    use super::parse_svg_image;
225
226    #[test]
227    fn missing_height_is_computed_from_width_and_viewbox_ratio() {
228        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>"#;
229        let svg = parse_svg_image(src).unwrap();
230        assert_eq!(svg.intrinsic_width(), Some(200.0));
231        assert_eq!(svg.intrinsic_height(), None);
232        assert_eq!(svg.viewbox_aspect_ratio(), Some(1.0));
233        assert_eq!(svg.tree.size().width(), 200.0);
234        assert_eq!(svg.tree.size().height(), 200.0);
235        assert_eq!(svg.intrinsic_size(), (200.0, 200.0));
236    }
237
238    #[test]
239    fn viewbox_only_has_no_intrinsic_dimensions() {
240        let src = br#"<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 485 58"></svg>"#;
241        let svg = parse_svg_image(src).unwrap();
242        assert_eq!(svg.intrinsic_width(), None);
243        assert_eq!(svg.intrinsic_height(), None);
244        // The aspect ratio is still available from the viewBox.
245        assert!((svg.aspect_ratio() - (485.0 / 58.0)).abs() < 1e-3);
246    }
247
248    #[test]
249    fn absolute_dimensions_are_intrinsic() {
250        let src = br#"<svg xmlns="http://www.w3.org/2000/svg" width="24" height="16" viewBox="0 0 48 32"></svg>"#;
251        let svg = parse_svg_image(src).unwrap();
252        assert_eq!(svg.intrinsic_width(), Some(24.0));
253        assert_eq!(svg.intrinsic_height(), Some(16.0));
254    }
255
256    #[test]
257    fn percentage_dimensions_are_not_intrinsic() {
258        let src = br#"<svg xmlns="http://www.w3.org/2000/svg" width="100%" height="50%" viewBox="0 0 200 100"></svg>"#;
259        let svg = parse_svg_image(src).unwrap();
260        assert_eq!(svg.intrinsic_width(), None);
261        assert_eq!(svg.intrinsic_height(), None);
262    }
263
264    #[test]
265    fn unit_lengths_are_intrinsic() {
266        let src = br#"<svg xmlns="http://www.w3.org/2000/svg" width="24px" height="1.5em" viewBox="0 0 48 32"></svg>"#;
267        let svg = parse_svg_image(src).unwrap();
268        assert!(svg.intrinsic_width().is_some());
269        assert!(svg.intrinsic_height().is_some());
270    }
271
272    #[test]
273    fn non_numeric_dimensions_are_not_intrinsic() {
274        let src = br#"<svg xmlns="http://www.w3.org/2000/svg" width="auto" height="foo" viewBox="0 0 200 100"></svg>"#;
275        let svg = parse_svg_image(src).unwrap();
276        assert_eq!(svg.intrinsic_width(), None);
277        assert_eq!(svg.intrinsic_height(), None);
278    }
279}
280
281/// Creates an markup5ever::QualName.
282/// Given a local name and an optional namespace
283#[macro_export]
284macro_rules! qual_name {
285    ($local:tt $(, $ns:ident)?) => {
286        $crate::QualName {
287            prefix: None,
288            ns: $crate::ns!($($ns)?),
289            local: $crate::local_name!($local),
290        }
291    };
292}
293
294/// Restore the camelCase spelling of SVG attributes.
295///
296/// The HTML serialiser lowercases attribute names, which is right for HTML but
297/// wrong for SVG: its attributes are case-sensitive, so `viewBox` written as
298/// `viewbox` is simply ignored. usvg then falls back to the bounding box of the
299/// path geometry, producing an intrinsic size with the wrong aspect ratio, and
300/// `width: auto` resolves against it.
301#[cfg(feature = "svg")]
302pub(crate) fn restore_svg_attribute_case(markup: &str) -> String {
303    // The SVG 1.1/2 attributes whose names carry capitals. Anything not listed
304    // is genuinely lowercase in the spec.
305    const CAMEL_CASED: &[&str] = &[
306        "viewBox",
307        "preserveAspectRatio",
308        "baseProfile",
309        "clipPath",
310        "clipPathUnits",
311        "diffuseConstant",
312        "edgeMode",
313        "filterUnits",
314        "glyphRef",
315        "gradientTransform",
316        "gradientUnits",
317        "kernelMatrix",
318        "kernelUnitLength",
319        "keyPoints",
320        "keySplines",
321        "keyTimes",
322        "lengthAdjust",
323        "limitingConeAngle",
324        "markerHeight",
325        "markerUnits",
326        "markerWidth",
327        "maskContentUnits",
328        "maskUnits",
329        "numOctaves",
330        "pathLength",
331        "patternContentUnits",
332        "patternTransform",
333        "patternUnits",
334        "pointsAtX",
335        "pointsAtY",
336        "pointsAtZ",
337        "primitiveUnits",
338        "refX",
339        "refY",
340        "repeatCount",
341        "repeatDur",
342        "requiredExtensions",
343        "requiredFeatures",
344        "specularConstant",
345        "specularExponent",
346        "spreadMethod",
347        "startOffset",
348        "stdDeviation",
349        "stitchTiles",
350        "surfaceScale",
351        "systemLanguage",
352        "tableValues",
353        "targetX",
354        "targetY",
355        "textLength",
356        "xChannelSelector",
357        "yChannelSelector",
358        "zoomAndPan",
359    ];
360
361    let mut output = markup.to_owned();
362    for name in CAMEL_CASED {
363        let lowered = name.to_ascii_lowercase();
364        if lowered == *name {
365            continue;
366        }
367        // Only rewrite what is unambiguously an attribute position: a space
368        // before the name and an `=` after it, so element text is untouched.
369        let needle = format!(" {lowered}=");
370        if output.contains(&needle) {
371            output = output.replace(&needle, &format!(" {name}="));
372        }
373    }
374    output
375}
376
377#[cfg(all(test, feature = "svg"))]
378mod svg_attribute_case_tests {
379    use super::{parse_svg_image, restore_svg_attribute_case};
380
381    /// The HTML serialiser lowercases attribute names. For SVG that silently
382    /// changes the geometry, because the attributes are case sensitive.
383    #[test]
384    fn a_lowercased_view_box_loses_the_declared_aspect_ratio() {
385        let lowered = r#"<svg viewbox="0 0 744 221" xmlns="http://www.w3.org/2000/svg"><path d="M0 0h10v10H0z"/></svg>"#;
386        let parsed = parse_svg_image(lowered.as_bytes()).unwrap();
387        let ratio = parsed.tree.size().width() / parsed.tree.size().height();
388        assert!(
389            (ratio - 3.367).abs() > 0.1,
390            "lowercased viewbox unexpectedly honoured: {ratio}"
391        );
392    }
393
394    #[test]
395    fn restoring_the_case_recovers_the_view_box() {
396        let lowered = r#"<svg viewbox="0 0 744 221" preserveaspectratio="xMidYMid meet" xmlns="http://www.w3.org/2000/svg"><path d="M0 0h10v10H0z"/></svg>"#;
397        let restored = restore_svg_attribute_case(lowered);
398        assert!(restored.contains("viewBox="));
399        assert!(restored.contains("preserveAspectRatio="));
400
401        let parsed = parse_svg_image(restored.as_bytes()).unwrap();
402        let size = parsed.tree.size();
403        assert_eq!(size.width().round(), 744.0);
404        assert_eq!(size.height().round(), 221.0);
405    }
406
407    #[test]
408    fn attribute_names_without_capitals_are_left_alone() {
409        let markup = r#"<svg width="10" height="10" xmlns="http://www.w3.org/2000/svg"/>"#;
410        assert_eq!(restore_svg_attribute_case(markup), markup);
411    }
412}