mirror of
https://github.com/storytold/deckcraft.git
synced 2026-10-24 10:37:03 -06:00
Theme background pictures (Office 2007-era themes such as Treck) carry an a:duotone recolour in phClr; DeckCraft read it but drew the picture unchanged, so those backgrounds came out grey. The painter now resolves the duotone's colours against the slide's colours and placeholder colour, and the decoded-image cache applies them (luminance picks a colour between dark and light) and keys on them. Layouts and slides can replace the master's colour map (p:clrMapOvr/a:overrideClrMapping, e.g. dark section layouts mapping bg1 to dk1). They were read as the master's map; now Layout and Slide carry an optional colour map, resolve::Ctx uses slide > layout > master, and the writer keeps them. Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
190 lines
8.3 KiB
Rust
190 lines
8.3 KiB
Rust
use super::*;
|
|
use crate::resolve::Ctx;
|
|
|
|
#[test]
|
|
fn new_presentation_is_valid() {
|
|
let p = Presentation::default();
|
|
assert_eq!(p.slides.len(), 1);
|
|
assert_eq!(p.masters.len(), 1);
|
|
assert_eq!(p.masters[0].layouts.len(), 11);
|
|
assert!(p.validate().is_empty(), "{:?}", p.validate());
|
|
let s = &p.slides[0];
|
|
let (m, l) = p.master_for(s).unwrap();
|
|
assert_eq!(l.unwrap().kind, LayoutType::Title);
|
|
assert_eq!(m.theme.name, "DeckCraft");
|
|
// Title slide has a centred title and a subtitle.
|
|
let kinds: Vec<_> = s.shapes.iter().filter_map(|x| x.ph_type()).collect();
|
|
assert_eq!(kinds, vec![PhType::CtrTitle, PhType::SubTitle]);
|
|
}
|
|
|
|
#[test]
|
|
fn placeholder_inherits_position_and_styles() {
|
|
let p = Presentation::default();
|
|
let s = &p.slides[0];
|
|
let ctx = Ctx::for_slide(&p, s).unwrap();
|
|
let title = &s.shapes[0];
|
|
let x = resolve::xfrm(&ctx, title);
|
|
assert!((x.x - 120.0).abs() < 1e-9 && (x.w - 720.0).abs() < 1e-9);
|
|
let lvl = resolve::level_style(&ctx, title, 0);
|
|
assert_eq!(lvl.run.size, Some(60.0)); // layout overrides the master's 44
|
|
assert_eq!(lvl.para.align, Some(text::Align::Center));
|
|
assert_eq!(resolve::font_family(&ctx, &lvl.run), "Inter");
|
|
let body = resolve::body(&ctx, title);
|
|
assert_eq!(body.anchor, Some(text::Anchor::Bottom));
|
|
}
|
|
|
|
#[test]
|
|
fn body_levels_from_master() {
|
|
let mut p = Presentation::default();
|
|
let lay = defaults::layout_of_kind(&p, LayoutType::TitleAndContent).unwrap();
|
|
let s = defaults::new_slide(&mut p, lay);
|
|
let ctx = Ctx::for_slide(&p, &s).unwrap();
|
|
let body = &s.shapes[1];
|
|
assert_eq!(body.ph_type(), Some(PhType::Obj));
|
|
let l0 = resolve::level_style(&ctx, body, 0);
|
|
let l1 = resolve::level_style(&ctx, body, 1);
|
|
assert_eq!(l0.run.size, Some(28.0));
|
|
assert_eq!(l1.run.size, Some(24.0));
|
|
assert!(matches!(l0.para.bullet, Some(text::Bullet::Char { .. })));
|
|
}
|
|
|
|
#[test]
|
|
fn shape_style_fill_resolves_through_theme() {
|
|
let p = Presentation::default();
|
|
let s = &p.slides[0];
|
|
let ctx = Ctx::for_slide(&p, s).unwrap();
|
|
let sh = Shape { style: Some(ShapeStyle::accent(SchemeSlot::Accent1)), xfrm: Some(Xfrm::new(0.0, 0.0, 10.0, 10.0)), ..Default::default() };
|
|
let (f, ph) = resolve::fill(&ctx, &sh);
|
|
let Some(Fill::Solid { color }) = f else { panic!("expected solid") };
|
|
assert_eq!(ctx.color(&color, ph), p.masters[0].theme.colors.get(SchemeSlot::Accent1));
|
|
let (l, ph) = resolve::line(&ctx, &sh);
|
|
assert!(l.width.is_some());
|
|
assert!(ph.is_some());
|
|
}
|
|
|
|
#[test]
|
|
fn serde_roundtrip() {
|
|
let p = Presentation::default();
|
|
let j = serde_json::to_string(&p).unwrap();
|
|
let q: Presentation = serde_json::from_str(&j).unwrap();
|
|
assert_eq!(p, q);
|
|
// Unknown and missing fields are tolerated.
|
|
let q: Presentation = serde_json::from_str(r#"{"slides":[],"zzz":1}"#).unwrap();
|
|
assert!(q.slides.is_empty());
|
|
}
|
|
|
|
#[test]
|
|
fn ids_are_unique_and_fix_next_id() {
|
|
let mut p = Presentation::default();
|
|
let a = p.alloc_id();
|
|
let b = p.alloc_id();
|
|
assert!(b > a);
|
|
p.next_id = 0;
|
|
p.fix_next_id();
|
|
assert!(p.next_id >= b.min(p.next_id));
|
|
let c = p.alloc_id();
|
|
let mut all = vec![];
|
|
for s in &p.slides {
|
|
all.push(s.id.0);
|
|
walk(&s.shapes, &mut |sh, _| all.push(sh.id.0));
|
|
}
|
|
assert!(!all.contains(&c));
|
|
}
|
|
|
|
#[test]
|
|
fn group_find_and_walk() {
|
|
let child = Shape { id: ShapeId(900), ..Default::default() };
|
|
let g = Shape { id: ShapeId(901), kind: ShapeKind::Group { children: vec![child], child: Xfrm::new(0.0, 0.0, 1.0, 1.0) }, ..Default::default() };
|
|
let mut slide = Slide { shapes: vec![g], ..Default::default() };
|
|
assert!(slide.shape(ShapeId(900)).is_some());
|
|
assert!(slide.shape_mut(ShapeId(900)).is_some());
|
|
let mut n = 0;
|
|
walk(&slide.shapes, &mut |_, _| n += 1);
|
|
assert_eq!(n, 2);
|
|
}
|
|
|
|
#[test]
|
|
fn text_body_helpers() {
|
|
let mut t = TextBody::from_text("Hello\nWorld");
|
|
assert_eq!(t.text(), "Hello\nWorld");
|
|
assert_eq!(t.char_len(), 11);
|
|
t.paragraphs[0].runs.push(Run::new(" there"));
|
|
t.paragraphs[0].normalize();
|
|
assert_eq!(t.paragraphs[0].runs.len(), 1);
|
|
assert_eq!(t.paragraphs[0].text(), "Hello there");
|
|
}
|
|
|
|
#[test]
|
|
fn builtin_themes_have_distinct_names() {
|
|
let t = theme::builtin_themes();
|
|
let mut names: Vec<_> = t.iter().map(|t| t.name.clone()).collect();
|
|
names.sort();
|
|
names.dedup();
|
|
assert_eq!(names.len(), t.len());
|
|
assert!(t.len() >= 8);
|
|
}
|
|
|
|
#[test]
|
|
fn four_by_three_master_scales() {
|
|
let p = defaults::blank_presentation(defaults::STANDARD, Theme::default(), true);
|
|
let m = &p.masters[0];
|
|
let title = m.shapes.iter().find(|s| s.ph_type() == Some(PhType::Title)).unwrap();
|
|
let x = title.xfrm.unwrap();
|
|
assert!(x.x + x.w <= 720.0 + 1e-6);
|
|
}
|
|
|
|
#[test]
|
|
fn hyperlinked_runs_take_the_theme_hyperlink_look() {
|
|
use crate::text::{Action, Hyperlink, Paragraph, RunProps};
|
|
let p = Presentation::default();
|
|
let s = &p.slides[0];
|
|
let ctx = Ctx::for_slide(&p, s).unwrap();
|
|
let title = &s.shapes[0];
|
|
let para = Paragraph::default();
|
|
let link = Some(Hyperlink { action: Action::Url { url: "https://example.org".into() }, tooltip: String::new(), highlight_click: false });
|
|
let hlink = Some(Fill::solid(ColorRef::scheme(SchemeSlot::Hlink)));
|
|
// The title's master style colours its text tx1; a link is hlink and underlined instead.
|
|
assert_eq!(resolve::run(&ctx, title, ¶, &RunProps::default()).fill, Some(Fill::solid(ColorRef::scheme(SchemeSlot::Tx1))));
|
|
let r = resolve::run(&ctx, title, ¶, &RunProps { link: link.clone(), ..Default::default() });
|
|
assert_eq!((r.fill, r.underline.as_deref()), (hlink.clone(), Some("sng")));
|
|
assert_eq!(ctx.color(&ColorRef::scheme(SchemeSlot::Hlink), None), p.masters[0].theme.colors.get(SchemeSlot::Hlink));
|
|
// A slide link looks the same.
|
|
let slide = Some(Hyperlink { action: Action::Slide { slide: s.id }, tooltip: String::new(), highlight_click: false });
|
|
assert_eq!(resolve::run(&ctx, title, ¶, &RunProps { link: slide, ..Default::default() }).fill, hlink);
|
|
// The run's own colour and underline win; u="none" removes the underline.
|
|
let red = Some(Fill::solid(ColorRef::rgb(deckcraft_color::Rgba::rgb(255, 0, 0))));
|
|
let own = RunProps { link: link.clone(), fill: red.clone(), underline: Some("none".into()), ..Default::default() };
|
|
let r = resolve::run(&ctx, title, ¶, &own);
|
|
assert_eq!((r.fill, r.underline.as_deref()), (red, Some("none")));
|
|
let dbl = resolve::run(&ctx, title, ¶, &RunProps { link, underline: Some("dbl".into()), ..Default::default() });
|
|
assert_eq!((dbl.fill, dbl.underline.as_deref()), (hlink, Some("dbl")));
|
|
}
|
|
|
|
#[test]
|
|
fn a_layout_colour_map_override_applies_to_its_slides() {
|
|
// A dark "section" layout maps bg1→dk1 and tx1→lt1; a slide on it that keeps the master
|
|
// mapping (`masterClrMapping`) follows the layout, as PowerPoint draws it.
|
|
let mut p = Presentation::default();
|
|
let dark: Vec<(String, String)> =
|
|
[("bg1", "dk1"), ("tx1", "lt1"), ("bg2", "dk2"), ("tx2", "lt2")].iter().map(|(a, b)| (a.to_string(), b.to_string())).collect();
|
|
let layout = p.slides[0].layout;
|
|
let m = std::sync::Arc::make_mut(&mut p.masters[0]);
|
|
let colors = m.theme.colors.clone();
|
|
m.layouts.iter_mut().find(|l| l.id == layout).unwrap().color_map = Some(dark.clone());
|
|
let s: Slide = (*p.slides[0]).clone();
|
|
let ctx = Ctx::for_slide(&p, &s).unwrap();
|
|
assert_eq!(ctx.color(&ColorRef::scheme(SchemeSlot::Bg1), None), colors.get(SchemeSlot::Dk1));
|
|
assert_eq!(ctx.color(&ColorRef::scheme(SchemeSlot::Tx2), None), colors.get(SchemeSlot::Lt2));
|
|
assert_eq!(ctx.scheme.get(SchemeSlot::Lt1), colors.get(SchemeSlot::Dk1));
|
|
// The slide's own override wins over the layout's.
|
|
let mut own = s.clone();
|
|
own.color_map = Some([("bg1", "lt1"), ("tx1", "dk1")].iter().map(|(a, b)| (a.to_string(), b.to_string())).collect());
|
|
let ctx = Ctx::for_slide(&p, &own).unwrap();
|
|
assert_eq!(ctx.color(&ColorRef::scheme(SchemeSlot::Bg1), None), colors.get(SchemeSlot::Lt1));
|
|
// The layout itself draws with its override; the master keeps its own map.
|
|
let m = &p.masters[0];
|
|
let l = m.layout(layout).unwrap();
|
|
assert_eq!(Ctx::for_layout(&p, m, l).color(&ColorRef::scheme(SchemeSlot::Bg1), None), colors.get(SchemeSlot::Dk1));
|
|
assert_eq!(Ctx::for_master(&p, m).color(&ColorRef::scheme(SchemeSlot::Bg1), None), colors.get(SchemeSlot::Lt1));
|
|
}
|