Compare commits

..
Author SHA1 Message Date
Brandon ThomasandClaude Opus 5.5 a837b81745 Render: shapes filled with the background (useBgFill) show it
A shape with useBgFill="1" is a window onto the slide's background, laid
out on the slide. DeckCraft drew nothing in it, so a master that covers the
background with a white panel and cuts a background-filled frame into it
(tdf93868) rendered all white.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-12 17:43:14 -04:00
Brandon ThomasandClaude Opus 5.5 2e68504e7c Text: single-spaced lines are 1.2 times the font size apart, as in PowerPoint (#266)
* Text: single-spaced lines are 1.2 times the font size apart, as in PowerPoint

PowerPoint spaces single-spaced lines 1.2 x the font size apart in every
font (measured on twenty Latin, CJK, Thai and Indic fonts at 18 and 40 pt
with PowerPoint's slide export) and puts the baseline where the font's
Windows ascent/descent split it. DeckCraft used each font's own ascent plus
descent, so Calibri lines were 15% too tight and Segoe UI ones too loose.

FontFace gains line_split (usWinAscent / (usWinAscent + usWinDescent));
line_metrics uses it with LINE_PITCH = 1.2.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* Text: tidy the line pitch test

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* Fonts: line_split guard reads plainly (clippy)

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* Text: note that the 1.2 line pitch was measured on one Windows install

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-12 17:42:53 -04:00
Brandon ThomasandClaude Opus 5.5 e6050b7d37 Text: a hang past the box edge keeps its width on the first line (#265)
When marL + indent is negative, PowerPoint pins the bullet to the box's edge
and starts the first line's text at -indent; DeckCraft put the text right
after the number, so numbered lists written with indent=-457200 and no marL
lost their gap.

Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-12 17:41:01 -04:00
Brandon ThomasandClaude Opus 5.5 72d8e0ab42 Draw only the first character of a multi-character bullet (#307)
PowerPoint draws buChar char="••" (seen in a LibreOffice test deck) as a
single dot; DeckCraft drew both, pushing the text right.

Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-12 17:36:44 -04:00
Brandon ThomasandClaude Opus 5.5 2347a53845 PPTX: a placeholder without geometry takes its layout's (#301)
PowerPoint leaves an inheriting placeholder's spPr without a geometry and
draws it with its layout (or master) placeholder's: a picture placeholder
cut into a shape, or with holes, keeps that shape on every slide. DeckCraft
read such placeholders as rectangles. They're now marked while reading
and given their parent's geometry once the layouts are read; anything
without a parent stays a rectangle. DeckCraft itself already writes
inheriting placeholders this way.

Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-12 17:36:37 -04:00
Brandon ThomasandClaude Opus 5.5 692aa0103b PPTX: a picture fill with no stretch or tile tiles (#290)
A shape or background blipFill with neither a:stretch nor a:tile (as some
tools write it, tdf#128596) was stretched across the shape; PowerPoint
tiles the picture at its own size.

Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-12 17:36:28 -04:00
Brandon ThomasandClaude Opus 5.5 ceb6e3b990 Render: two-colour gradients ramp like PowerPoint's (#273)
PowerPoint blends a gradient of exactly two stops at 0% and 100% in linear
light, with the second colour's share following an S-shaped curve (black to
white is 105/186/238 at a quarter/half/three quarters, not 64/128/191).
Gradients with more stops, or a stop moved off an end, stay linear in sRGB.
Measured with PowerPoint's slide export on shapes and backgrounds, in both
directions. The two-colour case is expanded into sixteen pieces per
segment so vello's sRGB interpolation draws the same ramp.

Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-12 17:35:59 -04:00
Brandon ThomasandClaude Opus 5.5 4d13652f0f Pictures: duotone recolours, and layout/slide colour map overrides apply (#269)
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>
2026-10-12 17:35:52 -04:00
Brandon ThomasandClaude Opus 5.5 235e34613b Text: superscript and subscript advance at their drawn size (#264)
Superscript and subscript glyphs were drawn at two thirds of the run's size but
advanced by the full size, so the letters spread apart ("s u p e r") and the
line ran wider than in PowerPoint. The cluster advance now uses the same scale
as the glyphs.

Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-12 17:35:28 -04:00
Brandon ThomasandClaude Opus 5.5 688c7ef592 Release 0.6.1: rebuild with the fixed shared release workflow
The previous release build failed in craft-common-actions, not in this
repo: riscv64 packaging got --formats deb,rpm,tar (fixed in
craft-common-actions#3), Linux jobs lacked the ALSA headers
(craft-common-actions#5), and Windows signing needed the AZURE_* secrets
listed in the caller.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 11:11:13 -04:00
Brandon ThomasandClaude Opus 5.5 a4540c1f0e Release workflow: pass the Azure signing secrets so Windows builds are signed (#181)
Since the release workflow moved to storytold/craft-common-actions, it has
passed only the Apple and WINDOWS_CERTIFICATE secrets. The signing job reads
values from the release environment, but only for names the caller lists, so
the AZURE_* names that Trusted Signing uses came through empty and Windows
builds would ship unsigned. Same fix as storytold/craft-launcher#174.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 11:08:13 -04:00
Brandon ThomasandClaude Opus 5.5 0ec0cc5e81 Release prep: 0.6.0
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 05:44:22 -04:00
23 changed files with 889 additions and 71 deletions
Generated
+27 -27
View File
@@ -877,7 +877,7 @@ checksum = "0c87e182de0887fd5361989c677c4e8f5000cd9491d6d563161a8f3a5519fc7f"
[[package]]
name = "deckcraft"
version = "0.5.0"
version = "0.6.1"
dependencies = [
"arboard",
"cpal",
@@ -896,7 +896,7 @@ dependencies = [
[[package]]
name = "deckcraft-anim"
version = "0.5.0"
version = "0.6.1"
dependencies = [
"deckcraft-geom",
"deckcraft-model",
@@ -905,7 +905,7 @@ dependencies = [
[[package]]
name = "deckcraft-av1"
version = "0.5.0"
version = "0.6.1"
dependencies = [
"deckcraft-bitstream",
"rayon",
@@ -914,14 +914,14 @@ dependencies = [
[[package]]
name = "deckcraft-bitstream"
version = "0.5.0"
version = "0.6.1"
dependencies = [
"proptest",
]
[[package]]
name = "deckcraft-cli"
version = "0.5.0"
version = "0.6.1"
dependencies = [
"deckcraft-engine",
"deckcraft-mcp",
@@ -930,14 +930,14 @@ dependencies = [
[[package]]
name = "deckcraft-color"
version = "0.5.0"
version = "0.6.1"
dependencies = [
"serde",
]
[[package]]
name = "deckcraft-engine"
version = "0.5.0"
version = "0.6.1"
dependencies = [
"deckcraft-color",
"deckcraft-fonts",
@@ -959,7 +959,7 @@ dependencies = [
[[package]]
name = "deckcraft-fonts"
version = "0.5.0"
version = "0.6.1"
dependencies = [
"epaint_default_fonts",
"harfrust 0.13.3",
@@ -971,7 +971,7 @@ dependencies = [
[[package]]
name = "deckcraft-format"
version = "0.5.0"
version = "0.6.1"
dependencies = [
"deckcraft-model",
"serde",
@@ -982,7 +982,7 @@ dependencies = [
[[package]]
name = "deckcraft-geom"
version = "0.5.0"
version = "0.6.1"
dependencies = [
"kurbo",
"serde",
@@ -990,7 +990,7 @@ dependencies = [
[[package]]
name = "deckcraft-h264"
version = "0.5.0"
version = "0.6.1"
dependencies = [
"deckcraft-bitstream",
"rayon",
@@ -999,7 +999,7 @@ dependencies = [
[[package]]
name = "deckcraft-hevc"
version = "0.5.0"
version = "0.6.1"
dependencies = [
"deckcraft-bitstream",
"rayon",
@@ -1008,7 +1008,7 @@ dependencies = [
[[package]]
name = "deckcraft-isobmff"
version = "0.5.0"
version = "0.6.1"
dependencies = [
"deckcraft-bitstream",
"thiserror 2.0.21",
@@ -1016,11 +1016,11 @@ dependencies = [
[[package]]
name = "deckcraft-matroska"
version = "0.5.0"
version = "0.6.1"
[[package]]
name = "deckcraft-mcp"
version = "0.5.0"
version = "0.6.1"
dependencies = [
"deckcraft-engine",
"deckcraft-render",
@@ -1030,7 +1030,7 @@ dependencies = [
[[package]]
name = "deckcraft-media"
version = "0.5.0"
version = "0.6.1"
dependencies = [
"deckcraft-av1",
"deckcraft-h264",
@@ -1048,7 +1048,7 @@ dependencies = [
[[package]]
name = "deckcraft-model"
version = "0.5.0"
version = "0.6.1"
dependencies = [
"deckcraft-color",
"deckcraft-geom",
@@ -1059,15 +1059,15 @@ dependencies = [
[[package]]
name = "deckcraft-ogg"
version = "0.5.0"
version = "0.6.1"
[[package]]
name = "deckcraft-opus"
version = "0.5.0"
version = "0.6.1"
[[package]]
name = "deckcraft-pdf"
version = "0.5.0"
version = "0.6.1"
dependencies = [
"deckcraft-fonts",
"deckcraft-model",
@@ -1082,7 +1082,7 @@ dependencies = [
[[package]]
name = "deckcraft-pptx"
version = "0.5.0"
version = "0.6.1"
dependencies = [
"deckcraft-color",
"deckcraft-engine",
@@ -1098,7 +1098,7 @@ dependencies = [
[[package]]
name = "deckcraft-render"
version = "0.5.0"
version = "0.6.1"
dependencies = [
"deckcraft-color",
"deckcraft-fonts",
@@ -1114,7 +1114,7 @@ dependencies = [
[[package]]
name = "deckcraft-text"
version = "0.5.0"
version = "0.6.1"
dependencies = [
"chrono",
"deckcraft-color",
@@ -1128,7 +1128,7 @@ dependencies = [
[[package]]
name = "deckcraft-ui-egui"
version = "0.5.0"
version = "0.6.1"
dependencies = [
"deckcraft-anim",
"deckcraft-color",
@@ -1150,7 +1150,7 @@ dependencies = [
[[package]]
name = "deckcraft-vp9"
version = "0.5.0"
version = "0.6.1"
dependencies = [
"deckcraft-bitstream",
"rayon",
@@ -1159,7 +1159,7 @@ dependencies = [
[[package]]
name = "deckcraft-web"
version = "0.5.0"
version = "0.6.1"
dependencies = [
"deckcraft-engine",
"deckcraft-ui-egui",
@@ -5655,7 +5655,7 @@ checksum = "9440ea3e5aeabb0ac63af70daf835274065238cdd0cec83418f417eae38bacee"
[[package]]
name = "xtask"
version = "0.5.0"
version = "0.6.1"
dependencies = [
"serde_json",
]
+1 -1
View File
@@ -4,7 +4,7 @@ members = ["crates/*", "apps/*", "xtask"]
default-members = ["crates/*", "apps/deckcraft", "apps/deckcraft-cli"]
[workspace.package]
version = "0.5.0"
version = "0.6.1"
edition = "2024"
license = "MIT OR Apache-2.0"
rust-version = "1.90"
+1
View File
@@ -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,
},
);
+22
View File
@@ -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,
+11
View File
@@ -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());
+1
View File
@@ -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
View File
@@ -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 {
+8 -4
View File
@@ -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(),
};
+28
View File
@@ -159,3 +159,31 @@ fn hyperlinked_runs_take_the_theme_hyperlink_look() {
let dbl = resolve::run(&ctx, title, &para, &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));
}
+6
View File
@@ -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,
},
+94 -1
View File
@@ -14,7 +14,7 @@ use std::sync::Arc;
use deckcraft_geom::{Size, emu_to_pt};
use deckcraft_model::text::TextBody;
use deckcraft_model::{
Background, Comment, CustomShow, HeaderFooter, Layout, LayoutId, LayoutType, Master, MasterId, MediaId, MediaItem, PhType, Presentation,
Background, Comment, CustomShow, Geom, HeaderFooter, Layout, LayoutId, LayoutType, Master, MasterId, MediaId, MediaItem, PhType, Presentation,
Properties, Section, ShowSettings, Slide, SlideId, defaults,
};
@@ -387,6 +387,7 @@ pub fn import(bytes: &[u8]) -> Result<Presentation, PptxError> {
p.props.company = d.root.child("Company").map(|c| c.text()).unwrap_or_default();
}
inherit_placeholder_geometry(&mut p);
p.header_footer = header_footer(&p);
p.media = std::mem::take(&mut imp.media);
p.next_id = imp.next;
@@ -419,6 +420,91 @@ pub fn color_map(cm: &El) -> Vec<(String, String)> {
cm.attrs.iter().filter(|(k, _)| !k.starts_with("xmlns")).map(|(k, v)| (k.clone(), v.clone())).collect()
}
/// Placeholders read without a geometry (an empty custom geometry, see `shapes::sp`) take the
/// geometry of the layout or master placeholder they inherit from, as PowerPoint draws them: a
/// layout's picture placeholder cut into a shape keeps that shape on the slide. Anything left
/// is a rectangle.
fn inherit_placeholder_geometry(p: &mut Presentation) {
let unset = |g: &Geom| matches!(g, Geom::Custom { paths } if paths.is_empty());
// Masters first, then layouts (from their masters), then slides (from layouts and masters).
for m in &mut p.masters {
let m = Arc::make_mut(m);
walk_shapes_mut(&mut m.shapes, &mut |s| {
if unset(&s.geom) {
s.geom = Geom::default();
}
});
}
let mut layout_geoms: Vec<(LayoutId, usize, Geom)> = vec![];
for m in &p.masters {
for l in &m.layouts {
let ctx = deckcraft_model::resolve::Ctx::for_layout(p, m, l);
for (i, s) in l.shapes.iter().enumerate() {
if unset(&s.geom) {
let (_, mp) = deckcraft_model::resolve::parents(&ctx, s);
layout_geoms.push((l.id, i, mp.map(|m| m.geom.clone()).filter(|g| !unset(g)).unwrap_or_default()));
}
}
}
}
for m in &mut p.masters {
let m = Arc::make_mut(m);
for l in &mut m.layouts {
for (id, i, g) in layout_geoms.iter().filter(|(id, _, _)| *id == l.id) {
let _ = id;
if let Some(s) = l.shapes.get_mut(*i) {
s.geom = g.clone();
}
}
}
}
let mut slide_geoms: Vec<(usize, usize, Geom)> = vec![];
for (si, sl) in p.slides.iter().enumerate() {
let Some(ctx) = deckcraft_model::resolve::Ctx::for_slide(p, sl) else { continue };
for (i, s) in sl.shapes.iter().enumerate() {
if unset(&s.geom) {
let (lp, mp) = deckcraft_model::resolve::parents(&ctx, s);
let g = lp.map(|l| l.geom.clone()).filter(|g| !unset(g)).or_else(|| mp.map(|m| m.geom.clone()).filter(|g| !unset(g)));
slide_geoms.push((si, i, g.unwrap_or_default()));
}
}
}
for (si, i, g) in slide_geoms {
if let Some(sl) = p.slides.get_mut(si)
&& let Some(s) = Arc::make_mut(sl).shapes.get_mut(i)
{
s.geom = g;
}
}
// Placeholders inside groups (rare) and anything still unset: rectangles.
for sl in &mut p.slides {
walk_shapes_mut(&mut Arc::make_mut(sl).shapes, &mut |s| {
if unset(&s.geom) {
s.geom = Geom::default();
}
});
}
for m in &mut p.masters {
for l in &mut Arc::make_mut(m).layouts {
walk_shapes_mut(&mut l.shapes, &mut |s| {
if unset(&s.geom) {
s.geom = Geom::default();
}
});
}
}
}
/// Every shape, groups included.
fn walk_shapes_mut(shapes: &mut [deckcraft_model::Shape], f: &mut dyn FnMut(&mut deckcraft_model::Shape)) {
for s in shapes {
f(s);
if let deckcraft_model::ShapeKind::Group { children, .. } = &mut s.kind {
walk_shapes_mut(children, f);
}
}
}
fn background(imp: &mut Imp, part: &Part, csld: &El) -> Option<Background> {
let bg = csld.child("bg")?;
if let Some(pr) = bg.child("bgPr") {
@@ -523,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 {
@@ -536,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();
+10
View File
@@ -180,6 +180,15 @@ fn preset_geom(pg: &El) -> Geom {
Geom::Preset { name, adj }
}
/// A placeholder whose `spPr` names no geometry takes its layout's (or master's) placeholder's:
/// marked here with an empty custom geometry, filled in once the layouts are read
/// (`inherit_placeholder_geometry`).
fn mark_inherited_geometry(s: &mut Shape, pr: Option<&El>) {
if s.ph.is_some() && pr.is_none_or(|pr| pr.child("prstGeom").is_none() && pr.child("custGeom").is_none()) {
s.geom = Geom::Custom { paths: vec![] };
}
}
/// `spPr` → transform, geometry, fill, line, effects.
fn sp_pr(imp: &mut Imp, part: &Part, s: &mut Shape, pr: &El) {
if let Some(x) = pr.child("xfrm") {
@@ -229,6 +238,7 @@ fn sp(imp: &mut Imp, part: &Part, ctx: &mut IdCtx, e: &El) -> Shape {
if let Some(pr) = e.child("spPr") {
sp_pr(imp, part, &mut s, pr);
}
mark_inherited_geometry(&mut s, e.child("spPr"));
if e.bool("useBgFill") == Some(true) {
s.fill = Some(Fill::Background);
}
+18 -7
View File
@@ -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);
}
+81
View File
@@ -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..])
}
+64
View File
@@ -0,0 +1,64 @@
//! Placeholder inheritance in decks written by PowerPoint.
use std::io::{Cursor, Read, Write};
use deckcraft_model::{Geom, 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()
}
#[test]
fn a_placeholder_without_geometry_takes_its_layouts() {
// PowerPoint leaves a slide placeholder's spPr empty and draws it with the layout
// placeholder's geometry (a picture placeholder cut into a shape keeps the shape).
let mut p = defaults::new_presentation(None);
let layout = p.slides[0].layout;
let m = std::sync::Arc::make_mut(&mut p.masters[0]);
let l = m.layouts.iter_mut().find(|l| l.id == layout).expect("layout");
for s in &mut l.shapes {
if s.ph.is_some() {
s.geom = Geom::Preset { name: "ellipse".into(), adj: vec![] };
}
}
let bytes = deckcraft_pptx::export(&p).expect("export");
// Strip the slide's own geometry, as PowerPoint writes inheriting placeholders.
let bare = edit_slides(&bytes, |s| {
let mut s = s;
while let Some(i) = s.find("<a:prstGeom") {
let Some(j) = s[i..].find("</a:prstGeom>").map(|j| j + i + "</a:prstGeom>".len()) else { break };
s.replace_range(i..j, "");
}
s
});
let q = deckcraft_pptx::import(&bare).expect("import");
let phs: Vec<_> = q.slides[0].shapes.iter().filter(|s| s.ph.is_some()).collect();
assert!(!phs.is_empty());
for s in phs {
assert!(matches!(&s.geom, Geom::Preset { name, .. } if name == "ellipse"), "{:?}", s.geom);
}
// A placeholder with its own geometry keeps it.
let first = p.slides[0].id;
for s in &mut p.slide_mut(first).expect("slide").shapes {
if s.ph.is_some() {
s.geom = Geom::Preset { name: "triangle".into(), adj: vec![] };
}
}
let q = deckcraft_pptx::import(&deckcraft_pptx::export(&p).expect("export")).expect("import");
assert!(q.slides[0].shapes.iter().filter(|s| s.ph.is_some()).all(|s| matches!(&s.geom, Geom::Preset { name, .. } if name == "triangle")));
}
+36
View File
@@ -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)));
}
+43
View File
@@ -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};
+17 -3
View File
@@ -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);
}
}
+16 -5
View File
@@ -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>) {
+128
View File
@@ -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));
}
}
+87
View File
@@ -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
View File
@@ -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));
}
}
+125
View File
@@ -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);
}
}