M12.AR10: keep right-to-left paragraph direction in text boxes and shapes

This commit is contained in:
Mouhand-Kaddo
2026-10-19 15:46:21 +04:00
parent 66ce6be474
commit 90344445d7
7 changed files with 109 additions and 12 deletions
+3 -1
View File
@@ -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<ShapeTextAlign>,
/// `None`: the shape's default (top for text boxes, middle for other shapes).
pub anchor: Option<ShapeTextAnchor>,
/// Paragraphs run right to left (`a:pPr rtl`).
pub rtl: bool,
}
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
+11 -8
View File
@@ -228,8 +228,8 @@ fn read_shape(obj: &El, anchor: Anchor) -> Option<Shape> {
}
/// 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, "<xdr:txBody><a:bodyPr wrap=\"square\" rtlCol=\"0\"{anchor}/><a:lstStyle/>");
let ppr = match f.align {
let algn = match f.align {
None => "",
Some(ShapeTextAlign::Left) => "<a:pPr algn=\"l\"/>",
Some(ShapeTextAlign::Center) => "<a:pPr algn=\"ctr\"/>",
Some(ShapeTextAlign::Right) => "<a:pPr algn=\"r\"/>",
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!("<a:pPr{algn}{rtl}/>") };
let (rpr_attrs, rpr_body) = run_props(f, theme);
for line in sh.text.split('\n') {
if line.is_empty() {
+1
View File
@@ -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;
+1
View File
@@ -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 {
+86
View File
@@ -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#"<xdr:wsDr xmlns:xdr="http://schemas.openxmlformats.org/drawingml/2006/spreadsheetDrawing" xmlns:a="http://schemas.openxmlformats.org/drawingml/2006/main">
<xdr:twoCellAnchor><xdr:from><xdr:col>1</xdr:col><xdr:colOff>0</xdr:colOff><xdr:row>1</xdr:row><xdr:rowOff>0</xdr:rowOff></xdr:from><xdr:to><xdr:col>4</xdr:col><xdr:colOff>0</xdr:colOff><xdr:row>4</xdr:row><xdr:rowOff>0</xdr:rowOff></xdr:to>
<xdr:sp macro="" textlink=""><xdr:nvSpPr><xdr:cNvPr id="2" name="TextBox 1"/><xdr:cNvSpPr txBox="1"/></xdr:nvSpPr>
<xdr:spPr><a:xfrm><a:off x="0" y="0"/><a:ext cx="1828800" cy="571500"/></a:xfrm><a:prstGeom prst="rect"><a:avLst/></a:prstGeom></xdr:spPr>
<xdr:txBody><a:bodyPr vertOverflow="clip" wrap="square" rtlCol="0" anchor="t"/><a:lstStyle/>
<a:p><a:pPr algn="r" rtl="1"/><a:r><a:rPr lang="ar-SA" sz="1100"/><a:t>مرحبا بالعالم</a:t></a:r></a:p>
</xdr:txBody></xdr:sp><xdr:clientData/></xdr:twoCellAnchor></xdr:wsDr>"#;
fn read_drawing(drawing: &str) -> Workbook {
let rels = r#"<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships"><Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/drawing" Target="../drawings/drawing1.xml"/></Relationships>"#;
let bytes = minimal(
r#"<sheetData/><drawing r:id="rId1"/>"#,
&[("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#"<a:pPr algn="r" rtl="1"/>"#), "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#"<a:pPr rtl="1"/>"#).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#"<a:pPr algn="r"/>"#).count(), 2, "{xml}");
assert!(!xml.contains("rtl=\"1\""), "{xml}");
let xml = written_drawing(&text_box("plain", ShapeFormat::default()));
assert!(!xml.contains("<a:pPr"), "no paragraph properties without alignment or direction: {xml}");
}
+2 -1
View File
@@ -1,6 +1,6 @@
# File-format parity
> **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) |
+5 -2
View File
@@ -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 |