Compare commits

..
Author SHA1 Message Date
Brandon ThomasandClaude Opus 5.5 e8622e1664 Text: a hang past the box edge keeps its width on the first line
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:40:39 -04:00
10 changed files with 7 additions and 301 deletions
-22
View File
@@ -79,10 +79,6 @@ 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.
@@ -440,23 +436,6 @@ 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,
@@ -488,7 +467,6 @@ 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,
-11
View File
@@ -143,17 +143,6 @@ 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());
-6
View File
@@ -223,10 +223,6 @@ 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 {
@@ -257,8 +253,6 @@ 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,8 +345,6 @@ 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()
};
-2
View File
@@ -432,8 +432,6 @@ 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()
-14
View File
@@ -766,17 +766,3 @@ fn a_picture_duotone_round_trips() {
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));
}
+2 -19
View File
@@ -222,14 +222,11 @@ 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(), bg: None }
Renderer { resources: Resources::new(), paths: Vec::new() }
}
}
@@ -592,12 +589,7 @@ 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) {
@@ -719,16 +711,7 @@ impl Renderer {
}
ctx.set_transform(m);
ctx.set_fill_rule(if sp.even_odd { peniko::Fill::EvenOdd } else { peniko::Fill::NonZero });
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),
}
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);
}
}
-60
View File
@@ -572,63 +572,3 @@ 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:?}");
}
+5 -71
View File
@@ -702,60 +702,13 @@ 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 h = size * LINE_PITCH;
(h * face.line_split, h * (1.0 - face.line_split))
let k = size / face.upem.max(1.0);
(face.ascent * k, face.descent * k)
}
/// 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>) {
/// Break opportunities: allowed[i] = a line may start at char i.
fn break_opportunities(text: &str) -> (Vec<bool>, Vec<bool>) {
let n = text.chars().count();
let mut allowed = vec![false; n + 1];
let mut mandatory = vec![false; n + 1];
@@ -776,17 +729,6 @@ fn break_opportunities(text: &str, latin_break: bool) -> (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)
}
@@ -808,8 +750,7 @@ fn break_lines(
x0_rest: f64,
) -> Vec<RawLine> {
let text: String = p.cells.iter().map(|c| c.ch).collect();
let (allowed, _) = break_opportunities(&text, p.props.latin_line_break.unwrap_or(false));
let hang = p.props.hanging_punct.unwrap_or(false);
let (allowed, _) = break_opportunities(&text);
let n = p.cells.len();
let mut lines = vec![];
let mut start = 0;
@@ -836,13 +777,6 @@ 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
-94
View File
@@ -489,97 +489,3 @@ 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);
}
}
/// 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:?}");
}