Merge pull request #1560 from Hamza0101/feat/cell-rotation-reported

Rotated cell text is settable and reported, not silently flattened (#1468)
This commit is contained in:
Brandon Thomas
2026-10-23 17:29:32 -04:00
committed by GitHub
3 changed files with 70 additions and 2 deletions
+24 -2
View File
@@ -297,7 +297,7 @@ fn table_specs() -> Vec<CommandSpec> {
"Cell Options",
[],
None,
"{fill?: swatch, tint?: 0..1, insets?: n | [t,l,b,r], vj? | verticalJustification?: top|center|bottom|justify, firstBaseline?: ascent|capHeight|leading|xHeight|fixed|emboxHeight, firstBaselineMin?: pt, text?, stroke?: {weight?, color?: swatch, tint?: 0..1, type?: solid|dashed|dotted|thickThin|thinThick|thinThin|thickThick|thinThickThin|thickThinThick|wavy|hashed | {kind:…}, gapColor?: swatch, gapTint?: 0..1, edges?: name | [names] (all|outer|inner|top|left|bottom|right|innerHorizontal|innerVertical; default all)}, story?, table?, rows?: [a, b] | row?, cols?: [a, b] | col? (the target cells; default: the selection)} — top/left/bottom/right are the target range's outer sides, innerHorizontal/innerVertical the lines between its rows/columns",
"{fill?: swatch, tint?: 0..1, insets?: n | [t,l,b,r], vj? | verticalJustification?: top|center|bottom|justify, firstBaseline?: ascent|capHeight|leading|xHeight|fixed|emboxHeight, firstBaselineMin?: pt, rotation?: 0|90|180|270 (Cell Options › Text › Rotation; kept and written back to IDML, composed horizontally for now), text?, stroke?: {weight?, color?: swatch, tint?: 0..1, type?: solid|dashed|dotted|thickThin|thinThick|thinThin|thickThick|thinThickThin|thickThinThick|wavy|hashed | {kind:…}, gapColor?: swatch, gapTint?: 0..1, edges?: name | [names] (all|outer|inner|top|left|bottom|right|innerHorizontal|innerVertical; default all)}, story?, table?, rows?: [a, b] | row?, cols?: [a, b] | col? (the target cells; default: the selection)} — top/left/bottom/right are the target range's outer sides, innerHorizontal/innerVertical the lines between its rows/columns",
in_table,
set_cell
),
@@ -971,6 +971,15 @@ fn set_cell(s: &mut Session, p: &Value) -> Result<Value> {
None => None,
};
let (first_baseline, first_baseline_min) = first_baseline_params(p, ID)?;
// Cell Options › Text › Rotation. The value is kept and written back to IDML; only 0 is
// composed so far, which the IDML import reports (#1468).
let rotation = match p.get("rotation").filter(|v| !v.is_null()) {
Some(v) => match v.as_f64().map(|d| d.rem_euclid(360.0)) {
Some(d) if d == 0.0 || d == 90.0 || d == 180.0 || d == 270.0 => Some(d),
_ => return Err(bad(ID, "rotation: 0, 90, 180 or 270 degrees")),
},
None => None,
};
let text = str_param(p, "text").map(|_| super::text_param(p, "text"));
let stroke = match p.get("stroke").filter(|v| !v.is_null()) {
Some(sv) => Some((StrokeEdit::parse(doc, sv, ID)?, EdgeSel::parse(sv.get("edges"), ID)?)),
@@ -1021,6 +1030,9 @@ fn set_cell(s: &mut Session, p: &Value) -> Result<Value> {
if let Some(v) = first_baseline_min {
cell.first_baseline_min = v;
}
if let Some(v) = rotation {
cell.rotation = v;
}
if let Some(tx) = &text {
let len = cell.text.len();
cell.text.replace(0..len, tx);
@@ -1548,7 +1560,7 @@ fn get(s: &mut Session, p: &Value) -> Result<Value> {
.filter_map(|(r, c)| {
let cell = t.cell(r, c)?;
Some(json!({"row": r, "col": c, "rowSpan": cell.row_span, "colSpan": cell.col_span, "text": cell.text.text, "fill": cell.fill,
"insets": cell.insets, "vj": cell.vj, "firstBaseline": cell.first_baseline, "firstBaselineMin": cell.first_baseline_min, "style": cell.style}))
"insets": cell.insets, "vj": cell.vj, "firstBaseline": cell.first_baseline, "firstBaselineMin": cell.first_baseline_min, "rotation": cell.rotation, "style": cell.style}))
})
.collect();
Ok(json!({
@@ -1832,6 +1844,16 @@ mod style_tests {
let cell = got["cells"].as_array().unwrap().iter().find(|c| c["row"] == 0 && c["col"] == 0).unwrap().clone();
assert_eq!((cell["firstBaseline"].as_str(), cell["firstBaselineMin"].as_f64()), (Some("capHeight"), Some(3.0)));
assert!(s.execute("table.setCell", &json!({"row": 0, "col": 0, "firstBaseline": "middle"})).is_err());
// Cell Options › Text › Rotation: carried and written back, composed horizontally so far,
// which the IDML import reports (#1468).
for (given, want) in [(90, 90.0), (270, 270.0), (0, 0.0), (-90, 270.0), (450, 90.0)] {
s.execute("table.setCell", &json!({"row": 0, "col": 0, "rotation": given})).unwrap_or_else(|e| panic!("{given}: {e}"));
let cell = s.execute("table.get", &json!({})).unwrap()["cells"][0].clone();
assert_eq!(cell["rotation"].as_f64(), Some(want), "{given}");
}
for bad in [45, 1, 100] {
assert!(s.execute("table.setCell", &json!({"row": 0, "col": 0, "rotation": bad})).is_err(), "{bad}");
}
assert!(s.execute("table.setCell", &json!({"row": 0, "col": 0, "firstBaselineMin": -1})).is_err());
s.execute("style.cell.create", &json!({"name": "Leading", "firstBaseline": "leading"})).unwrap();
s.execute("style.cell.apply", &json!({"name": "Leading", "row": 1, "col": 1})).unwrap();
+14
View File
@@ -295,6 +295,10 @@ struct Importer<'r> {
parents: Vec<Spread>,
/// Things read but not kept as they were, for the import report.
warnings: Vec<String>,
/// Cells whose text is rotated (Cell Options › Text › Rotation): the angle is kept and
/// written back, but only 0 is composed, so a narrow header column of vertical labels comes
/// in horizontal and wraps mid-word (#1468).
rotated_cells: usize,
parent_ids: HashMap<String, SpreadId>,
spreads: Vec<Spread>,
page_index: HashMap<String, usize>,
@@ -533,6 +537,7 @@ impl<'r> Importer<'r> {
story_ids: HashMap::new(),
parents: Vec::new(),
warnings: Vec::new(),
rotated_cells: 0,
parent_ids: HashMap::new(),
spreads: Vec::new(),
page_index: HashMap::new(),
@@ -788,6 +793,12 @@ impl<'r> Importer<'r> {
include_prefix: e.get("IncludeSectionPrefix") == Some("true"),
});
}
if self.rotated_cells > 0 {
let n = self.rotated_cells;
self.warnings.push(format!(
"{n} table cell(s) have rotated text (Cell Options › Text › Rotation): the angle is kept and written back, but the text is composed horizontally"
));
}
self.sections.sort_by_key(|s| s.start);
self.sections.dedup_by_key(|s| s.start);
if self.sections.first().is_none_or(|s| s.start != 0) {
@@ -2244,6 +2255,9 @@ impl<'r> Importer<'r> {
cell.first_baseline_min = min.clamp(0.0, 8640.0);
}
cell.rotation = ce.num("RotationAngle").unwrap_or(cell.rotation);
if cell.rotation != 0.0 {
self.rotated_cells += 1;
}
for (i, side) in ["TopEdge", "LeftEdge", "BottomEdge", "RightEdge"].iter().enumerate() {
let attrs = self.cell_stroke_attrs(ce, side);
attrs.apply_to(&mut cell.strokes[i]);
+32
View File
@@ -4467,3 +4467,35 @@ fn non_finite_number_lists_never_reach_the_model() {
}
assert_eq!(doc.non_finite_item(), None);
}
/// Cell Options › Text › Rotation: the angle survives the round trip, and the import says it is
/// not composed — narrow header columns of vertical labels come in horizontal and wrap mid-word,
/// and nothing said so (#1468).
#[test]
fn rotated_cell_text_round_trips_and_the_import_says_it_is_not_composed() {
let mut d = Document::new(&NewDocument::default());
let (_, sid) = d.add_text_frame(SpreadRef::Doc(0), Rect::new(0.0, 0.0, 300.0, 300.0), d.default_layer(), "", ParaFormat::default()).unwrap();
let mut t = designcraft_doc::Table::new(7, 1, 3, 0, 0, 300.0);
t.cell_mut(0, 0).unwrap().rotation = 90.0;
t.cell_mut(0, 1).unwrap().rotation = 270.0;
d.story_mut(sid).unwrap().insert_table(0, t);
let bytes = export_idml(&d);
let story = zip_text(&bytes, "Stories/");
assert!(story.contains(r#"RotationAngle="90""#) && story.contains(r#"RotationAngle="270""#), "{story}");
let back = import_idml_report(&bytes, &|_| None).unwrap();
let t = back.document.stories.values().find(|st| !st.tables.is_empty()).unwrap().tables.values().next().unwrap().clone();
assert_eq!((t.cell(0, 0).unwrap().rotation, t.cell(0, 1).unwrap().rotation, t.cell(0, 2).unwrap().rotation), (90.0, 270.0, 0.0));
let about: Vec<&String> = back.warnings.iter().filter(|w| w.contains("rotated text")).collect();
assert_eq!(about.len(), 1, "one line for the file, not one per cell: {:?}", back.warnings);
assert!(about[0].starts_with("2 table cell(s)"), "{}", about[0]);
assert!(about[0].contains("composed horizontally"), "{}", about[0]);
// A file with no rotated cell says nothing.
let mut plain = Document::new(&NewDocument::default());
let (_, sid) =
plain.add_text_frame(SpreadRef::Doc(0), Rect::new(0.0, 0.0, 300.0, 300.0), plain.default_layer(), "", ParaFormat::default()).unwrap();
plain.story_mut(sid).unwrap().insert_table(0, designcraft_doc::Table::new(8, 1, 2, 0, 0, 200.0));
let quiet = import_idml_report(&export_idml(&plain), &|_| None).unwrap();
assert!(quiet.warnings.iter().all(|w| !w.contains("rotated text")), "{:?}", quiet.warnings);
}