Compare commits

..
Author SHA1 Message Date
Brandon ThomasandClaude Opus 5.5 6702ec8878 Text: shadows and glows are drawn
Run shadows and glows (a:rPr/a:effectLst outerShdw, glow) were read but
never drawn. The text layout now carries them on each glyph run (colours
resolved, sizes scaled with autofit) and the renderer draws every run's
shadow, then every glow, blurred, under the text. A text glow is solid to
about a third of its radius past the letters and fades out by two thirds,
as measured on PowerPoint's export. needs_filters also looks at text
effects (runs, list styles, master text styles), so they render with the
blur filter.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-12 17:51:50 -04:00
Brandon ThomasandClaude Opus 5.5 1bb86db54a Text: gradient fills draw, starting over on every line (#281)
A gradient on text was read but drawn in its first colour. Each line is now
filled with the whole gradient, spanning that line's text from its top to
its bottom and its first to its last character, as PowerPoint does. The
gradient builder moves out of fill_path into paint::gradient so fills and
text share it (and PowerPoint's two-colour ramp). Gradient colours are
resolved when the text style is made.

Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-12 17:51:20 -04:00
Brandon ThomasandClaude Opus 5.5 ba56742fa9 Emoji: drawn in colour, with families, skin tones and flags joined (#278)
* Emoji: colour glyphs, and emoji take the colour emoji font

Emoji were drawn as black outlines from a symbol font, with ZWJ sequences
(families, flags) falling apart into their parts. Now:

- an emoji cluster (emoji presentation, U+FE0F, ZWJ between pictographs,
  skin tones, regional indicators, keycaps) takes an installed colour font
  (COLR, CBDT, sbix or SVG tables) before the document's fonts, so its
  sequences ligate as in PowerPoint; U+FE0E keeps a plain face;
- COLR v0 and v1 glyphs are painted in their CPAL colours (solid fills,
  linear, radial and sweep gradients, transforms, composite modes), with
  palette index 0xFFFF in the text colour.

A tiny COLRv0 test font (two squares, CC0, regenerated by
make_colr_font.py) backs the render test.

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

* Render: lock skrifa for colour glyphs

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:48:51 -04:00
Brandon ThomasandClaude Opus 5.5 8b160d5b03 Text: wrap URLs, Korean and CJK punctuation where PowerPoint does (#276)
* Text: wrap lines where PowerPoint does

- Without latinLnBrk (PowerPoint's default) a word doesn't break after '/'
  or '\': URLs and paths fill the line and break at its edge.
- Korean wraps between words, not between syllables.
- With hangingPunct, closing East Asian punctuation hangs past the end of
  the line instead of dragging the previous character down.

ParaProps gains latin_line_break and hanging_punct, read and written as
latinLnBrk and hangingPunct.

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

* Text: simpler break guard (clippy)

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:47:12 -04:00
Brandon ThomasandClaude Opus 5.5 39a8234633 Render: slide backgrounds are laid over white (#275)
PowerPoint shows white around a background picture stretched into part of
the slide and under a see-through background colour. DeckCraft left those
areas transparent, which exports and the slide show draw black.

Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-12 17:45:31 -04:00
Brandon ThomasandClaude Opus 5.5 98ea8d3195 Render: shapes filled with the background (useBgFill) show it (#274)
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:44:20 -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
29 changed files with 1678 additions and 76 deletions
+2
View File
@@ -27,6 +27,8 @@ deck and UI icons are original work of the DeckCraft contributors (drawn or gene
| `crates/media/tests/fixtures/tone*` (`tone.mp3`, `.m4a`, `.aac`, `.ogg`, `.opus`, `.flac`, `.wma`, `tone-alac.m4a`) | DeckCraft contributors | synthesised 0.5 s 440 Hz sine, encoded by `crates/media/tests/fixtures/make.sh` (ffmpeg as the encoder only) | CC0-1.0 | Audio decoding tests |
| `crates/media/tests/fixtures/clip-*` (`clip-h264.mp4`, `clip-h264.mov`, `clip-hevc.mp4`, `clip-vp9.webm`, `clip-av1.mkv`) | DeckCraft contributors | 6 frames of a colour gradient generated by `make.sh` (python3), encoded by ffmpeg (x264 / x265 / libvpx / SVT-AV1 as encoders only) | CC0-1.0 | Video decoding tests |
| `crates/media/tests/fixtures/make.sh` | DeckCraft contributors | this repository | MIT OR Apache-2.0 | Regenerates the media fixtures |
| `crates/render/tests/fixtures/colr-squares.ttf` | DeckCraft contributors | two squares as a COLRv0/CPAL test font, built by `make_colr_font.py` (fontTools as the builder only) | CC0-1.0 | Colour glyph rendering tests |
| `crates/render/tests/fixtures/make_colr_font.py` | DeckCraft contributors | this repository | MIT OR Apache-2.0 | Regenerates the COLR test font |
## Bundled through dependencies
Generated
+1
View File
@@ -1108,6 +1108,7 @@ dependencies = [
"image",
"kurbo",
"log",
"skrifa 0.47.0",
"vello_common 0.2.0",
"vello_cpu 0.2.0",
]
+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,
},
);
+35
View File
@@ -24,6 +24,22 @@ fn ignorable(c: char) -> bool {
|| matches!(c as u32, 0x200B..=0x200F | 0x202A..=0x202E | 0x2060..=0x2069 | 0xFE00..=0xFE0F | 0xFEFF | 0xE0100..=0xE01EF)
}
/// Is the cluster an emoji drawn in colour: a pictograph that defaults to emoji presentation, or
/// any sequence with an emoji presentation selector (U+FE0F), a zero-width joiner between
/// pictographs, a skin tone modifier, regional indicators (flags) or a keycap?
pub fn is_emoji_cluster(cluster: &[char]) -> bool {
if cluster.contains(&'\u{FE0E}') {
return false;
}
let pictographic = |c: char| matches!(c as u32, 0x1F000..=0x1FAFF | 0x2600..=0x27BF | 0x2B00..=0x2BFF | 0x2190..=0x21FF | 0x2300..=0x23FF);
let default_emoji =
|c: char| matches!(c as u32, 0x1F300..=0x1F64F | 0x1F680..=0x1F6FF | 0x1F900..=0x1F9FF | 0x1FA70..=0x1FAFF | 0x1F1E6..=0x1F1FF);
cluster.iter().any(|&c| default_emoji(c))
|| (cluster.contains(&'\u{FE0F}') && cluster.iter().any(|&c| pictographic(c) || c.is_ascii_digit() || c == '#' || c == '*'))
|| (cluster.contains(&'\u{200D}') && cluster.iter().any(|&c| pictographic(c)))
|| cluster.contains(&'\u{20E3}')
}
/// Does `face` draw every character of the cluster?
pub fn covers_cluster(face: &FontFace, cluster: &[char]) -> bool {
cluster.iter().all(|c| ignorable(*c) || face.covers(*c))
@@ -55,9 +71,28 @@ impl FontDb {
/// bundled face for its script, else an installed face for the script, else any loaded
/// face that draws it, else `chain[0]` (or the default face if `chain` is empty).
pub fn cascade(&self, cluster: &[char], chain: &[Arc<FontFace>], style: &str) -> Arc<FontFace> {
// Emoji take a colour face (with its ZWJ sequences, skin tones and flags) before the
// document's fonts, as PowerPoint draws them, even when a text font has a plain glyph.
if is_emoji_cluster(cluster) {
if let Some(f) = chain.iter().find(|f| f.is_color() && covers_cluster(f, cluster)) {
return f.clone();
}
if let Some(key) = cluster.iter().copied().find(|c| !ignorable(*c))
&& let Some(f) = self.color_face_for(key, cluster)
{
return f;
}
}
if let Some(f) = chain.iter().find(|f| covers_cluster(f, cluster)) {
return f.clone();
}
// Text presentation (U+FE0E): a plain face, not a colour emoji one.
if cluster.contains(&'\u{FE0E}') {
let faces = self.read_faces();
if let Some(f) = faces.iter().find(|f| !f.is_color() && covers_cluster(f, cluster)) {
return f.clone();
}
}
let first = || chain.first().cloned().unwrap_or_else(|| self.face(crate::DEFAULT_FAMILY, style));
let Some(key) = cluster.iter().copied().find(|c| !ignorable(*c)) else { return first() };
let script = cluster.iter().find_map(|c| script_of(*c));
+92
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.
@@ -87,6 +91,8 @@ pub struct FontFace {
pub instance: Option<harfrust::ShaperInstance>,
/// Basic Multilingual Plane coverage bitset, built on first use.
bmp: std::sync::OnceLock<Box<[u64]>>,
/// Colour glyph tables present (`is_color`), looked up on first use.
color: std::sync::OnceLock<bool>,
}
/// A cheap `Copy` handle to a face. Faces are never unloaded, so the handle lives for the rest of
@@ -154,6 +160,11 @@ impl FontFace {
pub fn skrifa(&self) -> Option<skrifa::FontRef<'_>> {
skrifa::FontRef::from_index(self.data(), self.index).ok()
}
/// Does the face draw colour glyphs (a `COLR`, `CBDT`, `sbix` or `SVG ` table, as colour emoji
/// fonts have)?
pub fn is_color(&self) -> bool {
*self.color.get_or_init(|| has_color_tables(self.data(), self.index))
}
pub fn hb(&self) -> Option<harfrust::FontRef<'_>> {
harfrust::FontRef::from_index(self.data(), self.index).ok()
}
@@ -254,6 +265,13 @@ struct SysFallback {
misses: std::collections::HashSet<char>,
}
/// Does face `index` of `data` carry colour glyph tables?
fn has_color_tables(data: &[u8], index: u32) -> bool {
use skrifa::raw::types::Tag;
let Ok(f) = skrifa::FontRef::from_index(data, index) else { return false };
[b"COLR", b"CBDT", b"sbix", b"SVG "].iter().any(|t| f.table_data(Tag::new(t)).is_some())
}
/// Families tried (when installed) for characters the loaded fonts lack: CJK, symbols, emoji.
#[cfg(not(target_arch = "wasm32"))]
const SYSTEM_FALLBACKS: &[&str] = &[
@@ -436,6 +454,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 +502,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,
@@ -474,6 +510,7 @@ fn make_face(bytes: FontBytes, index: u32, family: String, style: String, coords
bytes,
index,
bmp: std::sync::OnceLock::new(),
color: std::sync::OnceLock::new(),
})
}
@@ -880,6 +917,61 @@ impl FontDb {
false
}
/// A loaded colour face drawing all of `cluster`, loading an installed colour font that covers
/// `key` if none is loaded yet. Misses are remembered.
pub(crate) fn color_face_for(&self, key: char, cluster: &[char]) -> Option<Arc<FontFace>> {
let pick = |db: &FontDb| {
let faces = db.read_faces();
let mut v: Vec<&Arc<FontFace>> = faces.iter().filter(|f| crate::covers_cluster(f, cluster) && f.is_color()).collect();
v.sort_by_key(|f| (f.italic, (f.weight - 400.0).abs() as i32));
v.first().map(|f| (*f).clone())
};
if let Some(f) = pick(self) {
return Some(f);
}
#[cfg(not(target_arch = "wasm32"))]
if self.system_color_fallback(key) {
return pick(self);
}
let _ = key;
None
}
/// Load an installed colour font covering `c`. True if one was loaded.
#[cfg(not(target_arch = "wasm32"))]
fn system_color_fallback(&self, c: char) -> bool {
static MISSES: Mutex<Option<std::collections::HashSet<char>>> = Mutex::new(None);
if c.is_control() || c.is_whitespace() {
return false;
}
{
let sys = self.sys.lock().unwrap_or_else(|e| e.into_inner());
if !sys.enabled {
return false;
}
}
if MISSES.lock().unwrap_or_else(|e| e.into_inner()).get_or_insert_with(Default::default).contains(&c) {
return false;
}
let mut paths: Vec<std::path::PathBuf> = self.read_catalog().iter().map(|e| e.path.clone()).collect();
paths.sort();
paths.dedup();
for p in paths {
if std::fs::metadata(&p).map(|m| m.len() > 64 << 20).unwrap_or(true) {
continue;
}
let Ok(data) = std::fs::read(&p) else { continue };
let hit = enumerate_faces(&data).iter().any(|(i, _, _, _)| {
has_color_tables(&data, *i) && skrifa::FontRef::from_index(&data, *i).is_ok_and(|f| f.charmap().map(c).is_some())
});
if hit && self.add_font(data) > 0 {
return true;
}
}
MISSES.lock().unwrap_or_else(|e| e.into_inner()).get_or_insert_with(Default::default).insert(c);
false
}
/// Glyph outline in font units, y-down (flipped), cached per (face, glyph).
pub fn outline(&self, face: &FontFace, gid: u32) -> Arc<BezPath> {
let key = (face.id, gid);
+31 -1
View File
@@ -13,7 +13,7 @@ mod fontdb;
pub mod script;
mod shape;
pub use fallback::{bundled_families, covers_cluster, ligature_carets};
pub use fallback::{bundled_families, covers_cluster, is_emoji_cluster, ligature_carets};
pub use fontdb::{FALLBACK_FAMILY, FaceRef, FontDb, FontFace, LAST_RESORT_FAMILY, base_style, bundled, substitutes, system_font_dirs};
pub use harfrust::Feature;
use harfrust::Tag;
@@ -136,6 +136,25 @@ mod tests {
FontDb::global().face(DEFAULT_FAMILY, "Regular")
}
#[test]
fn emoji_clusters_are_recognised() {
let c = |s: &str| s.chars().collect::<Vec<char>>();
for yes in ["😀", "👍🏽", "❤\u{FE0F}", "👨\u{200D}👩\u{200D}👧", "🇯🇵", "1\u{FE0F}\u{20E3}", "🏳\u{FE0F}\u{200D}🌈"] {
assert!(is_emoji_cluster(&c(yes)), "{yes:?}");
}
for no in ["a", "1", "❤", "☺\u{FE0E}", "😀\u{FE0E}", "字", "→"] {
assert!(!is_emoji_cluster(&c(no)), "{no:?}");
}
}
#[test]
fn colour_fonts_are_recognised() {
let db = FontDb::with_font_dirs(Vec::new());
assert!(db.add_font(include_bytes!("../../render/tests/fixtures/colr-squares.ttf").to_vec()) > 0);
assert!(db.face("DeckCraft Colr Squares", "Regular").is_color());
assert!(!fontdb::last_resort_face().is_color());
}
#[test]
fn last_resort_face_parses() {
let f = fontdb::last_resort_face();
@@ -143,6 +162,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
@@ -223,6 +223,10 @@ pub struct ParaProps {
pub rtl: Option<bool>,
pub font_align: Option<String>,
pub east_asian_line_break: Option<bool>,
/// `latinLnBrk`: Latin words may break inside (after `/`, `\`); off by default.
pub latin_line_break: Option<bool>,
/// `hangingPunct`: closing East Asian punctuation may hang past the end of a line.
pub hanging_punct: Option<bool>,
}
impl ParaProps {
@@ -253,6 +257,8 @@ impl ParaProps {
self.font_align.clone_from(&p.font_align);
}
self.east_asian_line_break = self.east_asian_line_break.or(p.east_asian_line_break);
self.latin_line_break = self.latin_line_break.or(p.latin_line_break);
self.hanging_punct = self.hanging_punct.or(p.hanging_punct);
}
}
+2
View File
@@ -345,6 +345,8 @@ pub fn ppr(imp: &mut Imp, part: &Part, p: &El) -> (Option<u8>, ParaProps, Option
rtl: p.bool("rtl"),
font_align: p.attr("fontAlgn").map(String::from),
east_asian_line_break: p.bool("eaLnBrk"),
latin_line_break: p.bool("latinLnBrk"),
hanging_punct: p.bool("hangingPunct"),
default_tab: pt(p, "defTabSz"),
..Default::default()
};
+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);
}
+2
View File
@@ -432,6 +432,8 @@ pub fn ppr(w: &mut W, x: &mut Exp, o: &mut Out, tag: &str, level: Option<u8>, p:
.o("defTabSz", p.default_tab.map(|v| emu(v).max(0)))
.b("rtl", p.rtl)
.b("eaLnBrk", p.east_asian_line_break)
.b("latinLnBrk", p.latin_line_break)
.b("hangingPunct", p.hanging_punct)
.o("fontAlgn", p.font_align.as_deref().filter(|f| matches!(*f, "auto" | "t" | "ctr" | "base" | "b")));
let has_kids = p.line_spacing.is_some()
|| p.space_before.is_some()
+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);
}
+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")));
}
+50
View File
@@ -730,3 +730,53 @@ 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)));
}
#[test]
fn line_break_options_round_trip() {
let mut p = defaults::new_presentation(None);
let first = p.slides[0].id;
let s = p.slide_mut(first).expect("slide");
let t = s.shapes[0].text.insert(TextBody::from_text("x"));
t.paragraphs[0].props.latin_line_break = Some(true);
t.paragraphs[0].props.hanging_punct = Some(false);
let q = round(&p);
let props = &q.slides[0].shapes[0].text.as_ref().expect("text").paragraphs[0].props;
assert_eq!(props.latin_line_break, Some(true));
assert_eq!(props.hanging_punct, Some(false));
}
+1
View File
@@ -19,6 +19,7 @@ vello_common = { workspace = true }
image = { workspace = true }
log = { workspace = true }
kurbo = { workspace = true }
skrifa = { workspace = true }
[target.'cfg(not(target_arch = "wasm32"))'.dependencies]
vello_cpu = { workspace = true, features = ["multithreading"] }
+212
View File
@@ -0,0 +1,212 @@
//! Colour glyphs (OpenType `COLR` v0 and v1, colours from `CPAL`), as colour emoji fonts draw
//! them: layers of glyph outlines filled with solid colours or gradients.
use deckcraft_color::Rgba;
use deckcraft_fonts::FontFace;
use kurbo::{Affine, BezPath, Point, Rect};
use skrifa::color::{Brush, ColorPainter, ColorStop, CompositeMode, Extend, Transform};
use skrifa::raw::types::BoundingBox;
use skrifa::{GlyphId, MetadataProvider};
use vello_cpu::{RenderContext, peniko};
/// Nesting depth of clips and layers past which a glyph opens no more (hostile fonts).
const MAX_DEPTH: usize = 64;
/// Paint `gid` of `face` as a colour glyph, its origin mapped by `at` (y down, points) at `scale`
/// points per font unit. `fg` is the text colour (palette index 0xFFFF) and `alpha` fades it all.
/// False when the glyph has no colour version, so the caller draws the plain outline.
pub fn paint(ctx: &mut RenderContext, face: &FontFace, gid: u32, at: Affine, scale: f64, fg: Rgba, alpha: f64) -> bool {
let Some(font) = face.skrifa() else { return false };
let glyphs = font.color_glyphs();
let Some(glyph) = glyphs.get(GlyphId::new(gid)) else { return false };
let palette: Vec<Rgba> = font
.color_palettes()
.get(0)
.map(|p| p.colors().iter().map(|c| Rgba { r: c.red, g: c.green, b: c.blue, a: c.alpha }).collect())
.unwrap_or_default();
// Font units are y up; the slide is y down.
let base = at * Affine::scale(scale) * Affine::scale_non_uniform(1.0, -1.0);
let mut p = Painter { ctx, face, base, stack: vec![Affine::IDENTITY], palette, fg, opened: Vec::new() };
p.ctx.push_layer(None, None, Some(alpha.clamp(0.0, 1.0) as f32), None, None);
let ok = glyph.paint(face.location(), &mut p).is_ok();
// A malformed glyph may leave layers open: close them all.
while let Some(open) = p.opened.pop() {
if open {
p.ctx.pop_layer();
}
}
p.ctx.pop_layer();
ok
}
struct Painter<'a> {
ctx: &'a mut RenderContext,
face: &'a FontFace,
base: Affine,
/// Transforms pushed by the glyph (font units), innermost last.
stack: Vec<Affine>,
palette: Vec<Rgba>,
fg: Rgba,
/// Clips and composite groups pushed by the glyph, innermost last: true when a layer was
/// opened on the context for it (false past `MAX_DEPTH`, so its pop is skipped too).
opened: Vec<bool>,
}
fn affine(t: &Transform) -> Affine {
Affine::new([t.xx as f64, t.yx as f64, t.xy as f64, t.yy as f64, t.dx as f64, t.dy as f64])
}
fn pt(p: skrifa::raw::types::Point<f32>) -> Point {
Point::new(p.x as f64, p.y as f64)
}
fn extend(e: Extend) -> peniko::Extend {
match e {
Extend::Repeat => peniko::Extend::Repeat,
Extend::Reflect => peniko::Extend::Reflect,
_ => peniko::Extend::Pad,
}
}
fn blend(m: CompositeMode) -> peniko::BlendMode {
use peniko::{Compose, Mix};
let (mix, compose) = match m {
CompositeMode::Clear => (Mix::Normal, Compose::Clear),
CompositeMode::Src => (Mix::Normal, Compose::Copy),
CompositeMode::Dest => (Mix::Normal, Compose::Dest),
CompositeMode::DestOver => (Mix::Normal, Compose::DestOver),
CompositeMode::SrcIn => (Mix::Normal, Compose::SrcIn),
CompositeMode::DestIn => (Mix::Normal, Compose::DestIn),
CompositeMode::SrcOut => (Mix::Normal, Compose::SrcOut),
CompositeMode::DestOut => (Mix::Normal, Compose::DestOut),
CompositeMode::SrcAtop => (Mix::Normal, Compose::SrcAtop),
CompositeMode::DestAtop => (Mix::Normal, Compose::DestAtop),
CompositeMode::Xor => (Mix::Normal, Compose::Xor),
CompositeMode::Plus => (Mix::Normal, Compose::Plus),
CompositeMode::Screen => (Mix::Screen, Compose::SrcOver),
CompositeMode::Overlay => (Mix::Overlay, Compose::SrcOver),
CompositeMode::Darken => (Mix::Darken, Compose::SrcOver),
CompositeMode::Lighten => (Mix::Lighten, Compose::SrcOver),
CompositeMode::ColorDodge => (Mix::ColorDodge, Compose::SrcOver),
CompositeMode::ColorBurn => (Mix::ColorBurn, Compose::SrcOver),
CompositeMode::HardLight => (Mix::HardLight, Compose::SrcOver),
CompositeMode::SoftLight => (Mix::SoftLight, Compose::SrcOver),
CompositeMode::Difference => (Mix::Difference, Compose::SrcOver),
CompositeMode::Exclusion => (Mix::Exclusion, Compose::SrcOver),
CompositeMode::Multiply => (Mix::Multiply, Compose::SrcOver),
CompositeMode::HslHue => (Mix::Hue, Compose::SrcOver),
CompositeMode::HslSaturation => (Mix::Saturation, Compose::SrcOver),
CompositeMode::HslColor => (Mix::Color, Compose::SrcOver),
CompositeMode::HslLuminosity => (Mix::Luminosity, Compose::SrcOver),
_ => (Mix::Normal, Compose::SrcOver),
};
peniko::BlendMode::new(mix, compose)
}
impl Painter<'_> {
fn current(&self) -> Affine {
self.base * self.stack.last().copied().unwrap_or(Affine::IDENTITY)
}
fn color(&self, index: u16, alpha: f32) -> peniko::Color {
let c = if index == 0xFFFF { self.fg } else { self.palette.get(index as usize).copied().unwrap_or(self.fg) };
let a = (c.a as f64 * (alpha as f64).clamp(0.0, 1.0)).round().clamp(0.0, 255.0) as u8;
peniko::Color::from_rgba8(c.r, c.g, c.b, a)
}
fn stops(&self, stops: &[ColorStop]) -> Vec<peniko::ColorStop> {
stops.iter().map(|s| peniko::ColorStop { offset: s.offset.clamp(0.0, 1.0), color: self.color(s.palette_index, s.alpha).into() }).collect()
}
/// The glyph outline in font units, y up.
fn outline(&self, gid: GlyphId) -> BezPath {
let p = deckcraft_fonts::FontDb::global().outline(self.face, gid.to_u32());
Affine::scale_non_uniform(1.0, -1.0) * (*p).clone()
}
fn push_clip(&mut self, path: &BezPath) {
let open = self.opened.len() < MAX_DEPTH;
if open {
self.ctx.set_transform(self.current());
self.ctx.push_clip_layer(path);
}
self.opened.push(open);
}
fn pop(&mut self) {
if self.opened.pop() == Some(true) {
self.ctx.pop_layer();
}
}
/// Fill the open clip area (a large box in the current transform) with `paint`.
fn cover(&mut self, paint: impl Into<vello_cpu::PaintType>) {
self.ctx.set_transform(self.current());
self.ctx.set_paint(paint);
self.ctx.fill_rect(&Rect::new(-1.0e5, -1.0e5, 1.0e5, 1.0e5));
}
}
impl ColorPainter for Painter<'_> {
fn push_transform(&mut self, t: Transform) {
let next = self.stack.last().copied().unwrap_or(Affine::IDENTITY) * affine(&t);
self.stack.push(next);
}
fn pop_transform(&mut self) {
if self.stack.len() > 1 {
self.stack.pop();
}
}
fn push_clip_glyph(&mut self, gid: GlyphId) {
let path = self.outline(gid);
self.push_clip(&path);
}
fn push_clip_box(&mut self, b: BoundingBox<f32>) {
let r = Rect::new(b.x_min as f64, b.y_min as f64, b.x_max as f64, b.y_max as f64);
self.push_clip(&kurbo::Shape::to_path(&r, 0.1));
}
fn pop_clip(&mut self) {
self.pop();
}
fn fill(&mut self, brush: Brush<'_>) {
match brush {
Brush::Solid { palette_index, alpha } => {
let c = self.color(palette_index, alpha);
self.cover(c);
}
Brush::LinearGradient { p0, p1, color_stops, extend: e } => {
let g = peniko::Gradient::new_linear(pt(p0), pt(p1)).with_stops(self.stops(color_stops).as_slice()).with_extend(extend(e));
self.cover(g);
}
Brush::RadialGradient { c0, r0, c1, r1, color_stops, extend: e } => {
let g = peniko::Gradient::new_two_point_radial(pt(c0), r0.max(0.0), pt(c1), r1.max(0.0))
.with_stops(self.stops(color_stops).as_slice())
.with_extend(extend(e));
self.cover(g);
}
Brush::SweepGradient { c0, start_angle, end_angle, color_stops, extend: e } => {
let g = peniko::Gradient::new_sweep(pt(c0), start_angle.to_radians(), end_angle.to_radians())
.with_stops(self.stops(color_stops).as_slice())
.with_extend(extend(e));
self.cover(g);
}
}
}
fn push_layer(&mut self, mode: CompositeMode) {
let open = self.opened.len() < MAX_DEPTH;
if open {
self.ctx.push_blend_layer(blend(mode));
}
self.opened.push(open);
}
fn pop_layer(&mut self) {
self.pop();
}
}
+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};
+151 -7
View File
@@ -8,11 +8,13 @@
#![forbid(unsafe_code)]
mod chart;
mod colr;
mod images;
mod morph_path;
mod morph_text;
mod paint;
mod placed;
mod ramp;
mod table;
use std::collections::HashMap;
@@ -221,11 +223,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 }
}
}
@@ -243,7 +248,7 @@ pub fn needs_filters(pres: &Presentation, slide: &Slide) -> bool {
return;
}
let (e, _) = resolve::effects(&rctx, s);
if e.is_some_and(|e| !e.is_empty()) {
if e.is_some_and(|e| !e.is_empty()) || text_has_effects(&rctx, s) {
any = true;
}
});
@@ -256,6 +261,20 @@ pub fn needs_filters(pres: &Presentation, slide: &Slide) -> bool {
any
}
/// Does the shape's text have a shadow or glow (on a run, in its list style, or in the master's
/// text styles it inherits)? Such text is blurred, which needs filters.
fn text_has_effects(rctx: &Ctx, s: &Shape) -> bool {
use deckcraft_model::text::{ListStyle, RunProps};
let fx = |r: &RunProps| r.shadow.as_ref().is_some_and(|s| !s.inner) || r.glow.as_ref().is_some_and(|g| g.radius > 0.0);
let styled = |l: &ListStyle| l.levels.iter().flatten().any(|lv| fx(&lv.run));
let Some(t) = &s.text else { return false };
t.paragraphs.iter().any(|p| p.runs.iter().any(|r| fx(&r.props)) || fx(&p.end_props))
|| styled(&t.list_style)
|| styled(&rctx.master.title_style)
|| styled(&rctx.master.body_style)
|| styled(&rctx.master.other_style)
}
/// Render slide `index` at `opts.scale` pixels per point. A panic inside the renderer becomes a
/// blank image (logged), never a crash.
pub fn render_slide(pres: &Presentation, index: usize, opts: &RenderOpts) -> Image {
@@ -588,7 +607,12 @@ impl Renderer {
let r = Rect::new(0.0, 0.0, f.pres.slide_size.width, f.pres.slide_size.height);
ctx.set_transform(view);
let path = r.to_path(0.1);
// PowerPoint lays every background over white: a picture stretched into part of the
// slide, or a see-through colour, leaves white around and under it.
ctx.set_paint(color(Rgba::WHITE, 1.0));
ctx.fill_path(&path);
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 +707,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 +734,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);
}
}
@@ -1105,6 +1138,15 @@ pub fn draw_layout_with(ctx: &mut RenderContext, l: &deckcraft_text::TextLayout,
for d in l.decos.iter().filter(|d| d.behind) {
deco(ctx, d);
}
// Text shadows, then glows, under all the text.
for pass in [TextEffect::Shadow, TextEffect::Glow] {
for run in &l.runs {
let ps = para(run.para);
if ps.visible && ps.opacity > 0.001 {
text_effect(ctx, run, pass, m * Affine::translate(ps.offset), ps.opacity);
}
}
}
for run in &l.runs {
if (run.alpha <= 0.0 || run.color.a == 0) && run.outline.is_none() {
continue;
@@ -1114,8 +1156,24 @@ pub fn draw_layout_with(ctx: &mut RenderContext, l: &deckcraft_text::TextLayout,
continue;
}
let m = m * Affine::translate(ps.offset);
// A gradient fill starts over on every line and spans that line's text (from its top to
// its bottom, from its first to its last character), as in PowerPoint.
let mut grads: Vec<(usize, peniko::Gradient)> = Vec::new();
for (gid, x, y) in &run.glyphs {
draw_glyph(ctx, run, *gid, m * Affine::translate((*x, *y)), ps.opacity);
let at = m * Affine::translate((*x, *y));
let Some(g) = run.gradient.as_ref().filter(|g| !g.stops.is_empty()) else {
draw_glyph(ctx, run, *gid, at, ps.opacity);
continue;
};
let li = line_at(l, run.para, *y);
if !grads.iter().any(|(i, _)| *i == li) {
let scheme = deckcraft_color::ColorScheme { name: String::new(), colors: [Rgba::BLACK; 12] };
let stops = g.stops.iter().map(|s| (s.pos, s.color.resolve(&scheme, None))).collect();
let bounds = l.lines.get(li).and_then(line_bounds).unwrap_or(tr);
grads.push((li, paint::gradient(&g.shape, stops, bounds, run.alpha * ps.opacity)));
}
let fill = grads.iter().find(|(i, _)| *i == li).map(|(_, g)| (g, Affine::translate((-*x, -*y))));
draw_glyph_with(ctx, run, *gid, at, ps.opacity, fill);
}
}
for d in l.decos.iter().filter(|d| !d.behind) {
@@ -1123,20 +1181,106 @@ pub fn draw_layout_with(ctx: &mut RenderContext, l: &deckcraft_text::TextLayout,
}
}
/// The line of paragraph `para` holding a glyph on baseline `y` (the nearest baseline).
fn line_at(l: &deckcraft_text::TextLayout, para: usize, y: f64) -> usize {
l.lines
.iter()
.enumerate()
.filter(|(_, li)| li.para == para)
.min_by(|a, b| (a.1.baseline - y).abs().total_cmp(&(b.1.baseline - y).abs()))
.map(|(i, _)| i)
.unwrap_or(0)
}
/// The box a line's text covers: its top and bottom, its first and last caret.
fn line_bounds(li: &deckcraft_text::LineInfo) -> Option<Rect> {
let (x0, x1) = li.caret_x.iter().fold((f64::INFINITY, f64::NEG_INFINITY), |(a, b), x| (a.min(*x), b.max(*x)));
let r = Rect::new(x0, li.top, x1, li.bottom);
(r.is_finite() && r.width() > 0.0 && r.height() > 0.0).then_some(r)
}
#[derive(Clone, Copy, PartialEq)]
enum TextEffect {
Shadow,
Glow,
}
/// Draw `run`'s shadow or glow (if it has one) under its glyphs, placed by `m`.
fn text_effect(ctx: &mut RenderContext, run: &deckcraft_text::GlyphRun, which: TextEffect, m: Affine, opacity: f64) {
let Some(fx) = run.effects else { return };
let k = run.size / run.face.upem.max(1.0);
let skew = if run.fake_italic { Affine::new([1.0, 0.0, -0.2, 1.0, 0.0, 0.0]) } else { Affine::IDENTITY };
let px = scale_of(m);
let (c, sigma, offset, grow) = match (which, fx.shadow, fx.glow) {
(TextEffect::Shadow, Some(s), _) => (s.color, s.blur * 0.5 * px, Vec2::new(s.dx, s.dy), 0.0),
// Measured on PowerPoint's export: a text glow of radius r is solid to about a third of r
// past the glyph's edge and fades out by about two thirds of r.
(TextEffect::Glow, _, Some((gc, r))) => (gc, r * 0.15 * px, Vec2::ZERO, r * 0.68),
_ => return,
};
let alpha = (c.a as f64 / 255.0) * opacity;
if alpha <= 0.0 {
return;
}
ctx.set_transform(Affine::IDENTITY);
ctx.push_layer(None, None, Some(alpha as f32), None, Some(blur(sigma)));
ctx.set_paint(color(c.with_alpha(255), 1.0));
for (gid, x, y) in &run.glyphs {
let path = glyph(&run.face, *gid);
if path.elements().is_empty() {
continue;
}
// The offset is in slide space: a shadow falls the same way whatever the text's rotation.
let at = pre_translate(m * Affine::translate((*x, *y)), offset * px) * skew * Affine::scale(k);
ctx.set_transform(at);
ctx.fill_path(&path);
if grow > 0.0 {
ctx.set_stroke(kurbo::Stroke::new(grow / k.max(1e-9)).with_join(kurbo::Join::Round));
ctx.stroke_path(&path);
}
}
ctx.pop_layer();
}
/// Draw glyph `gid` of `run` with its origin (on the baseline) mapped by `at`, faded by `opacity`.
pub(crate) fn draw_glyph(ctx: &mut RenderContext, run: &deckcraft_text::GlyphRun, gid: u32, at: Affine, opacity: f64) {
draw_glyph_with(ctx, run, gid, at, opacity, None);
}
/// [`draw_glyph`], filled with `fill` (a gradient laid out in the text's space, and the map from
/// the glyph's origin to that space) instead of the run's colour.
fn draw_glyph_with(
ctx: &mut RenderContext,
run: &deckcraft_text::GlyphRun,
gid: u32,
at: Affine,
opacity: f64,
fill: Option<(&peniko::Gradient, Affine)>,
) {
let alpha = run.alpha * opacity;
if (alpha <= 0.0 || run.color.a == 0) && run.outline.is_none() {
return;
}
let k = run.size / run.face.upem.max(1.0);
// Colour glyphs (emoji) paint their own colours; an outline (text outline effect) still
// strokes the plain glyph below.
if alpha > 0.0 && run.outline.is_none() && run.face.is_color() && colr::paint(ctx, &run.face, gid, at, k, run.color, alpha) {
return;
}
let path = glyph(&run.face, gid);
if path.elements().is_empty() {
return;
}
let k = run.size / run.face.upem.max(1.0);
let skew = if run.fake_italic { Affine::new([1.0, 0.0, -0.2, 1.0, 0.0, 0.0]) } else { Affine::IDENTITY };
ctx.set_transform(at * skew * Affine::scale(k));
if alpha > 0.0 && run.color.a > 0 {
if let Some((g, to_text)) = fill
&& alpha > 0.0
{
ctx.set_paint(g.clone());
ctx.set_paint_transform((skew * Affine::scale(k)).inverse() * to_text);
ctx.fill_path(&path);
ctx.reset_paint_transform();
} else if alpha > 0.0 && run.color.a > 0 {
ctx.set_paint(color(run.color, alpha));
ctx.fill_path(&path);
if run.fake_bold {
+46 -28
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,37 +35,13 @@ 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();
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();
let grad = match &g.shape {
GradientShape::Linear { angle, .. } => {
// Angle measured clockwise from left→right; span the bounds along that direction.
let a = angle.to_radians();
let (dx, dy) = (a.cos(), a.sin());
let c = bounds.center();
let half = (bounds.width() * dx.abs() + bounds.height() * dy.abs()) / 2.0;
peniko::Gradient::new_linear(Point::new(c.x - dx * half, c.y - dy * half), Point::new(c.x + dx * half, c.y + dy * half))
.with_stops(stops.as_slice())
}
GradientShape::Path { focus, .. } => {
let fx = bounds.x0 + bounds.width() * (focus[0] + (1.0 - focus[2])) / 2.0;
let fy = bounds.y0 + bounds.height() * (focus[1] + (1.0 - focus[3])) / 2.0;
// Reach the farthest corner, so a gradient from a corner covers the whole box.
let r = [(bounds.x0, bounds.y0), (bounds.x1, bounds.y0), (bounds.x0, bounds.y1), (bounds.x1, bounds.y1)]
.iter()
.map(|(x, y)| ((x - fx).powi(2) + (y - fy).powi(2)).sqrt())
.fold(1e-6, f64::max);
peniko::Gradient::new_radial(Point::new(fx, fy), r as f32).with_stops(stops.as_slice())
}
};
ctx.set_paint(grad);
let stops: Vec<(f64, Rgba)> = g.stops.iter().map(|s| (s.pos, shade(rctx.color(&s.color, ph), mode))).collect();
ctx.set_paint(gradient(&g.shape, stops, bounds, alpha));
ctx.fill_path(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 +96,48 @@ 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)
}
/// The paint of a gradient of `shape` with `stops` (position, colour) spanning `bounds`, faded
/// by `alpha`, ramping between stops as PowerPoint does (see `ramp`).
pub fn gradient(shape: &GradientShape, mut stops: Vec<(f64, Rgba)>, bounds: Rect, alpha: f64) -> peniko::Gradient {
for s in &mut stops {
s.0 = s.0.clamp(0.0, 1.0);
}
stops.sort_by(|a, b| a.0.total_cmp(&b.0));
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();
match shape {
GradientShape::Linear { angle, .. } => {
// Angle measured clockwise from left→right; span the bounds along that direction.
let a = angle.to_radians();
let (dx, dy) = (a.cos(), a.sin());
let c = bounds.center();
let half = (bounds.width() * dx.abs() + bounds.height() * dy.abs()) / 2.0;
peniko::Gradient::new_linear(Point::new(c.x - dx * half, c.y - dy * half), Point::new(c.x + dx * half, c.y + dy * half))
.with_stops(stops.as_slice())
}
GradientShape::Path { focus, .. } => {
let fx = bounds.x0 + bounds.width() * (focus[0] + (1.0 - focus[2])) / 2.0;
let fy = bounds.y0 + bounds.height() * (focus[1] + (1.0 - focus[3])) / 2.0;
// Reach the farthest corner, so a gradient from a corner covers the whole box.
let r = [(bounds.x0, bounds.y0), (bounds.x1, bounds.y0), (bounds.x0, bounds.y1), (bounds.x1, bounds.y1)]
.iter()
.map(|(x, y)| ((x - fx).powi(2) + (y - fy).powi(2)).sqrt())
.fold(1e-6, f64::max);
peniko::Gradient::new_radial(Point::new(fx, fy), r as f32).with_stops(stops.as_slice())
}
}
}
/// 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));
}
}
+227
View File
@@ -523,3 +523,230 @@ 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}");
}
#[test]
fn a_background_is_laid_over_white() {
// A background picture stretched into the bottom-right quarter (fillRect l=50% t=50%), and a
// half-transparent colour: PowerPoint shows white around and under them, not transparency.
use deckcraft_model::style::{Fill, PictureFill, PictureMode};
let mut png = std::io::Cursor::new(Vec::new());
image::DynamicImage::ImageRgb8(image::RgbImage::from_pixel(4, 4, image::Rgb([0, 0, 200]))).write_to(&mut png, image::ImageFormat::Png).unwrap();
let mut p = deck_with(vec![]);
let media = p.add_media("bg.png", "image/png", png.into_inner());
let pf = PictureFill { media, mode: PictureMode::Stretch { fill_rect: [0.5, 0.5, 0.0, 0.0] }, ..Default::default() };
std::sync::Arc::make_mut(&mut p.slides[0]).background = Some(deckcraft_model::Background::Fill { fill: Fill::Picture(pf) });
let img = render_slide(&p, 0, &RenderOpts { scale: 0.5, ..Default::default() });
assert_eq!(img.pixel(20, 20), [255, 255, 255, 255], "white where the picture doesn't reach");
let q = img.pixel(400, 230);
assert!(q[2] > 190 && q[0] < 10 && q[3] == 255, "{q:?}");
let see_through = ColorRef::rgb(deckcraft_color::Rgba { r: 0, g: 0, b: 0, a: 128 });
std::sync::Arc::make_mut(&mut p.slides[0]).background = Some(deckcraft_model::Background::Fill { fill: Fill::solid(see_through) });
let img = render_slide(&p, 0, &RenderOpts { scale: 0.5, ..Default::default() });
let g = img.pixel(20, 20);
assert!(g[3] == 255 && (g[0] as i32 - 127).abs() <= 3, "half-transparent black over white is grey: {g:?}");
}
#[test]
fn colour_glyphs_paint_their_layers() {
// A COLRv0 test font (crates/render/tests/fixtures): U+E000 is a red square with a blue
// square in its middle, U+E001 a square in the text colour.
deckcraft_fonts::FontDb::global().add_font(include_bytes!("../tests/fixtures/colr-squares.ttf").to_vec());
let mut sh = Shape { id: ShapeId(720), xfrm: Some(Xfrm::new(0.0, 0.0, 400.0, 200.0)), text_box: true, ..Default::default() };
let mut t = TextBody::from_text("\u{E000} \u{E001}");
let props = &mut t.paragraphs[0].runs[0].props;
props.size = Some(72.0);
props.font = Some("DeckCraft Colr Squares".into());
props.fill = Some(deckcraft_model::style::Fill::solid(ColorRef::rgb(deckcraft_color::Rgba::rgb(0, 160, 0))));
sh.text = Some(t);
let p = deck_with(vec![sh]);
let img = render_slide(&p, 0, &RenderOpts { scale: 1.0, ..Default::default() });
let count = |f: &dyn Fn(&[u8; 4]) -> bool| img.pixels.as_chunks::<4>().0.iter().filter(|px| f(px)).count();
let red = count(&|p| p[0] > 230 && p[1] < 30 && p[2] < 30);
let blue = count(&|p| p[0] < 30 && p[1] < 30 && p[2] > 230);
let green = count(&|p| p[0] < 30 && p[1] > 140 && p[1] < 180 && p[2] < 30);
// 72 pt squares: the blue one is 40 % of the side (16 % of the area).
assert!(red > 2500 && blue > 500, "red {red} blue {blue}");
let share = blue as f64 / (red + blue) as f64;
assert!((share - 0.16).abs() < 0.03, "blue share {share}");
// The text-colour glyph is as big as the other square.
assert!((green as f64 / (red + blue) as f64 - 1.0).abs() < 0.1, "green {green} vs {}", red + blue);
}
#[test]
fn a_text_gradient_starts_over_on_every_line() {
// PowerPoint fills each line of gradient text with the whole gradient, spanning that line
// (black at the top of every line to white at its bottom), not the text box.
use deckcraft_model::style::{Fill, Gradient, GradientShape, GradientStop};
let stop = |pos, v| GradientStop { pos, color: ColorRef::rgb(deckcraft_color::Rgba::rgb(v, v, v)) };
let mut sh = Shape { id: ShapeId(730), xfrm: Some(Xfrm::new(0.0, 0.0, 600.0, 500.0)), text_box: true, ..Default::default() };
let mut t = TextBody::from_text("HHHH\nHHHH");
for p in &mut t.paragraphs {
let props = &mut p.runs[0].props;
props.size = Some(80.0);
props.fill = Some(Fill::Gradient(Gradient {
stops: vec![stop(0.0, 0), stop(1.0, 255)],
shape: GradientShape::Linear { angle: 90.0, scaled: false },
rotate_with_shape: true,
}));
}
sh.text = Some(t);
let p = deck_with(vec![sh]);
let img = render_slide(&p, 0, &RenderOpts { scale: 1.0, ..Default::default() });
// Ink rows (any pixel not white), grouped into the two lines.
let ink = |y: u32| (0..600).any(|x| img.pixel(x, y)[0] < 250);
let mut bands: Vec<(u32, u32)> = vec![];
for y in 0..500 {
if ink(y) {
match bands.last_mut() {
Some(b) if b.1 + 1 == y => b.1 = y,
_ => bands.push((y, y)),
}
}
}
assert_eq!(bands.len(), 2, "{bands:?}");
// The darkest ink in the first rows of each line.
let top = |b: (u32, u32)| (b.0..b.0 + 6).flat_map(|y| (0..600).map(move |x| (x, y))).map(|(x, y)| img.pixel(x, y)[0]).min().unwrap();
let (a, b) = (top(bands[0]), top(bands[1]));
assert!(a < 140 && (a as i32 - b as i32).abs() < 25, "line tops {a} and {b}: the second line starts dark again");
// ...and each line ends light.
let bottom = |b: (u32, u32)| (b.1 - 5..=b.1).flat_map(|y| (0..600).map(move |x| (x, y))).map(|(x, y)| img.pixel(x, y)[0]).min().unwrap();
for band in bands {
assert!(bottom(band) > 150, "line {band:?} ends at {}", bottom(band));
}
}
/// A text box of big black "IIII" with `set` applied to its run.
fn effect_text(set: impl Fn(&mut deckcraft_model::text::RunProps)) -> Presentation {
let mut sh = Shape { id: ShapeId(740), xfrm: Some(Xfrm::new(100.0, 100.0, 600.0, 300.0)), text_box: true, ..Default::default() };
let mut t = TextBody::from_text("IIII");
let props = &mut t.paragraphs[0].runs[0].props;
props.size = Some(100.0);
props.fill = Some(deckcraft_model::style::Fill::solid(ColorRef::rgb(deckcraft_color::Rgba::rgb(0, 0, 0))));
set(props);
sh.text = Some(t);
deck_with(vec![sh])
}
#[test]
fn text_glow_and_shadow_are_drawn() {
let count = |img: &Image, f: &dyn Fn(&[u8; 4]) -> bool| img.pixels.as_chunks::<4>().0.iter().filter(|px| f(px)).count();
let green = |p: &[u8; 4]| p[1] > 150 && p[0] < 120 && p[2] < 120;
let plain = effect_text(|_| {});
assert!(!needs_filters(&plain, &plain.slides[0]));
let img = render_slide(&plain, 0, &RenderOpts { scale: 1.0, ..Default::default() });
assert_eq!(count(&img, &green), 0);
// Glow: a green halo hugging the letters, needing the blur filter.
let glow =
effect_text(|p| p.glow = Some(deckcraft_model::style::Glow { color: ColorRef::rgb(deckcraft_color::Rgba::rgb(0, 255, 0)), radius: 8.0 }));
assert!(needs_filters(&glow, &glow.slides[0]), "text glow blurs");
let img = render_slide(&glow, 0, &RenderOpts { scale: 1.0, threads: 4, ..Default::default() });
let halo = count(&img, &green);
assert!(halo > 300, "green halo pixels {halo}");
// Shadow: grey ink below and right of the letters, where the plain text has none.
let shadow = effect_text(|p| {
p.shadow = Some(deckcraft_model::style::Shadow {
color: ColorRef::rgb(deckcraft_color::Rgba { r: 0, g: 0, b: 0, a: 128 }),
blur: 2.0,
dist: 12.0,
dir: 45.0,
inner: false,
sx: 1.0,
sy: 1.0,
kx: 0.0,
ky: 0.0,
align: String::new(),
rotate_with_shape: false,
})
});
let img = render_slide(&shadow, 0, &RenderOpts { scale: 1.0, threads: 4, ..Default::default() });
let base = render_slide(&plain, 0, &RenderOpts { scale: 1.0, ..Default::default() });
let grey = |p: &[u8; 4]| p[0] > 60 && p[0] < 230 && p[0] == p[1] && p[1] == p[2];
assert!(count(&img, &grey) > count(&base, &grey) + 300, "a grey shadow appears");
}
Binary file not shown.
+51
View File
@@ -0,0 +1,51 @@
"""Build the COLR test font: DeckCraft Colr Squares (contributor-original, CC0-1.0).
Usage: python make_colr_font.py colr-squares.ttf (needs fontTools)
U+E000: a 1000x1000 red square (palette 0) with a 400x400 blue square (palette 1) in its middle.
U+E001: a 1000x1000 square in the text colour (palette index 0xFFFF).
Plain outlines of the base glyphs are a single square, as colour fonts carry for renderers
without colour support.
"""
import sys
from fontTools.fontBuilder import FontBuilder
from fontTools.pens.ttGlyphPen import TTGlyphPen
from fontTools.colorLib.builder import buildCOLR, buildCPAL
def square(x0, y0, x1, y1):
pen = TTGlyphPen(None)
pen.moveTo((x0, y0))
pen.lineTo((x0, y1))
pen.lineTo((x1, y1))
pen.lineTo((x1, y0))
pen.closePath()
return pen.glyph()
def empty():
return TTGlyphPen(None).glyph()
order = [".notdef", "space", "redblue", "ink", "big", "small"]
fb = FontBuilder(1000, isTTF=True)
fb.setupGlyphOrder(order)
fb.setupCharacterMap({0x20: "space", 0xE000: "redblue", 0xE001: "ink"})
fb.setupGlyf({
".notdef": empty(),
"space": empty(),
"redblue": square(0, 0, 1000, 1000),
"ink": square(0, 0, 1000, 1000),
"big": square(0, 0, 1000, 1000),
"small": square(300, 300, 700, 700),
})
fb.setupHorizontalMetrics({g: (1000, 0) for g in order})
fb.setupHorizontalHeader(ascent=1000, descent=0)
fb.setupNameTable({"familyName": "DeckCraft Colr Squares", "styleName": "Regular"})
fb.setupOS2(sTypoAscender=1000, sTypoDescender=0, usWinAscent=1000, usWinDescent=0)
fb.setupPost()
fb.font["COLR"] = buildCOLR({"redblue": [("big", 0), ("small", 1)], "ink": [("big", 0xFFFF)]}, version=0)
fb.font["CPAL"] = buildCPAL([[(1.0, 0.0, 0.0, 1.0), (0.0, 0.0, 1.0, 1.0)]])
fb.save(sys.argv[1])
print("saved", sys.argv[1])
+132 -13
View File
@@ -57,6 +57,25 @@ pub struct GlyphRun {
/// Alpha of the fill (0–1).
pub alpha: f64,
pub gradient: Option<deckcraft_model::style::Gradient>,
/// Shadow and glow behind the glyphs (`a:rPr/a:effectLst`).
pub effects: Option<RunEffects>,
}
/// A text run's shadow and glow, colours resolved.
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct RunEffects {
pub shadow: Option<TextShadow>,
/// Glow colour (with its alpha) and radius in points.
pub glow: Option<(Rgba, f64)>,
}
/// An outer shadow: colour (with its alpha), blur radius and offset in points.
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct TextShadow {
pub color: Rgba,
pub blur: f64,
pub dx: f64,
pub dy: f64,
}
impl std::fmt::Debug for GlyphRun {
@@ -174,6 +193,7 @@ struct Style {
outline: Option<(Rgba, f64)>,
link: bool,
gradient: Option<deckcraft_model::style::Gradient>,
effects: Option<RunEffects>,
/// "Regular", "Bold"… for the slot and fallback faces.
style_name: &'static str,
/// BCP 47 language (`a:rPr/@lang`), for shaping.
@@ -245,7 +265,14 @@ fn make_style(ctx: &Ctx, r: &RunProps, scale: f64, prompt: Option<Rgba>) -> Styl
let size = (r.size.unwrap_or(18.0) * scale).clamp(0.5, 4000.0);
let (mut color, alpha, gradient) = match &r.fill {
Some(Fill::Solid { color }) => (ctx.color(color, None), color.alpha(), None),
Some(Fill::Gradient(g)) => (g.stops.first().map(|s| ctx.color(&s.color, None)).unwrap_or(Rgba::BLACK), 1.0, Some(g.clone())),
Some(Fill::Gradient(g)) => {
// Colours resolved here (theme, colour map), so the renderer needs no colour scheme.
let mut g = g.clone();
for s in &mut g.stops {
s.color = deckcraft_model::ColorRef::rgb(ctx.color(&s.color, None));
}
(g.stops.first().map(|s| ctx.color(&s.color, None)).unwrap_or(Rgba::BLACK), 1.0, Some(g))
}
Some(Fill::None) => (Rgba::TRANSPARENT, 0.0, None),
_ => (resolve::text_color(ctx, r), 1.0, None),
};
@@ -281,6 +308,7 @@ fn make_style(ctx: &Ctx, r: &RunProps, scale: f64, prompt: Option<Rgba>) -> Styl
outline,
link: r.link.is_some(),
gradient,
effects: run_effects(ctx, r, scale),
style_name: style_name(bold, italic),
lang: r.lang.clone().filter(|l| !l.is_empty()),
ea,
@@ -487,6 +515,16 @@ fn shape_para(ctx: &Ctx, shape: &Shape, body: &TextBody, pi: usize, scale: f64,
/// Shape a logical paragraph into cells: bidi levels, per-grapheme faces, items of one level,
/// script, face and shaping style, each shaped with the whole paragraph as context.
/// A run's outer shadow and glow (inner shadows aren't drawn on text), at the layout's `scale`.
fn run_effects(ctx: &Ctx, r: &RunProps, scale: f64) -> Option<RunEffects> {
let shadow = r.shadow.as_ref().filter(|s| !s.inner).map(|s| {
let a = s.dir.to_radians();
TextShadow { color: ctx.color(&s.color, None), blur: s.blur.max(0.0) * scale, dx: a.cos() * s.dist * scale, dy: a.sin() * s.dist * scale }
});
let glow = r.glow.as_ref().filter(|g| g.radius > 0.0).map(|g| (ctx.color(&g.color, None), g.radius * scale));
(shadow.is_some() || glow.is_some()).then_some(RunEffects { shadow, glow })
}
fn shape_cells(ctx: &Ctx, text: &str, char_style: &[usize], styles: &[Style], faces: &mut Vec<Arc<FontFace>>, rtl: bool) -> Vec<Cell> {
let db = FontDb::global();
let chars: Vec<char> = text.chars().collect();
@@ -524,7 +562,9 @@ fn shape_cells(ctx: &Ctx, text: &str, char_style: &[usize], styles: &[Style], fa
let (sc, slot) = scripts.get(a).copied().unwrap_or((None, FontSlot::Latin));
// Spaces, digits and punctuation stay in the face of the text before them when it can
// draw them, so a phrase isn't cut into items at every space.
let neutral = cluster.iter().all(|c| script::script_of(*c).is_none());
// Emoji, and pictographs asked to look like text (U+FE0E), pick their own face.
let neutral =
cluster.iter().all(|c| script::script_of(*c).is_none()) && !deckcraft_fonts::is_emoji_cluster(cluster) && !cluster.contains(&'\u{FE0E}');
let face = match prev.as_ref() {
Some(p) if neutral && deckcraft_fonts::covers_cluster(p, cluster) && char_style.get(a.wrapping_sub(1)) == Some(&si) => p.clone(),
_ => {
@@ -566,6 +606,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 +636,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 +726,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,13 +742,60 @@ 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.
fn break_opportunities(text: &str) -> (Vec<bool>, Vec<bool>) {
/// Hangul syllables and jamo: PowerPoint wraps Korean at word boundaries (spaces), not between
/// syllables.
fn is_hangul(c: char) -> bool {
matches!(c, '\u{1100}'..='\u{11FF}' | '\u{3130}'..='\u{318F}' | '\u{A960}'..='\u{A97F}' | '\u{AC00}'..='\u{D7FF}')
}
/// Closing East Asian punctuation that may hang past the end of a line (`hangingPunct`).
fn is_hanging_punct(c: char) -> bool {
matches!(
c,
'、' | '。'
| ','
| '.'
| ':'
| ';'
| '!'
| '?'
| ')'
| '」'
| '』'
| '】'
| '〕'
| '〉'
| '》'
| '〙'
| '〛'
| '}'
| ']'
| '」'
| '、'
| '。'
)
}
/// Break opportunities: allowed[i] = a line may start at char i. Unicode line breaking, then
/// PowerPoint's differences: without `latinLnBrk` a word doesn't break after `/` or `\`, and
/// Korean breaks only between words.
fn break_opportunities(text: &str, latin_break: bool) -> (Vec<bool>, Vec<bool>) {
let n = text.chars().count();
let mut allowed = vec![false; n + 1];
let mut mandatory = vec![false; n + 1];
@@ -721,6 +816,17 @@ fn break_opportunities(text: &str) -> (Vec<bool>, Vec<bool>) {
}
}
}
let chars: Vec<char> = text.chars().collect();
for (i, w) in chars.windows(2).enumerate() {
let (prev, next) = (w[0], w[1]);
let keep = if is_hangul(prev) && is_hangul(next) { false } else { latin_break || !matches!(prev, '/' | '\\') || next.is_whitespace() };
if !keep
&& !mandatory.get(i + 1).copied().unwrap_or(false)
&& let Some(a) = allowed.get_mut(i + 1)
{
*a = false;
}
}
(allowed, mandatory)
}
@@ -742,7 +848,8 @@ fn break_lines(
x0_rest: f64,
) -> Vec<RawLine> {
let text: String = p.cells.iter().map(|c| c.ch).collect();
let (allowed, _) = break_opportunities(&text);
let (allowed, _) = break_opportunities(&text, p.props.latin_line_break.unwrap_or(false));
let hang = p.props.hanging_punct.unwrap_or(false);
let n = p.cells.len();
let mut lines = vec![];
let mut start = 0;
@@ -769,6 +876,13 @@ fn break_lines(
if i > start && allowed.get(i).copied().unwrap_or(false) && breakable(i) {
last_break = Some(i);
}
// Closing East Asian punctuation hangs past the end instead of wrapping
// (`hangingPunct`); the line breaks after it.
if wrap && hang && x + adv > avail + 0.01 && x <= avail + 0.01 && is_hanging_punct(c) && i > start {
end = i + 1;
next = i + 1;
break;
}
if wrap && x + adv > avail + 0.01 && !c.is_whitespace() && i > start {
// Break at the last opportunity, or force mid-word (between clusters).
let b = last_break
@@ -909,7 +1023,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)
};
@@ -1144,6 +1262,7 @@ fn layout_scaled(ctx: &Ctx, shape: &Shape, body: &TextBody, bp: &BodyProps, opts
link: false,
alpha: st.alpha,
gradient: None,
effects: None,
});
}
// Group visually consecutive cells by (style, face).
@@ -1155,7 +1274,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 {
@@ -1177,6 +1296,7 @@ fn layout_scaled(ctx: &Ctx, shape: &Shape, body: &TextBody, bp: &BodyProps, opts
link: st.link,
alpha: st.alpha,
gradient: st.gradient.clone(),
effects: st.effects,
});
cur_key = key;
}
@@ -1204,9 +1324,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));
}
}
+207
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,196 @@ 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);
}
}
/// The lines of `text` in an Arial-free (whatever face resolves) box `w` wide, as strings.
fn wrapped(text: &str, w: f64, set: impl Fn(&mut ParaProps)) -> Vec<String> {
let (p, mut sh) = bidi_setup(vec![Run::new(text)], w, false, Align::Left);
let t = sh.text.as_mut().unwrap();
t.body.inset_l = Some(0.0);
t.body.inset_r = Some(0.0);
set(&mut t.paragraphs[0].props);
let l = lay(&p, &sh, w, 600.0);
let chars: Vec<char> = text.chars().collect();
l.lines.iter().map(|li| chars[li.start..li.end].iter().collect()).collect()
}
/// Width of `text` on one line.
fn one_line(text: &str) -> f64 {
let (p, sh) = bidi_setup(vec![Run::new(text)], 5000.0, false, Align::Left);
let l = lay(&p, &sh, 5000.0, 300.0);
l.lines[0].caret_x.last().unwrap() - l.lines[0].caret_x[0]
}
#[test]
fn a_word_does_not_break_after_a_slash_or_backslash() {
// PowerPoint (latinLnBrk off, its default) keeps "alpha/beta/gamma" whole and only breaks it
// where it no longer fits; Unicode line breaking would allow a break after each slash.
for sep in ["/", "\\"] {
let text = ["alpha", "beta", "gamma", "delta", "epsilon"].join(sep);
let w = one_line(&text) * 0.6;
let lines = wrapped(&text, w, |_| {});
assert!(lines.len() >= 2, "{lines:?}");
assert!(lines[0].chars().count() > "alpha/beta".len(), "filled to the edge, not cut at a separator: {lines:?}");
}
// latinLnBrk on: Unicode's break after a slash is allowed again.
let text = "alpha/beta/gamma/delta/epsilon";
let lines = wrapped(text, one_line(text) * 0.6, |p| p.latin_line_break = Some(true));
assert!(lines[0].ends_with('/'), "{lines:?}");
// A slash followed by a space still breaks, and hyphens still break.
let lines = wrapped("alpha-beta-gamma-delta-epsilon", one_line("alpha-beta-gamma-") + 1.0, |_| {});
assert_eq!(lines[0], "alpha-beta-gamma-");
}
#[test]
fn korean_wraps_between_words() {
let text = "한국어 안녕하세요 반갑습니다 감사합니다";
let w = one_line("한국어 안녕하세요 반") + 0.5;
let lines = wrapped(text, w, |_| {});
assert_eq!(lines.iter().map(|l| l.trim_end()).collect::<Vec<_>>(), ["한국어 안녕하세요", "반갑습니다", "감사합니다"], "{lines:?}");
// Japanese and Chinese still break between characters.
let lines = wrapped("日本語の文章は空白なしで続きます", one_line("日本語の文章") + 0.5, |_| {});
assert_eq!(lines[0], "日本語の文章");
}
#[test]
fn closing_punctuation_hangs_past_the_end_with_hanging_punct() {
// PowerPoint (hangingPunct on, as its default text styles set it) lets "。" hang past the
// edge instead of pulling the character before it onto the next line.
let text = "白なしで続きます。句読点も含めて。";
let w = one_line("白なしで続きます") + 0.5;
let lines = wrapped(text, w, |p| p.hanging_punct = Some(true));
assert_eq!(lines[0], "白なしで続きます。", "{lines:?}");
// Without it, "。" can't start a line, so "す。" moves down together.
let lines = wrapped(text, w, |p| p.hanging_punct = Some(false));
assert_eq!(lines[0], "白なしで続きま", "{lines:?}");
}
#[test]
fn emoji_take_a_colour_face_when_one_is_installed() {
// PowerPoint draws emoji (and ZWJ sequences, skin tones, flags) from the colour emoji font
// even when the run's font has a plain glyph for some of them. Skipped without an installed
// colour emoji font.
let db = FontDb::global();
let probe = db.cascade(&['😀'], &[], "Regular");
if !probe.is_color() {
return;
}
for s in ["😀", "👍🏽", "❤\u{FE0F}", "👨\u{200D}👩\u{200D}👧"] {
let (p, sh) = bidi_setup(vec![Run::new(format!("a{s}b"))], 2000.0, false, Align::Left);
let l = lay(&p, &sh, 2000.0, 300.0);
let emoji = l.runs.iter().find(|r| r.cells.iter().any(|(_, c)| !c.is_ascii())).unwrap();
assert!(emoji.face.is_color(), "{s:?} drawn with {}", emoji.face.family);
assert!(l.runs.iter().filter(|r| r.cells.iter().all(|(_, c)| c.is_ascii())).all(|r| !r.face.is_color()), "the letters keep a text face");
}
}