Skip to main content

devpage_design/
wire.rs

1//! Scenes on the wire: a scene to its `proto::Scene` and back, and the patch
2//! between two scenes. Each image file and font file travels once: a scene
3//! carries every one its nodes use, a patch only those the scene before it
4//! didn't have.
5
6use std::collections::BTreeSet;
7use std::sync::Arc;
8
9use crate::fonts::{FontFile, FontFiles, FontId};
10use crate::proto;
11use crate::resolve::{
12    Align, Color, Fill, Image, ImageFile, ImageFiles, ImageId, Kind, Layout, Node, Origin, Rgba,
13    Scene, Shadow, Size, TextAlign, TextStyle, Vector,
14};
15
16fn word(c: Rgba) -> u32 {
17    u32::from_be_bytes([c.r, c.g, c.b, c.a])
18}
19
20fn unword(v: u32) -> Rgba {
21    let [r, g, b, a] = v.to_be_bytes();
22    Rgba { r, g, b, a }
23}
24
25fn rgba(c: Color) -> proto::Color {
26    proto::Color {
27        light: word(c.light),
28        dark: word(c.dark),
29    }
30}
31
32/// A color from the wire; one the stream left out is transparent.
33fn color(c: Option<proto::Color>) -> Color {
34    let c = c.unwrap_or_default();
35    Color {
36        light: unword(c.light),
37        dark: unword(c.dark),
38    }
39}
40
41fn size(s: Size) -> f32 {
42    match s {
43        Size::Px(v) => v,
44        Size::Hug => -1.0,
45        Size::Fill => -2.0,
46    }
47}
48
49fn unsize(v: f32) -> Size {
50    if v == -1.0 {
51        Size::Hug
52    } else if v == -2.0 {
53        Size::Fill
54    } else {
55        Size::Px(v)
56    }
57}
58
59fn align(a: Align) -> proto::Align {
60    match a {
61        Align::Start => proto::Align::Start,
62        Align::Center => proto::Align::Center,
63        Align::End => proto::Align::End,
64        Align::SpaceBetween => proto::Align::SpaceBetween,
65    }
66}
67
68fn unalign(a: i32) -> Align {
69    match proto::Align::try_from(a).unwrap_or(proto::Align::Start) {
70        proto::Align::Start => Align::Start,
71        proto::Align::Center => Align::Center,
72        proto::Align::End => Align::End,
73        proto::Align::SpaceBetween => Align::SpaceBetween,
74    }
75}
76
77pub fn encode(n: &Node) -> proto::Node {
78    proto::Node {
79        kind: match n.kind {
80            Kind::Frame => proto::Kind::Frame,
81            Kind::Text => proto::Kind::Text,
82            Kind::Rect => proto::Kind::Rect,
83            Kind::Ellipse => proto::Kind::Ellipse,
84            Kind::Vector => proto::Kind::Vector,
85            Kind::Image => proto::Kind::Image,
86        } as i32,
87        name: n.name.clone(),
88        x: n.x,
89        y: n.y,
90        width: size(n.width),
91        height: size(n.height),
92        opacity: n.opacity,
93        on_click: n.on_click.clone().unwrap_or_default(),
94        fill: match &n.fill {
95            Some(Fill::Solid(c)) => Some(rgba(*c)),
96            _ => None,
97        },
98        gradient: match &n.fill {
99            Some(Fill::Linear { angle, stops }) => Some(proto::Gradient {
100                angle: *angle,
101                stops: stops
102                    .iter()
103                    .map(|(c, at)| proto::Stop {
104                        color: Some(rgba(*c)),
105                        at: *at,
106                    })
107                    .collect(),
108            }),
109            _ => None,
110        },
111        vector: n.vector.as_ref().map(|v| proto::Vector {
112            d: v.d.clone(),
113            view_box: v.view_box.to_vec(),
114        }),
115        image: n.image.as_ref().map(|i| proto::Image {
116            src: i.src.clone(),
117            id: i.id.0.to_vec(),
118        }),
119        stroke: n.stroke.map(rgba),
120        stroke_width: n.stroke_width,
121        radius: n.radius.to_vec(),
122        shadow: n.shadow.as_ref().map(|s| proto::Shadow {
123            x: s.x,
124            y: s.y,
125            blur: s.blur,
126            spread: s.spread,
127            color: Some(rgba(s.color)),
128        }),
129        clip: n.clip,
130        layout: match n.layout {
131            Layout::None => proto::Layout::None,
132            Layout::Row => proto::Layout::Row,
133            Layout::Column => proto::Layout::Column,
134        } as i32,
135        gap: n.gap,
136        padding: n.padding.to_vec(),
137        align: align(n.align) as i32,
138        justify: align(n.justify) as i32,
139        text: n.text.as_ref().map(|t| proto::Text {
140            font: t.font.map(|id| id.0.to_vec()).unwrap_or_default(),
141            content: t.content.clone(),
142            size: t.size,
143            weight: t.weight as u32,
144            color: Some(rgba(t.color)),
145            line_height: t.line_height,
146            align: match t.align {
147                TextAlign::Left => proto::TextAlign::Left,
148                TextAlign::Center => proto::TextAlign::Center,
149                TextAlign::Right => proto::TextAlign::Right,
150            } as i32,
151        }),
152        children: n.children.iter().map(encode).collect(),
153    }
154}
155
156fn image_id(bytes: &[u8]) -> Result<ImageId, String> {
157    bytes
158        .try_into()
159        .map(ImageId)
160        .map_err(|_| format!("an image file id of {} bytes, not 32", bytes.len()))
161}
162
163fn font_id(bytes: &[u8]) -> Result<FontId, String> {
164    bytes
165        .try_into()
166        .map(FontId)
167        .map_err(|_| format!("a font file id of {} bytes, not 32", bytes.len()))
168}
169
170/// A node from the wire, or why it isn't one.
171pub fn decode(n: &proto::Node) -> Result<Node, String> {
172    let four = |v: &[f32]| [0, 1, 2, 3].map(|i| v.get(i).copied().unwrap_or(0.0));
173    Ok(Node {
174        kind: match proto::Kind::try_from(n.kind).unwrap_or(proto::Kind::Frame) {
175            proto::Kind::Frame => Kind::Frame,
176            proto::Kind::Text => Kind::Text,
177            proto::Kind::Rect => Kind::Rect,
178            proto::Kind::Ellipse => Kind::Ellipse,
179            proto::Kind::Vector => Kind::Vector,
180            proto::Kind::Image => Kind::Image,
181        },
182        name: n.name.clone(),
183        x: n.x,
184        y: n.y,
185        width: unsize(n.width),
186        height: unsize(n.height),
187        opacity: n.opacity,
188        on_click: Some(n.on_click.clone()).filter(|s| !s.is_empty()),
189        fill: match (&n.fill, &n.gradient) {
190            (Some(c), _) => Some(Fill::Solid(color(Some(*c)))),
191            (None, Some(g)) => Some(Fill::Linear {
192                angle: g.angle,
193                stops: g.stops.iter().map(|s| (color(s.color), s.at)).collect(),
194            }),
195            (None, None) => None,
196        },
197        stroke: n.stroke.map(|c| color(Some(c))),
198        stroke_width: n.stroke_width,
199        radius: four(&n.radius),
200        shadow: n.shadow.as_ref().map(|s| Shadow {
201            x: s.x,
202            y: s.y,
203            blur: s.blur,
204            spread: s.spread,
205            color: color(s.color),
206            at: Origin::default(),
207        }),
208        clip: n.clip,
209        layout: match proto::Layout::try_from(n.layout).unwrap_or(proto::Layout::None) {
210            proto::Layout::None => Layout::None,
211            proto::Layout::Row => Layout::Row,
212            proto::Layout::Column => Layout::Column,
213        },
214        gap: n.gap,
215        padding: four(&n.padding),
216        align: unalign(n.align),
217        justify: unalign(n.justify),
218        text: match &n.text {
219            Some(t) => Some(TextStyle {
220                font: if t.font.is_empty() {
221                    None
222                } else {
223                    Some(font_id(&t.font)?)
224                },
225                content: t.content.clone(),
226                size: t.size,
227                weight: t.weight as u16,
228                color: color(t.color),
229                line_height: t.line_height,
230                align: match proto::TextAlign::try_from(t.align).unwrap_or(proto::TextAlign::Left) {
231                    proto::TextAlign::Left => TextAlign::Left,
232                    proto::TextAlign::Center => TextAlign::Center,
233                    proto::TextAlign::Right => TextAlign::Right,
234                },
235            }),
236            None => None,
237        },
238        vector: n.vector.as_ref().map(|v| Vector {
239            d: v.d.clone(),
240            view_box: four(&v.view_box),
241        }),
242        image: match &n.image {
243            Some(i) => Some(Image {
244                src: i.src.clone(),
245                id: image_id(&i.id)?,
246                at: Origin::default(),
247            }),
248            None => None,
249        },
250        children: n.children.iter().map(decode).collect::<Result<_, _>>()?,
251    })
252}
253
254/// A whole scene for the wire: its tree and every image file it shows.
255pub fn scene(scene: &Scene) -> proto::Scene {
256    proto::Scene {
257        root: Some(encode(&scene.root)),
258        images: scene.images.values().map(|f| file(f)).collect(),
259        fonts: scene.fonts.values().map(|f| font_file(f)).collect(),
260        themed: scene.themed,
261    }
262}
263
264fn font_file(f: &FontFile) -> proto::FontFile {
265    proto::FontFile {
266        id: f.id.0.to_vec(),
267        data: f.bytes.to_vec(),
268    }
269}
270
271fn file(f: &ImageFile) -> proto::ImageFile {
272    proto::ImageFile {
273        id: f.id.0.to_vec(),
274        data: f.bytes.clone(),
275    }
276}
277
278/// The patch that turns `old`, as last sent, into `new`: the nodes that
279/// changed and the image files `old` didn't have.
280pub fn patch(old: &Scene, new: &Scene) -> proto::Patch {
281    proto::Patch {
282        replace: diff(&old.root, &new.root),
283        images: new
284            .images
285            .values()
286            .filter(|f| !old.images.contains_key(&f.id))
287            .map(|f| file(f))
288            .collect(),
289        fonts: new
290            .fonts
291            .values()
292            .filter(|f| !old.fonts.contains_key(&f.id))
293            .map(|f| font_file(f))
294            .collect(),
295        themed: new.themed,
296    }
297}
298
299/// The image files `files` carries, decoded — each taken from `held` instead
300/// when it is already there, so a file is decoded once however often it is
301/// sent.
302fn files(files: &[proto::ImageFile], held: &ImageFiles) -> Result<ImageFiles, String> {
303    let mut out = ImageFiles::new();
304    for f in files {
305        let id = image_id(&f.id)?;
306        let decoded = match held.get(&id) {
307            Some(known) => known.clone(),
308            // The server decoded the same bytes, so they decode here too.
309            None => Arc::new(ImageFile::with_id(id, f.data.clone())?),
310        };
311        out.insert(id, decoded);
312    }
313    Ok(out)
314}
315
316/// The font files `files` carries, checked — each taken from `held` instead
317/// when it is already there, so a file is checked once however often it is
318/// sent.
319fn font_files(files: &[proto::FontFile], held: &FontFiles) -> Result<FontFiles, String> {
320    let mut out = FontFiles::new();
321    for f in files {
322        let id = font_id(&f.id)?;
323        let checked = match held.get(&id) {
324            Some(known) => known.clone(),
325            // The server checked the same bytes, so they pass here too.
326            None => Arc::new(
327                FontFile::with_id(id, f.data.clone()).map_err(|e| format!("a font file {e}"))?,
328            ),
329        };
330        out.insert(id, checked);
331    }
332    Ok(out)
333}
334
335/// Keeps only the image and font files `root` uses, and says if it uses one
336/// that isn't there.
337fn settle(root: &Node, images: &mut ImageFiles, fonts: &mut FontFiles) -> Result<(), String> {
338    fn used(n: &Node, images: &mut BTreeSet<ImageId>, fonts: &mut BTreeSet<FontId>) {
339        if let Some(i) = &n.image {
340            images.insert(i.id);
341        }
342        if let Some(font) = n.text.as_ref().and_then(|t| t.font) {
343            fonts.insert(font);
344        }
345        for c in &n.children {
346            used(c, images, fonts);
347        }
348    }
349    let (mut image_ids, mut font_ids) = (BTreeSet::new(), BTreeSet::new());
350    used(root, &mut image_ids, &mut font_ids);
351    if let Some(missing) = image_ids.iter().find(|id| !images.contains_key(id)) {
352        return Err(format!("the stream never sent the image file {missing:?}"));
353    }
354    if let Some(missing) = font_ids.iter().find(|id| !fonts.contains_key(id)) {
355        return Err(format!("the stream never sent the font file {missing:?}"));
356    }
357    images.retain(|id, _| image_ids.contains(id));
358    fonts.retain(|id, _| font_ids.contains(id));
359    Ok(())
360}
361
362/// The scene a stream message carries; image and font files `held` already
363/// has aren't decoded or checked again.
364pub fn decode_scene(
365    message: &proto::Scene,
366    held: (&ImageFiles, &FontFiles),
367) -> Result<Scene, String> {
368    let root = decode(message.root.as_ref().ok_or("a scene with no root")?)?;
369    let mut images = files(&message.images, held.0)?;
370    let mut fonts = font_files(&message.fonts, held.1)?;
371    settle(&root, &mut images, &mut fonts)?;
372    Ok(Scene {
373        root,
374        images,
375        fonts,
376        files: Vec::new(),
377        themed: message.themed,
378    })
379}
380
381/// The replacements that turn `old` into `new`: the highest nodes whose own
382/// attributes or number of children changed, by id.
383pub fn diff(old: &Node, new: &Node) -> Vec<proto::Replace> {
384    let mut out = Vec::new();
385    walk(old, new, "0", &mut out);
386    out
387}
388
389fn walk(old: &Node, new: &Node, id: &str, out: &mut Vec<proto::Replace>) {
390    if old == new {
391        return;
392    }
393    let same_shell = old.children.len() == new.children.len() && {
394        let (mut a, mut b) = (old.clone(), new.clone());
395        a.children.clear();
396        b.children.clear();
397        a == b
398    };
399    if !same_shell {
400        out.push(proto::Replace {
401            id: id.to_string(),
402            node: Some(encode(new)),
403        });
404        return;
405    }
406    for (i, (o, n)) in old.children.iter().zip(&new.children).enumerate() {
407        walk(o, n, &format!("{id}.{i}"), out);
408    }
409}
410
411/// Applies a patch to `scene`, or says why it can't — leaving the scene as it
412/// was.
413pub fn apply(scene: &mut Scene, patch: &proto::Patch) -> Result<(), String> {
414    let mut next = scene.clone();
415    let added = files(&patch.images, &scene.images)?;
416    next.images.extend(added);
417    let added = font_files(&patch.fonts, &scene.fonts)?;
418    next.fonts.extend(added);
419    for r in &patch.replace {
420        let node = r.node.as_ref().ok_or("a replacement with no node")?;
421        let at = find(&mut next.root, &r.id).ok_or_else(|| format!("no node {}", r.id))?;
422        *at = decode(node)?;
423    }
424    settle(&next.root, &mut next.images, &mut next.fonts)?;
425    next.themed = patch.themed;
426    *scene = next;
427    Ok(())
428}
429
430fn find<'a>(root: &'a mut Node, id: &str) -> Option<&'a mut Node> {
431    let mut at = root;
432    for step in id.split('.').skip(1) {
433        at = at.children.get_mut(step.parse::<usize>().ok()?)?;
434    }
435    Some(at)
436}
437
438#[cfg(test)]
439mod tests {
440    use std::collections::HashMap;
441
442    use prost::Message;
443
444    use super::*;
445    use crate::Files;
446
447    fn scene(src: &str) -> Scene {
448        let files: HashMap<String, String> = [("a.design".to_string(), src.to_string())].into();
449        crate::resolve(&files, "a.design").unwrap()
450    }
451
452    #[test]
453    fn a_patch_carries_only_what_changed() {
454        let old = scene(
455            r##"<Frame layout="column"><Text>One</Text><Frame><Text>Two</Text></Frame></Frame>"##,
456        );
457        let new = scene(
458            r##"<Frame layout="column"><Text>One</Text><Frame><Text>Three</Text></Frame></Frame>"##,
459        );
460        assert_eq!(decode(&encode(&old.root)).unwrap(), old.root);
461        let patch = patch(&old, &new);
462        assert_eq!(
463            patch
464                .replace
465                .iter()
466                .map(|r| r.id.as_str())
467                .collect::<Vec<_>>(),
468            vec!["0.1.0"]
469        );
470        let mut applied =
471            decode_scene(&super::scene(&old), (&ImageFiles::new(), &FontFiles::new())).unwrap();
472        apply(&mut applied, &patch).unwrap();
473        assert_eq!(applied.root, new.root);
474    }
475
476    /// Every color travels with both its values, and a scene and a patch
477    /// say whether it is themed.
478    #[test]
479    fn a_scene_carries_both_themes() {
480        let themed = |fill: &str| {
481            let files: HashMap<String, String> = [
482                (
483                    "themes.yml".to_string(),
484                    "kind: themes\nlight:\n  a: \"#ffffff\"\ndark:\n  a: \"#000000\"\n".to_string(),
485                ),
486                (
487                    "a.design".to_string(),
488                    format!(
489                        "<Frame fill={{{fill}}} stroke={{{fill}}} shadow={{{{ color: {fill} }}}}><Text color={{{fill}}}>Hi</Text><Rect fill=\"linear-gradient(0deg, theme.a 0%, #f00 100%)\" /></Frame>"
490                    ),
491                ),
492            ]
493            .into();
494            crate::resolve(&files, "a.design").unwrap()
495        };
496        let light_only = themed("\"#ffffff\"");
497        let both = themed("theme.a");
498        assert!(both.themed);
499        assert_eq!(decode(&encode(&both.root)).unwrap(), both.root);
500        let whole = decode_scene(
501            &super::scene(&light_only),
502            (&ImageFiles::new(), &FontFiles::new()),
503        )
504        .unwrap();
505        assert!(whole.themed, "the gradient names theme.a");
506        let mut applied = whole;
507        apply(&mut applied, &patch(&light_only, &both)).unwrap();
508        assert_eq!(applied.root, both.root);
509        let plain = scene("<Frame />");
510        apply(&mut applied, &patch(&both, &plain)).unwrap();
511        assert!(!applied.themed);
512    }
513
514    /// Files as bytes, so a test can hold an image.
515    struct Mem(HashMap<String, Vec<u8>>);
516
517    impl Files for Mem {
518        fn bytes(&self, path: &str) -> Option<Vec<u8>> {
519            self.0.get(path).cloned()
520        }
521        fn list(&self, path: &str) -> Option<Vec<String>> {
522            crate::resolve::listed(self.0.keys(), path)
523        }
524    }
525
526    fn png(w: u32, h: u32) -> Vec<u8> {
527        let mut out = Vec::new();
528        let mut e = ::png::Encoder::new(&mut out, w, h);
529        e.set_color(::png::ColorType::Rgba);
530        let mut writer = e.write_header().unwrap();
531        writer
532            .write_image_data(&vec![200; (4 * w * h) as usize])
533            .unwrap();
534        drop(writer);
535        out
536    }
537
538    fn shirt() -> Vec<u8> {
539        std::fs::read(concat!(
540            env!("CARGO_MANIFEST_DIR"),
541            "/../docs/designs/checkout/shirt.jpg"
542        ))
543        .unwrap()
544    }
545
546    fn cards(n: usize, extra: &str) -> Mem {
547        let mut page = String::from("import Card from \"./card.design\"\n<Frame layout=\"row\">");
548        for _ in 0..n {
549            page.push_str("<Card />");
550        }
551        page.push_str(extra);
552        page.push_str("</Frame>");
553        Mem([
554            (
555                "card.design".to_string(),
556                br#"<Frame><Image src="shirt.jpg" width={100} height={100} /></Frame>"#.to_vec(),
557            ),
558            ("page.design".to_string(), page.into_bytes()),
559            ("shirt.jpg".to_string(), shirt()),
560            ("copy.jpg".to_string(), shirt()),
561        ]
562        .into())
563    }
564
565    /// One photo shown fifty times is one image file: decoded once and sent
566    /// once, and a patch sends an image file only when the browser doesn't
567    /// have it yet.
568    #[test]
569    fn an_image_file_travels_once() {
570        let fifty = crate::resolve(&cards(50, ""), "page.design").unwrap();
571        assert_eq!(fifty.images.len(), 1);
572        let message = super::scene(&fifty).encode_to_vec().len();
573        let jpeg = shirt().len();
574        assert!(
575            message < jpeg + 50 * 200,
576            "a scene of fifty uses of a {jpeg}-byte file is {message} bytes"
577        );
578        // Another use of the same file, under another name too: nothing new
579        // to send.
580        let more = crate::resolve(
581            &cards(51, r#"<Image src="copy.jpg" width={10} height={10} />"#),
582            "page.design",
583        )
584        .unwrap();
585        assert_eq!(more.images.len(), 1, "copy.jpg is shirt.jpg's bytes");
586        let p = patch(&fifty, &more);
587        assert!(p.images.is_empty());
588        let mut browser = decode_scene(
589            &super::scene(&fifty),
590            (&ImageFiles::new(), &FontFiles::new()),
591        )
592        .unwrap();
593        let held = browser.images.values().next().unwrap().clone();
594        apply(&mut browser, &p).unwrap();
595        assert_eq!(browser.root, more.root);
596        // The browser kept the image file it had decoded.
597        assert!(Arc::ptr_eq(browser.images.values().next().unwrap(), &held));
598        // A patch whose nodes show a file the browser lacks carries it.
599        let mut fs = cards(1, r#"<Image src="other.png" width={10} height={10} />"#);
600        fs.0.insert("other.png".into(), png(2, 2));
601        let other = crate::resolve(&fs, "page.design").unwrap();
602        let p = patch(&more, &other);
603        assert_eq!(p.images.len(), 1);
604        apply(&mut browser, &p).unwrap();
605        assert_eq!(browser.images.len(), 2);
606        // A patch whose nodes show a file never sent is refused.
607        let mut lacking = p.clone();
608        lacking.images.clear();
609        let mut kept = decode_scene(
610            &super::scene(&more),
611            (&ImageFiles::new(), &FontFiles::new()),
612        )
613        .unwrap();
614        assert!(
615            apply(&mut kept, &lacking)
616                .unwrap_err()
617                .contains("never sent")
618        );
619        assert_eq!(kept.root, more.root, "a refused patch leaves the scene");
620    }
621
622    fn font(name: &str) -> Vec<u8> {
623        std::fs::read(format!(
624            "{}/fonts/Inter-{name}.ttf",
625            env!("CARGO_MANIFEST_DIR")
626        ))
627        .unwrap()
628    }
629
630    fn texts(body: &str) -> Scene {
631        let fs = Mem([
632            ("f/Inter-Regular.ttf".to_string(), font("Regular")),
633            ("f/Inter-Bold.ttf".to_string(), font("Bold")),
634            (
635                "themes.yml".to_string(),
636                b"kind: themes\nfonts:\n  inter: ./f\nlight: {}\ndark: {}\n".to_vec(),
637            ),
638            (
639                "a.design".to_string(),
640                format!("<Frame layout=\"column\">{body}</Frame>").into_bytes(),
641            ),
642        ]
643        .into());
644        crate::resolve(&fs, "a.design").unwrap()
645    }
646
647    /// A font file used by many texts travels once, with the scene; a patch
648    /// carries one only when the browser doesn't have it yet, which keeps the
649    /// one it checked; a node naming one never sent is refused.
650    #[test]
651    fn a_font_file_travels_once() {
652        let many = texts(&r#"<Text font="inter">a</Text>"#.repeat(20));
653        assert_eq!(many.fonts.len(), 1);
654        let message = super::scene(&many).encode_to_vec().len();
655        let size = font("Regular").len();
656        assert!(
657            message < size + 20 * 200,
658            "{message} bytes for one {size}-byte font file"
659        );
660        assert_eq!(decode(&encode(&many.root)).unwrap(), many.root);
661        let mut browser = decode_scene(
662            &super::scene(&many),
663            (&ImageFiles::new(), &FontFiles::new()),
664        )
665        .unwrap();
666        let held = browser.fonts.values().next().unwrap().clone();
667        let more = texts(&r#"<Text font="inter">a</Text>"#.repeat(21));
668        let p = patch(&many, &more);
669        assert!(p.fonts.is_empty());
670        apply(&mut browser, &p).unwrap();
671        assert!(Arc::ptr_eq(browser.fonts.values().next().unwrap(), &held));
672        let bold = texts(r#"<Text font="inter">a</Text><Text font="inter" weight={700}>b</Text>"#);
673        let p = patch(&more, &bold);
674        assert_eq!(p.fonts.len(), 1);
675        apply(&mut browser, &p).unwrap();
676        assert_eq!(browser.root, bold.root);
677        assert_eq!(browser.fonts.len(), 2);
678        let mut lacking = patch(&more, &bold);
679        lacking.fonts.clear();
680        let mut kept = decode_scene(
681            &super::scene(&more),
682            (&ImageFiles::new(), &FontFiles::new()),
683        )
684        .unwrap();
685        assert!(
686            apply(&mut kept, &lacking)
687                .unwrap_err()
688                .contains("never sent the font file")
689        );
690        // Back to Inter: the scene no longer holds the font files.
691        let plain = texts("<Text>a</Text>");
692        apply(&mut browser, &patch(&bold, &plain)).unwrap();
693        assert!(browser.fonts.is_empty());
694    }
695}