mirror of
https://github.com/storytold/deckcraft.git
synced 2026-10-24 10:37:03 -06:00
Compare commits
9
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a837b81745 | ||
|
|
2e68504e7c | ||
|
|
e6050b7d37 | ||
|
|
72d8e0ab42 | ||
|
|
2347a53845 | ||
|
|
692aa0103b | ||
|
|
ceb6e3b990 | ||
|
|
4d13652f0f | ||
|
|
235e34613b |
@@ -483,6 +483,7 @@ fn insert_layout(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
background: None,
|
||||
show_master_shapes: true,
|
||||
preserve: true,
|
||||
color_map: None,
|
||||
raw_ext: None,
|
||||
},
|
||||
);
|
||||
|
||||
@@ -79,6 +79,10 @@ pub struct FontFace {
|
||||
/// values).
|
||||
pub cap_height: f64,
|
||||
pub x_height: f64,
|
||||
/// Where the baseline sits in a line, as a share of the line's height from its top: the
|
||||
/// Windows ascent over the Windows ascent plus descent (OS/2 `usWinAscent`/`usWinDescent`),
|
||||
/// the split PowerPoint uses (computed by `line_split`).
|
||||
pub line_split: f64,
|
||||
pub shaper: harfrust::ShaperData,
|
||||
/// Variable fonts: the named instance's axis settings (user units; empty = default instance),
|
||||
/// the normalised location and the shaper's view of it.
|
||||
@@ -436,6 +440,23 @@ pub(crate) fn last_resort_face() -> Arc<FontFace> {
|
||||
.clone()
|
||||
}
|
||||
|
||||
/// The share of a line above the baseline (`FontFace::line_split`): OS/2 `usWinAscent` over
|
||||
/// `usWinAscent + usWinDescent`, else the ascent over ascent plus descent. Fonts whose Windows
|
||||
/// extent is huge (Gabriola, Cambria Math: over 1.5 em) get 0.81, where PowerPoint puts their
|
||||
/// baseline (observed).
|
||||
fn line_split(f: &skrifa::FontRef, ascent: f64, descent: f64, upem: f64) -> f64 {
|
||||
use skrifa::raw::TableProvider;
|
||||
let (a, d) = match f.os2() {
|
||||
Ok(os2) if os2.us_win_ascent() > 0 => (os2.us_win_ascent() as f64, os2.us_win_descent() as f64),
|
||||
_ => (ascent, descent),
|
||||
};
|
||||
let total = a + d;
|
||||
if !total.is_finite() || total <= 0.0 || a < 0.0 || d < 0.0 {
|
||||
return 0.8;
|
||||
}
|
||||
if total > upem * 1.5 { 0.81 } else { (a / total).clamp(0.5, 1.0) }
|
||||
}
|
||||
|
||||
fn make_face(bytes: FontBytes, index: u32, family: String, style: String, coords: Vec<([u8; 4], f32)>) -> Option<FontFace> {
|
||||
let data: &[u8] = match &bytes {
|
||||
FontBytes::Static(b) => b,
|
||||
@@ -467,6 +488,7 @@ fn make_face(bytes: FontBytes, index: u32, family: String, style: String, coords
|
||||
descent: -(m.descent as f64),
|
||||
cap_height: m.cap_height.map(|v| v as f64).filter(|v| *v > 0.0).unwrap_or(m.ascent as f64 * 0.72),
|
||||
x_height: m.x_height.map(|v| v as f64).filter(|v| *v > 0.0).unwrap_or(m.ascent as f64 * 0.5),
|
||||
line_split: line_split(&f, m.ascent as f64, -(m.descent as f64), m.units_per_em.max(1) as f64),
|
||||
shaper,
|
||||
coords,
|
||||
location,
|
||||
|
||||
@@ -143,6 +143,17 @@ mod tests {
|
||||
assert!(f.upem > 0.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn line_split_follows_the_windows_ascent_and_descent() {
|
||||
use skrifa::raw::TableProvider;
|
||||
let f = fontdb::last_resort_face();
|
||||
let font = skrifa::FontRef::new(f.data()).unwrap();
|
||||
let os2 = font.os2().unwrap();
|
||||
let (a, d) = (os2.us_win_ascent() as f64, os2.us_win_descent() as f64);
|
||||
assert!((f.line_split - a / (a + d)).abs() < 1e-9, "{} vs {}", f.line_split, a / (a + d));
|
||||
assert!(f.line_split > 0.5 && f.line_split < 1.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn some_face_always_resolves() {
|
||||
let db = FontDb::with_font_dirs(Vec::new());
|
||||
|
||||
@@ -243,6 +243,7 @@ pub fn build_master(size: Size, theme: Theme, first_id: u32) -> (Master, u32) {
|
||||
background: None,
|
||||
show_master_shapes: true,
|
||||
preserve: true,
|
||||
color_map: None,
|
||||
raw_ext: None,
|
||||
});
|
||||
};
|
||||
|
||||
+35
-15
@@ -488,6 +488,10 @@ pub struct Slide {
|
||||
/// Speaker notes.
|
||||
pub notes: TextBody,
|
||||
pub comments: Vec<Comment>,
|
||||
/// `clrMapOvr`: a colour map replacing the layout's or master's (`bg1`→`dk1`…); None follows
|
||||
/// the layout.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub color_map: Option<Vec<(String, String)>>,
|
||||
/// Unmodelled XML kept for round-trip (custom data, tags).
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub raw_ext: Option<String>,
|
||||
@@ -507,6 +511,7 @@ impl Default for Slide {
|
||||
animations: vec![],
|
||||
notes: TextBody::default(),
|
||||
comments: vec![],
|
||||
color_map: None,
|
||||
raw_ext: None,
|
||||
}
|
||||
}
|
||||
@@ -596,6 +601,10 @@ pub struct Layout {
|
||||
pub background: Option<Background>,
|
||||
pub show_master_shapes: bool,
|
||||
pub preserve: bool,
|
||||
/// `clrMapOvr`: a colour map replacing the master's for this layout and the slides using it
|
||||
/// (dark "section" layouts map `bg1` to `dk1`); None follows the master.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub color_map: Option<Vec<(String, String)>>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub raw_ext: Option<String>,
|
||||
}
|
||||
@@ -641,24 +650,17 @@ impl Master {
|
||||
}
|
||||
/// Resolve `bg1`/`tx1`/`bg2`/`tx2` through the colour map.
|
||||
pub fn map_slot(&self, slot: SchemeSlot) -> SchemeSlot {
|
||||
let key = match slot {
|
||||
SchemeSlot::Bg1 => "bg1",
|
||||
SchemeSlot::Tx1 => "tx1",
|
||||
SchemeSlot::Bg2 => "bg2",
|
||||
SchemeSlot::Tx2 => "tx2",
|
||||
other => return other,
|
||||
};
|
||||
self.color_map.iter().find(|(k, _)| k == key).and_then(|(_, v)| SchemeSlot::from_xml(v)).unwrap_or(match slot {
|
||||
SchemeSlot::Bg1 => SchemeSlot::Lt1,
|
||||
SchemeSlot::Tx1 => SchemeSlot::Dk1,
|
||||
SchemeSlot::Bg2 => SchemeSlot::Lt2,
|
||||
_ => SchemeSlot::Dk2,
|
||||
})
|
||||
map_slot(&self.color_map, slot)
|
||||
}
|
||||
/// The theme's colour scheme with the colour map applied (`bg1`… resolve to mapped colours).
|
||||
pub fn scheme(&self) -> ColorScheme {
|
||||
self.scheme_with(&self.color_map)
|
||||
}
|
||||
/// The theme's colour scheme with `map` (a layout's or slide's override, or the master's own)
|
||||
/// applied.
|
||||
pub fn scheme_with(&self, map: &[(String, String)]) -> ColorScheme {
|
||||
let mut s = self.theme.colors.clone();
|
||||
if self.color_map.is_empty() {
|
||||
if map.is_empty() {
|
||||
return s;
|
||||
}
|
||||
let base = self.theme.colors.clone();
|
||||
@@ -668,12 +670,30 @@ impl Master {
|
||||
(SchemeSlot::Bg2, SchemeSlot::Lt2),
|
||||
(SchemeSlot::Tx2, SchemeSlot::Dk2),
|
||||
] {
|
||||
s.set(slot, base.get(self.map_slot(alias)));
|
||||
s.set(slot, base.get(map_slot(map, alias)));
|
||||
}
|
||||
s
|
||||
}
|
||||
}
|
||||
|
||||
/// Resolve `bg1`/`tx1`/`bg2`/`tx2` through the colour map `map` (the default mapping when it has
|
||||
/// no entry).
|
||||
pub fn map_slot(map: &[(String, String)], slot: SchemeSlot) -> SchemeSlot {
|
||||
let key = match slot {
|
||||
SchemeSlot::Bg1 => "bg1",
|
||||
SchemeSlot::Tx1 => "tx1",
|
||||
SchemeSlot::Bg2 => "bg2",
|
||||
SchemeSlot::Tx2 => "tx2",
|
||||
other => return other,
|
||||
};
|
||||
map.iter().find(|(k, _)| k == key).and_then(|(_, v)| SchemeSlot::from_xml(v)).unwrap_or(match slot {
|
||||
SchemeSlot::Bg1 => SchemeSlot::Lt1,
|
||||
SchemeSlot::Tx1 => SchemeSlot::Dk1,
|
||||
SchemeSlot::Bg2 => SchemeSlot::Lt2,
|
||||
_ => SchemeSlot::Dk2,
|
||||
})
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(default, rename_all = "camelCase")]
|
||||
pub struct Section {
|
||||
|
||||
@@ -22,23 +22,27 @@ pub struct Ctx<'a> {
|
||||
pub layout: Option<&'a Layout>,
|
||||
pub owner: Owner,
|
||||
pub scheme: ColorScheme,
|
||||
/// The colour map in force: the slide's override, else its layout's, else the master's.
|
||||
pub color_map: Vec<(String, String)>,
|
||||
}
|
||||
|
||||
impl<'a> Ctx<'a> {
|
||||
pub fn for_slide(pres: &'a Presentation, slide: &Slide) -> Option<Ctx<'a>> {
|
||||
let (master, layout) = pres.master_for(slide)?;
|
||||
Some(Ctx { pres, master, layout, owner: Owner::Slide, scheme: master.scheme() })
|
||||
let map = slide.color_map.as_ref().or(layout.and_then(|l| l.color_map.as_ref())).unwrap_or(&master.color_map).clone();
|
||||
Some(Ctx { pres, master, layout, owner: Owner::Slide, scheme: master.scheme_with(&map), color_map: map })
|
||||
}
|
||||
pub fn for_layout(pres: &'a Presentation, master: &'a Master, layout: &'a Layout) -> Ctx<'a> {
|
||||
Ctx { pres, master, layout: Some(layout), owner: Owner::Layout, scheme: master.scheme() }
|
||||
let map = layout.color_map.as_ref().unwrap_or(&master.color_map).clone();
|
||||
Ctx { pres, master, layout: Some(layout), owner: Owner::Layout, scheme: master.scheme_with(&map), color_map: map }
|
||||
}
|
||||
pub fn for_master(pres: &'a Presentation, master: &'a Master) -> Ctx<'a> {
|
||||
Ctx { pres, master, layout: None, owner: Owner::Master, scheme: master.scheme() }
|
||||
Ctx { pres, master, layout: None, owner: Owner::Master, scheme: master.scheme(), color_map: master.color_map.clone() }
|
||||
}
|
||||
pub fn color(&self, c: &ColorRef, ph: Option<Rgba>) -> Rgba {
|
||||
let c = match &c.base {
|
||||
crate::ColorBase::Scheme { slot } if matches!(slot, SchemeSlot::Bg1 | SchemeSlot::Tx1 | SchemeSlot::Bg2 | SchemeSlot::Tx2) => {
|
||||
ColorRef { base: crate::ColorBase::Scheme { slot: self.master.map_slot(*slot) }, mods: c.mods.clone() }
|
||||
ColorRef { base: crate::ColorBase::Scheme { slot: crate::map_slot(&self.color_map, *slot) }, mods: c.mods.clone() }
|
||||
}
|
||||
_ => c.clone(),
|
||||
};
|
||||
|
||||
@@ -159,3 +159,31 @@ fn hyperlinked_runs_take_the_theme_hyperlink_look() {
|
||||
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));
|
||||
}
|
||||
|
||||
@@ -77,6 +77,12 @@ pub fn fill_el(imp: &mut Imp, part: &Part, f: &El) -> Option<Fill> {
|
||||
"solidFill" => Fill::Solid { color: color(f).unwrap_or(ColorRef::rgb(Rgba::BLACK)) },
|
||||
"gradFill" => Fill::Gradient(gradient(f)),
|
||||
"blipFill" => match blip_fill(imp, part, f) {
|
||||
// A picture filling a shape or background with neither `a:stretch` nor `a:tile` is
|
||||
// tiled at its own size, as PowerPoint draws it.
|
||||
Some(mut p) if f.child("stretch").is_none() && f.child("tile").is_none() => {
|
||||
p.mode = PictureMode::Tile { tx: 0.0, ty: 0.0, sx: 1.0, sy: 1.0, flip: "none".into(), align: "tl".into() };
|
||||
Fill::Picture(p)
|
||||
}
|
||||
Some(p) => Fill::Picture(p),
|
||||
None => Fill::None,
|
||||
},
|
||||
|
||||
@@ -609,12 +609,18 @@ fn read_layout(imp: &mut Imp, part: &Part, doc: &Doc) -> Layout {
|
||||
l.shapes = shapes::sp_tree(imp, part, &mut ctx, tree, 0);
|
||||
}
|
||||
}
|
||||
l.color_map = color_map_override(root);
|
||||
if let Some(ext) = root.child("extLst").filter(|e| !has_rel_refs(e)) {
|
||||
l.raw_ext = Some(ext.elements().map(|e| part.keep(e)).collect());
|
||||
}
|
||||
l
|
||||
}
|
||||
|
||||
/// A layout's or slide's `clrMapOvr/overrideClrMapping` (None for `masterClrMapping` or none).
|
||||
fn color_map_override(root: &El) -> Option<Vec<(String, String)>> {
|
||||
root.child("clrMapOvr")?.child("overrideClrMapping").map(color_map)
|
||||
}
|
||||
|
||||
fn read_slide(imp: &mut Imp, part: &Part, doc: &Doc, id: SlideId, layout: LayoutId) -> Slide {
|
||||
let root = &doc.root;
|
||||
let mut s = Slide {
|
||||
@@ -622,6 +628,7 @@ fn read_slide(imp: &mut Imp, part: &Part, doc: &Doc, id: SlideId, layout: Layout
|
||||
layout,
|
||||
hidden: root.bool("show") == Some(false),
|
||||
show_master_shapes: root.bool("showMasterSp").unwrap_or(true),
|
||||
color_map: color_map_override(root),
|
||||
..Default::default()
|
||||
};
|
||||
let mut ctx = shapes::IdCtx::default();
|
||||
|
||||
@@ -227,6 +227,21 @@ const CLR_KEYS: [(&str, &str); 12] = [
|
||||
];
|
||||
|
||||
fn clr_map(w: &mut W, map: &[(String, String)]) {
|
||||
w.empty("p:clrMap", clr_map_attrs(map));
|
||||
}
|
||||
|
||||
/// `p:clrMapOvr`: the layout's or slide's own colour map, else `a:masterClrMapping`.
|
||||
fn clr_map_ovr(w: &mut W, map: Option<&[(String, String)]>) {
|
||||
w.open0("p:clrMapOvr");
|
||||
match map {
|
||||
Some(m) => w.empty("a:overrideClrMapping", clr_map_attrs(m)),
|
||||
None => w.empty0("a:masterClrMapping"),
|
||||
}
|
||||
w.close("p:clrMapOvr");
|
||||
}
|
||||
|
||||
/// The twelve colour map attributes, each a theme slot (the default for anything else).
|
||||
fn clr_map_attrs(map: &[(String, String)]) -> A {
|
||||
let mut a = A::new();
|
||||
for (k, d) in CLR_KEYS {
|
||||
let v = map
|
||||
@@ -237,7 +252,7 @@ fn clr_map(w: &mut W, map: &[(String, String)]) {
|
||||
.unwrap_or(d);
|
||||
a = a.a(k, v);
|
||||
}
|
||||
w.empty("p:clrMap", a);
|
||||
a
|
||||
}
|
||||
|
||||
fn ext_list(w: &mut W, raw: Option<&str>) {
|
||||
@@ -522,9 +537,7 @@ fn layout_xml(x: &mut Exp, o: &mut Out, l: &Layout) -> Vec<u8> {
|
||||
A::new().o("showMasterSp", (!l.show_master_shapes).then_some("0")).a("type", l.kind.xml()).t("preserve", l.preserve),
|
||||
);
|
||||
csld(&mut w, x, o, &l.name, l.background.as_ref(), &l.shapes);
|
||||
w.open0("p:clrMapOvr");
|
||||
w.empty0("a:masterClrMapping");
|
||||
w.close("p:clrMapOvr");
|
||||
clr_map_ovr(&mut w, l.color_map.as_deref());
|
||||
ext_list(&mut w, ext_children(l.raw_ext.as_deref()).as_deref());
|
||||
w.close("p:sldLayout");
|
||||
w.finish()
|
||||
@@ -556,9 +569,7 @@ fn slide_xml(x: &mut Exp, o: &mut Out, s: &Slide) -> Vec<u8> {
|
||||
let mut w = W::new();
|
||||
root_open(&mut w, "p:sld", A::new().o("showMasterSp", (!s.show_master_shapes).then_some("0")).o("show", s.hidden.then_some("0")));
|
||||
let ids = csld(&mut w, x, o, &s.name, s.background.as_ref(), &s.shapes);
|
||||
w.open0("p:clrMapOvr");
|
||||
w.empty0("a:masterClrMapping");
|
||||
w.close("p:clrMapOvr");
|
||||
clr_map_ovr(&mut w, s.color_map.as_deref());
|
||||
if let Some(t) = &s.transition {
|
||||
timing::transition(&mut w, x, o, t);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
//! Fills read from decks written by other tools.
|
||||
|
||||
use std::io::{Cursor, Read, Write};
|
||||
|
||||
use deckcraft_geom::Xfrm;
|
||||
use deckcraft_model::style::{Fill, PictureFill, PictureMode};
|
||||
use deckcraft_model::{Shape, ShapeId, defaults};
|
||||
|
||||
/// `bytes` with `edit` applied to every slide part.
|
||||
fn edit_slides(bytes: &[u8], edit: impl Fn(String) -> String) -> Vec<u8> {
|
||||
let mut z = zip::ZipArchive::new(Cursor::new(bytes)).expect("zip");
|
||||
let mut out = zip::ZipWriter::new(Cursor::new(Vec::new()));
|
||||
let o = zip::write::SimpleFileOptions::default();
|
||||
for i in 0..z.len() {
|
||||
let mut f = z.by_index(i).expect("entry");
|
||||
let mut d = vec![];
|
||||
f.read_to_end(&mut d).expect("read");
|
||||
let name = f.name().to_string();
|
||||
if name.starts_with("ppt/slides/slide") && name.ends_with(".xml") {
|
||||
d = edit(String::from_utf8(d).expect("utf8")).into_bytes();
|
||||
}
|
||||
out.start_file(name, o).expect("start");
|
||||
out.write_all(&d).expect("write");
|
||||
}
|
||||
out.finish().expect("finish").into_inner()
|
||||
}
|
||||
|
||||
fn png() -> Vec<u8> {
|
||||
let mut out = Cursor::new(vec![]);
|
||||
image::DynamicImage::ImageRgb8(image::RgbImage::from_pixel(4, 4, image::Rgb([10, 200, 30])))
|
||||
.write_to(&mut out, image::ImageFormat::Png)
|
||||
.expect("png");
|
||||
out.into_inner()
|
||||
}
|
||||
|
||||
/// A deck with one shape filled with a stretched picture, exported.
|
||||
fn deck() -> Vec<u8> {
|
||||
let mut p = defaults::new_presentation(None);
|
||||
let media = p.add_media("tile.png", "image/png", png());
|
||||
let id = ShapeId(p.alloc_id());
|
||||
let first = p.slides[0].id;
|
||||
p.slide_mut(first).expect("slide").shapes.push(Shape {
|
||||
id,
|
||||
name: "Textured".into(),
|
||||
xfrm: Some(Xfrm::new(10.0, 10.0, 200.0, 100.0)),
|
||||
fill: Some(Fill::Picture(PictureFill { media, ..Default::default() })),
|
||||
..Default::default()
|
||||
});
|
||||
deckcraft_pptx::export(&p).expect("export")
|
||||
}
|
||||
|
||||
fn textured_mode(bytes: &[u8]) -> PictureMode {
|
||||
let q = deckcraft_pptx::import(bytes).expect("import");
|
||||
let s = q.slides[0].shapes.iter().find(|s| s.name == "Textured").expect("shape");
|
||||
match &s.fill {
|
||||
Some(Fill::Picture(pf)) => pf.mode.clone(),
|
||||
other => panic!("{other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_shape_picture_fill_without_stretch_or_tile_tiles() {
|
||||
// tdf#128596: `<a:blipFill><a:blip/></a:blipFill>` on a shape, as some tools write it;
|
||||
// PowerPoint tiles the picture at its own size.
|
||||
let bare = edit_slides(&deck(), |s| {
|
||||
let s = regex_free_strip(&s, "<a:stretch>", "</a:stretch>");
|
||||
assert!(!s.contains("<a:stretch"), "the test deck lost its stretch");
|
||||
s
|
||||
});
|
||||
assert!(matches!(textured_mode(&bare), PictureMode::Tile { sx, sy, .. } if sx == 1.0 && sy == 1.0));
|
||||
// An explicit stretch is still a stretch.
|
||||
assert!(matches!(textured_mode(&deck()), PictureMode::Stretch { .. }));
|
||||
}
|
||||
|
||||
/// `s` without the first `open`…`close` span inside a `blipFill`.
|
||||
fn regex_free_strip(s: &str, open: &str, close: &str) -> String {
|
||||
let Some(b) = s.find("<a:blipFill") else { return s.to_string() };
|
||||
let Some(i) = s[b..].find(open).map(|i| i + b) else { return s.to_string() };
|
||||
let Some(j) = s[i..].find(close).map(|j| j + i + close.len()) else { return s.to_string() };
|
||||
format!("{}{}", &s[..i], &s[j..])
|
||||
}
|
||||
@@ -730,3 +730,39 @@ fn date_fields_keep_their_type_and_saved_text() {
|
||||
assert_eq!(by_name(&q.slides[0], name).text, by_name(&p.slides[0], name).text, "{name}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn colour_map_overrides_round_trip() {
|
||||
let mut p = defaults::new_presentation(None);
|
||||
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;
|
||||
std::sync::Arc::make_mut(&mut p.masters[0]).layouts.iter_mut().find(|l| l.id == layout).expect("layout").color_map = Some(dark.clone());
|
||||
let first = p.slides[0].id;
|
||||
p.slide_mut(first).expect("slide").color_map = Some(dark.clone());
|
||||
let q = round(&p);
|
||||
let get = |m: &Option<Vec<(String, String)>>, k: &str| m.as_ref().and_then(|m| m.iter().find(|(a, _)| a == k).map(|(_, v)| v.clone()));
|
||||
let ql = q.masters[0].layouts.iter().find(|l| l.color_map.is_some()).expect("the layout keeps its override");
|
||||
assert_eq!(get(&ql.color_map, "bg1").as_deref(), Some("dk1"));
|
||||
assert_eq!(get(&q.slides[0].color_map, "tx1").as_deref(), Some("lt1"));
|
||||
// Layouts and slides without one keep following the master.
|
||||
assert!(q.masters[0].layouts.iter().filter(|l| l.color_map.is_none()).count() >= 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_picture_duotone_round_trips() {
|
||||
let mut p = defaults::new_presentation(None);
|
||||
let img = p.add_media("image1.png", "image/png", png(4, 4, [128, 128, 128]));
|
||||
let mut pic = shape(&mut p, "Duotone", 10.0, 10.0, 100.0, 100.0);
|
||||
let mut pf = PictureFill { media: img, ..Default::default() };
|
||||
pf.adjust.duotone = Some((ColorRef::scheme(SchemeSlot::Accent1).with(ColorTransform::Shade(30000)), ColorRef::rgb(Rgba::rgb(250, 240, 200))));
|
||||
pic.kind = deckcraft_model::ShapeKind::Picture { fill: pf };
|
||||
let first = p.slides[0].id;
|
||||
p.slide_mut(first).expect("slide").shapes.push(pic);
|
||||
let q = round(&p);
|
||||
let sh = by_name(&q.slides[0], "Duotone");
|
||||
let deckcraft_model::ShapeKind::Picture { fill } = &sh.kind else { panic!("picture") };
|
||||
let (d, l) = fill.adjust.duotone.as_ref().expect("duotone kept");
|
||||
assert!(matches!(d.base, deckcraft_model::ColorBase::Scheme { slot: SchemeSlot::Accent1 }));
|
||||
assert!(matches!(l.base, deckcraft_model::ColorBase::Rgb { rgb } if rgb == Rgba::rgb(250, 240, 200)));
|
||||
}
|
||||
|
||||
@@ -79,9 +79,26 @@ fn adjust_key(a: &PictureAdjust) -> u64 {
|
||||
if let Some(c) = a.clear_color {
|
||||
mix(((c.r as u64) << 16) | ((c.g as u64) << 8) | c.b as u64 | 1 << 40);
|
||||
}
|
||||
if let Some((d, l)) = duotone_rgb(a) {
|
||||
for c in [d, l] {
|
||||
mix(((c.r as u64) << 24) | ((c.g as u64) << 16) | ((c.b as u64) << 8) | c.a as u64 | 1 << 41);
|
||||
}
|
||||
}
|
||||
h
|
||||
}
|
||||
|
||||
/// The duotone's dark and light colours. The painter resolves them to plain RGB first (see
|
||||
/// `paint::resolved`); a colour that is still a scheme reference falls back to black and white.
|
||||
fn duotone_rgb(a: &PictureAdjust) -> Option<(deckcraft_color::Rgba, deckcraft_color::Rgba)> {
|
||||
use deckcraft_model::style::ColorBase;
|
||||
let (d, l) = a.duotone.as_ref()?;
|
||||
let rgb = |c: &deckcraft_model::style::ColorRef, or: deckcraft_color::Rgba| match &c.base {
|
||||
ColorBase::Rgb { rgb } if c.mods.is_empty() => *rgb,
|
||||
_ => or,
|
||||
};
|
||||
Some((rgb(d, deckcraft_color::Rgba::BLACK), rgb(l, deckcraft_color::Rgba::WHITE)))
|
||||
}
|
||||
|
||||
/// Decode encoded bytes (PNG, JPEG, GIF, WebP, BMP, TIFF) to straight RGBA.
|
||||
pub fn decode(bytes: &[u8]) -> Option<image::RgbaImage> {
|
||||
if bytes.len() > 512 << 20 {
|
||||
@@ -233,6 +250,7 @@ fn apply_adjust(img: &mut image::RgbaImage, a: &PictureAdjust) {
|
||||
if a.brightness == 0.0 && a.contrast == 0.0 && sat == 1.0 && !a.grayscale && a.clear_color.is_none() && a.duotone.is_none() {
|
||||
return;
|
||||
}
|
||||
let duo = duotone_rgb(a);
|
||||
let b = a.brightness.clamp(-1.0, 1.0) * 255.0;
|
||||
let c = a.contrast.clamp(-1.0, 1.0);
|
||||
let cf = if c >= 0.0 { 1.0 / (1.0 - c * 0.99) } else { 1.0 + c };
|
||||
@@ -252,6 +270,12 @@ fn apply_adjust(img: &mut image::RgbaImage, a: &PictureAdjust) {
|
||||
*v = y + (*v - y) * s;
|
||||
*v = (*v - 128.0) * cf + 128.0 + b;
|
||||
}
|
||||
// Duotone: the pixel's luminance picks a colour between the dark and the light one.
|
||||
if let Some((d, l)) = duo {
|
||||
let t = ((0.299 * rgb[0] + 0.587 * rgb[1] + 0.114 * rgb[2]) / 255.0).clamp(0.0, 1.0);
|
||||
rgb =
|
||||
[d.r as f64 + (l.r as f64 - d.r as f64) * t, d.g as f64 + (l.g as f64 - d.g as f64) * t, d.b as f64 + (l.b as f64 - d.b as f64) * t];
|
||||
}
|
||||
for (i, v) in rgb.iter().enumerate() {
|
||||
if let Some(ch) = p.0.get_mut(i) {
|
||||
*ch = v.round().clamp(0.0, 255.0) as u8;
|
||||
@@ -266,6 +290,25 @@ mod tests {
|
||||
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn duotone_maps_luminance_between_its_two_colours() {
|
||||
use deckcraft_color::Rgba;
|
||||
use deckcraft_model::style::ColorRef;
|
||||
let mut img = image::RgbaImage::from_fn(3, 1, |x, _| image::Rgba([[0, 0, 0, 255], [128, 128, 128, 200], [255, 255, 255, 255]][x as usize]));
|
||||
let adj =
|
||||
PictureAdjust { duotone: Some((ColorRef::rgb(Rgba::rgb(100, 40, 0)), ColorRef::rgb(Rgba::rgb(250, 240, 200)))), ..Default::default() };
|
||||
apply_adjust(&mut img, &adj);
|
||||
assert_eq!(img.get_pixel(0, 0).0, [100, 40, 0, 255], "black takes the dark colour");
|
||||
assert_eq!(img.get_pixel(2, 0).0, [250, 240, 200, 255], "white takes the light colour");
|
||||
let mid = img.get_pixel(1, 0).0;
|
||||
assert!((mid[0] as i32 - 175).abs() <= 1 && (mid[1] as i32 - 140).abs() <= 1 && (mid[2] as i32 - 100).abs() <= 1, "{mid:?}");
|
||||
assert_eq!(mid[3], 200, "alpha is kept");
|
||||
// Different duotones are cached separately.
|
||||
let other = PictureAdjust { duotone: Some((ColorRef::rgb(Rgba::rgb(0, 0, 0)), ColorRef::rgb(Rgba::rgb(255, 0, 0)))), ..Default::default() };
|
||||
assert_ne!(adjust_key(&adj), adjust_key(&other));
|
||||
assert_ne!(adjust_key(&adj), adjust_key(&PictureAdjust::default()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_gif_of_many_tiny_frames_keeps_only_the_frame_cap() {
|
||||
use image::{Delay, Frame, Rgba, RgbaImage};
|
||||
|
||||
@@ -13,6 +13,7 @@ mod morph_path;
|
||||
mod morph_text;
|
||||
mod paint;
|
||||
mod placed;
|
||||
mod ramp;
|
||||
mod table;
|
||||
|
||||
use std::collections::HashMap;
|
||||
@@ -221,11 +222,14 @@ pub struct Renderer {
|
||||
resources: Resources,
|
||||
/// Outlines morphing in the frame being drawn by `blend`.
|
||||
paths: Vec<PathMorph>,
|
||||
/// The background of the slide (layout, master) being drawn, its placeholder colour and the
|
||||
/// slide's view transform: shapes filled with the background (`useBgFill`) show it through.
|
||||
bg: Option<(Fill, Option<Rgba>, Affine)>,
|
||||
}
|
||||
|
||||
impl Default for Renderer {
|
||||
fn default() -> Self {
|
||||
Renderer { resources: Resources::new(), paths: Vec::new() }
|
||||
Renderer { resources: Resources::new(), paths: Vec::new(), bg: None }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -589,6 +593,7 @@ impl Renderer {
|
||||
ctx.set_transform(view);
|
||||
let path = r.to_path(0.1);
|
||||
paint::fill_path(ctx, rctx, &fill, ph, &path, r, view, FillMode::Norm, 1.0, f.pres);
|
||||
self.bg = Some((fill, ph, view));
|
||||
}
|
||||
|
||||
fn shape(&mut self, ctx: &mut RenderContext, f: &Frame, rctx: &Ctx, s: &Shape, parent: Affine, depth: usize) {
|
||||
@@ -683,7 +688,7 @@ impl Renderer {
|
||||
let bounds = Rect::new(0.0, 0.0, w, h);
|
||||
ctx.push_clip_layer(&outline);
|
||||
let t = f.opts.gif_times.iter().find(|g| g.0 == s.id).map(|g| g.1);
|
||||
paint::picture(ctx, f.pres, pf, bounds, m, t);
|
||||
paint::picture(ctx, f.pres, &paint::resolved(rctx, pf, None), bounds, m, t);
|
||||
ctx.pop_layer();
|
||||
}
|
||||
ShapeKind::Media(mc) => {
|
||||
@@ -710,7 +715,16 @@ impl Renderer {
|
||||
}
|
||||
ctx.set_transform(m);
|
||||
ctx.set_fill_rule(if sp.even_odd { peniko::Fill::EvenOdd } else { peniko::Fill::NonZero });
|
||||
paint::fill_path(ctx, rctx, fl, fill_ph, &sp.path, Rect::new(0.0, 0.0, w, h), m, sp.fill, fade(i).fill, f.pres);
|
||||
match (fl, &self.bg) {
|
||||
// `useBgFill`: the slide's background, laid out on the slide (not the
|
||||
// shape), shows through the shape.
|
||||
(Fill::Background, Some((bg, ph, view))) => {
|
||||
let slide = Rect::new(0.0, 0.0, f.pres.slide_size.width, f.pres.slide_size.height);
|
||||
let path = (view.inverse() * m) * sp.path.clone();
|
||||
paint::fill_path(ctx, rctx, bg, *ph, &path, slide, *view, sp.fill, fade(i).fill, f.pres);
|
||||
}
|
||||
_ => paint::fill_path(ctx, rctx, fl, fill_ph, &sp.path, Rect::new(0.0, 0.0, w, h), m, sp.fill, fade(i).fill, f.pres),
|
||||
}
|
||||
ctx.set_fill_rule(peniko::Fill::NonZero);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
use deckcraft_color::Rgba;
|
||||
use deckcraft_geom::preset::FillMode;
|
||||
use deckcraft_model::resolve::Ctx;
|
||||
use deckcraft_model::style::{Fill, GradientShape, PictureFill, PictureMode};
|
||||
use deckcraft_model::style::{ColorRef, Fill, GradientShape, PictureFill, PictureMode};
|
||||
use deckcraft_model::{PhType, Presentation};
|
||||
use kurbo::{Affine, BezPath, Point, Rect, Shape as _};
|
||||
use vello_cpu::{RenderContext, peniko};
|
||||
@@ -35,10 +35,11 @@ pub fn fill_path(
|
||||
if g.stops.is_empty() {
|
||||
return;
|
||||
}
|
||||
let mut stops: Vec<(f32, peniko::Color)> =
|
||||
g.stops.iter().map(|s| (s.pos.clamp(0.0, 1.0) as f32, color(shade(rctx.color(&s.color, ph), mode), alpha))).collect();
|
||||
let mut stops: Vec<(f64, Rgba)> = g.stops.iter().map(|s| (s.pos.clamp(0.0, 1.0), shade(rctx.color(&s.color, ph), mode))).collect();
|
||||
stops.sort_by(|a, b| a.0.total_cmp(&b.0));
|
||||
let stops: Vec<peniko::ColorStop> = stops.into_iter().map(|(o, c)| peniko::ColorStop { offset: o, color: c.into() }).collect();
|
||||
// PowerPoint's ramp between stops (linear light, S-shaped), see `ramp`.
|
||||
let stops: Vec<peniko::ColorStop> =
|
||||
crate::ramp::expand(&stops).into_iter().map(|(o, c)| peniko::ColorStop { offset: o as f32, color: color(c, alpha).into() }).collect();
|
||||
let grad = match &g.shape {
|
||||
GradientShape::Linear { angle, .. } => {
|
||||
// Angle measured clockwise from left→right; span the bounds along that direction.
|
||||
@@ -65,7 +66,7 @@ pub fn fill_path(
|
||||
}
|
||||
Fill::Picture(pf) => {
|
||||
ctx.push_clip_layer(path);
|
||||
picture(ctx, pres, pf, bounds, m, None);
|
||||
picture(ctx, pres, &resolved(rctx, pf, ph), bounds, m, None);
|
||||
ctx.pop_layer();
|
||||
}
|
||||
Fill::Pattern(p) => {
|
||||
@@ -120,6 +121,16 @@ fn p_rect(x: f64, y: f64, w: f64, h: f64) -> BezPath {
|
||||
}
|
||||
fn p_add(_a: &mut BezPath, _b: &mut BezPath) {}
|
||||
|
||||
/// `pf` with its duotone colours resolved to plain RGB against the slide's colours and the
|
||||
/// placeholder colour `ph` (theme background pictures recolour with `phClr`), so the decoded-image
|
||||
/// cache can apply and key them without a colour scheme.
|
||||
pub fn resolved<'a>(rctx: &Ctx, pf: &'a PictureFill, ph: Option<Rgba>) -> std::borrow::Cow<'a, PictureFill> {
|
||||
let Some((d, l)) = &pf.adjust.duotone else { return std::borrow::Cow::Borrowed(pf) };
|
||||
let mut out = pf.clone();
|
||||
out.adjust.duotone = Some((ColorRef::rgb(rctx.color(d, ph)), ColorRef::rgb(rctx.color(l, ph))));
|
||||
std::borrow::Cow::Owned(out)
|
||||
}
|
||||
|
||||
/// Draw a picture fill into `bounds` (shape-local) honouring crop and stretch/tile modes; an animated
|
||||
/// GIF shows its frame at `gif_time` seconds.
|
||||
pub fn picture(ctx: &mut RenderContext, pres: &Presentation, pf: &PictureFill, bounds: Rect, m: Affine, gif_time: Option<f64>) {
|
||||
|
||||
@@ -0,0 +1,128 @@
|
||||
//! Gradient colour ramps as PowerPoint draws them.
|
||||
//!
|
||||
//! A gradient of exactly two stops, at 0 % and 100 % (what a two-colour gradient is), is not
|
||||
//! blended linearly in sRGB by PowerPoint: it mixes the colours in linear light, and the second
|
||||
//! colour's share follows an S-shaped curve of the position. Black to white is 105 (not 64) a
|
||||
//! quarter of the way, 186 (not 128) half way and 238 (not 191) three quarters of the way, in any
|
||||
//! direction, on shapes and on slide backgrounds. Gradients with three or more stops, or with a
|
||||
//! stop moved off an end, blend linearly in sRGB (measured from PowerPoint's own slide export).
|
||||
//! The renderer interpolates linearly in sRGB, so a two-colour gradient is expanded into short
|
||||
//! pieces whose colours follow PowerPoint's ramp.
|
||||
|
||||
use deckcraft_color::Rgba;
|
||||
|
||||
/// The next stop's share of the linear-light mix at sixteenths of a segment (0 at the first stop,
|
||||
/// 1 at the second). Measured, then made symmetric: `w(1 - t) = 1 - w(t)`.
|
||||
const WEIGHT: [f64; 17] =
|
||||
[0.0, 0.0205, 0.0469, 0.0878, 0.1431, 0.2143, 0.2989, 0.3972, 0.5, 0.6028, 0.7011, 0.7857, 0.8569, 0.9122, 0.9531, 0.9795, 1.0];
|
||||
|
||||
/// Pieces each segment between two stops is cut into.
|
||||
const PIECES: usize = 16;
|
||||
|
||||
/// The next stop's share at `t` (0..1) between two stops.
|
||||
pub fn weight(t: f64) -> f64 {
|
||||
if !t.is_finite() {
|
||||
return 0.0;
|
||||
}
|
||||
let x = t.clamp(0.0, 1.0) * (WEIGHT.len() - 1) as f64;
|
||||
let i = (x.floor() as usize).min(WEIGHT.len() - 2);
|
||||
let f = x - i as f64;
|
||||
let (a, b) = (WEIGHT.get(i).copied().unwrap_or(0.0), WEIGHT.get(i + 1).copied().unwrap_or(1.0));
|
||||
a + (b - a) * f
|
||||
}
|
||||
|
||||
fn to_linear(v: u8) -> f64 {
|
||||
let v = v as f64 / 255.0;
|
||||
if v <= 0.04045 { v / 12.92 } else { ((v + 0.055) / 1.055).powf(2.4) }
|
||||
}
|
||||
|
||||
fn to_srgb(v: f64) -> u8 {
|
||||
let v = v.clamp(0.0, 1.0);
|
||||
let s = if v <= 0.0031308 { v * 12.92 } else { 1.055 * v.powf(1.0 / 2.4) - 0.055 };
|
||||
(s * 255.0).round().clamp(0.0, 255.0) as u8
|
||||
}
|
||||
|
||||
/// The colour at `t` between stops `a` and `b`. Alpha mixes by the same share.
|
||||
pub fn mix(a: Rgba, b: Rgba, t: f64) -> Rgba {
|
||||
let w = weight(t);
|
||||
let ch = |x: u8, y: u8| to_srgb(to_linear(x) + (to_linear(y) - to_linear(x)) * w);
|
||||
Rgba { r: ch(a.r, b.r), g: ch(a.g, b.g), b: ch(a.b, b.b), a: (a.a as f64 + (b.a as f64 - a.a as f64) * w).round().clamp(0.0, 255.0) as u8 }
|
||||
}
|
||||
|
||||
/// Sorted stops (position 0..1, colour) as a renderer interpolating linearly in sRGB should get
|
||||
/// them: a two-colour gradient (two stops, at 0 and 1) expanded to follow PowerPoint's ramp,
|
||||
/// anything else unchanged.
|
||||
pub fn expand(stops: &[(f64, Rgba)]) -> Vec<(f64, Rgba)> {
|
||||
let two_colour = matches!(stops, [(p, _), (q, _)] if *p <= 1e-6 && *q >= 1.0 - 1e-6);
|
||||
if !two_colour {
|
||||
return stops.to_vec();
|
||||
}
|
||||
let mut out = Vec::with_capacity(stops.len() * PIECES + 1);
|
||||
for (i, &(p, c)) in stops.iter().enumerate() {
|
||||
out.push((p, c));
|
||||
let Some(&(q, d)) = stops.get(i + 1) else { continue };
|
||||
if q - p <= 1e-6 || c == d {
|
||||
continue;
|
||||
}
|
||||
for k in 1..PIECES {
|
||||
let t = k as f64 / PIECES as f64;
|
||||
out.push((p + (q - p) * t, mix(c, d, t)));
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn black_to_white_follows_powerpoint() {
|
||||
// PowerPoint's export: 105 at a quarter, 186 half way, 238 at three quarters.
|
||||
let (k, w) = (Rgba::rgb(0, 0, 0), Rgba::rgb(255, 255, 255));
|
||||
for (t, want) in [(0.25, 105), (0.5, 186), (0.75, 238)] {
|
||||
let got = mix(k, w, t).r as i32;
|
||||
assert!((got - want).abs() <= 3, "{t}: {got} vs {want}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn colour_pairs_follow_powerpoint() {
|
||||
// Blue (4472C4) to orange (ED7D31) half way: PowerPoint draws (178, 120, 146).
|
||||
let m = mix(Rgba::rgb(68, 114, 196), Rgba::rgb(237, 125, 49), 0.5);
|
||||
assert!((m.r as i32 - 178).abs() <= 3 && (m.g as i32 - 120).abs() <= 3 && (m.b as i32 - 146).abs() <= 3, "{m:?}");
|
||||
// Red to blue a quarter of the way: (238, 0, 105).
|
||||
let m = mix(Rgba::rgb(255, 0, 0), Rgba::rgb(0, 0, 255), 0.25);
|
||||
assert!((m.r as i32 - 238).abs() <= 3 && (m.b as i32 - 105).abs() <= 3, "{m:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_ends_are_the_stops_and_the_weight_is_symmetric() {
|
||||
let (a, b) = (Rgba::rgb(10, 200, 30), Rgba::rgb(240, 20, 90));
|
||||
assert_eq!(mix(a, b, 0.0), a);
|
||||
assert_eq!(mix(a, b, 1.0), b);
|
||||
for k in 0..=32 {
|
||||
let t = k as f64 / 32.0;
|
||||
assert!((weight(t) + weight(1.0 - t) - 1.0).abs() < 1e-9);
|
||||
assert!(weight(t) <= weight((t + 0.01).min(1.0)), "monotonic");
|
||||
}
|
||||
assert_eq!(weight(f64::NAN), 0.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn expand_keeps_stops_and_hard_edges() {
|
||||
let (r, g) = (Rgba::rgb(255, 0, 0), Rgba::rgb(0, 255, 0));
|
||||
// Three or more stops, or a stop off an end, blend linearly in sRGB: unchanged.
|
||||
let e = expand(&[(0.0, r), (0.5, r), (0.5, g), (1.0, g)]);
|
||||
assert_eq!(e, vec![(0.0, r), (0.5, r), (0.5, g), (1.0, g)]);
|
||||
assert_eq!(expand(&[(0.2, r), (1.0, g)]), vec![(0.2, r), (1.0, g)]);
|
||||
assert_eq!(expand(&[(0.0, r), (0.5, g), (1.0, r)]).len(), 3);
|
||||
// Two equal colours add nothing.
|
||||
assert_eq!(expand(&[(0.0, r), (1.0, r)]), vec![(0.0, r), (1.0, r)]);
|
||||
let e = expand(&[(0.0, r), (1.0, g)]);
|
||||
assert_eq!(e.len(), PIECES + 1);
|
||||
assert_eq!(e.first(), Some(&(0.0, r)));
|
||||
assert_eq!(e.last(), Some(&(1.0, g)));
|
||||
assert!(e.windows(2).all(|w| w[0].0 <= w[1].0));
|
||||
}
|
||||
}
|
||||
@@ -523,3 +523,90 @@ fn hyperlinks_draw_in_the_hyperlink_colour_underlined() {
|
||||
let own = px(&run_deck(RunProps { link, fill: red.clone(), ..Default::default() }));
|
||||
assert_eq!(diff(&own, &px(&run_deck(RunProps { fill: red, underline: Some("sng".into()), ..Default::default() }))), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_linear_gradient_ramps_like_powerpoint() {
|
||||
// Black to white, left to right: PowerPoint draws 105 a quarter of the way and 186 half way
|
||||
// (linear-light mix, S-shaped), not 64 and 128.
|
||||
use deckcraft_model::style::{Fill, Gradient, GradientShape, GradientStop};
|
||||
let stop = |pos, c| GradientStop { pos, color: ColorRef::rgb(c) };
|
||||
let sh = Shape {
|
||||
id: ShapeId(700),
|
||||
xfrm: Some(Xfrm::new(0.0, 0.0, 800.0, 100.0)),
|
||||
fill: Some(Fill::Gradient(Gradient {
|
||||
stops: vec![stop(0.0, deckcraft_color::Rgba::rgb(0, 0, 0)), stop(1.0, deckcraft_color::Rgba::rgb(255, 255, 255))],
|
||||
shape: GradientShape::Linear { angle: 0.0, scaled: false },
|
||||
rotate_with_shape: true,
|
||||
})),
|
||||
line: Some(deckcraft_model::style::Line { fill: Some(Fill::None), ..Default::default() }),
|
||||
..Default::default()
|
||||
};
|
||||
let p = deck_with(vec![sh]);
|
||||
let img = render_slide(&p, 0, &RenderOpts { scale: 1.0, ..Default::default() });
|
||||
for (x, want) in [(200, 105), (400, 186), (600, 238)] {
|
||||
let got = img.pixel(x, 50)[0] as i32;
|
||||
assert!((got - want).abs() <= 4, "x {x}: {got} vs {want}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_gradient_with_a_stop_off_the_end_stays_linear() {
|
||||
// Stops at 20 % and 100 %: PowerPoint blends linearly in sRGB (96 at 50 %, 175 at 75 %).
|
||||
use deckcraft_model::style::{Fill, Gradient, GradientShape, GradientStop};
|
||||
let stop = |pos, c| GradientStop { pos, color: ColorRef::rgb(c) };
|
||||
let sh = Shape {
|
||||
id: ShapeId(701),
|
||||
xfrm: Some(Xfrm::new(0.0, 0.0, 800.0, 100.0)),
|
||||
fill: Some(Fill::Gradient(Gradient {
|
||||
stops: vec![stop(0.2, deckcraft_color::Rgba::rgb(0, 0, 0)), stop(1.0, deckcraft_color::Rgba::rgb(255, 255, 255))],
|
||||
shape: GradientShape::Linear { angle: 0.0, scaled: false },
|
||||
rotate_with_shape: true,
|
||||
})),
|
||||
line: Some(deckcraft_model::style::Line { fill: Some(Fill::None), ..Default::default() }),
|
||||
..Default::default()
|
||||
};
|
||||
let p = deck_with(vec![sh]);
|
||||
let img = render_slide(&p, 0, &RenderOpts { scale: 1.0, ..Default::default() });
|
||||
for (x, want) in [(400, 96), (600, 175)] {
|
||||
let got = img.pixel(x, 50)[0] as i32;
|
||||
assert!((got - want).abs() <= 4, "x {x}: {got} vs {want}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_shape_filled_with_the_background_shows_the_slide_background() {
|
||||
// `useBgFill`: PowerPoint paints the slide's background into the shape, laid out on the
|
||||
// slide. A white panel covers a black-to-grey background; a shape on it with the background
|
||||
// fill is a window onto that background.
|
||||
use deckcraft_model::style::{Fill, Gradient, GradientShape, GradientStop};
|
||||
let none = || Some(deckcraft_model::style::Line { fill: Some(Fill::None), ..Default::default() });
|
||||
let panel = Shape {
|
||||
id: ShapeId(710),
|
||||
xfrm: Some(Xfrm::new(0.0, 0.0, 960.0, 540.0)),
|
||||
fill: Some(Fill::solid(ColorRef::rgb(deckcraft_color::Rgba::rgb(255, 255, 255)))),
|
||||
line: none(),
|
||||
..Default::default()
|
||||
};
|
||||
let window = Shape {
|
||||
id: ShapeId(711),
|
||||
xfrm: Some(Xfrm::new(100.0, 100.0, 760.0, 340.0)),
|
||||
fill: Some(Fill::Background),
|
||||
line: none(),
|
||||
..Default::default()
|
||||
};
|
||||
let mut p = deck_with(vec![panel, window]);
|
||||
let stop = |pos, v| GradientStop { pos, color: ColorRef::rgb(deckcraft_color::Rgba::rgb(v, v, v)) };
|
||||
std::sync::Arc::make_mut(&mut p.slides[0]).background = Some(deckcraft_model::Background::Fill {
|
||||
fill: Fill::Gradient(Gradient {
|
||||
stops: vec![stop(0.0, 0), stop(0.5, 0), stop(1.0, 200)],
|
||||
shape: GradientShape::Linear { angle: 0.0, scaled: false },
|
||||
rotate_with_shape: true,
|
||||
}),
|
||||
});
|
||||
let img = render_slide(&p, 0, &RenderOpts { scale: 1.0, ..Default::default() });
|
||||
assert_eq!(img.pixel(50, 50)[0], 255, "the panel stays white outside the window");
|
||||
assert!(img.pixel(300, 270)[0] < 8, "the window shows the background's black half: {:?}", img.pixel(300, 270));
|
||||
// Laid out on the slide: three quarters across the slide is half way from black to grey.
|
||||
let v = img.pixel(720, 270)[0] as i32;
|
||||
assert!((v - 100).abs() <= 6, "{v}");
|
||||
}
|
||||
|
||||
+30
-8
@@ -566,6 +566,9 @@ fn shape_cells(ctx: &Ctx, text: &str, char_style: &[usize], styles: &[Style], fa
|
||||
cells
|
||||
}
|
||||
|
||||
/// Size of superscript and subscript text relative to the run's size.
|
||||
const SUB_SUPER: f64 = 0.66;
|
||||
|
||||
/// Shape cells `range` (one item) and hang each cluster's glyphs on its first cell, sharing the
|
||||
/// cluster's advance among its graphemes (by the ligature's GDEF carets when it has them).
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
@@ -593,7 +596,11 @@ fn shape_item(
|
||||
let st = styles.get(cell.style);
|
||||
let size = st.map(|s| s.size).unwrap_or(18.0);
|
||||
let small = st.is_some_and(|s| s.caps == Caps::Small) && cell.ch.is_lowercase();
|
||||
(if small { size * 0.8 } else { size }) / upem
|
||||
let size = if small { size * 0.8 } else { size };
|
||||
// Superscript and subscript glyphs are drawn at two thirds of the size, so they advance by
|
||||
// two thirds too.
|
||||
let size = if st.is_some_and(|s| s.baseline != 0.0) { size * SUB_SUPER } else { size };
|
||||
size / upem
|
||||
};
|
||||
// Cluster heads in this item, ascending; each cluster spans to the next head.
|
||||
let mut heads: Vec<usize> = glyphs.iter().filter_map(|g| char_at_byte.get(&g.cluster).copied()).filter(|h| range.contains(h)).collect();
|
||||
@@ -679,7 +686,8 @@ fn map_symbol_bullet(c: &str, font: Option<&str>) -> String {
|
||||
};
|
||||
return m.to_string();
|
||||
}
|
||||
c.to_string()
|
||||
// PowerPoint draws only the first character of a longer `buChar` ("••" draws one dot).
|
||||
c.graphemes(true).next().unwrap_or("•").to_string()
|
||||
}
|
||||
|
||||
fn measure_str(face: &FontFace, size: f64, s: &str) -> (f64, Vec<(u32, f64)>) {
|
||||
@@ -694,9 +702,20 @@ fn measure_str(face: &FontFace, size: f64, s: &str) -> (f64, Vec<(u32, f64)>) {
|
||||
(x, out)
|
||||
}
|
||||
|
||||
/// Single line spacing as a multiple of the font size. PowerPoint spaces single-spaced lines 1.2
|
||||
/// times the size apart in every font (observed on twenty Latin, CJK, Thai and Indic fonts at 18
|
||||
/// and 40 pt), whatever the font's own ascent and descent.
|
||||
///
|
||||
/// Caveat: this was measured on one Windows install of desktop PowerPoint. It may be a Windows or
|
||||
/// machine setting; PowerPoint on macOS or the web may space lines by the font's own metrics.
|
||||
/// Keep it a single tunable value, and re-measure on the other platform before changing it.
|
||||
const LINE_PITCH: f64 = 1.2;
|
||||
|
||||
/// A line's height above and below the baseline at `size`: [`LINE_PITCH`] × size, split where
|
||||
/// the font's Windows ascent and descent put the baseline.
|
||||
fn line_metrics(face: &FontFace, size: f64) -> (f64, f64) {
|
||||
let k = size / face.upem.max(1.0);
|
||||
(face.ascent * k, face.descent * k)
|
||||
let h = size * LINE_PITCH;
|
||||
(h * face.line_split, h * (1.0 - face.line_split))
|
||||
}
|
||||
|
||||
/// Break opportunities: allowed[i] = a line may start at char i.
|
||||
@@ -909,7 +928,11 @@ fn layout_scaled(ctx: &Ctx, shape: &Shape, body: &TextBody, bp: &BodyProps, opts
|
||||
let bullet_w = p.bullet.as_ref().map(|(s, st)| measure_str(&st.face, st.size, s).0).unwrap_or(0.0);
|
||||
let first_x = if p.bullet.is_some() {
|
||||
let bx = (margin + indent).max(0.0);
|
||||
if indent < 0.0 && bx + bullet_w <= margin { margin } else { bx + bullet_w + if indent >= 0.0 { 0.0 } else { 3.0 } }
|
||||
// A hanging bullet's text starts at the margin. When the hang reaches left of the box
|
||||
// (marL + indent < 0), PowerPoint pins the bullet to the box's edge and keeps the
|
||||
// hang: the first line's text starts at -indent (later lines stay at the margin).
|
||||
let stop = margin.max(-indent);
|
||||
if indent < 0.0 && bx + bullet_w <= stop { stop } else { bx + bullet_w + if indent >= 0.0 { 0.0 } else { 3.0 } }
|
||||
} else {
|
||||
(margin + indent).max(0.0)
|
||||
};
|
||||
@@ -1155,7 +1178,7 @@ fn layout_scaled(ctx: &Ctx, shape: &Shape, body: &TextBody, bp: &BodyProps, opts
|
||||
let Some(st) = p.styles.get(cell.style) else { continue };
|
||||
let Some(&(cx, cx1)) = line.edges.get(k) else { continue };
|
||||
let base_y = line.baseline - st.baseline * st.size;
|
||||
let size = if st.baseline != 0.0 { st.size * 0.66 } else { st.size };
|
||||
let size = if st.baseline != 0.0 { st.size * SUB_SUPER } else { st.size };
|
||||
let size = if st.caps == Caps::Small && cell.ch.is_lowercase() { size * 0.8 } else { size };
|
||||
let key = (cell.style, cell.face);
|
||||
if key != cur_key {
|
||||
@@ -1204,9 +1227,8 @@ fn layout_scaled(ctx: &Ctx, shape: &Shape, body: &TextBody, bp: &BodyProps, opts
|
||||
}
|
||||
}
|
||||
}
|
||||
let k66 = if st.baseline != 0.0 { 0.66 } else { 1.0 };
|
||||
for (gid, gx, gy) in &cell.glyphs {
|
||||
r.glyphs.push((*gid, cl + kash + gx * k66, base_y + gy));
|
||||
r.glyphs.push((*gid, cl + kash + gx, base_y + gy));
|
||||
r.cells.push((ci, cell.ch));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -45,6 +45,20 @@ fn bullets_are_drawn_for_body_text() {
|
||||
assert_eq!(l.para_lines.len(), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn only_the_first_character_of_a_bullet_is_drawn() {
|
||||
// "••" (seen in the wild) draws one dot in PowerPoint.
|
||||
for (char, glyphs) in [("\u{2022}\u{2022}", 1), ("\u{2022}", 1), ("-->", 1)] {
|
||||
let (p, mut sh) = setup("One", 600.0, 300.0);
|
||||
sh.text.as_mut().unwrap().paragraphs[0].props.bullet = Some(Bullet::Char { char: char.into() });
|
||||
let l = lay(&p, &sh, 600.0, 300.0);
|
||||
let bullet = l.runs.iter().find(|r| r.chars == (0, 0)).expect("bullet run");
|
||||
assert_eq!(bullet.glyphs.len(), glyphs, "{char:?}");
|
||||
}
|
||||
// The first grapheme cluster is kept whole.
|
||||
assert_eq!(map_symbol_bullet("e\u{301}x", None), "e\u{301}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shrink_on_overflow() {
|
||||
let text = (0..30).map(|i| format!("Line number {i}")).collect::<Vec<_>>().join("\n");
|
||||
@@ -395,3 +409,114 @@ fn hostile_bidi_input_never_panics() {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Width of `word` laid out in a run with `baseline` (0 = normal, 0.3 = superscript …).
|
||||
fn run_width(word: &str, baseline: Option<f64>) -> f64 {
|
||||
let mut run = Run::new(word);
|
||||
run.props.size = Some(24.0);
|
||||
run.props.baseline = baseline;
|
||||
let (p, sh) = bidi_setup(vec![run], 2000.0, false, Align::Left);
|
||||
let l = lay(&p, &sh, 2000.0, 300.0);
|
||||
let line = &l.lines[0];
|
||||
line.caret_x.last().unwrap() - line.caret_x.first().unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn superscript_and_subscript_advance_at_their_drawn_size() {
|
||||
// The glyphs are drawn at two thirds of the size; advancing by the full size spread the
|
||||
// letters apart ("s u p e r").
|
||||
let normal = run_width("superscript", None);
|
||||
for b in [Some(0.3), Some(-0.25)] {
|
||||
let w = run_width("superscript", b);
|
||||
assert!((w / normal - SUB_SUPER).abs() < 0.02, "{b:?}: {w} vs {normal}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn superscript_glyphs_sit_inside_their_cells() {
|
||||
let mut run = Run::new("super");
|
||||
run.props.size = Some(24.0);
|
||||
run.props.baseline = Some(0.3);
|
||||
let (p, sh) = bidi_setup(vec![run], 2000.0, false, Align::Left);
|
||||
let l = lay(&p, &sh, 2000.0, 300.0);
|
||||
let line = &l.lines[0];
|
||||
let r = l.runs.iter().find(|r| !r.cells.is_empty()).unwrap();
|
||||
assert!((r.size - 24.0 * SUB_SUPER).abs() < 1e-6);
|
||||
for (g, (ci, _)) in r.glyphs.iter().zip(&r.cells) {
|
||||
let (x0, x1) = line.edges[*ci - line.start];
|
||||
assert!(g.1 >= x0 - 0.01 && g.1 < x1, "glyph at {} outside its cell {x0}..{x1}", g.1);
|
||||
}
|
||||
}
|
||||
|
||||
/// A wrapping numbered paragraph with `marL` and `indent`; returns (bullet x, first line's text
|
||||
/// x, second line's text x).
|
||||
fn hanging(margin: f64, indent: f64, bullet: Bullet) -> (Option<f64>, f64, f64) {
|
||||
let text = "Wrapping words go on and on so the paragraph needs at least two or three lines here";
|
||||
let (p, mut sh) = setup(text, 300.0, 300.0);
|
||||
let para = &mut sh.text.as_mut().unwrap().paragraphs[0];
|
||||
para.props.bullet = Some(bullet);
|
||||
para.props.margin_left = Some(margin);
|
||||
para.props.indent = Some(indent);
|
||||
let l = lay(&p, &sh, 300.0, 300.0);
|
||||
assert!(l.lines.len() >= 2, "{} lines", l.lines.len());
|
||||
let bx = l.runs.iter().find(|r| r.cells.is_empty() && !r.glyphs.is_empty()).map(|r| r.glyphs[0].1);
|
||||
(bx, l.lines[0].caret_x[0], l.lines[1].caret_x[0])
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_hang_past_the_box_edge_keeps_its_width_on_the_first_line() {
|
||||
// PowerPoint, marL 0 / indent -36pt: the number sits at the box's edge, the first line's text
|
||||
// 36pt in, and the wrapped lines at the margin (observed with PowerPoint's slide export).
|
||||
let num = || Bullet::AutoNum { scheme: "arabicPeriod".into(), start_at: 1 };
|
||||
let (bx, first, rest) = hanging(0.0, -36.0, num());
|
||||
assert!((first - rest - 36.0).abs() < 0.5, "first {first} rest {rest}");
|
||||
assert!((bx.unwrap() - rest).abs() < 0.5, "bullet {bx:?} rest {rest}");
|
||||
// marL 18 / indent -54: number at the edge, first line at 54, the rest at 18.
|
||||
let (bx, first, rest) = hanging(18.0, -54.0, num());
|
||||
assert!((first - rest - 36.0).abs() < 0.5, "first {first} rest {rest}");
|
||||
assert!((rest - bx.unwrap() - 18.0).abs() < 0.5, "bullet {bx:?} rest {rest}");
|
||||
// A character bullet behaves the same.
|
||||
let (_, first, rest) = hanging(0.0, -36.0, Bullet::Char { char: "•".into() });
|
||||
assert!((first - rest - 36.0).abs() < 0.5, "first {first} rest {rest}");
|
||||
// Without a bullet the first line just starts at the edge.
|
||||
let (_, first, rest) = hanging(0.0, -36.0, Bullet::None);
|
||||
assert!((first - rest).abs() < 0.5, "first {first} rest {rest}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_ordinary_hang_still_puts_the_text_at_the_margin() {
|
||||
let (bx, first, rest) = hanging(36.0, -36.0, Bullet::AutoNum { scheme: "arabicPeriod".into(), start_at: 1 });
|
||||
assert!((first - rest).abs() < 0.5, "first {first} rest {rest}");
|
||||
assert!((rest - bx.unwrap() - 36.0).abs() < 0.5);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn single_spaced_lines_are_1_2_times_the_size_apart_whatever_the_font() {
|
||||
// PowerPoint spaces single-spaced lines 1.2 × the font size apart in every font, with the
|
||||
// baseline where the font's Windows ascent/descent split puts it (observed on twenty fonts).
|
||||
for size in [18.0, 40.0] {
|
||||
let mut run = Run::new("HHH");
|
||||
run.props.size = Some(size);
|
||||
let (p, mut sh) = bidi_setup(vec![run], 2000.0, false, Align::Left);
|
||||
let t = sh.text.as_mut().unwrap();
|
||||
t.body.autofit = Some(AutoFit::None);
|
||||
let second = t.paragraphs[0].clone();
|
||||
t.paragraphs.push(second.clone());
|
||||
t.paragraphs.push(second);
|
||||
for para in &mut t.paragraphs {
|
||||
para.props.line_spacing = Some(Spacing::Pct(1.0));
|
||||
para.props.space_before = Some(Spacing::Pts(0.0));
|
||||
para.props.space_after = Some(Spacing::Pts(0.0));
|
||||
}
|
||||
let l = lay(&p, &sh, 2000.0, 600.0);
|
||||
assert_eq!(l.lines.len(), 3);
|
||||
// Each line is 1.2 × the size tall (the face is not compared across lines: another
|
||||
// test's thread can load the family between them, see #252).
|
||||
for line in &l.lines {
|
||||
assert!((line.bottom - line.top - size * LINE_PITCH).abs() < 1e-6, "{size}: {}", line.bottom - line.top);
|
||||
}
|
||||
let face = &l.runs[0].face;
|
||||
let first = &l.lines[0];
|
||||
assert!((first.baseline - first.top - size * LINE_PITCH * face.line_split).abs() < 1e-6);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user