diff --git a/crates/model/src/text.rs b/crates/model/src/text.rs index b8138a3..d4206fd 100644 --- a/crates/model/src/text.rs +++ b/crates/model/src/text.rs @@ -55,6 +55,8 @@ pub enum TextDir { Vertical270, Stacked, EaVertical, + /// `mongolianVert`: turned like `vert`, lines running from left to right. + MongolianVertical, } impl TextDir { @@ -65,12 +67,14 @@ impl TextDir { TextDir::Vertical270 => "vert270", TextDir::Stacked => "wordArtVert", TextDir::EaVertical => "eaVert", + TextDir::MongolianVertical => "mongolianVert", } } pub fn from_xml(s: &str) -> Option { Some(match s { "horz" => TextDir::Horizontal, - "vert" | "mongolianVert" => TextDir::Vertical, + "vert" => TextDir::Vertical, + "mongolianVert" => TextDir::MongolianVertical, "vert270" => TextDir::Vertical270, "wordArtVert" | "wordArtVertRtl" => TextDir::Stacked, "eaVert" => TextDir::EaVertical, diff --git a/crates/pptx/src/read/dml.rs b/crates/pptx/src/read/dml.rs index 7bec2e6..800df0b 100644 --- a/crates/pptx/src/read/dml.rs +++ b/crates/pptx/src/read/dml.rs @@ -616,5 +616,11 @@ mod tests { assert_eq!(b.vert, Some(TextDir::Vertical270)); assert_eq!(b.columns, Some(2)); assert_eq!(b.autofit, Some(AutoFit::Shrink { font_scale: 0.625, line_reduction: 0.2 })); + // mongolianVert is its own direction (PowerPoint runs its lines left to right) and is + // written back as it was read. + let d = parse(br#""#).unwrap(); + let b = body_pr(&d.root); + assert_eq!(b.vert, Some(TextDir::MongolianVertical)); + assert_eq!(b.vert.map(|v| v.xml()), Some("mongolianVert")); } } diff --git a/crates/text/src/lib.rs b/crates/text/src/lib.rs index 0b02d2c..1df46e1 100644 --- a/crates/text/src/lib.rs +++ b/crates/text/src/lib.rs @@ -703,7 +703,7 @@ fn shape_para( opts: &Opts, number: Option, no_kern: bool, - vertical: bool, + vertical: Upright, ) -> ParaShaped { let db = FontDb::global(); let Some(para) = body.paragraphs.get(pi) else { @@ -831,7 +831,7 @@ fn shape_cells( styles: &[Style], faces: &mut Vec>, rtl: bool, - vertical: bool, + vertical: Upright, ) -> Vec { let db = FontDb::global(); let chars: Vec = text.chars().collect(); @@ -911,9 +911,9 @@ fn shape_cells( .collect(); // In East Asian vertical text, ideographs, kana, Hangul, full-width forms and the like stand // upright (a whole grapheme at a time); other text lies on its side. - if vertical { + if vertical != Upright::None { for &(a, b) in &graphemes { - let up = chars.get(a).is_some_and(|c| is_upright(*c)); + let up = chars.get(a).is_some_and(|c| if vertical == Upright::All { !c.is_whitespace() } else { is_upright(*c) }); for c in cells.get_mut(a..b).into_iter().flatten() { c.upright = up; } @@ -940,6 +940,30 @@ fn shape_cells( for (a, b) in items { shape_item(&mut cells, styles, faces, text, &byte_of, &char_at_byte, a..b, scripts.get(a).and_then(|s| s.0), lang(a), upper(a)); } + // Stacked text: each character takes one em down the line, centred across it (PowerPoint + // stacks 18 pt Latin letters about 18 pt apart, a space only its own width). + if vertical == Upright::All { + for i in 0..n { + let Some(c) = cells.get(i) else { continue }; + if !c.upright || c.head != i || c.glyphs.is_empty() { + continue; + } + let size = styles.get(c.style).map(|st| st.size).unwrap_or(18.0); + let width: f64 = (i..n).take_while(|&j| cells.get(j).is_some_and(|d| d.head == i)).filter_map(|j| cells.get(j)).map(|d| d.adv).sum(); + let pad = (size - width) / 2.0; + if let Some(c) = cells.get_mut(i) { + for g in &mut c.glyphs { + g.1 += pad; + } + } + for j in i..n { + match cells.get_mut(j) { + Some(d) if d.head == i => d.adv = if j == i { size } else { 0.0 }, + _ => break, + } + } + } + } cells } @@ -983,6 +1007,17 @@ fn is_upright(c: char) -> bool { ) } +/// Which characters stand upright in vertical text. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum Upright { + /// None (horizontal text, vert, vert270, mongolianVert). + None, + /// Ideographs, kana and the like (eaVert). + Cjk, + /// Every character, one under the other (wordArtVert). + All, +} + /// Size of superscript and subscript text relative to the run's size. const SUB_SUPER: f64 = 0.66; @@ -1553,10 +1588,18 @@ fn spacing_stops_kerning(ctx: &Ctx, shape: &Shape, body: &TextBody) -> bool { fn layout_scaled(ctx: &Ctx, shape: &Shape, body: &TextBody, bp: &BodyProps, opts: &Opts, scale: f64, reduce: f64, no_kern: bool) -> TextLayout { let r = opts.rect; let vert = bp.vert.unwrap_or(TextDir::Horizontal); - // East Asian vertical text: CJK stands upright in text turned a quarter turn. - let upright_text = vert == TextDir::EaVertical; + // East Asian vertical text: CJK stands upright in text turned a quarter turn; stacked text + // stands every character upright, one under the other. + let upright_text = match vert { + TextDir::EaVertical => Upright::Cjk, + TextDir::Stacked => Upright::All, + _ => Upright::None, + }; + // Mongolian vertical and stacked text: turned like vert, their lines run from left to right + // (as PowerPoint draws both). + let lines_ltr = matches!(vert, TextDir::MongolianVertical | TextDir::Stacked); let rotation = match vert { - TextDir::Vertical | TextDir::EaVertical | TextDir::Stacked => 90.0, + TextDir::Vertical | TextDir::EaVertical | TextDir::Stacked | TextDir::MongolianVertical => 90.0, TextDir::Vertical270 => 270.0, TextDir::Horizontal => 0.0, } + bp.rot.unwrap_or(0.0); @@ -1840,8 +1883,15 @@ fn layout_scaled(ctx: &Ctx, shape: &Shape, body: &TextBody, bp: &BodyProps, opts line.baseline += oy; line.bottom += oy; line.shift_x(ox); + if lines_ltr { + // The line's place mirrored across the box: the first line ends up on the left. + let d = inner.y0 + inner.y1 - line.top - line.bottom; + line.top += d; + line.baseline += d; + line.bottom += d; + } let Some(p) = shaped_paras.get(line.para) else { continue }; - if let Some((s, bx, by)) = bullet.take() + if let Some((s, bx, _)) = bullet.take() && let Some((_, st)) = p.bullet.as_ref() { let (_, g) = measure_str(&st.face, st.size, &s); @@ -1851,7 +1901,7 @@ fn layout_scaled(ctx: &Ctx, shape: &Shape, body: &TextBody, bp: &BodyProps, opts color: st.color, glyphs: g .into_iter() - .map(|(gid, x)| (gid, ox + bx + x + if line.column > 0 { line.column as f64 * (col_w + col_gap) } else { 0.0 }, by + oy)) + .map(|(gid, x)| (gid, ox + bx + x + if line.column > 0 { line.column as f64 * (col_w + col_gap) } else { 0.0 }, line.baseline)) .collect(), cells: vec![], fake_bold: false, diff --git a/crates/text/src/tests.rs b/crates/text/src/tests.rs index 4609180..ac02faf 100644 --- a/crates/text/src/tests.rs +++ b/crates/text/src/tests.rs @@ -1088,3 +1088,33 @@ fn east_asian_vertical_text_marks_cjk_upright() { assert!(l.runs.iter().all(|r| !r.upright)); assert!(is_upright('あ') && is_upright('、') && is_upright('A') && !is_upright('A') && !is_upright('1')); } + +#[test] +fn mongolian_lines_run_left_to_right_and_stacked_letters_stand_one_em_apart() { + let (p, mut sh) = setup("one\ntwo", 200.0, 400.0); + sh.text.as_mut().unwrap().body.vert = Some(deckcraft_model::text::TextDir::Vertical); + let v = lay(&p, &sh, 200.0, 400.0); + sh.text.as_mut().unwrap().body.vert = Some(deckcraft_model::text::TextDir::MongolianVertical); + let m = lay(&p, &sh, 200.0, 400.0); + assert_eq!(m.rotation, 90.0); + // In layout space (turned a quarter turn) vert puts the first line at the top, which shows on + // the right; mongolianVert mirrors the line places, so the first line shows on the left. + assert!(v.lines[0].top < v.lines[1].top); + assert!(m.lines[0].top > m.lines[1].top); + let mid = (m.inner.y0 + m.inner.y1) / 2.0; + assert!(((m.lines[0].top + m.lines[0].bottom) / 2.0 - mid + ((v.lines[0].top + v.lines[0].bottom) / 2.0 - mid)).abs() < 0.01); + // Stacked: every letter upright, one em apart. + let (p, mut sh) = setup("Lil", 200.0, 400.0); + let t = sh.text.as_mut().unwrap(); + t.body.vert = Some(deckcraft_model::text::TextDir::Stacked); + t.paragraphs[0].runs[0].props.size = Some(20.0); + let s = lay(&p, &sh, 200.0, 400.0); + assert!(s.runs.iter().filter(|r| !r.cells.is_empty()).all(|r| r.upright)); + // Its lines run left to right too. + let (p2, mut sh2) = setup("one\ntwo", 200.0, 400.0); + sh2.text.as_mut().unwrap().body.vert = Some(deckcraft_model::text::TextDir::Stacked); + let st = lay(&p2, &sh2, 200.0, 400.0); + assert!(st.lines[0].top > st.lines[1].top); + let e = &s.lines[0].edges; + assert!((e[1].1 - e[1].0 - 20.0).abs() < 0.01 && (e[2].1 - e[2].0 - 20.0).abs() < 0.01, "{e:?}"); +}