mirror of
https://github.com/storytold/gridcraft.git
synced 2026-10-24 10:46:50 -06:00
M12.AR10: keep right-to-left paragraph direction in text boxes and shapes
This commit is contained in:
@@ -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)]
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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}");
|
||||
}
|
||||
@@ -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) |
|
||||
|
||||
@@ -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 |
|
||||
|
||||
Reference in New Issue
Block a user