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