mirror of
https://github.com/storytold/deckcraft.git
synced 2026-11-01 06:26:59 -06:00
Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b2be813006 |
@@ -793,14 +793,11 @@ impl Renderer {
|
|||||||
ctx.push_clip_layer(&outline);
|
ctx.push_clip_layer(&outline);
|
||||||
let c = rctx.color(&sh.color, fx_ph);
|
let c = rctx.color(&sh.color, fx_ph);
|
||||||
let d = Vec2::new(sh.dir.to_radians().cos(), sh.dir.to_radians().sin()) * sh.dist;
|
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
|
ctx.push_layer(None, None, None, None, Some(blur(sh.blur * 0.5 * scale_of(m))));
|
||||||
// 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.set_paint(color(c, 1.0));
|
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 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();
|
let mut hole = outline.clone();
|
||||||
hole.apply_affine(Affine::translate(-d));
|
hole.apply_affine(Affine::translate(d));
|
||||||
ring.extend(hole.iter());
|
ring.extend(hole.iter());
|
||||||
ctx.set_fill_rule(peniko::Fill::EvenOdd);
|
ctx.set_fill_rule(peniko::Fill::EvenOdd);
|
||||||
ctx.fill_path(&ring);
|
ctx.fill_path(&ring);
|
||||||
@@ -871,8 +868,7 @@ impl Renderer {
|
|||||||
// Shadow offset is in slide space (doesn't rotate with the shape unless asked).
|
// 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 };
|
let sm = if sh.rotate_with_shape { m * Affine::translate(d) * local } else { pre_translate(m, d * k) * local };
|
||||||
ctx.set_transform(Affine::IDENTITY);
|
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 * 0.5 * k)));
|
||||||
ctx.push_layer(None, None, Some((c.a as f32) / 255.0), None, Some(blur(sh.blur / 3.0 * k)));
|
|
||||||
ctx.set_transform(sm);
|
ctx.set_transform(sm);
|
||||||
ctx.set_paint(color(c.with_alpha(255), 1.0));
|
ctx.set_paint(color(c.with_alpha(255), 1.0));
|
||||||
ctx.fill_path(&silhouette);
|
ctx.fill_path(&silhouette);
|
||||||
@@ -881,12 +877,10 @@ impl Renderer {
|
|||||||
if let Some(g) = &e.glow {
|
if let Some(g) = &e.glow {
|
||||||
let c = rctx.color(&g.color, ph);
|
let c = rctx.color(&g.color, ph);
|
||||||
ctx.set_transform(Affine::IDENTITY);
|
ctx.set_transform(Affine::IDENTITY);
|
||||||
// Measured on PowerPoint's export: a glow of radius r is half strength about half of r
|
ctx.push_layer(None, None, Some((c.a as f32) / 255.0), None, Some(blur(g.radius * 0.35 * k)));
|
||||||
// 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.set_transform(m);
|
ctx.set_transform(m);
|
||||||
ctx.set_paint(color(c.with_alpha(255), 1.0));
|
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.stroke_path(&silhouette);
|
||||||
ctx.fill_path(&silhouette);
|
ctx.fill_path(&silhouette);
|
||||||
ctx.pop_layer();
|
ctx.pop_layer();
|
||||||
|
|||||||
@@ -119,10 +119,9 @@ fn effects_tables_charts_render() {
|
|||||||
all.extend(charts);
|
all.extend(charts);
|
||||||
let p = deck_with(all);
|
let p = deck_with(all);
|
||||||
let img = render_slide(&p, 0, &RenderOpts { scale: 1.0, ..Default::default() });
|
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
|
// Shadow darkens below-right of the shape.
|
||||||
// 8 pt blur radius.
|
let px = img.pixel(258, 156);
|
||||||
let px = img.pixel(255, 155);
|
assert!(px[0] < 250, "{px:?}");
|
||||||
assert!(px[0] < 240 && px[0] == px[2], "{px:?}");
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[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(150, 160)[..3], [0, 112, 192]);
|
||||||
assert_eq!(img.pixel(50, 50)[..3], [255, 255, 255]);
|
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);
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -493,17 +493,6 @@ fn shape_para(ctx: &Ctx, shape: &Shape, body: &TextBody, pi: usize, scale: f64,
|
|||||||
// Symbol fonts (Wingdings) aren't available: map common private-use bullets.
|
// Symbol fonts (Wingdings) aren't available: map common private-use bullets.
|
||||||
let ch = map_symbol_bullet(char, props.bullet_font.as_deref());
|
let ch = map_symbol_bullet(char, props.bullet_font.as_deref());
|
||||||
bullet_face(db, &mut st, &ch);
|
bullet_face(db, &mut st, &ch);
|
||||||
|
|
||||||
// A symbol font (Wingdings, Symbol…) that is installed draws its own glyph, found
|
|
||||||
// at U+F000 + the character code as such fonts map them; otherwise common symbol
|
|
||||||
// bullets are mapped to Unicode look-alikes.
|
|
||||||
let ch = match symbol_font_char(&st.face, char, props.bullet_font.as_deref()) {
|
|
||||||
Some(c) => c.to_string(),
|
|
||||||
None => map_symbol_bullet(char, props.bullet_font.as_deref()),
|
|
||||||
};
|
|
||||||
if !st.face.covers(ch.chars().next().unwrap_or('•')) {
|
|
||||||
st.face = db.face("Inter", "Regular");
|
|
||||||
}
|
|
||||||
Some((ch, st))
|
Some((ch, st))
|
||||||
}
|
}
|
||||||
Some(Bullet::AutoNum { scheme, start_at }) => {
|
Some(Bullet::AutoNum { scheme, start_at }) => {
|
||||||
@@ -726,59 +715,11 @@ fn shape_item(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Is `family` a symbol font (characters at U+F000 + code)?
|
|
||||||
fn is_symbol_font(family: &str) -> bool {
|
|
||||||
let f = family.to_ascii_lowercase();
|
|
||||||
f.starts_with("wingdings") || f == "symbol" || f.starts_with("webdings") || f.starts_with("marlett")
|
|
||||||
}
|
|
||||||
|
|
||||||
/// The character to draw bullet `c` with the installed symbol font `face`, when `font` names a
|
|
||||||
/// symbol font that `face` is (not a substitute) and it has the glyph.
|
|
||||||
fn symbol_font_char(face: &FontFace, c: &str, font: Option<&str>) -> Option<char> {
|
|
||||||
let font = font?;
|
|
||||||
if !is_symbol_font(font) || !face.family.eq_ignore_ascii_case(font) {
|
|
||||||
return None;
|
|
||||||
}
|
|
||||||
let first = c.chars().next()?;
|
|
||||||
let pua = char::from_u32(0xF000 | ((first as u32) & 0xFF))?;
|
|
||||||
if face.covers(pua) {
|
|
||||||
Some(pua)
|
|
||||||
} else if face.covers(first) {
|
|
||||||
Some(first)
|
|
||||||
} else {
|
|
||||||
None
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn map_symbol_bullet(c: &str, font: Option<&str>) -> String {
|
fn map_symbol_bullet(c: &str, font: Option<&str>) -> String {
|
||||||
let f = font.unwrap_or("").to_ascii_lowercase();
|
let f = font.unwrap_or("").to_ascii_lowercase();
|
||||||
let first = c.chars().next().unwrap_or('•');
|
let first = c.chars().next().unwrap_or('•');
|
||||||
if f.contains("wingdings") || f.contains("symbol") || ('\u{f000}'..='\u{f0ff}').contains(&first) {
|
if f.contains("wingdings") || f.contains("symbol") || ('\u{f000}'..='\u{f0ff}').contains(&first) {
|
||||||
let code = (first as u32) & 0xff;
|
let code = (first as u32) & 0xff;
|
||||||
// Wingdings 2 and 3 put different shapes at the same codes.
|
|
||||||
if f.starts_with("wingdings 2") {
|
|
||||||
let m = match code {
|
|
||||||
0x97 | 0x98 => '●',
|
|
||||||
0x9e..=0xa1 => '■',
|
|
||||||
0xa2..=0xa5 => '◆',
|
|
||||||
0xea..=0xf3 => '★',
|
|
||||||
0xb7..=0xbf => '○',
|
|
||||||
0x50 => '✓',
|
|
||||||
_ => '•',
|
|
||||||
};
|
|
||||||
return m.to_string();
|
|
||||||
}
|
|
||||||
if f.starts_with("wingdings 3") {
|
|
||||||
let m = match code {
|
|
||||||
0x7d | 0x75 => '►',
|
|
||||||
0x7c | 0x74 => '◄',
|
|
||||||
0x70 => '▲',
|
|
||||||
0x71 => '▼',
|
|
||||||
0xc6 | 0xc7 => '➔',
|
|
||||||
_ => '▶',
|
|
||||||
};
|
|
||||||
return m.to_string();
|
|
||||||
}
|
|
||||||
let m = match code {
|
let m = match code {
|
||||||
0xa7 => '▪',
|
0xa7 => '▪',
|
||||||
0xd8 => '➢',
|
0xd8 => '➢',
|
||||||
|
|||||||
@@ -624,28 +624,3 @@ fn a_bullet_the_text_font_lacks_falls_back_to_a_face_that_has_it() {
|
|||||||
assert!(b.glyphs.iter().all(|g| g.0 != 0), "no missing-glyph box");
|
assert!(b.glyphs.iter().all(|g| g.0 != 0), "no missing-glyph box");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn symbol_font_bullets_map_by_font() {
|
|
||||||
// Wingdings 2 code 0xEA is a star, Wingdings 3 0x7D a right-pointing triangle; plain
|
|
||||||
// Wingdings keeps its own table (0xA7 is a small square).
|
|
||||||
let star = String::from_utf8(vec![0xEF, 0x83, 0xAA]).unwrap(); // U+F0EA
|
|
||||||
assert_eq!(map_symbol_bullet(&star, Some("Wingdings 2")), "★");
|
|
||||||
assert_eq!(map_symbol_bullet("\u{7d}", Some("Wingdings 3")), "►");
|
|
||||||
assert_eq!(map_symbol_bullet("\u{f0a7}", Some("Wingdings")), "▪");
|
|
||||||
assert_eq!(map_symbol_bullet("•", Some("Arial")), "•");
|
|
||||||
assert!(is_symbol_font("Wingdings 2") && is_symbol_font("Symbol") && !is_symbol_font("Arial"));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn an_installed_symbol_font_draws_its_own_bullet() {
|
|
||||||
// Where the deck's symbol font is installed, its glyph is drawn (as PowerPoint does), not a
|
|
||||||
// look-alike. Skipped without Wingdings 2.
|
|
||||||
let db = FontDb::global();
|
|
||||||
if !db.has_family("Wingdings 2") {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
let face = db.face("Wingdings 2", "Regular");
|
|
||||||
assert_eq!(symbol_font_char(&face, "\u{ea}", Some("Wingdings 2")), Some('\u{f0ea}'));
|
|
||||||
assert_eq!(symbol_font_char(&face, "\u{ea}", Some("Arial")), None);
|
|
||||||
}
|
|
||||||
|
|||||||
Reference in New Issue
Block a user