Compare commits

..
Author SHA1 Message Date
Brandon ThomasandClaude Opus 5.5 d78522469b Bullets: an installed symbol font draws its own glyph
Bullets in Wingdings, Wingdings 2/3, Symbol and other symbol fonts were
always swapped for a look-alike from one Wingdings table, so a Wingdings 2
star (0xEA) became a round dot. When the deck's symbol font is installed,
its glyph is drawn (symbol fonts map character codes at U+F000 + code), as
PowerPoint does; otherwise Wingdings 2 and 3 codes get their own
look-alikes.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-12 18:05:17 -04:00
4 changed files with 10 additions and 171 deletions
+5 -11
View File
@@ -793,14 +793,11 @@ impl Renderer {
ctx.push_clip_layer(&outline);
let c = rctx.color(&sh.color, fx_ph);
let d = Vec2::new(sh.dir.to_radians().cos(), sh.dir.to_radians().sin()) * sh.dist;
// As PowerPoint draws it (measured on its export): the shadow falls inside along the
// edges it points to (45 degrees: the bottom and right), blurred by a third of the
// blur radius.
ctx.push_layer(None, None, None, None, Some(blur(sh.blur / 3.0 * scale_of(m))));
ctx.push_layer(None, None, None, None, Some(blur(sh.blur * 0.5 * scale_of(m))));
ctx.set_paint(color(c, 1.0));
let mut ring = Rect::new(0.0, 0.0, w, h).inflate(w.max(h), w.max(h)).to_path(0.1);
let mut hole = outline.clone();
hole.apply_affine(Affine::translate(-d));
hole.apply_affine(Affine::translate(d));
ring.extend(hole.iter());
ctx.set_fill_rule(peniko::Fill::EvenOdd);
ctx.fill_path(&ring);
@@ -871,8 +868,7 @@ impl Renderer {
// Shadow offset is in slide space (doesn't rotate with the shape unless asked).
let sm = if sh.rotate_with_shape { m * Affine::translate(d) * local } else { pre_translate(m, d * k) * local };
ctx.set_transform(Affine::IDENTITY);
// Blurred by a third of the blur radius, as PowerPoint's export shows.
ctx.push_layer(None, None, Some((c.a as f32) / 255.0), None, Some(blur(sh.blur / 3.0 * k)));
ctx.push_layer(None, None, Some((c.a as f32) / 255.0), None, Some(blur(sh.blur * 0.5 * k)));
ctx.set_transform(sm);
ctx.set_paint(color(c.with_alpha(255), 1.0));
ctx.fill_path(&silhouette);
@@ -881,12 +877,10 @@ impl Renderer {
if let Some(g) = &e.glow {
let c = rctx.color(&g.color, ph);
ctx.set_transform(Affine::IDENTITY);
// Measured on PowerPoint's export: a glow of radius r is half strength about half of r
// out from the outline and gone by r.
ctx.push_layer(None, None, Some((c.a as f32) / 255.0), None, Some(blur(g.radius * 0.17 * k)));
ctx.push_layer(None, None, Some((c.a as f32) / 255.0), None, Some(blur(g.radius * 0.35 * k)));
ctx.set_transform(m);
ctx.set_paint(color(c.with_alpha(255), 1.0));
ctx.set_stroke(kurbo::Stroke::new(g.radius.max(0.0) * 0.95).with_join(kurbo::Join::Round));
ctx.set_stroke(kurbo::Stroke::new(g.radius.max(0.0) * 1.2).with_join(kurbo::Join::Round));
ctx.stroke_path(&silhouette);
ctx.fill_path(&silhouette);
ctx.pop_layer();
+3 -66
View File
@@ -119,10 +119,9 @@ fn effects_tables_charts_render() {
all.extend(charts);
let p = deck_with(all);
let img = render_slide(&p, 0, &RenderOpts { scale: 1.0, ..Default::default() });
// Shadow darkens below-right of the shape (grey, past the glow), blurred by a third of its
// 8 pt blur radius.
let px = img.pixel(255, 155);
assert!(px[0] < 240 && px[0] == px[2], "{px:?}");
// Shadow darkens below-right of the shape.
let px = img.pixel(258, 156);
assert!(px[0] < 250, "{px:?}");
}
#[test]
@@ -932,65 +931,3 @@ fn a_table_fill_shows_behind_cells_without_their_own() {
assert_eq!(img.pixel(150, 160)[..3], [0, 112, 192]);
assert_eq!(img.pixel(50, 50)[..3], [255, 255, 255]);
}
/// A white 280x200 pt rectangle at (20, 20) with `effects`, rendered at 1 px/pt.
fn effect_box(effects: deckcraft_model::Effects) -> Image {
use deckcraft_model::style::{Fill, Line};
let sh = Shape {
id: ShapeId(800),
xfrm: Some(Xfrm::new(20.0, 20.0, 280.0, 200.0)),
fill: Some(Fill::solid(ColorRef::rgb(deckcraft_color::Rgba::rgb(255, 255, 255)))),
line: Some(Line { fill: Some(Fill::None), ..Default::default() }),
effects: Some(effects),
..Default::default()
};
render_slide(&deck_with(vec![sh]), 0, &RenderOpts { scale: 1.0, ..Default::default() })
}
fn shadow(blur: f64, dist: f64, dir: f64) -> deckcraft_model::style::Shadow {
deckcraft_model::style::Shadow {
color: ColorRef::rgb(deckcraft_color::Rgba::rgb(0, 0, 0)),
blur,
dist,
dir,
inner: false,
sx: 1.0,
sy: 1.0,
kx: 0.0,
ky: 0.0,
align: String::new(),
rotate_with_shape: false,
}
}
#[test]
fn shadows_and_glows_spread_like_powerpoints() {
let red = |img: &Image, x: u32, y: u32| img.pixel(x, y)[0] as i32;
// Outer shadow, 9 pt blur, no offset: PowerPoint's export reads 144, 204, 238 at 1, 3 and 5
// px outside the left edge.
let img = effect_box(deckcraft_model::Effects { outer_shadow: Some(shadow(9.0, 0.0, 0.0)), ..Default::default() });
for (d, want) in [(1, 144), (3, 204), (5, 238)] {
assert!((red(&img, 20 - d, 120) - want).abs() <= 6, "outer {d}: {}", red(&img, 20 - d, 120));
}
// Inner shadow pointing down-right (45 degrees): it falls inside along the bottom and right
// edges, not the top and left.
let mut sh = shadow(0.0, 10.0, 45.0);
sh.inner = true;
let img = effect_box(deckcraft_model::Effects { inner_shadow: Some(sh), ..Default::default() });
assert!(red(&img, 296, 120) < 20, "right edge {}", red(&img, 296, 120));
assert!(red(&img, 160, 216) < 20, "bottom edge {}", red(&img, 160, 216));
assert!(red(&img, 24, 120) > 250 && red(&img, 160, 24) > 250, "top and left stay clear");
// Inner shadow with a 9 pt blur and no offset: the same profile as the outer one, inside.
let mut sh = shadow(9.0, 0.0, 0.0);
sh.inner = true;
let img = effect_box(deckcraft_model::Effects { inner_shadow: Some(sh), ..Default::default() });
for (d, want) in [(0, 144), (2, 204), (4, 238)] {
assert!((red(&img, 20 + d, 120) - want).abs() <= 6, "inner {d}: {}", red(&img, 20 + d, 120));
}
// Glow of radius 10 pt: nearly solid 1 px out, about half strength 5 px out, gone by 10.
let glow = deckcraft_model::style::Glow { color: ColorRef::rgb(deckcraft_color::Rgba::rgb(0, 0, 0)), radius: 10.0 };
let img = effect_box(deckcraft_model::Effects { glow: Some(glow), ..Default::default() });
assert!(red(&img, 19, 120) < 20);
assert!((60..170).contains(&red(&img, 15, 120)), "{}", red(&img, 15, 120));
assert!(red(&img, 10, 120) > 245);
}
+2 -25
View File
@@ -203,8 +203,6 @@ struct Style {
cs: Option<String>,
/// Heading font (`+mj-…`): the theme's major font set applies.
major: bool,
/// Pair kerning on: `a:rPr/@kern` is set and the size reaches it.
kern: bool,
}
/// One character of a paragraph after shaping.
@@ -316,7 +314,6 @@ fn make_style(ctx: &Ctx, r: &RunProps, scale: f64, prompt: Option<Rgba>) -> Styl
ea,
cs,
major,
kern: r.kern.is_some_and(|k| k > 0.0 && size + 1e-6 >= k * scale),
}
}
@@ -616,8 +613,7 @@ fn shape_cells(ctx: &Ctx, text: &str, char_style: &[usize], styles: &[Style], fa
// Items: maximal ranges of one level, face, script and shaping style (case mapping, language).
let upper = |i: usize| styles.get(char_style.get(i).copied().unwrap_or(0)).is_some_and(|st| st.caps != Caps::None);
let lang = |i: usize| styles.get(char_style.get(i).copied().unwrap_or(0)).and_then(|st| st.lang.as_deref());
let kern = |i: usize| styles.get(char_style.get(i).copied().unwrap_or(0)).is_some_and(|st| st.kern);
let key = |i: usize| (levels.get(i).copied(), char_face.get(i).copied(), scripts.get(i).map(|s| s.0), upper(i), lang(i), kern(i));
let key = |i: usize| (levels.get(i).copied(), char_face.get(i).copied(), scripts.get(i).map(|s| s.0), upper(i), lang(i));
let mut a = 0;
while a < n {
let mut b = a + 1;
@@ -633,24 +629,6 @@ fn shape_cells(ctx: &Ctx, text: &str, char_style: &[usize], styles: &[Style], fa
/// Size of superscript and subscript text relative to the run's size.
const SUB_SUPER: f64 = 0.66;
/// The OpenType features to change from the shaper's defaults for an item, as PowerPoint draws
/// it: kerning only where the run asks for it (`kern`), and no standard or contextual ligatures in
/// Latin, Greek and Cyrillic ("fi", "ffi" and Calibri's "ti" stay apart). Other scripts need
/// their ligatures (Arabic lam-alef), and emoji sequences are joined by them.
fn shaping_features(cells: &[Cell], styles: &[Style], script: Option<ScriptTag>) -> Vec<deckcraft_fonts::Feature> {
let mut f = vec![];
if !cells.first().and_then(|c| styles.get(c.style)).is_some_and(|st| st.kern) {
f.extend(deckcraft_fonts::feature("-kern"));
}
let alphabetic = matches!(script.as_ref(), Some(b"Latn" | b"Grek" | b"Cyrl"));
let emoji = cells.iter().any(|c| matches!(c.ch as u32, 0x200D | 0x20E3 | 0xFE0F | 0x2600..=0x27BF | 0x1F000..=0x1FAFF | 0xE0020..=0xE007F));
if alphabetic && !emoji {
f.extend(deckcraft_fonts::feature("-liga"));
f.extend(deckcraft_fonts::feature("-clig"));
}
f
}
/// 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)]
@@ -671,8 +649,7 @@ fn shape_item(
let Some(face) = faces.get(fi) else { return };
let rtl = level % 2 == 1;
let (Some(&b0), Some(&b1)) = (byte_of.get(range.start), byte_of.get(range.end)) else { return };
let features = shaping_features(cells.get(range.clone()).unwrap_or(&[]), styles, script);
let params = ShapeParams { features: &features, rtl, script, language: lang };
let params = ShapeParams { features: &[], rtl, script, language: lang };
let glyphs = deckcraft_fonts::shape_range(face, text, b0..b1, &params, |ch| if upper { ch.to_uppercase().next().unwrap_or(ch) } else { ch });
let upem = face.upem.max(1.0);
let scale_of = |cell: &Cell| {
-69
View File
@@ -45,75 +45,6 @@ fn bullets_are_drawn_for_body_text() {
assert_eq!(l.para_lines.len(), 2);
}
/// Glyph count and width of `text` laid out in one line of `family` at 40 pt, kern threshold `kern`.
fn glyphs_and_width(family: &str, text: &str, kern: Option<f64>) -> (usize, f64) {
let (p, mut sh) = setup(text, 2000.0, 300.0);
let para = &mut sh.text.as_mut().unwrap().paragraphs[0];
para.props.bullet = Some(Bullet::None);
for r in &mut para.runs {
r.props.font = Some(family.into());
r.props.size = Some(40.0);
r.props.kern = kern;
}
let l = lay(&p, &sh, 2000.0, 300.0);
let n = l.runs.iter().map(|r| r.glyphs.len()).sum();
(n, l.lines[0].extent.1 - l.lines[0].extent.0)
}
/// The first installed or bundled family of `families` for which `pick` holds.
fn first_family(families: &[&str], pick: impl Fn(&FontFace) -> bool) -> Option<String> {
let db = FontDb::global();
families.iter().filter(|f| db.has_family(f)).map(|f| db.face(f, "Regular")).find(|f| pick(f)).map(|f| f.family.clone())
}
const LATIN_FAMILIES: &[&str] = &["Carlito", "Calibri", "DejaVu Sans", "Liberation Sans", "Inter", "Arial", "Cambria", "Times New Roman"];
#[test]
fn latin_text_draws_no_ligatures() {
// PowerPoint keeps "fi", "ffi" and Calibri's "ti" apart, although the fonts have them as
// standard ligatures.
let ligates = |f: &FontFace| deckcraft_fonts::shape(f, "office", &[], |c| c).len() < 6;
let Some(family) = first_family(LATIN_FAMILIES, ligates) else {
eprintln!("skipped: no font with an ffi ligature");
return;
};
let (n, _) = glyphs_and_width(&family, "office fifth", Some(0.0));
assert_eq!(n, 12, "{family}: one glyph per character");
}
#[test]
fn pairs_are_kerned_only_from_the_kern_size() {
let kerns = |f: &FontFace| {
let w = |feat: &[deckcraft_fonts::Feature]| deckcraft_fonts::shape(f, "AVATAR", feat, |c| c).iter().map(|g| g.x_advance).sum::<i32>();
w(&[]) != w(&deckcraft_fonts::feature("-kern").into_iter().collect::<Vec<_>>())
};
let Some(family) = first_family(LATIN_FAMILIES, kerns) else {
eprintln!("skipped: no font that kerns AVATAR");
return;
};
// `kern` is the smallest size kerned; 0 is no kerning.
let (_, off) = glyphs_and_width(&family, "AVATAR WAVE", Some(0.0));
let (_, on) = glyphs_and_width(&family, "AVATAR WAVE", Some(12.0));
let (_, at) = glyphs_and_width(&family, "AVATAR WAVE", Some(40.0));
let (_, above) = glyphs_and_width(&family, "AVATAR WAVE", Some(48.0));
assert!(on < off - 2.0, "{family}: kerned {on} vs {off}");
assert!((at - on).abs() < 0.01, "kerned at exactly the threshold size");
assert!((above - off).abs() < 0.01, "40 pt is below a 48 pt threshold");
}
#[test]
fn ligatures_stay_for_scripts_that_need_them() {
let has = |f: &[deckcraft_fonts::Feature], tag: &str| f.iter().any(|x| Some(*x) == deckcraft_fonts::feature(tag));
let cells =
|s: &str| s.chars().map(|ch| Cell { ch, style: 0, face: 0, level: 0, glyphs: vec![], adv: 0.0, head: 0, boundary: true }).collect::<Vec<_>>();
let latin = shaping_features(&cells("office"), &[], Some(*b"Latn"));
assert!(has(&latin, "-liga") && has(&latin, "-clig") && has(&latin, "-kern"));
// Arabic needs its ligatures; emoji sequences are joined by them.
assert!(!has(&shaping_features(&cells("\u{644}\u{627}"), &[], Some(*b"Arab")), "-liga"));
assert!(!has(&shaping_features(&cells("a\u{1F469}\u{200D}\u{1F4BB}"), &[], Some(*b"Latn")), "-liga"));
assert!(!has(&shaping_features(&cells("12"), &[], None), "-liga"));
}
#[test]
fn only_the_first_character_of_a_bullet_is_drawn() {
// "••" (seen in the wild) draws one dot in PowerPoint.