Compare commits

...
Author SHA1 Message Date
Brandon ThomasandClaude Opus 5.5 acd72408d8 Text: simpler break guard (clippy)
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-12 17:45:36 -04:00
Brandon ThomasandClaude Opus 5.5 85566e047b Text: wrap lines where PowerPoint does
- Without latinLnBrk (PowerPoint's default) a word doesn't break after '/'
  or '\': URLs and paths fill the line and break at its edge.
- Korean wraps between words, not between syllables.
- With hangingPunct, closing East Asian punctuation hangs past the end of
  the line instead of dragging the previous character down.

ParaProps gains latin_line_break and hanging_punct, read and written as
latinLnBrk and hangingPunct.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-12 17:45:36 -04:00
Brandon ThomasandClaude Opus 5.5 39a8234633 Render: slide backgrounds are laid over white (#275)
PowerPoint shows white around a background picture stretched into part of
the slide and under a see-through background colour. DeckCraft left those
areas transparent, which exports and the slide show draw black.

Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-12 17:45:31 -04:00
Brandon ThomasandClaude Opus 5.5 98ea8d3195 Render: shapes filled with the background (useBgFill) show it (#274)
A shape with useBgFill="1" is a window onto the slide's background, laid
out on the slide. DeckCraft drew nothing in it, so a master that covers the
background with a white panel and cuts a background-filled frame into it
(tdf93868) rendered all white.

Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-12 17:44:20 -04:00
Brandon ThomasandClaude Opus 5.5 2e68504e7c Text: single-spaced lines are 1.2 times the font size apart, as in PowerPoint (#266)
* Text: single-spaced lines are 1.2 times the font size apart, as in PowerPoint

PowerPoint spaces single-spaced lines 1.2 x the font size apart in every
font (measured on twenty Latin, CJK, Thai and Indic fonts at 18 and 40 pt
with PowerPoint's slide export) and puts the baseline where the font's
Windows ascent/descent split it. DeckCraft used each font's own ascent plus
descent, so Calibri lines were 15% too tight and Segoe UI ones too loose.

FontFace gains line_split (usWinAscent / (usWinAscent + usWinDescent));
line_metrics uses it with LINE_PITCH = 1.2.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* Text: tidy the line pitch test

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* Fonts: line_split guard reads plainly (clippy)

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* Text: note that the 1.2 line pitch was measured on one Windows install

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-12 17:42:53 -04:00
Brandon ThomasandClaude Opus 5.5 e6050b7d37 Text: a hang past the box edge keeps its width on the first line (#265)
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:41:01 -04:00
10 changed files with 348 additions and 8 deletions
+22
View File
@@ -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,
+11
View File
@@ -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());
+6
View File
@@ -223,6 +223,10 @@ 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 {
@@ -253,6 +257,8 @@ 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,6 +345,8 @@ 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,6 +432,8 @@ 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,3 +766,17 @@ 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));
}
+19 -2
View File
@@ -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);
}
}
+60
View File
@@ -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:?}");
}
+76 -6
View File
@@ -702,13 +702,60 @@ 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.
fn break_opportunities(text: &str) -> (Vec<bool>, Vec<bool>) {
/// 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>) {
let n = text.chars().count();
let mut allowed = vec![false; n + 1];
let mut mandatory = vec![false; n + 1];
@@ -729,6 +776,17 @@ fn break_opportunities(text: &str) -> (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)
}
@@ -750,7 +808,8 @@ fn break_lines(
x0_rest: f64,
) -> Vec<RawLine> {
let text: String = p.cells.iter().map(|c| c.ch).collect();
let (allowed, _) = break_opportunities(&text);
let (allowed, _) = break_opportunities(&text, p.props.latin_line_break.unwrap_or(false));
let hang = p.props.hanging_punct.unwrap_or(false);
let n = p.cells.len();
let mut lines = vec![];
let mut start = 0;
@@ -777,6 +836,13 @@ 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
@@ -917,7 +983,11 @@ fn layout_scaled(ctx: &Ctx, shape: &Shape, body: &TextBody, bp: &BodyProps, opts
let bullet_w = p.bullet.as_ref().map(|(s, st)| measure_str(&st.face, st.size, s).0).unwrap_or(0.0);
let first_x = if p.bullet.is_some() {
let bx = (margin + indent).max(0.0);
if indent < 0.0 && bx + bullet_w <= margin { margin } else { bx + bullet_w + if indent >= 0.0 { 0.0 } else { 3.0 } }
// A hanging bullet's text starts at the margin. When the hang reaches left of the box
// (marL + indent < 0), PowerPoint pins the bullet to the box's edge and keeps the
// hang: the first line's text starts at -indent (later lines stay at the margin).
let stop = margin.max(-indent);
if indent < 0.0 && bx + bullet_w <= stop { stop } else { bx + bullet_w + if indent >= 0.0 { 0.0 } else { 3.0 } }
} else {
(margin + indent).max(0.0)
};
+136
View File
@@ -447,3 +447,139 @@ fn superscript_glyphs_sit_inside_their_cells() {
assert!(g.1 >= x0 - 0.01 && g.1 < x1, "glyph at {} outside its cell {x0}..{x1}", g.1);
}
}
/// A wrapping numbered paragraph with `marL` and `indent`; returns (bullet x, first line's text
/// x, second line's text x).
fn hanging(margin: f64, indent: f64, bullet: Bullet) -> (Option<f64>, f64, f64) {
let text = "Wrapping words go on and on so the paragraph needs at least two or three lines here";
let (p, mut sh) = setup(text, 300.0, 300.0);
let para = &mut sh.text.as_mut().unwrap().paragraphs[0];
para.props.bullet = Some(bullet);
para.props.margin_left = Some(margin);
para.props.indent = Some(indent);
let l = lay(&p, &sh, 300.0, 300.0);
assert!(l.lines.len() >= 2, "{} lines", l.lines.len());
let bx = l.runs.iter().find(|r| r.cells.is_empty() && !r.glyphs.is_empty()).map(|r| r.glyphs[0].1);
(bx, l.lines[0].caret_x[0], l.lines[1].caret_x[0])
}
#[test]
fn a_hang_past_the_box_edge_keeps_its_width_on_the_first_line() {
// PowerPoint, marL 0 / indent -36pt: the number sits at the box's edge, the first line's text
// 36pt in, and the wrapped lines at the margin (observed with PowerPoint's slide export).
let num = || Bullet::AutoNum { scheme: "arabicPeriod".into(), start_at: 1 };
let (bx, first, rest) = hanging(0.0, -36.0, num());
assert!((first - rest - 36.0).abs() < 0.5, "first {first} rest {rest}");
assert!((bx.unwrap() - rest).abs() < 0.5, "bullet {bx:?} rest {rest}");
// marL 18 / indent -54: number at the edge, first line at 54, the rest at 18.
let (bx, first, rest) = hanging(18.0, -54.0, num());
assert!((first - rest - 36.0).abs() < 0.5, "first {first} rest {rest}");
assert!((rest - bx.unwrap() - 18.0).abs() < 0.5, "bullet {bx:?} rest {rest}");
// A character bullet behaves the same.
let (_, first, rest) = hanging(0.0, -36.0, Bullet::Char { char: "•".into() });
assert!((first - rest - 36.0).abs() < 0.5, "first {first} rest {rest}");
// Without a bullet the first line just starts at the edge.
let (_, first, rest) = hanging(0.0, -36.0, Bullet::None);
assert!((first - rest).abs() < 0.5, "first {first} rest {rest}");
}
#[test]
fn an_ordinary_hang_still_puts_the_text_at_the_margin() {
let (bx, first, rest) = hanging(36.0, -36.0, Bullet::AutoNum { scheme: "arabicPeriod".into(), start_at: 1 });
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:?}");
}