mirror of
https://github.com/storytold/deckcraft.git
synced 2026-10-24 10:37:03 -06:00
Compare commits
5
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1f2f90935b | ||
|
|
a520dd4d1d | ||
|
|
fd2f8146b4 | ||
|
|
1679836540 | ||
|
|
d8407c11f9 |
@@ -55,6 +55,9 @@ pub struct Table {
|
||||
pub last_col: bool,
|
||||
pub band_row: bool,
|
||||
pub band_col: bool,
|
||||
/// `a:tblPr` fill: a fill behind the whole table (over the style's own background).
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub background: Option<Fill>,
|
||||
}
|
||||
|
||||
impl Default for Table {
|
||||
@@ -69,6 +72,7 @@ impl Default for Table {
|
||||
last_col: false,
|
||||
band_row: true,
|
||||
band_col: false,
|
||||
background: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -394,6 +394,7 @@ fn smartart(imp: &mut Imp, part: &Part, gd: &El, frame: &Shape, depth: usize) ->
|
||||
fn table(imp: &mut Imp, part: &Part, tbl: &El) -> Table {
|
||||
let mut t = Table { style: "none".into(), first_row: false, band_row: false, ..Default::default() };
|
||||
if let Some(pr) = tbl.child("tblPr") {
|
||||
t.background = pr.elements().find(|e| e.local().ends_with("Fill") && e.local() != "noFill").and_then(|f| dml::fill_el(imp, part, f));
|
||||
t.first_row = pr.bool("firstRow").unwrap_or(false);
|
||||
t.first_col = pr.bool("firstCol").unwrap_or(false);
|
||||
t.last_row = pr.bool("lastRow").unwrap_or(false);
|
||||
|
||||
@@ -494,6 +494,9 @@ fn table(w: &mut W, x: &mut Exp, o: &mut Out, t: &Table) {
|
||||
.t("bandRow", t.band_row)
|
||||
.t("bandCol", t.band_col),
|
||||
);
|
||||
if let Some(f) = &t.background {
|
||||
dml::fill(w, x, o, f);
|
||||
}
|
||||
w.elt("a:tableStyleId", &guid);
|
||||
w.close("a:tblPr");
|
||||
w.open0("a:tblGrid");
|
||||
|
||||
@@ -780,3 +780,19 @@ fn line_break_options_round_trip() {
|
||||
assert_eq!(props.latin_line_break, Some(true));
|
||||
assert_eq!(props.hanging_punct, Some(false));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_table_fill_round_trips() {
|
||||
let mut p = defaults::new_presentation(None);
|
||||
let mut t = Table::new(2, 2, 200.0, 40.0);
|
||||
t.background = Some(Fill::solid(ColorRef::rgb(Rgba::rgb(0, 112, 192))));
|
||||
let mut sh = shape(&mut p, "Filled table", 10.0, 10.0, 200.0, 80.0);
|
||||
sh.kind = deckcraft_model::ShapeKind::Table(t);
|
||||
let first = p.slides[0].id;
|
||||
p.slide_mut(first).expect("slide").shapes.push(sh);
|
||||
let q = round(&p);
|
||||
let deckcraft_model::ShapeKind::Table(t) = &by_name(&q.slides[0], "Filled table").kind else { panic!("table") };
|
||||
assert!(
|
||||
matches!(&t.background, Some(Fill::Solid { color }) if matches!(color.base, deckcraft_model::ColorBase::Rgb { rgb } if rgb == Rgba::rgb(0, 112, 192)))
|
||||
);
|
||||
}
|
||||
|
||||
@@ -793,11 +793,14 @@ 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;
|
||||
ctx.push_layer(None, None, None, None, Some(blur(sh.blur * 0.5 * scale_of(m))));
|
||||
// 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.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);
|
||||
@@ -868,7 +871,8 @@ 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);
|
||||
ctx.push_layer(None, None, Some((c.a as f32) / 255.0), None, Some(blur(sh.blur * 0.5 * k)));
|
||||
// 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.set_transform(sm);
|
||||
ctx.set_paint(color(c.with_alpha(255), 1.0));
|
||||
ctx.fill_path(&silhouette);
|
||||
@@ -877,10 +881,12 @@ impl Renderer {
|
||||
if let Some(g) = &e.glow {
|
||||
let c = rctx.color(&g.color, ph);
|
||||
ctx.set_transform(Affine::IDENTITY);
|
||||
ctx.push_layer(None, None, Some((c.a as f32) / 255.0), None, Some(blur(g.radius * 0.35 * k)));
|
||||
// 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.set_transform(m);
|
||||
ctx.set_paint(color(c.with_alpha(255), 1.0));
|
||||
ctx.set_stroke(kurbo::Stroke::new(g.radius.max(0.0) * 1.2).with_join(kurbo::Join::Round));
|
||||
ctx.set_stroke(kurbo::Stroke::new(g.radius.max(0.0) * 0.95).with_join(kurbo::Join::Round));
|
||||
ctx.stroke_path(&silhouette);
|
||||
ctx.fill_path(&silhouette);
|
||||
ctx.pop_layer();
|
||||
|
||||
@@ -300,13 +300,20 @@ pub fn cell_rects(t: &Table, heights: &[f64]) -> Vec<(usize, usize, Rect)> {
|
||||
pub fn draw(ctx: &mut RenderContext, pres: &Presentation, rctx: &Ctx, t: &Table, m: Affine, _w: f64, _h: f64, fields: &dyn deckcraft_text::Fields) {
|
||||
let _ = pres;
|
||||
let rects = cell_rects(t, &row_heights(rctx, t, fields));
|
||||
// A file-defined style's table background, behind every cell.
|
||||
if let Some(def) = rctx.pres.table_styles.iter().find(|d| d.id.eq_ignore_ascii_case(&t.style))
|
||||
&& let Some((bg, ph)) = def.background.as_ref().and_then(|b| style_fill(rctx, b))
|
||||
&& let Some(all) = rects.iter().map(|(_, _, r)| *r).reduce(|a, b| a.union(b))
|
||||
{
|
||||
ctx.set_transform(m);
|
||||
crate::paint::fill_path(ctx, rctx, &bg, ph, &all.to_path(0.1), all, m, deckcraft_geom::preset::FillMode::Norm, 1.0, rctx.pres);
|
||||
// Table backgrounds behind every cell: a file-defined style's, then the table's own fill.
|
||||
let style_bg = rctx
|
||||
.pres
|
||||
.table_styles
|
||||
.iter()
|
||||
.find(|d| d.id.eq_ignore_ascii_case(&t.style))
|
||||
.and_then(|d| d.background.as_ref())
|
||||
.and_then(|b| style_fill(rctx, b));
|
||||
let own_bg = t.background.clone().map(|f| (f, None));
|
||||
if let Some(all) = rects.iter().map(|(_, _, r)| *r).reduce(|a, b| a.union(b)) {
|
||||
for (bg, ph) in style_bg.into_iter().chain(own_bg) {
|
||||
ctx.set_transform(m);
|
||||
crate::paint::fill_path(ctx, rctx, &bg, ph, &all.to_path(0.1), all, m, deckcraft_geom::preset::FillMode::Norm, 1.0, rctx.pres);
|
||||
}
|
||||
}
|
||||
for (r, c, rect) in &rects {
|
||||
let Some(cell) = t.cell(*r, *c) else { continue };
|
||||
|
||||
@@ -119,9 +119,10 @@ 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.
|
||||
let px = img.pixel(258, 156);
|
||||
assert!(px[0] < 250, "{px:?}");
|
||||
// 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:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -912,3 +913,84 @@ fn a_file_defined_table_style_draws_its_parts() {
|
||||
let left = px(100, 175);
|
||||
assert!(left[2] > 200 && left[0] < 60, "{left:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_table_fill_shows_behind_cells_without_their_own() {
|
||||
// `a:tblPr` solidFill (tdf#127237): cells with no fill show the table's blue.
|
||||
use deckcraft_model::style::Fill;
|
||||
let mut t = Table::new(2, 2, 200.0, 50.0);
|
||||
t.style = "none".into();
|
||||
t.background = Some(Fill::solid(ColorRef::rgb(deckcraft_color::Rgba::rgb(0, 112, 192))));
|
||||
for r in 0..2 {
|
||||
for c in 0..2 {
|
||||
t.cell_mut(r, c).unwrap().fill = Some(Fill::None);
|
||||
}
|
||||
}
|
||||
let tbl = Shape { id: ShapeId(795), xfrm: Some(Xfrm::new(100.0, 100.0, 200.0, 100.0)), kind: ShapeKind::Table(t), ..Default::default() };
|
||||
let p = deck_with(vec![tbl]);
|
||||
let img = render_slide(&p, 0, &RenderOpts { scale: 1.0, ..Default::default() });
|
||||
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);
|
||||
}
|
||||
|
||||
+84
-2
@@ -203,6 +203,8 @@ 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.
|
||||
@@ -314,6 +316,7 @@ 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),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -493,6 +496,17 @@ fn shape_para(ctx: &Ctx, shape: &Shape, body: &TextBody, pi: usize, scale: f64,
|
||||
// Symbol fonts (Wingdings) aren't available: map common private-use bullets.
|
||||
let ch = map_symbol_bullet(char, props.bullet_font.as_deref());
|
||||
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(Bullet::AutoNum { scheme, start_at }) => {
|
||||
@@ -602,7 +616,8 @@ 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 key = |i: usize| (levels.get(i).copied(), char_face.get(i).copied(), scripts.get(i).map(|s| s.0), upper(i), lang(i));
|
||||
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 mut a = 0;
|
||||
while a < n {
|
||||
let mut b = a + 1;
|
||||
@@ -618,6 +633,24 @@ 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)]
|
||||
@@ -638,7 +671,8 @@ 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 params = ShapeParams { features: &[], rtl, script, language: lang };
|
||||
let features = shaping_features(cells.get(range.clone()).unwrap_or(&[]), styles, script);
|
||||
let params = ShapeParams { features: &features, rtl, script, language: lang };
|
||||
let glyphs = deckcraft_fonts::shape_range(face, text, b0..b1, ¶ms, |ch| if upper { ch.to_uppercase().next().unwrap_or(ch) } else { ch });
|
||||
let upem = face.upem.max(1.0);
|
||||
let scale_of = |cell: &Cell| {
|
||||
@@ -715,11 +749,59 @@ 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 {
|
||||
let f = font.unwrap_or("").to_ascii_lowercase();
|
||||
let first = c.chars().next().unwrap_or('•');
|
||||
if f.contains("wingdings") || f.contains("symbol") || ('\u{f000}'..='\u{f0ff}').contains(&first) {
|
||||
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 {
|
||||
0xa7 => '▪',
|
||||
0xd8 => '➢',
|
||||
|
||||
@@ -45,6 +45,75 @@ 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.
|
||||
@@ -624,3 +693,28 @@ 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");
|
||||
}
|
||||
}
|
||||
|
||||
#[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