Text: rtlCol puts the first column on the right; just/dist anchoring at the bottom; wordArtVertRtl

Observed on PowerPoint's export (w14-xml s12):
- a two-column body with rtlCol="1" starts its first column on the right; DeckCraft read and
  saved the attribute but laid columns out left to right;
- anchor="just" and anchor="dist" place the text at the bottom of the box, like anchor="b";
  DeckCraft drew them at the top;
- wordArtVertRtl stacks characters like wordArtVert but runs its lines from right to left; it was
  read as wordArtVert (and saved back as it). It is now its own TextDir, kept on save.

w14-xml s12 9.47 -> 5.39.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Brandon Thomas 2026-10-13 06:28:29 -04:00
parent 973af106bb
commit c858c11bf6
4 changed files with 51 additions and 10 deletions

View File

@ -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<Self> {
@ -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,
})

View File

@ -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#"<a:bodyPr vert="wordArtVertRtl"/>"#).unwrap();
assert_eq!(body_pr(&d.root).vert.map(|v| v.xml()), Some("wordArtVertRtl"));
}
}

View File

@ -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,

View File

@ -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(&ltr) < second(&ltr));
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:?}");
}
}