diff --git a/crates/model/src/text.rs b/crates/model/src/text.rs index d4206fd..6a1a752 100644 --- a/crates/model/src/text.rs +++ b/crates/model/src/text.rs @@ -57,6 +57,8 @@ pub enum TextDir { EaVertical, /// `mongolianVert`: turned like `vert`, lines running from left to right. MongolianVertical, + /// `wordArtVertRtl`: stacked, lines running from right to left. + StackedRtl, } impl TextDir { @@ -68,6 +70,7 @@ impl TextDir { TextDir::Stacked => "wordArtVert", TextDir::EaVertical => "eaVert", TextDir::MongolianVertical => "mongolianVert", + TextDir::StackedRtl => "wordArtVertRtl", } } pub fn from_xml(s: &str) -> Option { @@ -76,7 +79,8 @@ impl TextDir { "vert" => TextDir::Vertical, "mongolianVert" => TextDir::MongolianVertical, "vert270" => TextDir::Vertical270, - "wordArtVert" | "wordArtVertRtl" => TextDir::Stacked, + "wordArtVert" => TextDir::Stacked, + "wordArtVertRtl" => TextDir::StackedRtl, "eaVert" => TextDir::EaVertical, _ => return None, }) diff --git a/crates/pptx/src/read/dml.rs b/crates/pptx/src/read/dml.rs index 800df0b..ab67a25 100644 --- a/crates/pptx/src/read/dml.rs +++ b/crates/pptx/src/read/dml.rs @@ -622,5 +622,7 @@ mod tests { let b = body_pr(&d.root); assert_eq!(b.vert, Some(TextDir::MongolianVertical)); assert_eq!(b.vert.map(|v| v.xml()), Some("mongolianVert")); + let d = parse(br#""#).unwrap(); + assert_eq!(body_pr(&d.root).vert.map(|v| v.xml()), Some("wordArtVertRtl")); } } diff --git a/crates/text/src/lib.rs b/crates/text/src/lib.rs index 125be16..d6bc66b 100644 --- a/crates/text/src/lib.rs +++ b/crates/text/src/lib.rs @@ -1569,14 +1569,14 @@ fn layout_scaled(ctx: &Ctx, shape: &Shape, body: &TextBody, bp: &BodyProps, opts // stands every character upright, one under the other. let upright_text = match vert { TextDir::EaVertical => Upright::Cjk, - TextDir::Stacked => Upright::All, + TextDir::Stacked | TextDir::StackedRtl => 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 | TextDir::MongolianVertical => 90.0, + TextDir::Vertical | TextDir::EaVertical | TextDir::Stacked | TextDir::StackedRtl | TextDir::MongolianVertical => 90.0, TextDir::Vertical270 => 270.0, TextDir::Horizontal => 0.0, } + bp.rot.unwrap_or(0.0); @@ -1828,6 +1828,10 @@ fn layout_scaled(ctx: &Ctx, shape: &Shape, body: &TextBody, bp: &BodyProps, opts } let total = y; out.content_height = total; + // Where column `c` starts: from the left, or from the right with `rtlCol` (PowerPoint puts the + // first column on the right). + let rtl_cols = bp.rtl_col.unwrap_or(false) && ncols > 1; + let col_x = |c: usize| (if rtl_cols { ncols.saturating_sub(1 + c) } else { c }) as f64 * (col_w + col_gap); // Columns: distribute lines by height. let col_h = if ncols > 1 { inner.height() } else { f64::INFINITY }; let mut col = 0usize; @@ -1843,16 +1847,20 @@ fn layout_scaled(ctx: &Ctx, shape: &Shape, body: &TextBody, bp: &BodyProps, opts li.top -= dy; li.baseline -= dy; li.bottom -= dy; - li.shift_x(col as f64 * (col_w + col_gap)); + } + let cx = col_x(col); + if cx != 0.0 { + li.shift_x(cx); } } let used_h = if ncols > 1 { all_lines.iter().map(|(l, ..)| l.bottom).fold(0.0, f64::max) } else { total }; out.overflow = used_h > inner.height() + 0.5 || (ncols > 1 && all_lines.last().is_some_and(|(l, ..)| l.bottom > inner.height() + 0.5)); let anchor = bp.anchor.unwrap_or(Anchor::Top); let dy = match anchor { - Anchor::Top | Anchor::Justified | Anchor::Distributed => 0.0, + Anchor::Top => 0.0, Anchor::Middle => (inner.height() - used_h) / 2.0, - Anchor::Bottom => inner.height() - used_h, + // PowerPoint draws justified and distributed anchoring at the bottom. + Anchor::Bottom | Anchor::Justified | Anchor::Distributed => inner.height() - used_h, }; // anchorCtr: centre the block horizontally. let dx = if bp.anchor_ctr.unwrap_or(false) { ((inner.width() - out.content_width) / 2.0).max(0.0) } else { 0.0 }; @@ -1880,10 +1888,7 @@ fn layout_scaled(ctx: &Ctx, shape: &Shape, body: &TextBody, bp: &BodyProps, opts face: st.face.clone(), size: st.size, 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 }, line.baseline)) - .collect(), + glyphs: g.into_iter().map(|(gid, x)| (gid, ox + bx + x + col_x(line.column), line.baseline)).collect(), cells: vec![], fake_bold: false, fake_italic: false, diff --git a/crates/text/src/tests.rs b/crates/text/src/tests.rs index 5276b7f..39e4626 100644 --- a/crates/text/src/tests.rs +++ b/crates/text/src/tests.rs @@ -1116,6 +1116,11 @@ fn mongolian_lines_run_left_to_right_and_stacked_letters_stand_one_em_apart() { 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); + // wordArtVertRtl stacks the same way with lines from right to left. + sh2.text.as_mut().unwrap().body.vert = Some(deckcraft_model::text::TextDir::StackedRtl); + let sr = lay(&p2, &sh2, 200.0, 400.0); + assert!(sr.lines[0].top < sr.lines[1].top); + assert!(sr.runs.iter().filter(|r| !r.cells.is_empty()).all(|r| r.upright)); 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:?}"); } @@ -1166,3 +1171,28 @@ fn strikethrough_sits_at_the_font_strikeout_position() { assert!((l.lines[0].baseline - d.rect.y0 - r.face.strikeout.0 * k).abs() < 0.01, "top at the strikeout position"); assert!((d.rect.height() - (r.face.strikeout.1 * k).max(0.5)).abs() < 0.01); } + +#[test] +fn rtl_columns_start_on_the_right_and_just_anchoring_sits_at_the_bottom() { + let text = "word ".repeat(200); + let (p, mut sh) = setup(&text, 400.0, 200.0); + let t = sh.text.as_mut().unwrap(); + t.body.columns = Some(2); + t.body.col_spacing = Some(20.0); + t.paragraphs[0].runs[0].props.size = Some(12.0); + let ltr = lay(&p, &sh, 400.0, 200.0); + sh.text.as_mut().unwrap().body.rtl_col = Some(true); + let rtl = lay(&p, &sh, 400.0, 200.0); + let first = |l: &TextLayout| l.lines.iter().find(|x| x.column == 0).map(|x| x.extent.0).unwrap(); + let second = |l: &TextLayout| l.lines.iter().find(|x| x.column == 1).map(|x| x.extent.0).unwrap(); + assert!(first(<r) < second(<r)); + assert!(first(&rtl) > second(&rtl), "with rtlCol the first column is on the right"); + // anchor="just" and "dist" draw like "b". + let (p, mut sh) = setup("one line", 400.0, 300.0); + sh.text.as_mut().unwrap().body.anchor = Some(Anchor::Bottom); + let b = lay(&p, &sh, 400.0, 300.0).lines[0].top; + for a in [Anchor::Justified, Anchor::Distributed] { + sh.text.as_mut().unwrap().body.anchor = Some(a); + assert!((lay(&p, &sh, 400.0, 300.0).lines[0].top - b).abs() < 0.01, "{a:?}"); + } +}