mirror of
https://github.com/storytold/deckcraft.git
synced 2026-10-24 10:37:03 -06:00
Compare commits
3
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e019690354 | ||
|
|
98ea8d3195 | ||
|
|
2e68504e7c |
@@ -79,6 +79,10 @@ pub struct FontFace {
|
||||
/// values).
|
||||
pub cap_height: f64,
|
||||
pub x_height: f64,
|
||||
/// Where the baseline sits in a line, as a share of the line's height from its top: the
|
||||
/// Windows ascent over the Windows ascent plus descent (OS/2 `usWinAscent`/`usWinDescent`),
|
||||
/// the split PowerPoint uses (computed by `line_split`).
|
||||
pub line_split: f64,
|
||||
pub shaper: harfrust::ShaperData,
|
||||
/// Variable fonts: the named instance's axis settings (user units; empty = default instance),
|
||||
/// the normalised location and the shaper's view of it.
|
||||
@@ -436,6 +440,23 @@ pub(crate) fn last_resort_face() -> Arc<FontFace> {
|
||||
.clone()
|
||||
}
|
||||
|
||||
/// The share of a line above the baseline (`FontFace::line_split`): OS/2 `usWinAscent` over
|
||||
/// `usWinAscent + usWinDescent`, else the ascent over ascent plus descent. Fonts whose Windows
|
||||
/// extent is huge (Gabriola, Cambria Math: over 1.5 em) get 0.81, where PowerPoint puts their
|
||||
/// baseline (observed).
|
||||
fn line_split(f: &skrifa::FontRef, ascent: f64, descent: f64, upem: f64) -> f64 {
|
||||
use skrifa::raw::TableProvider;
|
||||
let (a, d) = match f.os2() {
|
||||
Ok(os2) if os2.us_win_ascent() > 0 => (os2.us_win_ascent() as f64, os2.us_win_descent() as f64),
|
||||
_ => (ascent, descent),
|
||||
};
|
||||
let total = a + d;
|
||||
if !total.is_finite() || total <= 0.0 || a < 0.0 || d < 0.0 {
|
||||
return 0.8;
|
||||
}
|
||||
if total > upem * 1.5 { 0.81 } else { (a / total).clamp(0.5, 1.0) }
|
||||
}
|
||||
|
||||
fn make_face(bytes: FontBytes, index: u32, family: String, style: String, coords: Vec<([u8; 4], f32)>) -> Option<FontFace> {
|
||||
let data: &[u8] = match &bytes {
|
||||
FontBytes::Static(b) => b,
|
||||
@@ -467,6 +488,7 @@ fn make_face(bytes: FontBytes, index: u32, family: String, style: String, coords
|
||||
descent: -(m.descent as f64),
|
||||
cap_height: m.cap_height.map(|v| v as f64).filter(|v| *v > 0.0).unwrap_or(m.ascent as f64 * 0.72),
|
||||
x_height: m.x_height.map(|v| v as f64).filter(|v| *v > 0.0).unwrap_or(m.ascent as f64 * 0.5),
|
||||
line_split: line_split(&f, m.ascent as f64, -(m.descent as f64), m.units_per_em.max(1) as f64),
|
||||
shaper,
|
||||
coords,
|
||||
location,
|
||||
|
||||
@@ -143,6 +143,17 @@ mod tests {
|
||||
assert!(f.upem > 0.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn line_split_follows_the_windows_ascent_and_descent() {
|
||||
use skrifa::raw::TableProvider;
|
||||
let f = fontdb::last_resort_face();
|
||||
let font = skrifa::FontRef::new(f.data()).unwrap();
|
||||
let os2 = font.os2().unwrap();
|
||||
let (a, d) = (os2.us_win_ascent() as f64, os2.us_win_descent() as f64);
|
||||
assert!((f.line_split - a / (a + d)).abs() < 1e-9, "{} vs {}", f.line_split, a / (a + d));
|
||||
assert!(f.line_split > 0.5 && f.line_split < 1.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn some_face_always_resolves() {
|
||||
let db = FontDb::with_font_dirs(Vec::new());
|
||||
|
||||
@@ -222,11 +222,14 @@ pub struct Renderer {
|
||||
resources: Resources,
|
||||
/// Outlines morphing in the frame being drawn by `blend`.
|
||||
paths: Vec<PathMorph>,
|
||||
/// The background of the slide (layout, master) being drawn, its placeholder colour and the
|
||||
/// slide's view transform: shapes filled with the background (`useBgFill`) show it through.
|
||||
bg: Option<(Fill, Option<Rgba>, Affine)>,
|
||||
}
|
||||
|
||||
impl Default for Renderer {
|
||||
fn default() -> Self {
|
||||
Renderer { resources: Resources::new(), paths: Vec::new() }
|
||||
Renderer { resources: Resources::new(), paths: Vec::new(), bg: None }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -589,7 +592,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) {
|
||||
@@ -711,7 +719,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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -572,3 +572,63 @@ fn a_gradient_with_a_stop_off_the_end_stays_linear() {
|
||||
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:?}");
|
||||
}
|
||||
|
||||
+13
-2
@@ -702,9 +702,20 @@ fn measure_str(face: &FontFace, size: f64, s: &str) -> (f64, Vec<(u32, f64)>) {
|
||||
(x, out)
|
||||
}
|
||||
|
||||
/// Single line spacing as a multiple of the font size. PowerPoint spaces single-spaced lines 1.2
|
||||
/// times the size apart in every font (observed on twenty Latin, CJK, Thai and Indic fonts at 18
|
||||
/// and 40 pt), whatever the font's own ascent and descent.
|
||||
///
|
||||
/// Caveat: this was measured on one Windows install of desktop PowerPoint. It may be a Windows or
|
||||
/// machine setting; PowerPoint on macOS or the web may space lines by the font's own metrics.
|
||||
/// Keep it a single tunable value, and re-measure on the other platform before changing it.
|
||||
const LINE_PITCH: f64 = 1.2;
|
||||
|
||||
/// A line's height above and below the baseline at `size`: [`LINE_PITCH`] × size, split where
|
||||
/// the font's Windows ascent and descent put the baseline.
|
||||
fn line_metrics(face: &FontFace, size: f64) -> (f64, f64) {
|
||||
let k = size / face.upem.max(1.0);
|
||||
(face.ascent * k, face.descent * k)
|
||||
let h = size * LINE_PITCH;
|
||||
(h * face.line_split, h * (1.0 - face.line_split))
|
||||
}
|
||||
|
||||
/// Break opportunities: allowed[i] = a line may start at char i.
|
||||
|
||||
@@ -489,3 +489,34 @@ fn an_ordinary_hang_still_puts_the_text_at_the_margin() {
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user