From a473793c07c622b76915a417311808652d25f1a6 Mon Sep 17 00:00:00 2001 From: Hamza Date: Thu, 22 Oct 2026 15:59:33 +0500 Subject: [PATCH 1/2] Rotated cell text is settable and reported, not silently flattened (#1468) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A cell's text rotation (Cell Options › Text › Rotation) was already read from IDML and written back, but only 0 is composed, so an imported form's narrow header columns of vertical labels came in horizontal and wrapped mid-word ("Eingela / ngt am") — and nothing anywhere said why. The reporter's file has 30 such cells. Composing rotated cell text is a larger change than this. What this does is stop it being silent, and let the value be set from the API: - the IDML import reports it once per file: "2 table cell(s) have rotated text (Cell Options › Text › Rotation): the angle is kept and written back, but the text is composed horizontally"; - `table.setCell` takes `rotation` (0, 90, 180 or 270, any other angle refused, negatives and multiples normalised), which nothing could set before; - `table.get` reports each cell's rotation, so a round trip can be checked. Tests: the angle through an export and back with the warning counted once for the file (and no warning when no cell is rotated), and the command's accepted and refused angles. Each fails without its own part. --- crates/engine/src/cmd/table.rs | 26 ++++++++++++++++++++++++-- crates/idml/src/import.rs | 14 ++++++++++++++ crates/idml/src/tests.rs | 32 ++++++++++++++++++++++++++++++++ 3 files changed, 70 insertions(+), 2 deletions(-) diff --git a/crates/engine/src/cmd/table.rs b/crates/engine/src/cmd/table.rs index 7049c2f39..7e8bcc6cb 100644 --- a/crates/engine/src/cmd/table.rs +++ b/crates/engine/src/cmd/table.rs @@ -297,7 +297,7 @@ fn table_specs() -> Vec { "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 { 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 import says and preflight 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 { 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 { .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(); diff --git a/crates/idml/src/import.rs b/crates/idml/src/import.rs index d770aef79..f9cfed0e2 100644 --- a/crates/idml/src/import.rs +++ b/crates/idml/src/import.rs @@ -295,6 +295,10 @@ struct Importer<'r> { parents: Vec, /// Things read but not kept as they were, for the import report. warnings: Vec, + /// 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, spreads: Vec, page_index: HashMap, @@ -536,6 +540,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(), @@ -791,6 +796,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) { @@ -2232,6 +2243,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]); diff --git a/crates/idml/src/tests.rs b/crates/idml/src/tests.rs index 035333da4..9fcbf2d4b 100644 --- a/crates/idml/src/tests.rs +++ b/crates/idml/src/tests.rs @@ -4234,3 +4234,35 @@ fn cell_first_baseline_comes_from_cell_styles_table_regions_and_cells_and_round_ let s2 = back.styles.cell.iter().find(|c| c.name == "Cap").unwrap(); assert_eq!(s2.first_baseline, Some(FirstBaseline::CapHeight)); } + +/// 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); +} From 1e86a669cab3301684f7ef993c97f11c6a27643d Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Fri, 23 Oct 2026 17:18:56 -0400 Subject: [PATCH 2/2] table: the rotation comment names only the IDML import warning Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/cmd/table.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/engine/src/cmd/table.rs b/crates/engine/src/cmd/table.rs index 7e8bcc6cb..04a805568 100644 --- a/crates/engine/src/cmd/table.rs +++ b/crates/engine/src/cmd/table.rs @@ -972,7 +972,7 @@ fn set_cell(s: &mut Session, p: &Value) -> Result { }; 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 import says and preflight reports (#1468). + // 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),