From d4471376caf73b5a35f9afb3ce11fa70fa63dc82 Mon Sep 17 00:00:00 2001 From: Zaxaerith Date: Tue, 27 Oct 2026 21:50:46 +0800 Subject: [PATCH 1/2] fix(engine): honor selected frame for file thumbnails (#1428) --- crates/engine/src/cmd/file/save.rs | 198 +++++++++++++++++++++++++++-- 1 file changed, 187 insertions(+), 11 deletions(-) diff --git a/crates/engine/src/cmd/file/save.rs b/crates/engine/src/cmd/file/save.rs index 593c4c21..e2716bc5 100644 --- a/crates/engine/src/cmd/file/save.rs +++ b/crates/engine/src/cmd/file/save.rs @@ -3,19 +3,38 @@ use super::load::{ext, file_stem, write}; use super::open::remember; use super::*; -/// Thumbnail PNG of the first page's content (for file browsers / the home screen). +/// Thumbnail PNG of the chosen frame, or the first page's content (file browsers / home screen). pub fn thumbnail(doc: &Document) -> Option> { - let page = doc.first_page()?; - let b = doc.page_content_bounds(page)?; - let scale = (400.0 / b.width().max(b.height()).max(1.0)).min(2.0); - let view = figcraft_render::View { - origin: (b.x0, b.y0), - scale, - width: (b.width() * scale).ceil().max(1.0) as u32, - height: (b.height() * scale).ceil().max(1.0) as u32, + let render = |page, b: figcraft_geom::Rect, solo| { + let scale = (400.0 / b.width().max(b.height()).max(1.0)).min(2.0); + let view = figcraft_render::View { + origin: (b.x0, b.y0), + scale, + width: (b.width() * scale).ceil().max(1.0) as u32, + height: (b.height() * scale).ceil().max(1.0) as u32, + }; + let opts = figcraft_render::Options { solo, ..Default::default() }; + let pix = figcraft_render::render_view(doc, page, &view, &opts)?; + figcraft_render::encode_png(&pix).ok() }; - let pix = figcraft_render::render_view(doc, page, &view, &Default::default())?; - figcraft_render::encode_png(&pix).ok() + doc.ids() + .find_map(|id| { + let n = doc.node(id)?; + if !matches!(n.kind, NodeKind::Frame) || n.plugin_data.get("figcraft.thumbnail").map(String::as_str) != Some("true") { + return None; + } + // Hidden library-store pages are not document pages. + let page = doc.page_of(id).filter(|p| doc.pages.contains(p))?; + let b = figcraft_render::export_bounds(doc, id)?; + if ![b.x0, b.y0, b.x1, b.y1, b.width(), b.height()].into_iter().all(f64::is_finite) || b.width() <= 0.0 || b.height() <= 0.0 { + return None; + } + render(page, b, Some(id)) + }) + .or_else(|| { + let page = doc.first_page()?; + render(page, doc.page_content_bounds(page)?, None) + }) } /// The `.figcraft` file for a document: content, thumbnail and version history. @@ -223,3 +242,160 @@ pub(super) fn save_as(s: &mut Session, p: &Value) -> Result { } } } + +#[cfg(test)] +mod thumbnail_tests { + use super::*; + use figcraft_color::{Color, Paint}; + use figcraft_geom::Affine; + + const RED: Color = Color::from_rgb8_const(255, 0, 0); + const GREEN: Color = Color::from_rgb8_const(0, 255, 0); + const BLUE: Color = Color::from_rgb8_const(0, 0, 255); + + fn scene() -> (Session, NodeId, NodeId) { + let mut doc = Document::new("Thumbnail"); + let page = doc.first_page().unwrap(); + let cover = doc.create(page, NodeKind::Frame, "Cover", 0.0, 600.0, 100.0, 100.0).unwrap(); + let wide = doc.create(page, NodeKind::Frame, "Wide", 700.0, 600.0, 600.0, 100.0).unwrap(); + doc.get_mut(cover).unwrap().fills = vec![Paint::solid(RED)]; + doc.get_mut(wide).unwrap().fills = vec![Paint::solid(BLUE)]; + (Session::with_document(doc), cover, wide) + } + + fn choose(s: &mut Session, id: NodeId) { + s.execute("object.setThumbnail", &json!({"id": id})).unwrap(); + } + + fn picture(doc: &Document) -> image::RgbaImage { + image::load_from_memory(&thumbnail(doc).unwrap()).unwrap().to_rgba8() + } + + #[test] + fn chosen_frame_draws_its_children_without_siblings_or_document_changes() { + let (mut s, cover, _) = scene(); + let doc = s.doc_mut().unwrap(); + let page = doc.first_page().unwrap(); + let child = doc.create(cover, NodeKind::Rectangle, "Child", 20.0, 20.0, 20.0, 20.0).unwrap(); + doc.get_mut(child).unwrap().fills = vec![Paint::solid(GREEN)]; + // Cropping the whole page to Cover's bounds would still draw this overlapping sibling. + let sibling = doc.create(page, NodeKind::Rectangle, "Overlap", 0.0, 600.0, 100.0, 100.0).unwrap(); + doc.get_mut(sibling).unwrap().fills = vec![Paint::solid(BLUE)]; + choose(&mut s, cover); + let before = s.doc().unwrap().clone(); + let png = picture(s.doc().unwrap()); + assert_eq!(png.dimensions(), (200, 200)); + assert_eq!(png.get_pixel(10, 10).0, [255, 0, 0, 255]); + assert_eq!(png.get_pixel(60, 60).0, [0, 255, 0, 255]); + assert!(!png.pixels().any(|p| p.0 == [0, 0, 255, 255]), "neither blue sibling is drawn"); + assert_eq!(s.doc().unwrap(), &before); + s.doc_mut().unwrap().set_world_transform(cover, Affine::translate((4200.0, -3600.0))).unwrap(); + assert_eq!(picture(s.doc().unwrap()), png, "moving the frame changes neither size nor contents"); + } + + #[test] + fn no_thumbnail_choice_keeps_the_first_page_content_bounds() { + let (s, _, _) = scene(); + let png = picture(s.doc().unwrap()); + assert_eq!(png.dimensions(), (400, 31)); + assert_eq!(png.get_pixel(10, 15).0, [255, 0, 0, 255]); + assert_eq!(png.get_pixel(310, 15).0, [0, 0, 255, 255]); + } + + #[test] + fn invalid_flags_and_non_frames_keep_the_default_thumbnail() { + let (mut s, cover, wide) = scene(); + let default = thumbnail(s.doc().unwrap()).unwrap(); + for value in ["false", "TRUE", ""] { + s.doc_mut().unwrap().get_mut(cover).unwrap().plugin_data.insert("figcraft.thumbnail".into(), value.into()); + assert_eq!(thumbnail(s.doc().unwrap()).unwrap(), default, "flag {value:?}"); + } + choose(&mut s, wide); + s.doc_mut().unwrap().get_mut(wide).unwrap().kind = NodeKind::Rectangle; + assert_eq!(thumbnail(s.doc().unwrap()).unwrap(), default, "only frames can be thumbnails"); + } + + #[test] + fn deleting_the_chosen_frame_falls_back_to_the_remaining_page_content() { + let (mut s, cover, _) = scene(); + choose(&mut s, cover); + s.doc_mut().unwrap().remove(cover).unwrap(); + let png = picture(s.doc().unwrap()); + assert_eq!(png.dimensions(), (400, 67)); + assert_eq!(png.get_pixel(200, 30).0, [0, 0, 255, 255]); + } + + #[test] + fn invalid_thumbnail_ranges_and_pages_fall_back() { + let (mut s, _, _) = scene(); + let default = thumbnail(s.doc().unwrap()).unwrap(); + let doc = s.doc_mut().unwrap(); + let page = doc.add_page("Invalid cover"); + let cover = doc.create(page, NodeKind::Frame, "Cover", 0.0, 0.0, 100.0, 100.0).unwrap(); + choose(&mut s, cover); + for (width, height) in [(0.0, 100.0), (100.0, 0.0), (f64::INFINITY, 100.0), (f64::NAN, 100.0)] { + let n = s.doc_mut().unwrap().get_mut(cover).unwrap(); + n.width = width; + n.height = height; + assert_eq!(thumbnail(s.doc().unwrap()).unwrap(), default, "invalid range {width} x {height}"); + } + let doc = s.doc_mut().unwrap(); + let n = doc.get_mut(cover).unwrap(); + n.width = 100.0; + n.height = 100.0; + n.parent = Some(NodeId(u64::MAX)); + assert_eq!(thumbnail(doc).unwrap(), default, "missing parent page"); + doc.get_mut(cover).unwrap().parent = Some(page); + doc.pages.retain(|p| *p != page); + assert_eq!(thumbnail(doc).unwrap(), default, "page outside the document's page list"); + } + + #[test] + fn a_nested_frame_on_another_page_uses_its_page_background() { + let (mut s, _, _) = scene(); + let doc = s.doc_mut().unwrap(); + let page = doc.add_page("Cover page"); + if let NodeKind::Page { background, .. } = &mut doc.get_mut(page).unwrap().kind { + *background = GREEN; + } + let section = doc.create(page, NodeKind::Section { contents_hidden: false }, "Section", 700.0, 900.0, 300.0, 200.0).unwrap(); + let cover = doc.create(section, NodeKind::Frame, "Cover", -50.0, 80.0, 80.0, 40.0).unwrap(); + doc.get_mut(cover).unwrap().fills.clear(); + let child = doc.create(cover, NodeKind::Rectangle, "Child", 10.0, 10.0, 20.0, 10.0).unwrap(); + doc.get_mut(child).unwrap().fills = vec![Paint::solid(RED)]; + choose(&mut s, cover); + let png = picture(s.doc().unwrap()); + assert_eq!(png.dimensions(), (160, 80)); + assert_eq!(png.get_pixel(10, 10).0, [0, 255, 0, 255]); + assert_eq!(png.get_pixel(30, 30).0, [255, 0, 0, 255]); + } + + #[test] + fn custom_thumbnails_keep_the_size_limit_and_aspect_ratio() { + let (mut s, cover, _) = scene(); + choose(&mut s, cover); + for (width, height, want) in [(100.0, 100.0, (200, 200)), (800.0, 200.0, (400, 100)), (200.0, 800.0, (100, 400))] { + let n = s.doc_mut().unwrap().get_mut(cover).unwrap(); + n.width = width; + n.height = height; + assert_eq!(picture(s.doc().unwrap()).dimensions(), want); + } + } + + #[test] + fn native_save_and_fig_export_embed_the_same_chosen_thumbnail() { + let (mut s, cover, _) = scene(); + choose(&mut s, cover); + let before = s.doc().unwrap().clone(); + let expected = picture(s.doc().unwrap()); + let native = file_bytes(s.ds().unwrap()).unwrap(); + let (fig, _) = super::super::export::fig_bytes(s.doc().unwrap()).unwrap(); + for bytes in [native, fig] { + let png = figcraft_format::thumbnail::thumbnail_png(std::io::Cursor::new(bytes)).unwrap(); + let actual = image::load_from_memory(&png).unwrap().to_rgba8(); + assert_eq!(actual.dimensions(), (200, 200)); + assert_eq!(actual, expected); + } + assert_eq!(s.doc().unwrap(), &before); + } +} From d3551f3c6c37350ff9509dbc46a893581a049bb4 Mon Sep 17 00:00:00 2001 From: bflatastic Date: Thu, 29 Oct 2026 09:26:09 +0700 Subject: [PATCH 2/2] #1432: accept components and instances as thumbnails; move the tests to thumbnail_tests.rs --- crates/engine/src/cmd/file.rs | 3 + crates/engine/src/cmd/file/save.rs | 159 +----------------- crates/engine/src/cmd/file/thumbnail_tests.rs | 155 +++++++++++++++++ 3 files changed, 159 insertions(+), 158 deletions(-) create mode 100644 crates/engine/src/cmd/file/thumbnail_tests.rs diff --git a/crates/engine/src/cmd/file.rs b/crates/engine/src/cmd/file.rs index 7c901293..576452c5 100644 --- a/crates/engine/src/cmd/file.rs +++ b/crates/engine/src/cmd/file.rs @@ -432,5 +432,8 @@ mod rest_import_tests; #[cfg(test)] mod staging_write_tests; +#[cfg(test)] +mod thumbnail_tests; + #[cfg(test)] mod zip_cap_tests; diff --git a/crates/engine/src/cmd/file/save.rs b/crates/engine/src/cmd/file/save.rs index e2716bc5..4b8939c7 100644 --- a/crates/engine/src/cmd/file/save.rs +++ b/crates/engine/src/cmd/file/save.rs @@ -20,7 +20,7 @@ pub fn thumbnail(doc: &Document) -> Option> { doc.ids() .find_map(|id| { let n = doc.node(id)?; - if !matches!(n.kind, NodeKind::Frame) || n.plugin_data.get("figcraft.thumbnail").map(String::as_str) != Some("true") { + if !n.kind.is_frame_like() || n.plugin_data.get("figcraft.thumbnail").map(String::as_str) != Some("true") { return None; } // Hidden library-store pages are not document pages. @@ -242,160 +242,3 @@ pub(super) fn save_as(s: &mut Session, p: &Value) -> Result { } } } - -#[cfg(test)] -mod thumbnail_tests { - use super::*; - use figcraft_color::{Color, Paint}; - use figcraft_geom::Affine; - - const RED: Color = Color::from_rgb8_const(255, 0, 0); - const GREEN: Color = Color::from_rgb8_const(0, 255, 0); - const BLUE: Color = Color::from_rgb8_const(0, 0, 255); - - fn scene() -> (Session, NodeId, NodeId) { - let mut doc = Document::new("Thumbnail"); - let page = doc.first_page().unwrap(); - let cover = doc.create(page, NodeKind::Frame, "Cover", 0.0, 600.0, 100.0, 100.0).unwrap(); - let wide = doc.create(page, NodeKind::Frame, "Wide", 700.0, 600.0, 600.0, 100.0).unwrap(); - doc.get_mut(cover).unwrap().fills = vec![Paint::solid(RED)]; - doc.get_mut(wide).unwrap().fills = vec![Paint::solid(BLUE)]; - (Session::with_document(doc), cover, wide) - } - - fn choose(s: &mut Session, id: NodeId) { - s.execute("object.setThumbnail", &json!({"id": id})).unwrap(); - } - - fn picture(doc: &Document) -> image::RgbaImage { - image::load_from_memory(&thumbnail(doc).unwrap()).unwrap().to_rgba8() - } - - #[test] - fn chosen_frame_draws_its_children_without_siblings_or_document_changes() { - let (mut s, cover, _) = scene(); - let doc = s.doc_mut().unwrap(); - let page = doc.first_page().unwrap(); - let child = doc.create(cover, NodeKind::Rectangle, "Child", 20.0, 20.0, 20.0, 20.0).unwrap(); - doc.get_mut(child).unwrap().fills = vec![Paint::solid(GREEN)]; - // Cropping the whole page to Cover's bounds would still draw this overlapping sibling. - let sibling = doc.create(page, NodeKind::Rectangle, "Overlap", 0.0, 600.0, 100.0, 100.0).unwrap(); - doc.get_mut(sibling).unwrap().fills = vec![Paint::solid(BLUE)]; - choose(&mut s, cover); - let before = s.doc().unwrap().clone(); - let png = picture(s.doc().unwrap()); - assert_eq!(png.dimensions(), (200, 200)); - assert_eq!(png.get_pixel(10, 10).0, [255, 0, 0, 255]); - assert_eq!(png.get_pixel(60, 60).0, [0, 255, 0, 255]); - assert!(!png.pixels().any(|p| p.0 == [0, 0, 255, 255]), "neither blue sibling is drawn"); - assert_eq!(s.doc().unwrap(), &before); - s.doc_mut().unwrap().set_world_transform(cover, Affine::translate((4200.0, -3600.0))).unwrap(); - assert_eq!(picture(s.doc().unwrap()), png, "moving the frame changes neither size nor contents"); - } - - #[test] - fn no_thumbnail_choice_keeps_the_first_page_content_bounds() { - let (s, _, _) = scene(); - let png = picture(s.doc().unwrap()); - assert_eq!(png.dimensions(), (400, 31)); - assert_eq!(png.get_pixel(10, 15).0, [255, 0, 0, 255]); - assert_eq!(png.get_pixel(310, 15).0, [0, 0, 255, 255]); - } - - #[test] - fn invalid_flags_and_non_frames_keep_the_default_thumbnail() { - let (mut s, cover, wide) = scene(); - let default = thumbnail(s.doc().unwrap()).unwrap(); - for value in ["false", "TRUE", ""] { - s.doc_mut().unwrap().get_mut(cover).unwrap().plugin_data.insert("figcraft.thumbnail".into(), value.into()); - assert_eq!(thumbnail(s.doc().unwrap()).unwrap(), default, "flag {value:?}"); - } - choose(&mut s, wide); - s.doc_mut().unwrap().get_mut(wide).unwrap().kind = NodeKind::Rectangle; - assert_eq!(thumbnail(s.doc().unwrap()).unwrap(), default, "only frames can be thumbnails"); - } - - #[test] - fn deleting_the_chosen_frame_falls_back_to_the_remaining_page_content() { - let (mut s, cover, _) = scene(); - choose(&mut s, cover); - s.doc_mut().unwrap().remove(cover).unwrap(); - let png = picture(s.doc().unwrap()); - assert_eq!(png.dimensions(), (400, 67)); - assert_eq!(png.get_pixel(200, 30).0, [0, 0, 255, 255]); - } - - #[test] - fn invalid_thumbnail_ranges_and_pages_fall_back() { - let (mut s, _, _) = scene(); - let default = thumbnail(s.doc().unwrap()).unwrap(); - let doc = s.doc_mut().unwrap(); - let page = doc.add_page("Invalid cover"); - let cover = doc.create(page, NodeKind::Frame, "Cover", 0.0, 0.0, 100.0, 100.0).unwrap(); - choose(&mut s, cover); - for (width, height) in [(0.0, 100.0), (100.0, 0.0), (f64::INFINITY, 100.0), (f64::NAN, 100.0)] { - let n = s.doc_mut().unwrap().get_mut(cover).unwrap(); - n.width = width; - n.height = height; - assert_eq!(thumbnail(s.doc().unwrap()).unwrap(), default, "invalid range {width} x {height}"); - } - let doc = s.doc_mut().unwrap(); - let n = doc.get_mut(cover).unwrap(); - n.width = 100.0; - n.height = 100.0; - n.parent = Some(NodeId(u64::MAX)); - assert_eq!(thumbnail(doc).unwrap(), default, "missing parent page"); - doc.get_mut(cover).unwrap().parent = Some(page); - doc.pages.retain(|p| *p != page); - assert_eq!(thumbnail(doc).unwrap(), default, "page outside the document's page list"); - } - - #[test] - fn a_nested_frame_on_another_page_uses_its_page_background() { - let (mut s, _, _) = scene(); - let doc = s.doc_mut().unwrap(); - let page = doc.add_page("Cover page"); - if let NodeKind::Page { background, .. } = &mut doc.get_mut(page).unwrap().kind { - *background = GREEN; - } - let section = doc.create(page, NodeKind::Section { contents_hidden: false }, "Section", 700.0, 900.0, 300.0, 200.0).unwrap(); - let cover = doc.create(section, NodeKind::Frame, "Cover", -50.0, 80.0, 80.0, 40.0).unwrap(); - doc.get_mut(cover).unwrap().fills.clear(); - let child = doc.create(cover, NodeKind::Rectangle, "Child", 10.0, 10.0, 20.0, 10.0).unwrap(); - doc.get_mut(child).unwrap().fills = vec![Paint::solid(RED)]; - choose(&mut s, cover); - let png = picture(s.doc().unwrap()); - assert_eq!(png.dimensions(), (160, 80)); - assert_eq!(png.get_pixel(10, 10).0, [0, 255, 0, 255]); - assert_eq!(png.get_pixel(30, 30).0, [255, 0, 0, 255]); - } - - #[test] - fn custom_thumbnails_keep_the_size_limit_and_aspect_ratio() { - let (mut s, cover, _) = scene(); - choose(&mut s, cover); - for (width, height, want) in [(100.0, 100.0, (200, 200)), (800.0, 200.0, (400, 100)), (200.0, 800.0, (100, 400))] { - let n = s.doc_mut().unwrap().get_mut(cover).unwrap(); - n.width = width; - n.height = height; - assert_eq!(picture(s.doc().unwrap()).dimensions(), want); - } - } - - #[test] - fn native_save_and_fig_export_embed_the_same_chosen_thumbnail() { - let (mut s, cover, _) = scene(); - choose(&mut s, cover); - let before = s.doc().unwrap().clone(); - let expected = picture(s.doc().unwrap()); - let native = file_bytes(s.ds().unwrap()).unwrap(); - let (fig, _) = super::super::export::fig_bytes(s.doc().unwrap()).unwrap(); - for bytes in [native, fig] { - let png = figcraft_format::thumbnail::thumbnail_png(std::io::Cursor::new(bytes)).unwrap(); - let actual = image::load_from_memory(&png).unwrap().to_rgba8(); - assert_eq!(actual.dimensions(), (200, 200)); - assert_eq!(actual, expected); - } - assert_eq!(s.doc().unwrap(), &before); - } -} diff --git a/crates/engine/src/cmd/file/thumbnail_tests.rs b/crates/engine/src/cmd/file/thumbnail_tests.rs new file mode 100644 index 00000000..139fdc5a --- /dev/null +++ b/crates/engine/src/cmd/file/thumbnail_tests.rs @@ -0,0 +1,155 @@ +use super::*; +use figcraft_color::{Color, Paint}; +use figcraft_geom::Affine; + +const RED: Color = Color::from_rgb8_const(255, 0, 0); +const GREEN: Color = Color::from_rgb8_const(0, 255, 0); +const BLUE: Color = Color::from_rgb8_const(0, 0, 255); + +fn scene() -> (Session, NodeId, NodeId) { + let mut doc = Document::new("Thumbnail"); + let page = doc.first_page().unwrap(); + let cover = doc.create(page, NodeKind::Frame, "Cover", 0.0, 600.0, 100.0, 100.0).unwrap(); + let wide = doc.create(page, NodeKind::Frame, "Wide", 700.0, 600.0, 600.0, 100.0).unwrap(); + doc.get_mut(cover).unwrap().fills = vec![Paint::solid(RED)]; + doc.get_mut(wide).unwrap().fills = vec![Paint::solid(BLUE)]; + (Session::with_document(doc), cover, wide) +} + +fn choose(s: &mut Session, id: NodeId) { + s.execute("object.setThumbnail", &json!({"id": id})).unwrap(); +} + +fn picture(doc: &Document) -> image::RgbaImage { + image::load_from_memory(&thumbnail(doc).unwrap()).unwrap().to_rgba8() +} + +#[test] +fn chosen_frame_draws_its_children_without_siblings_or_document_changes() { + let (mut s, cover, _) = scene(); + let doc = s.doc_mut().unwrap(); + let page = doc.first_page().unwrap(); + let child = doc.create(cover, NodeKind::Rectangle, "Child", 20.0, 20.0, 20.0, 20.0).unwrap(); + doc.get_mut(child).unwrap().fills = vec![Paint::solid(GREEN)]; + // Cropping the whole page to Cover's bounds would still draw this overlapping sibling. + let sibling = doc.create(page, NodeKind::Rectangle, "Overlap", 0.0, 600.0, 100.0, 100.0).unwrap(); + doc.get_mut(sibling).unwrap().fills = vec![Paint::solid(BLUE)]; + choose(&mut s, cover); + let before = s.doc().unwrap().clone(); + let png = picture(s.doc().unwrap()); + assert_eq!(png.dimensions(), (200, 200)); + assert_eq!(png.get_pixel(10, 10).0, [255, 0, 0, 255]); + assert_eq!(png.get_pixel(60, 60).0, [0, 255, 0, 255]); + assert!(!png.pixels().any(|p| p.0 == [0, 0, 255, 255]), "neither blue sibling is drawn"); + assert_eq!(s.doc().unwrap(), &before); + s.doc_mut().unwrap().set_world_transform(cover, Affine::translate((4200.0, -3600.0))).unwrap(); + assert_eq!(picture(s.doc().unwrap()), png, "moving the frame changes neither size nor contents"); +} + +#[test] +fn no_thumbnail_choice_keeps_the_first_page_content_bounds() { + let (s, _, _) = scene(); + let png = picture(s.doc().unwrap()); + assert_eq!(png.dimensions(), (400, 31)); + assert_eq!(png.get_pixel(10, 15).0, [255, 0, 0, 255]); + assert_eq!(png.get_pixel(310, 15).0, [0, 0, 255, 255]); +} + +#[test] +fn invalid_flags_and_non_frames_keep_the_default_thumbnail() { + let (mut s, cover, wide) = scene(); + let default = thumbnail(s.doc().unwrap()).unwrap(); + for value in ["false", "TRUE", ""] { + s.doc_mut().unwrap().get_mut(cover).unwrap().plugin_data.insert("figcraft.thumbnail".into(), value.into()); + assert_eq!(thumbnail(s.doc().unwrap()).unwrap(), default, "flag {value:?}"); + } + choose(&mut s, wide); + s.doc_mut().unwrap().get_mut(wide).unwrap().kind = NodeKind::Rectangle; + assert_eq!(thumbnail(s.doc().unwrap()).unwrap(), default, "only frames can be thumbnails"); + s.doc_mut().unwrap().get_mut(wide).unwrap().kind = NodeKind::Component { description: String::new(), props: Default::default() }; + assert_ne!(thumbnail(s.doc().unwrap()).unwrap(), default, "a component counts as a frame"); +} + +#[test] +fn deleting_the_chosen_frame_falls_back_to_the_remaining_page_content() { + let (mut s, cover, _) = scene(); + choose(&mut s, cover); + s.doc_mut().unwrap().remove(cover).unwrap(); + let png = picture(s.doc().unwrap()); + assert_eq!(png.dimensions(), (400, 67)); + assert_eq!(png.get_pixel(200, 30).0, [0, 0, 255, 255]); +} + +#[test] +fn invalid_thumbnail_ranges_and_pages_fall_back() { + let (mut s, _, _) = scene(); + let default = thumbnail(s.doc().unwrap()).unwrap(); + let doc = s.doc_mut().unwrap(); + let page = doc.add_page("Invalid cover"); + let cover = doc.create(page, NodeKind::Frame, "Cover", 0.0, 0.0, 100.0, 100.0).unwrap(); + choose(&mut s, cover); + for (width, height) in [(0.0, 100.0), (100.0, 0.0), (f64::INFINITY, 100.0), (f64::NAN, 100.0)] { + let n = s.doc_mut().unwrap().get_mut(cover).unwrap(); + n.width = width; + n.height = height; + assert_eq!(thumbnail(s.doc().unwrap()).unwrap(), default, "invalid range {width} x {height}"); + } + let doc = s.doc_mut().unwrap(); + let n = doc.get_mut(cover).unwrap(); + n.width = 100.0; + n.height = 100.0; + n.parent = Some(NodeId(u64::MAX)); + assert_eq!(thumbnail(doc).unwrap(), default, "missing parent page"); + doc.get_mut(cover).unwrap().parent = Some(page); + doc.pages.retain(|p| *p != page); + assert_eq!(thumbnail(doc).unwrap(), default, "page outside the document's page list"); +} + +#[test] +fn a_nested_frame_on_another_page_uses_its_page_background() { + let (mut s, _, _) = scene(); + let doc = s.doc_mut().unwrap(); + let page = doc.add_page("Cover page"); + if let NodeKind::Page { background, .. } = &mut doc.get_mut(page).unwrap().kind { + *background = GREEN; + } + let section = doc.create(page, NodeKind::Section { contents_hidden: false }, "Section", 700.0, 900.0, 300.0, 200.0).unwrap(); + let cover = doc.create(section, NodeKind::Frame, "Cover", -50.0, 80.0, 80.0, 40.0).unwrap(); + doc.get_mut(cover).unwrap().fills.clear(); + let child = doc.create(cover, NodeKind::Rectangle, "Child", 10.0, 10.0, 20.0, 10.0).unwrap(); + doc.get_mut(child).unwrap().fills = vec![Paint::solid(RED)]; + choose(&mut s, cover); + let png = picture(s.doc().unwrap()); + assert_eq!(png.dimensions(), (160, 80)); + assert_eq!(png.get_pixel(10, 10).0, [0, 255, 0, 255]); + assert_eq!(png.get_pixel(30, 30).0, [255, 0, 0, 255]); +} + +#[test] +fn custom_thumbnails_keep_the_size_limit_and_aspect_ratio() { + let (mut s, cover, _) = scene(); + choose(&mut s, cover); + for (width, height, want) in [(100.0, 100.0, (200, 200)), (800.0, 200.0, (400, 100)), (200.0, 800.0, (100, 400))] { + let n = s.doc_mut().unwrap().get_mut(cover).unwrap(); + n.width = width; + n.height = height; + assert_eq!(picture(s.doc().unwrap()).dimensions(), want); + } +} + +#[test] +fn native_save_and_fig_export_embed_the_same_chosen_thumbnail() { + let (mut s, cover, _) = scene(); + choose(&mut s, cover); + let before = s.doc().unwrap().clone(); + let expected = picture(s.doc().unwrap()); + let native = file_bytes(s.ds().unwrap()).unwrap(); + let (fig, _) = super::export::fig_bytes(s.doc().unwrap()).unwrap(); + for bytes in [native, fig] { + let png = figcraft_format::thumbnail::thumbnail_png(std::io::Cursor::new(bytes)).unwrap(); + let actual = image::load_from_memory(&png).unwrap().to_rgba8(); + assert_eq!(actual.dimensions(), (200, 200)); + assert_eq!(actual, expected); + } + assert_eq!(s.doc().unwrap(), &before); +}