diff --git a/crates/model/src/features.rs b/crates/model/src/features.rs index b51f7bc98..c35254f0e 100644 --- a/crates/model/src/features.rs +++ b/crates/model/src/features.rs @@ -564,7 +564,7 @@ pub enum ShapeTextAnchor { } /// How a shape's outline and text look. The text properties apply to the whole text (the first -/// run's properties when a file has several). +/// paragraph's and first run's properties when a file has several). #[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)] #[serde(default)] pub struct ShapeFormat { @@ -584,6 +584,8 @@ pub struct ShapeFormat { pub align: Option, /// `None`: the shape's default (top for text boxes, middle for other shapes). pub anchor: Option, + /// Paragraphs run right to left (`a:pPr rtl`). + pub rtl: bool, } #[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] diff --git a/crates/xlsx/src/drawing.rs b/crates/xlsx/src/drawing.rs index 6dbd17e7e..a6892f0b8 100644 --- a/crates/xlsx/src/drawing.rs +++ b/crates/xlsx/src/drawing.rs @@ -228,8 +228,8 @@ fn read_shape(obj: &El, anchor: Anchor) -> Option { } /// A shape's text formatting: vertical anchor (`a:bodyPr anchor`), the first paragraph's -/// alignment (`a:pPr algn`) and the first run's properties (`a:rPr`: size, bold, italic, fill, -/// outline, `a:latin` typeface). +/// alignment and direction (`a:pPr algn`, `rtl`) and the first run's properties (`a:rPr`: size, +/// bold, italic, fill, outline, `a:latin` typeface). fn read_text_format(body: Option<&El>) -> ShapeFormat { let mut f = ShapeFormat::default(); let Some(body) = body else { return f }; @@ -238,12 +238,13 @@ fn read_text_format(body: Option<&El>) -> ShapeFormat { "b" => ShapeTextAnchor::Bottom, _ => ShapeTextAnchor::Top, }); - let first_p = body.kids("p").next(); - f.align = first_p.and_then(|p| p.child("pPr")).and_then(|p| p.attr("algn")).map(|a| match a { + let ppr = body.kids("p").next().and_then(|p| p.child("pPr")); + f.align = ppr.and_then(|p| p.attr("algn")).map(|a| match a { "ctr" => ShapeTextAlign::Center, "r" => ShapeTextAlign::Right, _ => ShapeTextAlign::Left, }); + f.rtl = ppr.is_some_and(|p| p.flag("rtl", false)); let rpr = body.kids("p").flat_map(|p| p.kids("r")).find_map(|r| r.child("rPr")); if let Some(r) = rpr { // `sz` is in hundredths of a point (100..=400000 in the schema). @@ -415,12 +416,14 @@ pub fn write_drawing(sheet: &Sheet, theme: &Theme, objs: &[Obj<'_>]) -> String { None => "", }; let _ = write!(s, ""); - let ppr = match f.align { + let algn = match f.align { None => "", - Some(ShapeTextAlign::Left) => "", - Some(ShapeTextAlign::Center) => "", - Some(ShapeTextAlign::Right) => "", + Some(ShapeTextAlign::Left) => " algn=\"l\"", + Some(ShapeTextAlign::Center) => " algn=\"ctr\"", + Some(ShapeTextAlign::Right) => " algn=\"r\"", }; + let rtl = if f.rtl { " rtl=\"1\"" } else { "" }; + let ppr = if algn.is_empty() && rtl.is_empty() { String::new() } else { format!("") }; let (rpr_attrs, rpr_body) = run_props(f, theme); for line in sh.text.split('\n') { if line.is_empty() { diff --git a/crates/xlsx/src/tests/mod.rs b/crates/xlsx/src/tests/mod.rs index 517113d0f..bdbd66e05 100644 --- a/crates/xlsx/src/tests/mod.rs +++ b/crates/xlsx/src/tests/mod.rs @@ -13,6 +13,7 @@ mod passthrough_tests; mod pivot_tests; mod richdata_tests; mod roundtrip; +mod shape_text_rtl; mod sheet_names; mod smooth_roundtrip; mod trendline_roundtrip; diff --git a/crates/xlsx/src/tests/roundtrip.rs b/crates/xlsx/src/tests/roundtrip.rs index 901802905..7c7c19056 100644 --- a/crates/xlsx/src/tests/roundtrip.rs +++ b/crates/xlsx/src/tests/roundtrip.rs @@ -365,6 +365,7 @@ fn sample() -> Workbook { text_outline: Some((Color::Rgb(0xFF0000), 1.5)), align: Some(ShapeTextAlign::Right), anchor: Some(ShapeTextAnchor::Bottom), + rtl: true, }, }); let base_chart = Chart { diff --git a/crates/xlsx/src/tests/shape_text_rtl.rs b/crates/xlsx/src/tests/shape_text_rtl.rs new file mode 100644 index 000000000..36bdb861d --- /dev/null +++ b/crates/xlsx/src/tests/shape_text_rtl.rs @@ -0,0 +1,86 @@ +//! Text boxes and shapes keep their paragraphs' right-to-left direction (`a:pPr rtl`). + +use std::io::Read; +use std::sync::Arc; + +use gridcraft_model::{Anchor, Color, Shape, ShapeFormat, ShapeKind, ShapeTextAlign, Sheet, Workbook}; + +use super::minimal; +use crate::{read_xlsx, write_xlsx}; + +const ARABIC_TEXT_BOX: &str = r#" +10104040 + + + +مرحبا بالعالم +"#; + +fn read_drawing(drawing: &str) -> Workbook { + let rels = r#""#; + let bytes = minimal( + r#""#, + &[("xl/worksheets/_rels/sheet1.xml.rels", rels), ("xl/drawings/drawing1.xml", drawing)], + "", + "", + ); + read_xlsx(&bytes).unwrap().0 +} + +fn written_drawing(wb: &Workbook) -> String { + let bytes = write_xlsx(wb).unwrap(); + let mut archive = zip::ZipArchive::new(std::io::Cursor::new(bytes)).unwrap(); + let mut drawing = String::new(); + archive.by_name("xl/drawings/drawing1.xml").unwrap().read_to_string(&mut drawing).unwrap(); + drawing +} + +fn text_box(text: &str, format: ShapeFormat) -> Workbook { + let mut sheet = Sheet::new("Sheet1"); + sheet.shapes.push(Shape { + id: 1, + kind: ShapeKind::TextBox, + anchor: Anchor { width: 120.0, height: 40.0, ..Default::default() }, + fill: Color::Auto, + line: Color::Auto, + text: text.into(), + format, + }); + Workbook { sheets: vec![Arc::new(sheet)], ..Default::default() } +} + +#[test] +fn shape_text_keeps_right_to_left_paragraphs() { + let wb = read_drawing(ARABIC_TEXT_BOX); + let shape = &wb.sheet(0).unwrap().shapes[0]; + assert_eq!(shape.text, "مرحبا بالعالم"); + assert!(shape.format.rtl, "rtl=\"1\" on the first paragraph is read"); + assert_eq!(shape.format.align, Some(ShapeTextAlign::Right)); + + let xml = written_drawing(&wb); + assert!(xml.contains(r#""#), "saved paragraph keeps rtl: {xml}"); + + let (reopened, _) = read_xlsx(&write_xlsx(&wb).unwrap()).unwrap(); + let after = &reopened.sheet(0).unwrap().shapes[0]; + assert!(after.format.rtl); + assert_eq!(after.format.align, Some(ShapeTextAlign::Right)); + assert_eq!(after.text, "مرحبا بالعالم"); + + let xml = written_drawing(&text_box("سطر\nآخر", ShapeFormat { rtl: true, ..Default::default() })); + assert_eq!(xml.matches(r#""#).count(), 2, "every paragraph is right-to-left without an alignment: {xml}"); +} + +#[test] +fn shape_text_without_rtl_writes_unchanged_ppr() { + let wb = read_drawing(&ARABIC_TEXT_BOX.replace(r#" rtl="1""#, "")); + assert!(!wb.sheet(0).unwrap().shapes[0].format.rtl); + let wb = read_drawing(&ARABIC_TEXT_BOX.replace(r#" rtl="1""#, r#" rtl="0""#)); + assert!(!wb.sheet(0).unwrap().shapes[0].format.rtl); + + let xml = written_drawing(&text_box("left\nto right", ShapeFormat { align: Some(ShapeTextAlign::Right), ..Default::default() })); + assert_eq!(xml.matches(r#""#).count(), 2, "{xml}"); + assert!(!xml.contains("rtl=\"1\""), "{xml}"); + + let xml = written_drawing(&text_box("plain", ShapeFormat::default())); + assert!(!xml.contains(" **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-14 · **Change:** trivial (formulas over Excel's 8,192 characters, and `%` or call chains past the nesting limit, are parse errors instead of stack overflows when a file is opened; previously trivial: XLSX chart sheets are kept on save (#727)) · **Target:** Microsoft Excel (Microsoft 365), Excel for Mac 16.113.4 +> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-19 · **Change:** trivial (XLSX text boxes and shapes keep their paragraphs' right-to-left direction on save; previously trivial: formulas over Excel's 8,192 characters, and `%` or call chains past the nesting limit, are parse errors instead of stack overflows when a file is opened) · **Target:** Microsoft Excel (Microsoft 365), Excel for Mac 16.113.4 Every format Excel opens or saves, with GridCraft's read and write support, fidelity and tests. Part of [target-app-parity.md](target-app-parity.md). **File formats: ~50% ready, 80–120 h** @@ -85,6 +85,7 @@ refresh. Counted under features (Get & Transform, ~10% ready), not here. | Date | Change | Summary | |---|---|---| +| 2026-10-19 | trivial | XLSX text boxes and shapes keep right-to-left paragraph direction (`a:pPr rtl`, ECMA-376 Part 1 §21.1.2.2.7): read from the first paragraph and written on every paragraph; output without it is unchanged | | 2026-10-14 | trivial | Formula parser: text longer than Excel's 8,192 characters, and postfix chains (`1%%%…`, `f(1)(1)…`) past the 256-level nesting limit, are parse errors (the cell keeps its formula text and cached value) instead of trees deep enough to overflow the stack when a hostile file is opened, saved or dropped | | 2026-10-12 | trivial | XLSX chart sheets are kept on save (#727) | | 2026-10-12 | trivial | CSV and TSV export is capped at 512 MiB and refuses a larger used range with an error (#528) | diff --git a/docs/localization-parity.md b/docs/localization-parity.md index 09197ff6c..692d8d44c 100644 --- a/docs/localization-parity.md +++ b/docs/localization-parity.md @@ -1,6 +1,6 @@ # Localization parity -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-12 · **Change:** minor (Format Cells dialog looks its labels up in the catalog) · **Target:** Microsoft Excel (Microsoft 365), Excel for Mac 16.113.4 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-19 · **Change:** trivial (text boxes and shapes keep right-to-left paragraph direction through XLSX save; previously minor: Format Cells dialog looks its labels up in the catalog) · **Target:** Microsoft Excel (Microsoft 365), Excel for Mac 16.113.4 Per-language status against Excel, which ships its UI in dozens of languages (Windows language packs cover 100+), with localized function names, argument separators, number and date formats, @@ -31,7 +31,9 @@ right-to-left sheets and full complex-script support. Part of (`view.rightToLeft`, Page Layout › Sheet Options, XLSX `rightToLeft`) mirrors the grid (#146): column A and the row headers on the right, the vertical scrollbar on the left; hit-testing, arrow keys, editor, popups and floating objects follow; text stays left-to-right inside its box - (no bidi), and the sheet tabs, horizontal scrollbar and printing/PDF are not mirrored yet. IME input goes + (no bidi), and the sheet tabs, horizontal scrollbar and printing/PDF are not mirrored yet. + Text boxes and shapes keep their paragraphs' right-to-left direction (`a:pPr rtl`) through XLSX + save, though GridCraft still draws that text left to right. IME input goes through egui/winit and is unverified for Chinese, Japanese and Korean. - **PDF export** uses only the 14 standard PDF fonts: non-Latin text cannot export. @@ -78,6 +80,7 @@ language (none so far) and of localized function names. | Date | Change | Summary | |---|---|---| +| 2026-10-19 | trivial | Text boxes and shapes keep right-to-left paragraph direction (`a:pPr rtl`) on XLSX save, so Excel-authored Arabic and Hebrew text boxes reopen right to left | | 2026-10-12 | minor | Format Cells looks up its tabs, labels, options and note through `Language::tr` (Fill tab keyed `Format Cells\|Fill`); dialog titles and OK/Cancel too. Labels already in a table (Number, Currency, Fill…) show translated; the rest fall back to English until the tables add them | | 2026-10-12 | minor | Brazilian Portuguese catalog (`i18n/pt.rs`) grows from 377 to 923 entries: every key the other catalogs translate, including the dialog strings #669 starts looking up (~42%) | | 2026-10-11 | minor | German ribbon catalog (`i18n/de.rs`, 266 labels, ~12%); `de` locale tags (de-DE, de-AT, de-CH…) resolve to German. PR #163 (localized German formulas/numbers) still open |