mirror of
https://github.com/storytold/deckcraft.git
synced 2026-10-24 10:37:03 -06:00
Compare commits
6
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
acd72408d8 | ||
|
|
85566e047b | ||
|
|
39a8234633 | ||
|
|
98ea8d3195 | ||
|
|
2e68504e7c | ||
|
|
e6050b7d37 |
@@ -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());
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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()
|
||||
};
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
@@ -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:?}");
|
||||
}
|
||||
|
||||
+76
-6
@@ -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)
|
||||
};
|
||||
|
||||
@@ -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:?}");
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user