Skip to main content

bern_target_api/
svg.rs

1//! Shared animated SVG assets and runtime templates for all presenters.
2use crate::{BuildContext, Diagnostic};
3use bern_ir::{Application, Node};
4
5/// Whether the application shows an `Svg` or a `WebPage`: both live in the
6/// web engine this module brings in.
7pub fn used(app: &Application) -> bool {
8    let mut used = false;
9    for node in app
10        .components
11        .iter()
12        .map(|c| &c.body)
13        .chain(app.windows.windows.iter().map(|w| &w.content))
14    {
15        node.walk(&mut |node| {
16            if matches!(node, Node::Element(e) if e.kind == "Svg" || e.kind == "WebPage") {
17                used = true;
18            }
19        });
20    }
21    used
22}
23
24pub fn validate(app: &Application, context: &BuildContext) -> Result<(), Vec<Diagnostic>> {
25    let mut errors = Vec::new();
26    let mut seen = std::collections::BTreeSet::new();
27    for node in app
28        .components
29        .iter()
30        .map(|c| &c.body)
31        .chain(app.windows.windows.iter().map(|w| &w.content))
32    {
33        node.walk(&mut |node| {
34            if let Node::Element(e) = node
35                && e.kind == "Svg"
36                && let Some(name) = e.resource("source")
37                && seen.insert(name.to_owned())
38            {
39                let result = std::fs::read_to_string(context.project_dir.join(name))
40                    .map_err(|e| e.to_string())
41                    .and_then(|source| bern_svg::root(&source).map(|_| ()));
42                if let Err(error) = result {
43                    errors.push(
44                        Diagnostic::error(format!("SVG `{name}`: {error}")).at(e.source.clone()),
45                    );
46                }
47            }
48        });
49    }
50    if errors.is_empty() {
51        Ok(())
52    } else {
53        Err(errors)
54    }
55}
56
57/// Templates use JSON-compatible quoted strings; Kotlin additionally
58/// needs dollar signs escaped to preserve JavaScript template literals.
59pub fn template(source: &str, kotlin: bool) -> String {
60    let quote = |s: &str| {
61        let quoted = serde_json::to_string(s).unwrap();
62        if kotlin {
63            quoted.replace('$', "\\$")
64        } else {
65            quoted
66        }
67    };
68    source
69        .replace("/*bern:svg-prefix*/", &quote(bern_svg::PREFIX))
70        .replace("/*bern:svg-suffix*/", &quote(&bern_svg::suffix()))
71}
72
73pub fn cpp_template(source: &str) -> String {
74    source
75        .replace(
76            "/*bern:svg-prefix*/",
77            &format!("R\"BERNSVG({})BERNSVG\"", bern_svg::PREFIX),
78        )
79        .replace(
80            "/*bern:svg-suffix*/",
81            &format!("R\"BERNSVG({})BERNSVG\"", bern_svg::suffix()),
82        )
83}
84
85const STATIC_PREFIX: &str = "__bern_static_svg/";
86/// The picture of a document in the dark appearance ends so, beside the
87/// one in the light appearance that ends in `.png`.
88const DARK_SUFFIX: &str = ".dark.png";
89
90/// Target lowering is shared by checking, generation, and packaging. A
91/// document engine is heavy: every `Svg` whose document neither moves nor
92/// runs becomes a picture of it, drawn when the app is built (one in the
93/// color of the text around it as a template; one with colors per
94/// appearance as two, the dark one in the `Image`'s own `dark_source`,
95/// which each frontend shows where the image is dark), and the others do
96/// too where the target has no WebView.
97pub fn prepare(app: &Application, config: &crate::TargetConfig) -> Application {
98    let mut app = app.with_webview(config.has_webview());
99    if config.platform == "tvos" {
100        app = app.on_television();
101    }
102    let pictures: std::collections::BTreeMap<String, bern_ir::SvgPicture> = app
103        .resources
104        .iter()
105        .filter_map(|resource| Some((resource.name.clone(), resource.picture?)))
106        .collect();
107    let mut resources = std::collections::BTreeMap::new();
108    for node in app
109        .components
110        .iter_mut()
111        .map(|c| &mut c.body)
112        .chain(app.windows.windows.iter_mut().map(|w| &mut w.content))
113    {
114        node.elements_mut(&mut |e| {
115            if e.kind != "Svg" {
116                return;
117            }
118            let picture = e
119                .resource("source")
120                .and_then(|source| pictures.get(source))
121                .copied();
122            if picture.is_none() && config.has_webview() {
123                return;
124            }
125            let Some(source) = (match picture {
126                Some(_) => e.resource("source"),
127                None => e.resource("static_source").or_else(|| e.resource("source")),
128            }) else {
129                return;
130            };
131            let name = format!("{STATIC_PREFIX}{source}.png");
132            resources.insert(name.clone(), e.source.clone());
133            let dark = (picture == Some(bern_ir::SvgPicture::Themed))
134                .then(|| format!("{STATIC_PREFIX}{source}{DARK_SUFFIX}"));
135            if let Some(dark) = &dark {
136                resources.insert(dark.clone(), e.source.clone());
137            }
138            e.kind = "Image".into();
139            for prop in [
140                "static_source",
141                "playing",
142                "speed",
143                "restart",
144                "interactive",
145                "data",
146                "external_links",
147            ] {
148                e.props.remove(prop);
149            }
150            e.props
151                .insert("source".into(), bern_ir::Prop::Resource { name });
152            if picture == Some(bern_ir::SvgPicture::Tinted) {
153                e.props.insert(
154                    "rendering".into(),
155                    bern_ir::Prop::Keyword {
156                        value: "template".into(),
157                    },
158                );
159            }
160            if let Some(name) = dark {
161                e.props
162                    .insert("dark_source".into(), bern_ir::Prop::Resource { name });
163            }
164        });
165    }
166    for (name, declared) in resources {
167        if !app.resources.iter().any(|r| r.name == name) {
168            app.resources.push(bern_ir::ResourceDef {
169                name,
170                declared,
171                picture: None,
172            });
173        }
174    }
175    app
176}
177
178/// Whether an `Image` shows a declared picture at one size: every side it
179/// gives is a number, and it does not fly to another (`hero`). Such a
180/// picture is double-sampled where it shows: decoded at twice the physical
181/// pixels it covers, which the compositor halves. Squeezed down from a much
182/// larger picture by the compositor alone, its edges would break up.
183pub fn one_size(element: &bern_ir::Element) -> bool {
184    element.resource("source").is_some()
185        && element.keyword("hero").is_none()
186        && (element.prop("width").is_some() || element.prop("height").is_some())
187        && ["width", "height"]
188            .iter()
189            .all(|side| element.prop(side).is_none() || element.number(side).is_some())
190}
191
192pub fn resource_path(context: &BuildContext, name: &str) -> std::path::PathBuf {
193    if name.starts_with(STATIC_PREFIX) {
194        context.output_dir.join(name)
195    } else {
196        context.project_dir.join(name)
197    }
198}
199
200/// Rasterization runs in the build tool, never in the shipped app.
201/// `static_source` supplies a deliberate final pose for animated artwork.
202pub fn static_files(
203    app: &Application,
204    context: &BuildContext,
205) -> Result<Vec<crate::GeneratedFile>, Vec<Diagnostic>> {
206    // The largest side each picture is declared at, in points.
207    let mut declared = std::collections::BTreeMap::<String, f64>::new();
208    for node in app
209        .components
210        .iter()
211        .map(|c| &c.body)
212        .chain(app.windows.windows.iter().map(|w| &w.content))
213    {
214        node.walk(&mut |node| {
215            let Node::Element(e) = node else { return };
216            if e.kind != "Image" {
217                return;
218            }
219            for prop in ["source", "dark_source"] {
220                if let Some(name) = e.resource(prop)
221                    && name.starts_with(STATIC_PREFIX)
222                {
223                    let side = e
224                        .number("width")
225                        .unwrap_or(0.0)
226                        .max(e.number("height").unwrap_or(0.0));
227                    let largest = declared.entry(name.to_owned()).or_insert(0.0);
228                    *largest = largest.max(side);
229                }
230            }
231        });
232    }
233    let mut files = Vec::new();
234    for resource in &app.resources {
235        let Some(picture) = resource.name.strip_prefix(STATIC_PREFIX) else {
236            continue;
237        };
238        let (source, dark) = match picture.strip_suffix(DARK_SUFFIX) {
239            Some(source) => (source, true),
240            None => match picture.strip_suffix(".png") {
241                Some(source) => (source, false),
242                None => continue,
243            },
244        };
245        let render = || -> Result<Vec<u8>, String> {
246            let path = context.project_dir.join(source);
247            let data = std::fs::read(&path).map_err(|e| e.to_string())?;
248            // resvg follows neither media queries nor `light-dark()`: the
249            // document as it shows in this appearance.
250            let data = match std::str::from_utf8(&data) {
251                Ok(text) => bern_svg::in_appearance(text, dark).into_bytes(),
252                Err(_) => data,
253            };
254            let mut options = resvg::usvg::Options {
255                resources_dir: path.parent().map(|p| p.to_owned()),
256                ..Default::default()
257            };
258            options.fontdb_mut().load_system_fonts();
259            let tree = resvg::usvg::Tree::from_data(&data, &options).map_err(|e| e.to_string())?;
260            // As large as it shows, double-sampled on a display of three
261            // pixels per point: six pixels per point of its largest
262            // declared side. Without a size of its own, two pixels per
263            // intrinsic unit. Each edge at most 4096.
264            let size = tree.size();
265            let longer = size.width().max(size.height());
266            let shown = declared.get(&resource.name).copied().unwrap_or(0.0) as f32;
267            let pixels = if shown > 0.0 {
268                shown * 6.0
269            } else {
270                longer * 2.0
271            };
272            let scale = (pixels / longer).min(4096.0 / longer);
273            let mut pixmap = resvg::tiny_skia::Pixmap::new(
274                (size.width() * scale).ceil().max(1.0) as u32,
275                (size.height() * scale).ceil().max(1.0) as u32,
276            )
277            .ok_or("cannot allocate SVG raster")?;
278            resvg::render(
279                &tree,
280                resvg::tiny_skia::Transform::from_scale(scale, scale),
281                &mut pixmap.as_mut(),
282            );
283            pixmap.encode_png().map_err(|e| e.to_string())
284        };
285        files.push(crate::GeneratedFile {
286            path: resource.name.clone().into(),
287            contents: render().map_err(|e| {
288                vec![
289                    Diagnostic::error(format!("static SVG `{source}`: {e}"))
290                        .at(resource.declared.clone()),
291                ]
292            })?,
293        });
294    }
295    Ok(files)
296}