IDML: resolve table styles and correct cell height fitting (#288)

* ci: simplify testkit dependency match for Clippy

* IDML: resolve table styles and shared cell edges

* Compose: size table cells by baseline and physical bounds

---------

Co-authored-by: Brandon Thomas <echelon@gmail.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
skylar taylor-barrick 2026-10-10 01:50:09 -06:00 committed by GitHub
parent d0d4812ada
commit b581031d91
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
10 changed files with 2559 additions and 212 deletions

File diff suppressed because it is too large Load Diff

View File

@ -148,6 +148,236 @@ impl Default for Styles {
pub const NO_CELL_STYLE: &str = "[None]";
pub const BASIC_TABLE: &str = "[Basic Table]";
pub const NO_TABLE_STYLE: &str = "[No table style]";
/// Partial edge formatting. Missing attributes inherit; zero weight and the None swatch
/// are explicit values, rather than a request to use the parent/default edge.
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase", default)]
pub struct CellStrokeAttrs {
#[serde(skip_serializing_if = "Option::is_none")]
pub weight: Option<f64>,
#[serde(skip_serializing_if = "Option::is_none")]
pub color: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub tint: Option<f32>,
#[serde(skip_serializing_if = "Option::is_none")]
pub kind: Option<crate::item::StrokeType>,
}
impl CellStrokeAttrs {
pub fn is_empty(&self) -> bool {
self.weight.is_none() && self.color.is_none() && self.tint.is_none() && self.kind.is_none()
}
pub fn apply_to(&self, stroke: &mut crate::table::CellStroke) {
if let Some(v) = self.weight {
stroke.weight = v;
}
if let Some(v) = &self.color {
stroke.color = v.clone();
}
if let Some(v) = self.tint {
stroke.tint = v;
}
if let Some(v) = &self.kind {
stroke.kind = v.clone();
}
}
fn merge(&mut self, other: &Self) {
if other.weight.is_some() {
self.weight = other.weight;
}
if other.color.is_some() {
self.color = other.color.clone();
}
if other.tint.is_some() {
self.tint = other.tint;
}
if other.kind.is_some() {
self.kind = other.kind.clone();
}
}
fn from_stroke(stroke: &crate::table::CellStroke) -> Self {
Self { weight: Some(stroke.weight), color: Some(stroke.color.clone()), tint: Some(stroke.tint), kind: Some(stroke.kind.clone()) }
}
}
fn no_edge_attrs(edges: &[CellStrokeAttrs; 4]) -> bool {
edges.iter().all(CellStrokeAttrs::is_empty)
}
fn no_inset_attrs(insets: &[Option<f64>; 4]) -> bool {
insets.iter().all(Option::is_none)
}
/// Partial alternating-fill settings, retained separately from the legacy complete value.
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase", default)]
pub struct AltFillsAttrs {
#[serde(skip_serializing_if = "Option::is_none")]
pub first: Option<u32>,
#[serde(skip_serializing_if = "Option::is_none")]
pub first_color: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub first_tint: Option<f32>,
#[serde(skip_serializing_if = "Option::is_none")]
pub next: Option<u32>,
#[serde(skip_serializing_if = "Option::is_none")]
pub next_color: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub next_tint: Option<f32>,
#[serde(skip_serializing_if = "Option::is_none")]
pub skip_first: Option<u32>,
#[serde(skip_serializing_if = "Option::is_none")]
pub skip_last: Option<u32>,
}
impl AltFillsAttrs {
pub fn is_empty(&self) -> bool {
self == &Self::default()
}
/// Overlay supplied attributes, retaining explicit zero counts and None swatches.
pub fn merge(&mut self, other: &Self) {
if other.first.is_some() {
self.first = other.first;
}
if other.first_color.is_some() {
self.first_color = other.first_color.clone();
}
if other.first_tint.is_some() {
self.first_tint = other.first_tint;
}
if other.next.is_some() {
self.next = other.next;
}
if other.next_color.is_some() {
self.next_color = other.next_color.clone();
}
if other.next_tint.is_some() {
self.next_tint = other.next_tint;
}
if other.skip_first.is_some() {
self.skip_first = other.skip_first;
}
if other.skip_last.is_some() {
self.skip_last = other.skip_last;
}
}
fn from_fills(fills: &crate::table::AltFills) -> Self {
Self {
first: Some(fills.first),
first_color: Some(fills.first_color.clone()),
first_tint: Some(fills.first_tint),
next: Some(fills.next),
next_color: Some(fills.next_color.clone()),
next_tint: Some(fills.next_tint),
skip_first: Some(fills.skip_first),
skip_last: Some(fills.skip_last),
}
}
pub fn apply_to(&self, fills: &mut Option<crate::table::AltFills>) {
if self.is_empty() {
return;
}
let mut value = fills.clone().unwrap_or_default();
if let Some(v) = self.first {
value.first = v;
}
if let Some(v) = &self.first_color {
value.first_color = v.clone();
}
if let Some(v) = self.first_tint {
value.first_tint = v;
}
if let Some(v) = self.next {
value.next = v;
}
if let Some(v) = &self.next_color {
value.next_color = v.clone();
}
if let Some(v) = self.next_tint {
value.next_tint = v;
}
if let Some(v) = self.skip_first {
value.skip_first = v;
}
if let Some(v) = self.skip_last {
value.skip_last = v;
}
*fills = (value.first > 0).then_some(value);
}
}
/// Partial table alternating strokes; missing attributes inherit independently.
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase", default)]
pub struct AltStrokesAttrs {
#[serde(skip_serializing_if = "Option::is_none")]
pub first: Option<u32>,
#[serde(skip_serializing_if = "Option::is_none")]
pub next: Option<u32>,
#[serde(skip_serializing_if = "CellStrokeAttrs::is_empty")]
pub first_stroke: CellStrokeAttrs,
#[serde(skip_serializing_if = "CellStrokeAttrs::is_empty")]
pub next_stroke: CellStrokeAttrs,
#[serde(skip_serializing_if = "Option::is_none")]
pub skip_first: Option<u32>,
#[serde(skip_serializing_if = "Option::is_none")]
pub skip_last: Option<u32>,
}
impl AltStrokesAttrs {
pub fn is_empty(&self) -> bool {
self == &Self::default()
}
pub fn merge(&mut self, other: &Self) {
if other.first.is_some() {
self.first = other.first;
}
if other.next.is_some() {
self.next = other.next;
}
self.first_stroke.merge(&other.first_stroke);
self.next_stroke.merge(&other.next_stroke);
if other.skip_first.is_some() {
self.skip_first = other.skip_first;
}
if other.skip_last.is_some() {
self.skip_last = other.skip_last;
}
}
pub fn apply_to(&self, strokes: &mut Option<crate::table::AltStrokes>) {
if self.is_empty() {
return;
}
let mut value = strokes.clone().unwrap_or_default();
if let Some(v) = self.first {
value.first = v;
}
if let Some(v) = self.next {
value.next = v;
}
self.first_stroke.apply_to(&mut value.first_stroke);
self.next_stroke.apply_to(&mut value.next_stroke);
if let Some(v) = self.skip_first {
value.skip_first = v;
}
if let Some(v) = self.skip_last {
value.skip_last = v;
}
// Zero counts disable rendering, but the remaining settings still belong to
// the document (and are reused if the pattern is enabled later).
*strokes = Some(value);
}
}
/// A cell style: the cell attributes it sets (unset = left as is) and a paragraph style for the
/// cell's text.
@ -156,16 +386,24 @@ pub const BASIC_TABLE: &str = "[Basic Table]";
pub struct CellStyle {
pub name: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub based_on: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub fill: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub fill_tint: Option<f32>,
#[serde(skip_serializing_if = "Option::is_none")]
pub insets: Option<[f64; 4]>,
/// Per-side values override `insets`; top, left, bottom, right.
#[serde(skip_serializing_if = "no_inset_attrs")]
pub inset_overrides: [Option<f64>; 4],
#[serde(skip_serializing_if = "Option::is_none")]
pub vj: Option<crate::item::VerticalJustification>,
/// All four edges.
#[serde(skip_serializing_if = "Option::is_none")]
pub stroke: Option<crate::table::CellStroke>,
/// Per-edge attributes override the legacy uniform `stroke`.
#[serde(skip_serializing_if = "no_edge_attrs")]
pub strokes: [CellStrokeAttrs; 4],
#[serde(skip_serializing_if = "Option::is_none")]
pub paragraph_style: Option<String>,
}
@ -176,6 +414,16 @@ pub struct CellStyle {
pub struct TableStyle {
pub name: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub based_on: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub header_same_as_body: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
pub footer_same_as_body: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
pub left_column_same_as_body: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
pub right_column_same_as_body: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
pub header: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub body: Option<String>,
@ -187,8 +435,18 @@ pub struct TableStyle {
pub right_column: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub border: Option<crate::table::CellStroke>,
#[serde(skip_serializing_if = "no_edge_attrs")]
pub borders: [CellStrokeAttrs; 4],
#[serde(skip_serializing_if = "Option::is_none")]
pub alt_rows: Option<crate::table::AltFills>,
#[serde(skip_serializing_if = "AltFillsAttrs::is_empty")]
pub row_fills: AltFillsAttrs,
#[serde(skip_serializing_if = "AltFillsAttrs::is_empty")]
pub column_fills: AltFillsAttrs,
#[serde(skip_serializing_if = "AltStrokesAttrs::is_empty")]
pub row_strokes: AltStrokesAttrs,
#[serde(skip_serializing_if = "AltStrokesAttrs::is_empty")]
pub column_strokes: AltStrokesAttrs,
#[serde(skip_serializing_if = "Option::is_none")]
pub space_before: Option<f64>,
#[serde(skip_serializing_if = "Option::is_none")]
@ -207,11 +465,25 @@ impl CellStyle {
if let Some(v) = self.insets {
cell.insets = v;
}
for (slot, value) in cell.insets.iter_mut().zip(self.inset_overrides) {
if let Some(value) = value {
*slot = value;
}
}
if let Some(v) = self.vj {
cell.vj = v;
}
if let Some(v) = &self.stroke {
cell.strokes = [v.clone(), v.clone(), v.clone(), v.clone()];
cell.border_overrides = [true; 4];
cell.stroke_defined = [true; 4];
}
for (i, edge) in self.strokes.iter().enumerate() {
edge.apply_to(&mut cell.strokes[i]);
if !edge.is_empty() {
cell.border_overrides[i] = true;
cell.stroke_defined[i] = true;
}
}
if let Some(ps) = &self.paragraph_style {
for p in &mut cell.text.paras {
@ -224,14 +496,33 @@ impl CellStyle {
}
impl TableStyle {
/// Write this style into `t`: options, then each cell's region style.
/// Write the resolved table options, then region cell styles in precedence order:
/// body, left/right columns, header/footer. Explicit cell formatting is applied later.
pub fn apply_to(&self, t: &mut crate::table::Table, cells: &[CellStyle]) {
if let Some(b) = &self.border {
t.options.border = b.clone();
t.options.borders = Default::default();
}
for (i, attrs) in self.borders.iter().enumerate() {
if !attrs.is_empty() {
let mut edge = t.options.border_for(i).clone();
attrs.apply_to(&mut edge);
t.options.borders[i] = Some(edge);
}
}
if self.borders.iter().any(|edge| !edge.is_empty()) {
// Keep the legacy uniform accessor representative without changing absent sides.
let borders = std::array::from_fn::<_, 4, _>(|i| t.options.border_for(i).clone());
t.options.border = borders[0].clone();
t.options.borders = borders.map(Some);
}
if self.alt_rows.is_some() {
t.options.alt_rows = self.alt_rows.clone();
}
self.row_fills.apply_to(&mut t.options.alt_rows);
self.column_fills.apply_to(&mut t.options.alt_cols);
self.row_strokes.apply_to(&mut t.options.row_strokes);
self.column_strokes.apply_to(&mut t.options.column_strokes);
if let Some(v) = self.space_before {
t.options.space_before = v;
}
@ -243,27 +534,148 @@ impl TableStyle {
let kind = t.rows[r].kind;
for c in 0..nc {
let visual_column = if t.options.direction == crate::TextDirection::RightToLeft { nc - 1 - c } else { c };
let region = match kind {
crate::table::RowKind::Header => self.header.as_ref(),
crate::table::RowKind::Footer => self.footer.as_ref(),
_ if visual_column == 0 && self.left_column.is_some() => self.left_column.as_ref(),
_ if visual_column + 1 == nc && self.right_column.is_some() => self.right_column.as_ref(),
_ => self.body.as_ref(),
let same_or_region = |same, region: &Option<String>| {
if same == Some(true) { self.body.clone() } else { region.clone() }
};
if let (Some(name), Some(cell)) = (region, t.cells.get_mut(r * nc + c))
&& let Some(cs) = cells.iter().find(|s| s.name == *name)
{
cs.apply_to(cell);
let left = same_or_region(self.left_column_same_as_body, &self.left_column);
let column = if visual_column == 0 && left.is_some() {
left
} else if visual_column + 1 == nc {
same_or_region(self.right_column_same_as_body, &self.right_column)
} else {
None
};
let row = match kind {
crate::table::RowKind::Header => same_or_region(self.header_same_as_body, &self.header),
crate::table::RowKind::Footer => same_or_region(self.footer_same_as_body, &self.footer),
crate::table::RowKind::Body => None,
};
if let Some(cell) = t.cells.get_mut(r * nc + c) {
for name in [self.body.as_ref(), column.as_ref(), row.as_ref()].into_iter().flatten() {
if name != NO_CELL_STYLE {
resolve_cell_style(cells, name).apply_to(cell);
}
}
}
}
}
t.style = if self.name == BASIC_TABLE { String::new() } else { self.name.clone() };
}
fn merge(&mut self, other: &Self) {
if other.header.is_some() {
self.header = other.header.clone();
}
if other.body.is_some() {
self.body = other.body.clone();
}
if other.footer.is_some() {
self.footer = other.footer.clone();
}
if other.left_column.is_some() {
self.left_column = other.left_column.clone();
}
if other.right_column.is_some() {
self.right_column = other.right_column.clone();
}
if other.header_same_as_body.is_some() {
self.header_same_as_body = other.header_same_as_body;
}
if other.footer_same_as_body.is_some() {
self.footer_same_as_body = other.footer_same_as_body;
}
if other.left_column_same_as_body.is_some() {
self.left_column_same_as_body = other.left_column_same_as_body;
}
if other.right_column_same_as_body.is_some() {
self.right_column_same_as_body = other.right_column_same_as_body;
}
if let Some(border) = &other.border {
self.borders = std::array::from_fn(|_| CellStrokeAttrs::from_stroke(border));
}
for (edge, value) in self.borders.iter_mut().zip(&other.borders) {
edge.merge(value);
}
if let Some(fills) = &other.alt_rows {
self.row_fills = AltFillsAttrs::from_fills(fills);
}
self.row_fills.merge(&other.row_fills);
self.column_fills.merge(&other.column_fills);
self.row_strokes.merge(&other.row_strokes);
self.column_strokes.merge(&other.column_strokes);
if other.space_before.is_some() {
self.space_before = other.space_before;
}
if other.space_after.is_some() {
self.space_after = other.space_after;
}
}
}
/// Resolve a cell style with the same 32-level/cycle limit as paragraph styles.
pub fn resolve_cell_style(cells: &[CellStyle], name: &str) -> CellStyle {
let mut chain = Vec::new();
let mut cur = cells.iter().find(|s| s.name == name);
while let Some(style) = cur {
if chain.len() >= MAX_DEPTH || chain.iter().any(|s: &&CellStyle| s.name == style.name) {
break;
}
chain.push(style);
cur = style.based_on.as_deref().and_then(|base| cells.iter().find(|s| s.name == base));
}
let mut out = CellStyle { name: name.into(), ..Default::default() };
for style in chain.into_iter().rev() {
if style.fill.is_some() {
out.fill = style.fill.clone();
}
if style.fill_tint.is_some() {
out.fill_tint = style.fill_tint;
}
if let Some(insets) = style.insets {
out.inset_overrides = insets.map(Some);
}
for (slot, value) in out.inset_overrides.iter_mut().zip(style.inset_overrides) {
if value.is_some() {
*slot = value;
}
}
if style.vj.is_some() {
out.vj = style.vj;
}
if let Some(stroke) = &style.stroke {
out.strokes = std::array::from_fn(|_| CellStrokeAttrs::from_stroke(stroke));
}
for (slot, attrs) in out.strokes.iter_mut().zip(&style.strokes) {
slot.merge(attrs);
}
if style.paragraph_style.is_some() {
out.paragraph_style = style.paragraph_style.clone();
}
}
out
}
const MAX_DEPTH: usize = 32;
impl Styles {
/// Resolve table style inheritance without recursion; repeated/missing bases stop the chain.
pub fn resolve_table_style(&self, name: &str) -> TableStyle {
let mut chain = Vec::new();
let mut cur = self.table.iter().find(|s| s.name == name);
while let Some(style) = cur {
if chain.len() >= MAX_DEPTH || chain.iter().any(|s: &&TableStyle| s.name == style.name) {
break;
}
chain.push(style);
cur = style.based_on.as_deref().and_then(|base| self.table.iter().find(|s| s.name == base));
}
let mut out = TableStyle { name: name.into(), ..Default::default() };
for style in chain.into_iter().rev() {
out.merge(style);
}
out
}
pub fn para(&self, name: &str) -> Option<&ParagraphStyle> {
self.paragraph.iter().find(|s| s.name == name)
}
@ -411,6 +823,104 @@ mod tests {
assert!(!st.para_based_on_cycles(BASIC_PARAGRAPH, NO_PARA_STYLE));
}
#[test]
fn legacy_cell_and_table_style_json_keep_uniform_values() {
let cell: CellStyle =
serde_json::from_str(r#"{"name":"Legacy","insets":[1,2,3,4],"stroke":{"weight":2,"color":"Brand","tint":0.5,"kind":{"kind":"solid"}}}"#)
.unwrap();
assert!(cell.based_on.is_none());
assert!(no_edge_attrs(&cell.strokes));
assert!(no_inset_attrs(&cell.inset_overrides));
let resolved = resolve_cell_style(std::slice::from_ref(&cell), "Legacy");
let mut target = crate::table::Cell::default();
resolved.apply_to(&mut target);
assert_eq!(target.insets, [1.0, 2.0, 3.0, 4.0]);
assert!(target.strokes.iter().all(|s| s.weight == 2.0 && s.color == "Brand" && s.tint == 0.5));
let json = serde_json::to_value(&cell).unwrap();
assert!(json.get("strokes").is_none());
assert_eq!(serde_json::from_value::<CellStyle>(json).unwrap(), cell);
let table: TableStyle =
serde_json::from_str(r#"{"name":"Legacy","body":"Legacy","border":{"weight":3},"altRows":{"first":2,"firstColor":"Brand"}}"#).unwrap();
let styles = Styles { cell: vec![cell], table: vec![table.clone()], ..Styles::default() };
let mut target = crate::table::Table::new(1, 1, 2, 0, 0, 100.0);
styles.resolve_table_style("Legacy").apply_to(&mut target, &styles.cell);
assert_eq!(target.options.border_for(3).weight, 3.0);
assert_eq!(target.options.alt_rows.as_ref().unwrap().first, 2);
assert_eq!(target.cell(0, 0).unwrap().strokes[0].weight, 2.0);
assert_eq!(serde_json::from_str::<TableStyle>(&serde_json::to_string(&table).unwrap()).unwrap(), table);
}
#[test]
fn table_region_flags_cover_footer_right_column_and_rtl() {
let cells = [
CellStyle { name: "Body".into(), fill: Some("Body".into()), ..Default::default() },
CellStyle { name: "Footer".into(), fill: Some("Footer".into()), ..Default::default() },
CellStyle { name: "Right".into(), fill: Some("Right".into()), ..Default::default() },
];
let mut style = TableStyle {
body: Some("Body".into()),
footer: Some("Footer".into()),
right_column: Some("Right".into()),
footer_same_as_body: Some(false),
right_column_same_as_body: Some(false),
..Default::default()
};
for direction in [crate::TextDirection::LeftToRight, crate::TextDirection::RightToLeft] {
let mut table = crate::table::Table::new(1, 1, 3, 0, 1, 100.0);
table.options.direction = direction;
let right = if direction == crate::TextDirection::LeftToRight { 2 } else { 0 };
style.apply_to(&mut table, &cells);
assert_eq!(table.cell(0, right).unwrap().fill, "Right");
assert_eq!(table.cell(1, right).unwrap().fill, "Footer");
}
style.footer_same_as_body = Some(true);
style.right_column_same_as_body = Some(true);
let mut table = crate::table::Table::new(1, 1, 3, 0, 1, 100.0);
style.apply_to(&mut table, &cells);
assert!(table.cells.iter().all(|c| c.fill == "Body"));
}
#[test]
fn single_column_tables_use_a_configured_right_region_when_left_is_absent() {
let cells = [CellStyle { name: "Right".into(), fill: Some("Right".into()), ..Default::default() }];
let style = TableStyle { right_column: Some("Right".into()), ..Default::default() };
let mut table = crate::table::Table::new(1, 1, 1, 0, 0, 100.0);
style.apply_to(&mut table, &cells);
assert_eq!(table.cell(0, 0).unwrap().fill, "Right");
}
#[test]
fn cell_and_table_style_resolution_stop_at_the_depth_limit() {
let cells: Vec<_> = (0..40)
.map(|i| CellStyle {
name: i.to_string(),
based_on: Some((i + 1).to_string()),
fill: (i == 32).then(|| "Too deep".into()),
fill_tint: (i == 31).then_some(0.5),
..Default::default()
})
.collect();
let resolved = resolve_cell_style(&cells, "0");
assert!(resolved.fill.is_none());
assert_eq!(resolved.fill_tint, Some(0.5));
let styles = Styles {
table: (0..40)
.map(|i| TableStyle {
name: i.to_string(),
based_on: Some((i + 1).to_string()),
space_before: (i == 32).then_some(123.0),
space_after: (i == 31).then_some(15.0),
..Default::default()
})
.collect(),
..Styles::default()
};
let resolved = styles.resolve_table_style("0");
assert!(resolved.space_before.is_none());
assert_eq!(resolved.space_after, Some(15.0));
}
#[test]
fn character_style_attrs_include_its_based_on_chain_only() {
let mut st = Styles::default();

View File

@ -79,6 +79,58 @@ impl CellStroke {
}
}
/// Table-level alternating row/column strokes. Retained independently of cell formatting
/// so an explicit cell style or local edge continues to take precedence.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase", default)]
pub struct AltStrokes {
pub first: u32,
pub next: u32,
pub first_stroke: CellStroke,
pub next_stroke: CellStroke,
pub skip_first: u32,
pub skip_last: u32,
}
impl Default for AltStrokes {
fn default() -> Self {
Self {
first: 0,
next: 0,
first_stroke: CellStroke::default(),
next_stroke: CellStroke { weight: 0.25, ..Default::default() },
skip_first: 0,
skip_last: 0,
}
}
}
impl AltStrokes {
/// A phase-independent stroke for unskipped, visually uniform patterns. Unequal
/// patterns and skip settings are preserved for round trips until their boundary
/// phase is supported; they must not be rendered with a guessed phase.
pub fn uniform_stroke(&self) -> Option<&CellStroke> {
if self.skip_first > 0 || self.skip_last > 0 {
return None;
}
match (self.first > 0, self.next > 0) {
(true, false) => Some(&self.first_stroke),
(false, true) => Some(&self.next_stroke),
(true, true) if self.first_stroke == self.next_stroke || (!self.first_stroke.is_visible() && !self.next_stroke.is_visible()) => {
Some(&self.first_stroke)
}
_ => None,
}
}
}
fn no_flags(flags: &[bool; 4]) -> bool {
!flags.iter().any(|v| *v)
}
fn no_priorities(priorities: &[i32; 4]) -> bool {
priorities.iter().all(|v| *v == 0)
}
fn one() -> u32 {
1
}
@ -120,6 +172,12 @@ pub struct Cell {
/// top, left, bottom, right order as `strokes`. Older documents keep their table border.
#[serde(default)]
pub border_overrides: [bool; 4],
/// Whether an edge has supplied cell/style attributes, independently of perimeter priority.
#[serde(default, skip_serializing_if = "no_flags")]
pub stroke_defined: [bool; 4],
/// IDML edge-conflict priorities, in top, left, bottom, right order.
#[serde(default, skip_serializing_if = "no_priorities")]
pub stroke_priorities: [i32; 4],
/// Applied cell style ("" = [None]).
#[serde(default, skip_serializing_if = "String::is_empty")]
pub style: String,
@ -142,6 +200,8 @@ impl Default for Cell {
rotation: 0.0,
strokes: Default::default(),
border_overrides: [false; 4],
stroke_priorities: [0; 4],
stroke_defined: [false; 4],
style: String::new(),
graphic: None,
}
@ -200,12 +260,12 @@ impl AltFills {
/// Fill for the `i`-th of `n` body rows/columns, if the pattern paints it.
pub fn fill_for(&self, i: usize, n: usize) -> Option<(&str, f32)> {
let (sf, sl) = (self.skip_first as usize, self.skip_last as usize);
if i < sf || i + sl >= n {
if i < sf || i >= n.saturating_sub(sl) {
return None;
}
let period = (self.first + self.next).max(1) as usize;
let k = (i - sf) % period;
let (c, t) = if k < self.first as usize { (&self.first_color, self.first_tint) } else { (&self.next_color, self.next_tint) };
let period = (u64::from(self.first) + u64::from(self.next)).max(1);
let k = (i - sf) as u64 % period;
let (c, t) = if k < u64::from(self.first) { (&self.first_color, self.first_tint) } else { (&self.next_color, self.next_tint) };
(c != designcraft_color::swatch::NONE).then_some((c.as_str(), t))
}
}
@ -217,6 +277,9 @@ pub struct TableOptions {
pub direction: crate::TextDirection,
/// Outer border (overrides the outer cell edges).
pub border: CellStroke,
/// Per-side borders; absent sides use the legacy uniform `border`.
#[serde(skip_serializing_if = "no_borders")]
pub borders: [Option<CellStroke>; 4],
pub space_before: f64,
pub space_after: f64,
/// Header rows repeat at the top of every text column / frame the table continues into.
@ -226,6 +289,20 @@ pub struct TableOptions {
pub alt_rows: Option<AltFills>,
#[serde(skip_serializing_if = "Option::is_none")]
pub alt_cols: Option<AltFills>,
#[serde(skip_serializing_if = "Option::is_none")]
pub row_strokes: Option<AltStrokes>,
#[serde(skip_serializing_if = "Option::is_none")]
pub column_strokes: Option<AltStrokes>,
}
fn no_borders(borders: &[Option<CellStroke>; 4]) -> bool {
borders.iter().all(Option::is_none)
}
impl TableOptions {
pub fn border_for(&self, side: usize) -> &CellStroke {
self.borders.get(side).and_then(Option::as_ref).unwrap_or(&self.border)
}
}
impl Default for TableOptions {
@ -233,12 +310,15 @@ impl Default for TableOptions {
TableOptions {
direction: crate::TextDirection::LeftToRight,
border: CellStroke::default(),
borders: Default::default(),
space_before: 4.0,
space_after: -4.0,
repeat_header: true,
repeat_footer: true,
alt_rows: None,
alt_cols: None,
row_strokes: None,
column_strokes: None,
}
}
}
@ -1097,6 +1177,16 @@ mod tests {
assert!(b.fill_for(3, 9).is_none());
}
#[test]
fn alternating_fill_counts_cannot_overflow() {
let fills = AltFills { first: u32::MAX, next: 1, ..Default::default() };
assert!(fills.fill_for(0, 2).is_some());
assert!(fills.fill_for(1, 2).is_some());
let skipped = AltFills { skip_last: u32::MAX, ..fills };
assert!(skipped.fill_for(1, 2).is_none());
assert!(skipped.fill_for(usize::MAX, usize::MAX).is_none());
}
#[test]
fn serde_roundtrip() {
let mut s = story("x");
@ -1118,8 +1208,22 @@ mod tests {
old.as_object_mut().unwrap().remove("borderOverrides");
let back: Cell = serde_json::from_value(old).unwrap();
assert_eq!(back.border_overrides, [false; 4]);
assert_eq!(back.stroke_defined, [false; 4]);
assert_eq!(back.stroke_priorities, [0; 4]);
let legacy_options: TableOptions = serde_json::from_str(r#"{"border":{"weight":3}}"#).unwrap();
assert!(legacy_options.borders.iter().all(Option::is_none));
assert!(legacy_options.row_strokes.is_none());
assert!(legacy_options.column_strokes.is_none());
for side in 0..4 {
assert_eq!(legacy_options.border_for(side).weight, 3.0);
}
let explicit = Cell { border_overrides: [true, false, true, false], ..cell };
let explicit = Cell {
border_overrides: [true, false, true, false],
stroke_defined: [true, true, false, false],
stroke_priorities: [3, 0, 7, 0],
..cell
};
let back: Cell = serde_json::from_str(&serde_json::to_string(&explicit).unwrap()).unwrap();
assert_eq!(back, explicit);
assert_eq!(back.blank_like().border_overrides, explicit.border_overrides);

View File

@ -650,6 +650,11 @@ fn set_cell(s: &mut Session, p: &Value) -> Result<Value> {
edit_table(s, &g, "table.setCell", |t| {
let owners = t.owners();
let nc = t.ncols();
let nr = t.nrows();
let rtl = t.options.direction == designcraft_doc::TextDirection::RightToLeft;
let borders: [CellStroke; 4] = std::array::from_fn(|side| t.options.border_for(side).clone());
// Local edits outrank previously imported edges, including explicit zero/None.
let priority = if stroke.is_some() { next_cell_stroke_priority(t) } else { 0 };
let rg = g.range;
for r in rg.r0..=rg.r1 {
for c in rg.c0..=rg.c1 {
@ -675,47 +680,111 @@ fn set_cell(s: &mut Session, p: &Value) -> Result<Value> {
}
if let Some(sv) = &stroke {
let edges = str_param(sv, "edges").unwrap_or("all");
let at_bottom = r.saturating_add(cell.row_span.max(1) as usize);
let at_right = c.saturating_add(cell.col_span.max(1) as usize);
let perimeter = [r == 0, if rtl { at_right == nc } else { c == 0 }, at_bottom == nr, if rtl { c == 0 } else { at_right == nc }];
for (i, on) in [
("top", r == rg.r0),
("left", c == rg.c0),
("bottom", r + cell.row_span as usize - 1 == rg.r1),
("right", c + cell.col_span as usize - 1 == rg.c1),
("left", if rtl { at_right.saturating_sub(1) == rg.c1 } else { c == rg.c0 }),
("bottom", at_bottom.saturating_sub(1) == rg.r1),
("right", if rtl { c == rg.c0 } else { at_right.saturating_sub(1) == rg.c1 }),
]
.iter()
.enumerate()
.map(|(i, (name, outer))| (i, edges == "all" || edges == *name || (edges == "outer" && *outer) || (edges == "inner" && !*outer)))
{
if on {
cell.strokes[i] = parse_stroke(sv, cell.strokes[i].clone());
let base = if perimeter[i] && !cell.border_overrides[i] { &borders[i] } else { &cell.strokes[i] };
cell.strokes[i] = parse_stroke(sv, base.clone());
cell.stroke_defined[i] = true;
cell.stroke_priorities[i] = priority;
cell.border_overrides[i] = true;
}
}
}
}
}
// Neighbouring cells share edges: mirror the edges along the range boundary.
// Keep both copies of unmerged shared boundaries consistent. Left/right name
// physical sides, also in RTL tables.
if let Some(sv) = &stroke {
let edges = str_param(sv, "edges").unwrap_or("all");
if matches!(edges, "all" | "outer" | "bottom") && rg.r1 + 1 < t.nrows() {
let selected = |side: &str| edges == "all" || edges == "outer" || edges == side;
if selected("top")
&& let Some(row) = rg.r0.checked_sub(1)
{
for c in rg.c0..=rg.c1 {
let s0 = t.cell(rg.r1, c).map(|x| x.strokes[2].clone());
if let (Some(s0), Some(n)) = (s0, t.cell_mut(rg.r1 + 1, c)) {
n.strokes[0] = s0;
mirror_cell_edge(t, &owners, (rg.r0, c), 0, (row, c), 2);
}
}
if selected("bottom") && rg.r1.saturating_add(1) < nr {
for c in rg.c0..=rg.c1 {
mirror_cell_edge(t, &owners, (rg.r1, c), 2, (rg.r1 + 1, c), 0);
}
if matches!(edges, "all" | "outer" | "right") && rg.c1 + 1 < t.ncols() {
}
if selected(if rtl { "right" } else { "left" })
&& let Some(col) = rg.c0.checked_sub(1)
{
for r in rg.r0..=rg.r1 {
let s0 = t.cell(r, rg.c1).map(|x| x.strokes[3].clone());
if let (Some(s0), Some(n)) = (s0, t.cell_mut(r, rg.c1 + 1)) {
n.strokes[1] = s0;
mirror_cell_edge(t, &owners, (r, rg.c0), if rtl { 3 } else { 1 }, (r, col), if rtl { 1 } else { 3 });
}
}
if selected(if rtl { "left" } else { "right" }) && rg.c1.saturating_add(1) < nc {
for r in rg.r0..=rg.r1 {
mirror_cell_edge(t, &owners, (r, rg.c1), if rtl { 1 } else { 3 }, (r, rg.c1 + 1), if rtl { 3 } else { 1 });
}
}
}
ok()
})
}
/// Only saturation needs rebasing. Preserve every distinct priority's order and keep
/// zero/negative priorities untouched; the work is bounded by the table's edges.
fn next_cell_stroke_priority(t: &mut Table) -> i32 {
let highest = t.cells.iter().flat_map(|cell| cell.stroke_priorities).max().unwrap_or(0).max(0);
if highest < i32::MAX {
return highest + 1;
}
let values: std::collections::BTreeSet<_> = t.cells.iter().flat_map(|cell| cell.stroke_priorities).filter(|p| *p > 0).collect();
let mut ranks = std::collections::BTreeMap::new();
let mut highest = 0i32;
for value in values {
highest = highest.saturating_add(1);
ranks.insert(value, highest);
}
for cell in &mut t.cells {
for priority in &mut cell.stroke_priorities {
if let Some(rank) = ranks.get(priority) {
*priority = *rank;
}
}
}
highest.saturating_add(1)
}
fn mirror_cell_edge(t: &mut Table, owners: &[(usize, usize)], from: (usize, usize), edge: usize, to: (usize, usize), opposite: usize) {
let owner = |pos: (usize, usize)| owners.get(pos.0.checked_mul(t.ncols())?.checked_add(pos.1)?).copied();
if owner(from) != Some(from) || owner(to) != Some(to) {
return;
}
// A merged cell's edge may border several separately editable cells. Mirroring
// a segment onto that whole edge would change unselected neighbors; priorities
// instead resolve each segment without altering the merged cell's formatting.
let merged = |cell: &designcraft_doc::Cell| cell.row_span > 1 || cell.col_span > 1;
if t.cell(from.0, from.1).is_some_and(merged) || t.cell(to.0, to.1).is_some_and(merged) {
return;
}
let source = t
.cell(from.0, from.1)
.map(|cell| (cell.strokes[edge].clone(), cell.stroke_defined[edge], cell.stroke_priorities[edge], cell.border_overrides[edge]));
if let (Some((stroke, defined, priority, border_override)), Some(neighbor)) = (source, t.cell_mut(to.0, to.1)) {
neighbor.strokes[opposite] = stroke;
neighbor.stroke_defined[opposite] = defined;
neighbor.stroke_priorities[opposite] = priority;
neighbor.border_overrides[opposite] = border_override;
}
}
fn set_row_height(s: &mut Session, p: &Value) -> Result<Value> {
let g = target(s, p, "table.setRowHeight")?;
let h = p.get("height").and_then(Value::as_f64).map(|h| h.clamp(0.0, 10000.0));
@ -846,6 +915,7 @@ fn options(s: &mut Session, p: &Value) -> Result<Value> {
}
if let Some(b) = p.get("border") {
o.border = parse_stroke(b, o.border.clone());
o.borders = Default::default();
}
o.space_before = f64_or(&p, "spaceBefore", o.space_before);
o.space_after = f64_or(&p, "spaceAfter", o.space_after);
@ -982,6 +1052,9 @@ fn stroke_param(v: Option<&Value>) -> Option<designcraft_doc::CellStroke> {
/// Fill in the cell style fields given in `p`.
fn cell_style_fields(cs: &mut designcraft_doc::CellStyle, p: &Value, cmd: &str) -> Result<()> {
if let Some(v) = p.get("basedOn") {
cs.based_on = v.as_str().map(str::to_string);
}
if let Some(f) = color_param(p, "fill") {
cs.fill = Some(f);
}
@ -989,10 +1062,14 @@ fn cell_style_fields(cs: &mut designcraft_doc::CellStyle, p: &Value, cmd: &str)
cs.fill_tint = Some(t.clamp(0.0, 1.0) as f32);
}
match p.get("insets") {
Some(Value::Number(n)) => cs.insets = n.as_f64().map(|v| [v.max(0.0); 4]),
Some(Value::Number(n)) => {
cs.insets = n.as_f64().map(|v| [v.max(0.0); 4]);
cs.inset_overrides = Default::default();
}
Some(Value::Array(a)) if a.len() == 4 => {
let v: Vec<f64> = a.iter().map(|x| x.as_f64().unwrap_or(0.0).max(0.0)).collect();
cs.insets = Some([v[0], v[1], v[2], v[3]]);
cs.inset_overrides = Default::default();
}
_ => {}
}
@ -1001,6 +1078,7 @@ fn cell_style_fields(cs: &mut designcraft_doc::CellStyle, p: &Value, cmd: &str)
}
if let Some(st) = stroke_param(p.get("stroke")) {
cs.stroke = Some(st);
cs.strokes = Default::default();
}
if let Some(ps) = str_param(p, "paragraphStyle") {
cs.paragraph_style = Some(ps.to_string());
@ -1037,15 +1115,11 @@ fn cell_style_create(s: &mut Session, p: &Value) -> Result<Value> {
fn cell_style_apply(s: &mut Session, p: &Value) -> Result<Value> {
let name = str_param(p, "name").ok_or_else(|| bad("style.cell.apply", "missing name"))?.to_string();
let cs = s
.doc()?
.doc
.styles
.cell
.iter()
.find(|c| c.name == name)
.cloned()
.ok_or_else(|| bad("style.cell.apply", format!("no cell style `{name}`")))?;
let styles = &s.doc()?.doc.styles.cell;
if !styles.iter().any(|c| c.name == name) {
return Err(bad("style.cell.apply", format!("no cell style `{name}`")));
}
let cs = designcraft_doc::resolve_cell_style(styles, &name);
let g = target(s, p, "style.cell.apply")?;
edit_table(s, &g, "style.cell.apply", |t| {
let owners = t.owners();
@ -1067,7 +1141,7 @@ fn cell_style_apply(s: &mut Session, p: &Value) -> Result<Value> {
/// Re-apply cell styles named in `names` to every cell using them (after a style edit).
fn reapply_cell_styles(d: &mut Document, names: &[String]) {
let styles = d.styles.cell.clone();
let styles: Vec<_> = names.iter().map(|name| designcraft_doc::resolve_cell_style(&d.styles.cell, name)).collect();
for sid in d.stories.keys().copied().collect::<Vec<_>>() {
let Some(st) = d.story_mut(sid) else { continue };
for t in st.tables.values_mut() {
@ -1086,18 +1160,42 @@ fn reapply_cell_styles(d: &mut Document, names: &[String]) {
}
}
/// Include descendants when editing an inherited style, with the resolver's depth limit.
fn dependent_styles<'a>(name: &str, styles: impl Iterator<Item = (&'a str, Option<&'a str>)>) -> Vec<String> {
let styles: Vec<_> = styles.collect();
styles
.iter()
.filter(|(candidate, _)| {
let mut current = Some(*candidate);
for _ in 0..32 {
let Some(current_name) = current else { return false };
if current_name == name {
return true;
}
current = styles.iter().find(|(n, _)| *n == current_name).and_then(|(_, base)| *base);
}
false
})
.map(|(name, _)| (*name).to_string())
.collect()
}
fn cell_style_edit(s: &mut Session, p: &Value) -> Result<Value> {
let name = str_param(p, "name").ok_or_else(|| bad("style.cell.edit", "missing name"))?.to_string();
let p = p.clone();
s.edit(|d, _| {
let cs = d.styles_mut().cell.iter_mut().find(|c| c.name == name).ok_or_else(|| bad("style.cell.edit", format!("no cell style `{name}`")))?;
cell_style_fields(cs, &p, "style.cell.edit")?;
reapply_cell_styles(d, std::slice::from_ref(&name));
let names = dependent_styles(&name, d.styles.cell.iter().map(|cs| (cs.name.as_str(), cs.based_on.as_deref())));
reapply_cell_styles(d, &names);
ok()
})
}
fn table_style_fields(ts: &mut designcraft_doc::TableStyle, p: &Value) {
if let Some(v) = p.get("basedOn") {
ts.based_on = v.as_str().map(str::to_string);
}
for (k, f) in [
("header", &mut ts.header),
("body", &mut ts.body),
@ -1111,6 +1209,7 @@ fn table_style_fields(ts: &mut designcraft_doc::TableStyle, p: &Value) {
}
if let Some(b) = stroke_param(p.get("border")) {
ts.border = Some(b);
ts.borders = Default::default();
}
if let Some(a) = p.get("altRows").and_then(Value::as_object) {
let n = |k: &str, d: u32| a.get(k).and_then(Value::as_u64).map_or(d, |v| v as u32);
@ -1150,7 +1249,10 @@ fn table_style_create(s: &mut Session, p: &Value) -> Result<Value> {
fn table_style_apply(s: &mut Session, p: &Value) -> Result<Value> {
let name = str_param(p, "name").ok_or_else(|| bad("style.table.apply", "missing name"))?.to_string();
let doc = &s.doc()?.doc;
let ts = doc.styles.table.iter().find(|c| c.name == name).cloned().ok_or_else(|| bad("style.table.apply", format!("no table style `{name}`")))?;
if !doc.styles.table.iter().any(|c| c.name == name) {
return Err(bad("style.table.apply", format!("no table style `{name}`")));
}
let ts = doc.styles.resolve_table_style(&name);
let cells = doc.styles.cell.clone();
let g = target(s, p, "style.table.apply")?;
edit_table(s, &g, "style.table.apply", |t| {
@ -1166,11 +1268,13 @@ fn table_style_edit(s: &mut Session, p: &Value) -> Result<Value> {
let ts =
d.styles_mut().table.iter_mut().find(|c| c.name == name).ok_or_else(|| bad("style.table.edit", format!("no table style `{name}`")))?;
table_style_fields(ts, &p);
let (ts, cells) = (ts.clone(), d.styles.cell.clone());
let names = dependent_styles(&name, d.styles.table.iter().map(|ts| (ts.name.as_str(), ts.based_on.as_deref())));
let styles: Vec<_> = names.iter().map(|name| d.styles.resolve_table_style(name)).collect();
let cells = d.styles.cell.clone();
for sid in d.stories.keys().copied().collect::<Vec<_>>() {
let Some(st) = d.story_mut(sid) else { continue };
for t in st.tables.values_mut() {
if t.style == name {
if let Some(ts) = styles.iter().find(|ts| ts.name == t.style) {
ts.apply_to(std::sync::Arc::make_mut(t), &cells);
}
}

View File

@ -176,3 +176,226 @@ fn file_roundtrip_keeps_tables() {
let st = back.story(designcraft_doc::StoryId(sid)).unwrap();
assert_eq!(st.tables.values().next().unwrap().cells[0].text.text, "A1");
}
fn table_for(s: &Session, sid: u64, tid: u64) -> &doc::Table {
s.doc().unwrap().doc.story(doc::StoryId(sid)).unwrap().tables.get(&tid).unwrap()
}
#[test]
fn explicit_cell_edges_win_imported_priorities_and_keep_undo_and_file_roundtrip() {
for (direction, neighbors) in [
(doc::TextDirection::LeftToRight, [(0, 1, 2), (1, 0, 3), (1, 2, 1), (2, 1, 0)]),
(doc::TextDirection::RightToLeft, [(0, 1, 2), (1, 0, 1), (1, 2, 3), (2, 1, 0)]),
] {
for imported_priority in [-7, 41, i32::MAX] {
let (mut s, sid, _) = session_with_frame();
let r = s.execute("table.insert", &json!({"rows": 3, "cols": 3})).unwrap();
let tid = r["table"].as_u64().unwrap();
s.edit(|d, _| {
let t = d.story_mut(doc::StoryId(sid)).unwrap().table_mut(tid).unwrap();
t.options.border = doc::CellStroke::none();
t.options.direction = direction;
for cell in &mut t.cells {
cell.strokes = std::array::from_fn(|_| doc::CellStroke::none());
}
t.cell_mut(0, 0).unwrap().stroke_priorities[0] = -9;
for (row, col, edge) in neighbors {
let cell = t.cell_mut(row, col).unwrap();
cell.strokes[edge] = doc::CellStroke { weight: 7.0, ..Default::default() };
cell.stroke_defined[edge] = true;
cell.stroke_priorities[edge] = imported_priority;
}
Ok(())
})
.unwrap();
let before = table_for(&s, sid, tid).clone();
s.execute("table.setCell", &json!({"row": 1, "col": 1, "stroke": {"weight": 0, "edges": "all"}})).unwrap();
let after = table_for(&s, sid, tid).clone();
assert_eq!(after.cell(0, 0).unwrap().stroke_priorities[0], -9, "unrelated signed priorities are preserved");
let edited = after.cell(1, 1).unwrap();
let priority = if imported_priority == i32::MAX { 2 } else { imported_priority.max(0) + 1 };
assert_eq!(edited.stroke_defined, [true; 4]);
assert_eq!(edited.border_overrides, [true; 4]);
assert_eq!(edited.stroke_priorities, [priority; 4]);
for (row, col, edge) in neighbors {
let neighbor = after.cell(row, col).unwrap();
assert_eq!(neighbor.strokes[edge].weight, 0.0);
assert!(neighbor.stroke_defined[edge]);
assert!(neighbor.border_overrides[edge]);
assert_eq!(neighbor.stroke_priorities[edge], priority);
}
let composed = s.cache.get(&s.doc().unwrap().doc, doc::StoryId(sid), None);
assert!(composed.frames.iter().flat_map(|f| &f.tables).all(|t| t.strokes.is_empty()));
s.execute("edit.undo", &json!({})).unwrap();
assert_eq!(table_for(&s, sid, tid), &before);
s.execute("edit.redo", &json!({})).unwrap();
assert_eq!(table_for(&s, sid, tid), &after);
let bytes = crate::cmd::file_bytes(&s.doc().unwrap().doc);
let back = crate::cmd::file_from(&bytes).unwrap();
assert_eq!(back.story(doc::StoryId(sid)).unwrap().tables.get(&tid).unwrap().as_ref(), &after);
check(&s);
}
}
}
#[test]
fn perimeter_edits_override_imported_sides_and_uniform_border_clears_side_options() {
let (mut s, sid, _) = session_with_frame();
let r = s.execute("table.insert", &json!({"rows": 1, "cols": 1})).unwrap();
let tid = r["table"].as_u64().unwrap();
s.edit(|d, _| {
let t = d.story_mut(doc::StoryId(sid)).unwrap().table_mut(tid).unwrap();
t.options.border.weight = 4.0;
t.options.borders = std::array::from_fn(|_| Some(doc::CellStroke { weight: 9.0, ..Default::default() }));
Ok(())
})
.unwrap();
s.execute("table.setCell", &json!({"stroke": {"tint": 0.25, "edges": "left"}})).unwrap();
let t = table_for(&s, sid, tid);
let cell = t.cell(0, 0).unwrap();
assert_eq!(cell.stroke_defined, [false, true, false, false]);
assert_eq!(cell.border_overrides, [false, true, false, false]);
assert_eq!(cell.strokes[1].weight, 9.0, "partial edits preserve the visible perimeter base");
assert_eq!(cell.strokes[1].tint, 0.25);
let before = t.clone();
s.execute("table.options", &json!({"border": {"weight": 3}})).unwrap();
let t = table_for(&s, sid, tid);
assert!(t.options.borders.iter().all(Option::is_none));
assert_eq!(t.options.border.weight, 3.0);
let composed = s.cache.get(&s.doc().unwrap().doc, doc::StoryId(sid), None);
let frag = composed.frames.iter().flat_map(|f| &f.tables).next().unwrap();
assert_eq!(frag.strokes.iter().filter(|s| s.stroke.weight == 3.0).count(), 3);
assert_eq!(frag.strokes.iter().filter(|s| s.stroke.weight == 9.0 && s.stroke.tint == 0.25).count(), 1);
s.execute("edit.undo", &json!({})).unwrap();
assert_eq!(table_for(&s, sid, tid), &before);
}
#[test]
fn inherited_cell_and_table_styles_apply_and_follow_base_edits() {
let (mut s, sid, _) = session_with_frame();
let r = s.execute("table.insert", &json!({"rows": 1, "cols": 2})).unwrap();
let tid = r["table"].as_u64().unwrap();
s.execute("style.cell.create", &json!({"name": "Cell Base", "insets": 7, "stroke": {"weight": 2}})).unwrap();
s.execute("style.cell.create", &json!({"name": "Cell Child", "basedOn": "Cell Base", "fill": "[Paper]"})).unwrap();
s.execute("style.cell.apply", &json!({"name": "Cell Child", "table": tid})).unwrap();
let t = table_for(&s, sid, tid);
assert!(t.cells.iter().all(|cell| cell.style == "Cell Child" && cell.insets == [7.0; 4] && cell.strokes[0].weight == 2.0));
s.execute("style.cell.edit", &json!({"name": "Cell Base", "insets": 5})).unwrap();
assert!(table_for(&s, sid, tid).cells.iter().all(|cell| cell.insets == [5.0; 4]));
s.execute("style.table.create", &json!({"name": "Table Base", "body": "Cell Child", "border": {"weight": 6}, "spaceBefore": 8})).unwrap();
s.execute("style.table.create", &json!({"name": "Table Child", "basedOn": "Table Base", "spaceAfter": 3})).unwrap();
s.execute("style.table.apply", &json!({"name": "Table Child"})).unwrap();
let t = table_for(&s, sid, tid);
assert_eq!(t.style, "Table Child");
assert_eq!(t.options.border_for(0).weight, 6.0);
assert_eq!(t.options.space_before, 8.0);
assert_eq!(t.options.space_after, 3.0);
assert!(t.cells.iter().all(|cell| cell.style == "Cell Child" && cell.insets == [5.0; 4]));
s.execute("style.table.edit", &json!({"name": "Table Base", "border": {"weight": 4}, "spaceBefore": 10})).unwrap();
let t = table_for(&s, sid, tid);
assert_eq!(t.options.border_for(0).weight, 4.0);
assert_eq!(t.options.space_before, 10.0);
assert_eq!(t.options.space_after, 3.0);
s.execute("edit.undo", &json!({})).unwrap();
assert_eq!(table_for(&s, sid, tid).options.border_for(0).weight, 6.0);
}
#[test]
fn uniform_style_edits_replace_imported_per_side_attributes() {
let (mut s, sid, _) = session_with_frame();
let r = s.execute("table.insert", &json!({"rows": 1, "cols": 1})).unwrap();
let tid = r["table"].as_u64().unwrap();
s.edit(|d, _| {
d.styles_mut().cell.push(doc::CellStyle {
name: "Imported Cell".into(),
strokes: std::array::from_fn(|_| doc::CellStrokeAttrs { weight: Some(9.0), ..Default::default() }),
inset_overrides: [Some(12.0); 4],
..Default::default()
});
d.styles_mut().table.push(doc::TableStyle {
name: "Imported Table".into(),
borders: std::array::from_fn(|_| doc::CellStrokeAttrs { weight: Some(10.0), ..Default::default() }),
..Default::default()
});
Ok(())
})
.unwrap();
s.execute("style.cell.apply", &json!({"name": "Imported Cell"})).unwrap();
s.execute("style.cell.edit", &json!({"name": "Imported Cell", "stroke": {"weight": 2}, "insets": 3})).unwrap();
let cell = table_for(&s, sid, tid).cell(0, 0).unwrap();
assert!(cell.strokes.iter().all(|edge| edge.weight == 2.0));
assert_eq!(cell.insets, [3.0; 4]);
s.execute("style.table.apply", &json!({"name": "Imported Table"})).unwrap();
s.execute("style.table.edit", &json!({"name": "Imported Table", "border": {"weight": 4}})).unwrap();
let t = table_for(&s, sid, tid);
assert!((0..4).all(|edge| t.options.border_for(edge).weight == 4.0));
}
#[test]
fn rtl_cell_edge_selection_uses_physical_sides() {
let (mut s, sid, _) = session_with_frame();
let r = s.execute("table.insert", &json!({"rows": 1, "cols": 3})).unwrap();
let tid = r["table"].as_u64().unwrap();
s.execute("table.options", &json!({"direction": "rightToLeft", "border": {"weight": 0}})).unwrap();
s.execute("table.setCell", &json!({"cols": [0, 2], "stroke": {"weight": 0}})).unwrap();
s.execute("table.setCell", &json!({"row": 0, "col": 0, "stroke": {"weight": 2, "edges": "left"}})).unwrap();
let t = table_for(&s, sid, tid);
assert_eq!(t.cell(0, 0).unwrap().strokes[1].weight, 2.0);
assert_eq!(t.cell(0, 1).unwrap().strokes[3].weight, 2.0);
assert_eq!(t.cell(0, 1).unwrap().strokes[1].weight, 0.0);
s.execute("table.setCell", &json!({"cols": [0, 2], "stroke": {"weight": 5, "edges": "outer"}})).unwrap();
let t = table_for(&s, sid, tid);
assert_eq!(t.cell(0, 0).unwrap().strokes[3].weight, 5.0);
assert_eq!(t.cell(0, 2).unwrap().strokes[1].weight, 5.0);
assert_eq!(t.cell(0, 0).unwrap().strokes[1].weight, 2.0);
assert_eq!(t.cell(0, 2).unwrap().strokes[3].weight, 0.0);
s.execute("table.setCell", &json!({"cols": [0, 2], "stroke": {"weight": 0, "edges": "inner"}})).unwrap();
let t = table_for(&s, sid, tid);
assert_eq!(t.cell(0, 0).unwrap().strokes[3].weight, 5.0);
assert_eq!(t.cell(0, 2).unwrap().strokes[1].weight, 5.0);
assert_eq!(t.cell(0, 0).unwrap().strokes[1].weight, 0.0);
assert_eq!(t.cell(0, 1).unwrap().strokes[3].weight, 0.0);
}
#[test]
fn editing_beside_a_merged_cell_only_changes_the_selected_edge_segment() {
for direction in [doc::TextDirection::LeftToRight, doc::TextDirection::RightToLeft] {
for imported_priority in [41, i32::MAX] {
let (mut s, sid, _) = session_with_frame();
let r = s.execute("table.insert", &json!({"rows": 2, "cols": 2})).unwrap();
let tid = r["table"].as_u64().unwrap();
s.execute("table.merge", &json!({"row": 0, "cols": [0, 1]})).unwrap();
s.edit(|d, _| {
let t = d.story_mut(doc::StoryId(sid)).unwrap().table_mut(tid).unwrap();
t.options.direction = direction;
t.options.border = doc::CellStroke::none();
for cell in &mut t.cells {
cell.strokes = std::array::from_fn(|_| doc::CellStroke::none());
}
let merged = t.cell_mut(0, 0).unwrap();
merged.strokes[2] = doc::CellStroke { weight: 7.0, ..Default::default() };
merged.stroke_defined[2] = true;
merged.stroke_priorities[2] = imported_priority;
t.cell_mut(1, 1).unwrap().stroke_priorities[2] = 7;
Ok(())
})
.unwrap();
let before = table_for(&s, sid, tid).clone();
s.execute("table.setCell", &json!({"row": 1, "col": 0, "stroke": {"weight": 0, "edges": "top"}})).unwrap();
let t = table_for(&s, sid, tid);
assert_eq!(t.cell(0, 0).unwrap().strokes[2].weight, 7.0, "the merged edge itself must not be erased");
let priorities =
[t.cell(1, 1).unwrap().stroke_priorities[2], t.cell(0, 0).unwrap().stroke_priorities[2], t.cell(1, 0).unwrap().stroke_priorities[0]];
assert_eq!(priorities, if imported_priority == i32::MAX { [1, 2, 3] } else { [7, 41, 42] });
let composed = s.cache.get(&s.doc().unwrap().doc, doc::StoryId(sid), None);
let frag = composed.frames.iter().flat_map(|f| &f.tables).next().unwrap();
let unselected = frag.cell(1, 1).unwrap().rect;
let visible: Vec<_> = frag.strokes.iter().filter(|s| s.stroke.weight == 7.0).collect();
assert_eq!(visible.len(), 1);
assert_eq!(visible[0].a, designcraft_geom::Point::new(unselected.x0, unselected.y0));
assert_eq!(visible[0].b, designcraft_geom::Point::new(unselected.x1, unselected.y0));
s.execute("edit.undo", &json!({})).unwrap();
assert_eq!(table_for(&s, sid, tid), &before);
}
}
}

View File

@ -886,6 +886,11 @@ impl<'a> Ex<'a> {
el.set(k, num(v));
}
}
for (key, value) in ["TopInset", "LeftInset", "BottomInset", "RightInset"].iter().zip(cs.inset_overrides) {
if let Some(value) = value {
el.set(key, num(value));
}
}
if let Some(vj) = cs.vj {
el.set("VerticalJustification", names::vj_out(vj));
}
@ -894,20 +899,32 @@ impl<'a> Ex<'a> {
self.cell_stroke_attrs(&mut el, side, st);
}
}
for (side, attrs) in ["TopEdge", "LeftEdge", "BottomEdge", "RightEdge"].iter().zip(&cs.strokes) {
self.partial_cell_stroke_attrs(&mut el, side, attrs);
}
if let Some(ps) = &cs.paragraph_style {
el.set("AppliedParagraphStyle", names::style_self("ParagraphStyle", PARA_BUILTINS, ps));
}
cells = cells.child(with_props(el, vec![p("BasedOn", "string", "$ID/[None]")]));
let base = cs.based_on.as_deref().unwrap_or(designcraft_doc::NO_CELL_STYLE);
let based_on = if base == designcraft_doc::NO_CELL_STYLE {
p("BasedOn", "string", "$ID/[None]")
} else {
p("BasedOn", "object", names::style_self("CellStyle", names::CELL_BUILTINS, base))
};
cells = cells.child(with_props(el, vec![based_on]));
}
root.push(cells);
let mut tables = El::new("RootTableStyleGroup")
.attr("Self", self.fresh())
.child(El::new("TableStyle").attr("Self", "TableStyle/$ID/[No table style]").attr("Name", "$ID/[No table style]"))
.child(with_props(
let mut tables = El::new("RootTableStyleGroup").attr("Self", self.fresh());
if !styles.table.iter().any(|s| s.name == designcraft_doc::NO_TABLE_STYLE) {
tables.push(El::new("TableStyle").attr("Self", "TableStyle/$ID/[No table style]").attr("Name", "$ID/[No table style]"));
}
if !styles.table.iter().any(|s| s.name == designcraft_doc::BASIC_TABLE) {
tables.push(with_props(
El::new("TableStyle").attr("Self", "TableStyle/$ID/[Basic Table]").attr("Name", "$ID/[Basic Table]"),
vec![p("BasedOn", "string", "$ID/[No table style]")],
));
for ts in styles.table.iter().filter(|t| t.name != designcraft_doc::BASIC_TABLE) {
}
for ts in &styles.table {
let mut el = El::new("TableStyle")
.attr("Self", names::style_self("TableStyle", names::TABLE_BUILTINS, &ts.name))
.attr("Name", names::style_name_out(names::TABLE_BUILTINS, &ts.name));
@ -922,11 +939,24 @@ impl<'a> Ex<'a> {
el.set(k, names::style_self("CellStyle", names::CELL_BUILTINS, n));
}
}
for (key, value) in [
("HeaderRegionSameAsBodyRegion", ts.header_same_as_body),
("FooterRegionSameAsBodyRegion", ts.footer_same_as_body),
("LeftColumnRegionSameAsBodyRegion", ts.left_column_same_as_body),
("RightColumnRegionSameAsBodyRegion", ts.right_column_same_as_body),
] {
if let Some(value) = value {
el.set(key, value);
}
}
if let Some(b) = &ts.border {
for side in ["Top", "Left", "Bottom", "Right"] {
self.cell_stroke_attrs(&mut el, &format!("{side}Border"), b);
}
}
for (side, attrs) in ["TopBorder", "LeftBorder", "BottomBorder", "RightBorder"].iter().zip(&ts.borders) {
self.partial_cell_stroke_attrs(&mut el, side, attrs);
}
if let Some(a) = &ts.alt_rows {
el.set("StartRowFillColor", self.sw(&a.first_color));
el.set("StartRowFillCount", a.first);
@ -935,13 +965,26 @@ impl<'a> Ex<'a> {
el.set("EndRowFillCount", a.next);
el.set("EndRowFillTint", pct(a.next_tint as f64));
}
self.partial_alt_fill_attrs(&mut el, "Row", &ts.row_fills);
self.partial_alt_fill_attrs(&mut el, "Column", &ts.column_fills);
self.partial_alt_stroke_attrs(&mut el, "Row", &ts.row_strokes);
self.partial_alt_stroke_attrs(&mut el, "Column", &ts.column_strokes);
if let Some(v) = ts.space_before {
el.set("SpaceBefore", num(v));
}
if let Some(v) = ts.space_after {
el.set("SpaceAfter", num(v));
}
tables = tables.child(with_props(el, vec![p("BasedOn", "string", "$ID/[Basic Table]")]));
let mut props = Vec::new();
if ts.name != designcraft_doc::NO_TABLE_STYLE {
let base = ts.based_on.as_deref().unwrap_or(designcraft_doc::NO_TABLE_STYLE);
props.push(if base == designcraft_doc::NO_TABLE_STYLE {
p("BasedOn", "string", "$ID/[No table style]")
} else {
p("BasedOn", "object", names::style_self("TableStyle", names::TABLE_BUILTINS, base))
});
}
tables = tables.child(with_props(el, props));
}
root.push(tables);
// Named numbered lists.
@ -2137,6 +2180,71 @@ impl<'a> Ex<'a> {
el.set(&format!("{prefix}StrokeType"), names::stroke_type_out(&s.kind));
}
fn partial_cell_stroke_attrs(&self, el: &mut El, prefix: &str, attrs: &designcraft_doc::CellStrokeAttrs) {
if let Some(value) = attrs.weight {
el.set(&format!("{prefix}StrokeWeight"), num(value));
}
if let Some(value) = &attrs.color {
el.set(&format!("{prefix}StrokeColor"), self.sw(value));
}
if let Some(value) = attrs.tint {
el.set(&format!("{prefix}StrokeTint"), pct(value as f64));
}
if let Some(value) = &attrs.kind {
el.set(&format!("{prefix}StrokeType"), names::stroke_type_out(value));
}
}
fn partial_alt_fill_attrs(&self, el: &mut El, kind: &str, attrs: &designcraft_doc::AltFillsAttrs) {
if let Some(value) = attrs.first {
el.set(&format!("Start{kind}FillCount"), value);
}
if let Some(value) = &attrs.first_color {
el.set(&format!("Start{kind}FillColor"), self.sw(value));
}
if let Some(value) = attrs.first_tint {
el.set(&format!("Start{kind}FillTint"), pct(value as f64));
}
if let Some(value) = attrs.next {
el.set(&format!("End{kind}FillCount"), value);
}
if let Some(value) = &attrs.next_color {
el.set(&format!("End{kind}FillColor"), self.sw(value));
}
if let Some(value) = attrs.next_tint {
el.set(&format!("End{kind}FillTint"), pct(value as f64));
}
if let Some(value) = attrs.skip_first {
el.set(&format!("SkipFirstAlternatingFill{kind}s"), value);
}
if let Some(value) = attrs.skip_last {
el.set(&format!("SkipLastAlternatingFill{kind}s"), value);
}
}
fn partial_alt_stroke_attrs(&self, el: &mut El, kind: &str, attrs: &designcraft_doc::AltStrokesAttrs) {
if let Some(value) = attrs.first {
el.set(&format!("Start{kind}StrokeCount"), value);
}
if let Some(value) = attrs.next {
el.set(&format!("End{kind}StrokeCount"), value);
}
self.partial_cell_stroke_attrs(el, &format!("Start{kind}"), &attrs.first_stroke);
self.partial_cell_stroke_attrs(el, &format!("End{kind}"), &attrs.next_stroke);
if kind == "Column"
&& let Some(value) = &attrs.next_stroke.kind
{
el.attrs.retain(|(key, _)| key != "EndColumnStrokeType");
el.set("EndColumnLineStyle", names::stroke_type_out(value));
}
if let Some(value) = attrs.skip_first {
el.set(&format!("SkipFirstAlternatingStroke{kind}s"), value);
}
if let Some(value) = attrs.skip_last {
el.set(&format!("SkipLastAlternatingStroke{kind}s"), value);
}
}
/// `<Table>` with rows, columns and cells (cell names are `column:row`).
fn table_el(&mut self, t: &designcraft_doc::Table) -> El {
let id = uid(t.id);
@ -2159,8 +2267,8 @@ impl<'a> Ex<'a> {
.attr("SpaceAfter", num(t.options.space_after))
.attr("HeaderBehavior", if t.options.repeat_header { "RepeatOnEachTextColumn" } else { "RepeatOnce" })
.attr("FooterBehavior", if t.options.repeat_footer { "RepeatOnEachTextColumn" } else { "RepeatOnce" });
for side in ["Top", "Left", "Bottom", "Right"] {
self.cell_stroke_attrs(&mut el, &format!("{side}Border"), &t.options.border);
for (i, side) in ["Top", "Left", "Bottom", "Right"].iter().enumerate() {
self.cell_stroke_attrs(&mut el, &format!("{side}Border"), t.options.border_for(i));
}
for (kind, alt) in [("Row", &t.options.alt_rows), ("Column", &t.options.alt_cols)] {
if let Some(a) = alt {
@ -2172,6 +2280,27 @@ impl<'a> Ex<'a> {
el.set(&format!("End{kind}FillTint"), pct(a.next_tint as f64));
el.set(&format!("SkipFirstAlternatingFill{kind}s"), a.skip_first);
el.set(&format!("SkipLastAlternatingFill{kind}s"), a.skip_last);
} else {
// A local off value must override any enabled pattern in the named style.
el.set(&format!("Start{kind}FillCount"), 0);
el.set(&format!("End{kind}FillCount"), 0);
}
}
for (kind, strokes) in [("Row", &t.options.row_strokes), ("Column", &t.options.column_strokes)] {
if let Some(strokes) = strokes {
el.set(&format!("Start{kind}StrokeCount"), strokes.first);
el.set(&format!("End{kind}StrokeCount"), strokes.next);
self.cell_stroke_attrs(&mut el, &format!("Start{kind}"), &strokes.first_stroke);
self.cell_stroke_attrs(&mut el, &format!("End{kind}"), &strokes.next_stroke);
if kind == "Column" {
el.attrs.retain(|(key, _)| key != "EndColumnStrokeType");
el.set("EndColumnLineStyle", names::stroke_type_out(&strokes.next_stroke.kind));
}
el.set(&format!("SkipFirstAlternatingStroke{kind}s"), strokes.skip_first);
el.set(&format!("SkipLastAlternatingStroke{kind}s"), strokes.skip_last);
} else {
el.set(&format!("Start{kind}StrokeCount"), 0);
el.set(&format!("End{kind}StrokeCount"), 0);
}
}
for (r, row) in t.rows.iter().enumerate() {
@ -2223,9 +2352,21 @@ impl<'a> Ex<'a> {
.attr("VerticalJustification", vj)
.attr("RotationAngle", num(cell.rotation));
for (i, side) in ["TopEdge", "LeftEdge", "BottomEdge", "RightEdge"].iter().enumerate() {
// Do not turn absent native defaults into explicit shared-edge candidates.
if cell.stroke_defined[i]
|| cell.border_overrides[i]
|| cell.stroke_priorities[i] != 0
|| cell.strokes[i] != designcraft_doc::CellStroke::default()
{
self.cell_stroke_attrs(&mut ce, side, &cell.strokes[i]);
if cell.border_overrides[i] {
ce.set(&format!("{side}StrokePriority"), 1);
}
let priority = if cell.border_overrides[i] && !cell.stroke_defined[i] {
cell.stroke_priorities[i].max(1)
} else {
cell.stroke_priorities[i]
};
if priority != 0 {
ce.set(&format!("{side}StrokePriority"), priority);
}
}
for psr in self.story_paras(&cell.text) {

View File

@ -7,11 +7,10 @@ use std::sync::Arc;
use designcraft_color::cms::lab;
use designcraft_color::{Color, ColorType, Gradient, GradientKind, GradientStop, Swatch, SwatchValue, swatch};
use designcraft_doc::{
AltFills, Asset, AssetId, BaselineGrid, Cell, CellRange, CellStroke, CharAttrs, CharFormat, CharRun, CharacterStyle, Columns, ColumnsKind,
Content, DocSettings, Document, DropShadow, Effects, Fill, GridAlign, GridRelative, Guide, Item, ItemId, Kerning, LAYER_COLORS, Layer, LayerId,
ListType, Margins, ObjectStyle, Page, PageId, PageSide, ParaAttrs, ParaFormat, ParagraphStyle, ParentInfo, RowHeightMode, Rule, Section, Shape,
SpanColumns, Spread, SpreadId, Story, StoryId, Stroke, Styles, TabStop, Table, TextFrame, TextFrameOptions, TextWrap, VerticalJustification,
story as st,
Asset, AssetId, BaselineGrid, CellRange, CharAttrs, CharFormat, CharRun, CharacterStyle, Columns, ColumnsKind, Content, DocSettings, Document,
DropShadow, Effects, Fill, GridAlign, GridRelative, Guide, Item, ItemId, Kerning, LAYER_COLORS, Layer, LayerId, ListType, Margins, ObjectStyle,
Page, PageId, PageSide, ParaAttrs, ParaFormat, ParagraphStyle, ParentInfo, RowHeightMode, Rule, Section, Shape, SpanColumns, Spread, SpreadId,
Story, StoryId, Stroke, Styles, TabStop, Table, TextFrame, TextFrameOptions, TextWrap, story as st,
};
use designcraft_geom::corners::{Corner, CornerOptions};
use designcraft_geom::{Affine, Anchor, PathData, Point, Rect, SubPath};
@ -20,6 +19,16 @@ use crate::names::{self, CHAR_BUILTINS, OBJECT_BUILTINS, PARA_BUILTINS, unescape
use crate::xml::{El, Node, parse};
use crate::{IdmlError, MIMETYPE, Result, base64_decode, sniff_image};
/// Preserve the legacy uniform field only when all four sides explicitly specify every
/// property. Partial records must stay sparse so they can inherit from a parent style.
fn uniform_cell_stroke(edges: &[designcraft_doc::CellStrokeAttrs; 4]) -> Option<designcraft_doc::CellStroke> {
let first = edges.first()?;
if edges.iter().any(|edge| edge != first) {
return None;
}
Some(designcraft_doc::CellStroke { weight: first.weight?, color: first.color.clone()?, tint: first.tint?, kind: first.kind.clone()? })
}
/// Import an IDML package. Linked images are read from disk when available (not on wasm).
pub fn import_idml(bytes: &[u8]) -> Result<Document> {
import_idml_with(bytes, &default_reader)
@ -109,6 +118,8 @@ struct Importer<'r> {
char_names: HashMap<String, String>,
lists: Vec<designcraft_doc::NumberedList>,
object_names: HashMap<String, String>,
cell_names: HashMap<String, String>,
table_names: HashMap<String, String>,
/// Object style Self → element (attribute fallback for page items).
object_els: HashMap<String, El>,
layers: Vec<Layer>,
@ -193,6 +204,8 @@ impl<'r> Importer<'r> {
char_names: HashMap::new(),
lists: Vec::new(),
object_names: HashMap::new(),
cell_names: HashMap::new(),
table_names: HashMap::new(),
object_els: HashMap::new(),
layers: Vec::new(),
layer_ids: HashMap::new(),
@ -815,30 +828,31 @@ impl<'r> Importer<'r> {
None => self.styles.character.push(s),
}
}
// Cell and table styles (the built-ins stay as they are).
let cell_ref = |r: &str| names::style_name_in(names::CELL_BUILTINS, &unescape_id(r.trim_start_matches("CellStyle/")));
// Keep built-ins as well: [Basic Table] is editable, and [No table style]
// supplies the defaults used by named styles in ordinary IDML files.
for (id, e) in &cell_styles {
self.cell_names.insert(id.clone(), names::style_name_in(names::CELL_BUILTINS, e.get("Name").unwrap_or("")));
}
for (id, e) in &table_styles {
self.table_names.insert(id.clone(), names::style_name_in(names::TABLE_BUILTINS, e.get("Name").unwrap_or("")));
}
for (_, e) in &cell_styles {
let name = names::style_name_in(names::CELL_BUILTINS, e.get("Name").unwrap_or(""));
if name == designcraft_doc::NO_CELL_STYLE {
continue;
}
let mut cs = designcraft_doc::CellStyle { name: name.clone(), ..Default::default() };
cs.fill = e.get("FillColor").map(|c| self.swatch_ref(c));
cs.fill_tint = e.num("FillTint").and_then(|v| tint(Some(v)));
let ins: Vec<Option<f64>> = ["TopInset", "LeftInset", "BottomInset", "RightInset"].iter().map(|k| e.num(k)).collect();
if ins.iter().all(Option::is_some) {
cs.insets = Some([ins[0].unwrap_or(0.0), ins[1].unwrap_or(0.0), ins[2].unwrap_or(0.0), ins[3].unwrap_or(0.0)]);
cs.based_on =
e.prop("BasedOn").map(|r| self.cell_style_ref(&r)).or_else(|| Some(designcraft_doc::NO_CELL_STYLE.into())).filter(|n| *n != name);
cs.fill = e.prop("FillColor").map(|c| self.swatch_ref(&c));
cs.fill_tint = tint(e.num("FillTint"));
cs.inset_overrides = ["TopInset", "LeftInset", "BottomInset", "RightInset"].map(|k| e.num(k).map(|v| v.max(0.0)));
if let [Some(top), Some(left), Some(bottom), Some(right)] = cs.inset_overrides {
cs.insets = Some([top, left, bottom, right]);
cs.inset_overrides = [None; 4];
}
cs.vj = e.get("VerticalJustification").map(names::vj_in);
if e.get("TopEdgeStrokeWeight").is_some() || e.get("TopEdgeStrokeColor").is_some() {
let mut st = CellStroke::default();
if let Some(w) = e.num("TopEdgeStrokeWeight") {
st.weight = w.max(0.0);
}
if let Some(c) = e.get("TopEdgeStrokeColor") {
st.color = self.swatch_ref(c);
}
cs.stroke = Some(st);
cs.strokes = ["TopEdge", "LeftEdge", "BottomEdge", "RightEdge"].map(|side| self.cell_stroke_attrs(e, side));
if let Some(stroke) = uniform_cell_stroke(&cs.strokes) {
cs.stroke = Some(stroke);
cs.strokes = Default::default();
}
cs.paragraph_style = e.get("AppliedParagraphStyle").map(|r| self.para_style_ref(r)).filter(|n| n != designcraft_doc::NO_PARA_STYLE);
match self.styles.cell.iter_mut().find(|c| c.name == name) {
@ -848,37 +862,29 @@ impl<'r> Importer<'r> {
}
for (_, e) in &table_styles {
let name = names::style_name_in(names::TABLE_BUILTINS, e.get("Name").unwrap_or(""));
if name == designcraft_doc::BASIC_TABLE || name == "[No table style]" {
continue;
}
let mut ts = designcraft_doc::TableStyle { name: name.clone(), ..Default::default() };
ts.header = e.get("HeaderRegionCellStyle").map(cell_ref).filter(|n| n != designcraft_doc::NO_CELL_STYLE);
ts.body = e.get("BodyRegionCellStyle").map(cell_ref).filter(|n| n != designcraft_doc::NO_CELL_STYLE);
ts.footer = e.get("FooterRegionCellStyle").map(cell_ref).filter(|n| n != designcraft_doc::NO_CELL_STYLE);
ts.left_column = e.get("LeftColumnRegionCellStyle").map(cell_ref).filter(|n| n != designcraft_doc::NO_CELL_STYLE);
ts.right_column = e.get("RightColumnRegionCellStyle").map(cell_ref).filter(|n| n != designcraft_doc::NO_CELL_STYLE);
if e.get("TopBorderStrokeWeight").is_some() {
let mut b = CellStroke::default();
if let Some(w) = e.num("TopBorderStrokeWeight") {
b.weight = w.max(0.0);
}
if let Some(c) = e.get("TopBorderStrokeColor") {
b.color = self.swatch_ref(c);
}
ts.border = Some(b);
}
if let Some(first) = e.num("StartRowFillCount").filter(|n| *n > 0.0) {
ts.alt_rows = Some(AltFills {
first: first as u32,
first_color: e.get("StartRowFillColor").map(|c| self.swatch_ref(c)).unwrap_or_else(|| swatch::NONE.into()),
first_tint: tint(e.num("StartRowFillTint")).unwrap_or(1.0),
next: e.num("EndRowFillCount").unwrap_or(1.0) as u32,
next_color: e.get("EndRowFillColor").map(|c| self.swatch_ref(c)).unwrap_or_else(|| swatch::NONE.into()),
next_tint: tint(e.num("EndRowFillTint")).unwrap_or(1.0),
skip_first: 0,
skip_last: 0,
});
ts.based_on =
e.prop("BasedOn").map(|r| self.table_style_ref(&r)).or_else(|| Some(designcraft_doc::NO_TABLE_STYLE.into())).filter(|n| *n != name);
ts.header = e.get("HeaderRegionCellStyle").map(|r| if r == "n" { designcraft_doc::NO_CELL_STYLE.into() } else { self.cell_style_ref(r) });
ts.body = e.get("BodyRegionCellStyle").map(|r| if r == "n" { designcraft_doc::NO_CELL_STYLE.into() } else { self.cell_style_ref(r) });
ts.footer = e.get("FooterRegionCellStyle").map(|r| if r == "n" { designcraft_doc::NO_CELL_STYLE.into() } else { self.cell_style_ref(r) });
ts.left_column =
e.get("LeftColumnRegionCellStyle").map(|r| if r == "n" { designcraft_doc::NO_CELL_STYLE.into() } else { self.cell_style_ref(r) });
ts.right_column =
e.get("RightColumnRegionCellStyle").map(|r| if r == "n" { designcraft_doc::NO_CELL_STYLE.into() } else { self.cell_style_ref(r) });
ts.header_same_as_body = e.boolean("HeaderRegionSameAsBodyRegion");
ts.footer_same_as_body = e.boolean("FooterRegionSameAsBodyRegion");
ts.left_column_same_as_body = e.boolean("LeftColumnRegionSameAsBodyRegion");
ts.right_column_same_as_body = e.boolean("RightColumnRegionSameAsBodyRegion");
ts.borders = ["TopBorder", "LeftBorder", "BottomBorder", "RightBorder"].map(|side| self.cell_stroke_attrs(e, side));
if let Some(border) = uniform_cell_stroke(&ts.borders) {
ts.border = Some(border);
ts.borders = Default::default();
}
ts.row_fills = self.alt_fill_attrs(e, "Row");
ts.column_fills = self.alt_fill_attrs(e, "Column");
ts.row_strokes = self.alt_stroke_attrs(e, "Row");
ts.column_strokes = self.alt_stroke_attrs(e, "Column");
ts.space_before = e.num("SpaceBefore");
ts.space_after = e.num("SpaceAfter");
match self.styles.table.iter_mut().find(|c| c.name == name) {
@ -1386,12 +1392,73 @@ impl<'r> Importer<'r> {
Some(r)
}
fn cell_style_ref(&self, reference: &str) -> String {
let reference = reference.trim();
self.cell_names
.get(reference)
.cloned()
.unwrap_or_else(|| names::style_name_in(names::CELL_BUILTINS, &unescape_id(reference.trim_start_matches("CellStyle/"))))
}
fn table_style_ref(&self, reference: &str) -> String {
let reference = reference.trim();
self.table_names
.get(reference)
.cloned()
.unwrap_or_else(|| names::style_name_in(names::TABLE_BUILTINS, &unescape_id(reference.trim_start_matches("TableStyle/"))))
}
fn cell_stroke_attrs(&mut self, el: &El, prefix: &str) -> designcraft_doc::CellStrokeAttrs {
designcraft_doc::CellStrokeAttrs {
weight: el.num(&format!("{prefix}StrokeWeight")).map(|v| v.max(0.0)),
color: el.prop(&format!("{prefix}StrokeColor")).map(|v| self.swatch_ref(&v)),
tint: tint(el.num(&format!("{prefix}StrokeTint"))),
kind: el.prop(&format!("{prefix}StrokeType")).map(|v| names::stroke_type_in(&v)),
}
}
fn alt_fill_attrs(&mut self, el: &El, kind: &str) -> designcraft_doc::AltFillsAttrs {
designcraft_doc::AltFillsAttrs {
first: el.num(&format!("Start{kind}FillCount")).map(|v| v.max(0.0) as u32),
first_color: el.prop(&format!("Start{kind}FillColor")).map(|v| self.swatch_ref(&v)),
first_tint: tint(el.num(&format!("Start{kind}FillTint"))),
next: el.num(&format!("End{kind}FillCount")).map(|v| v.max(0.0) as u32),
next_color: el.prop(&format!("End{kind}FillColor")).map(|v| self.swatch_ref(&v)),
next_tint: tint(el.num(&format!("End{kind}FillTint"))),
skip_first: el.num(&format!("SkipFirstAlternatingFill{kind}s")).map(|v| v.max(0.0) as u32),
skip_last: el.num(&format!("SkipLastAlternatingFill{kind}s")).map(|v| v.max(0.0) as u32),
}
}
fn alt_stroke_attrs(&mut self, el: &El, kind: &str) -> designcraft_doc::AltStrokesAttrs {
let first_stroke = self.cell_stroke_attrs(el, &format!("Start{kind}"));
let mut next_stroke = self.cell_stroke_attrs(el, &format!("End{kind}"));
// IDML uses LineStyle for this one attribute (rather than StrokeType).
if kind == "Column"
&& let Some(value) = el.prop("EndColumnLineStyle")
{
next_stroke.kind = Some(names::stroke_type_in(&value));
}
designcraft_doc::AltStrokesAttrs {
first: el.num(&format!("Start{kind}StrokeCount")).map(|v| v.max(0.0) as u32),
next: el.num(&format!("End{kind}StrokeCount")).map(|v| v.max(0.0) as u32),
first_stroke,
next_stroke,
skip_first: el.num(&format!("SkipFirstAlternatingStroke{kind}s")).map(|v| v.max(0.0) as u32),
skip_last: el.num(&format!("SkipLastAlternatingStroke{kind}s")).map(|v| v.max(0.0) as u32),
}
}
// ---------- stories ----------
fn story(&mut self, id: StoryId, e: &El) -> Story {
self.story_with_para(id, e, ParaFormat::default())
}
fn story_with_para(&mut self, id: StoryId, e: &El, default_para: ParaFormat) -> Story {
let mut b = StoryBuilder {
text: String::new(),
paras: vec![ParaFormat::default()],
paras: vec![default_para.clone()],
runs: Vec::new(),
fresh: true,
last: CharFormat::default(),
@ -1403,7 +1470,7 @@ impl<'r> Importer<'r> {
index_refs: Vec::new(),
objects: Vec::new(),
};
self.walk_story(e, &mut b, &ParaFormat::default(), &CharAttrs::default(), &CharFormat::default(), None);
self.walk_story(e, &mut b, &default_para, &CharAttrs::default(), &CharFormat::default(), None);
let StoryBuilder { text, mut paras, runs, last, tables: tbls, notes, anchors, xrefs, index_refs, objects, .. } = b;
let mut chars: Vec<CharRun> = runs.into_iter().filter(|r| r.len > 0).collect();
if chars.is_empty() {
@ -1490,50 +1557,39 @@ impl<'r> Importer<'r> {
if h.saturating_add(f) < nr {
t.set_header_footer(h, f);
}
let stroke = |me: &mut Self, el: &El, prefix: &str, base: &CellStroke| -> CellStroke {
let mut s = base.clone();
if let Some(w) = el.num(&format!("{prefix}StrokeWeight")) {
s.weight = w.max(0.0);
let style_name = e.get("AppliedTableStyle").map(|r| self.table_style_ref(r)).unwrap_or_else(|| designcraft_doc::BASIC_TABLE.into());
let style = self.styles.resolve_table_style(&style_name);
style.apply_to(&mut t, &self.styles.cell);
// IDML distinguishes an explicit AppliedCellStyle from the effective region style.
for cell in &mut t.cells {
cell.style.clear();
}
if let Some(c) = el.get(&format!("{prefix}StrokeColor")) {
s.color = me.swatch_ref(c);
// Table-local options override the named table style. Cell styles are already
// materialized independently, so their edges retain their higher precedence.
let mut borders = std::array::from_fn::<_, 4, _>(|i| t.options.border_for(i).clone());
for (side, border) in ["TopBorder", "LeftBorder", "BottomBorder", "RightBorder"].iter().zip(&mut borders) {
self.cell_stroke_attrs(e, side).apply_to(border);
}
if let Some(v) = el.num(&format!("{prefix}StrokeTint")) {
s.tint = tint(Some(v)).unwrap_or(1.0);
}
if let Some(v) = el.get(&format!("{prefix}StrokeType")) {
s.kind = names::stroke_type_in(v);
}
s
};
t.options.border = stroke(self, e, "TopBorder", &t.options.border);
t.options.border = borders[0].clone();
t.options.borders = borders.map(Some);
t.options.space_before = e.num("SpaceBefore").unwrap_or(t.options.space_before);
t.options.space_after = e.num("SpaceAfter").unwrap_or(t.options.space_after);
t.options.repeat_header = e.get("HeaderBehavior") != Some("RepeatOnce");
t.options.repeat_footer = e.get("FooterBehavior") != Some("RepeatOnce");
for kind in ["Row", "Column"] {
let Some(first) = e.num(&format!("Start{kind}FillCount")).filter(|n| *n > 0.0) else { continue };
let alt = AltFills {
first: first as u32,
first_color: e.get(&format!("Start{kind}FillColor")).map(|c| self.swatch_ref(c)).unwrap_or_else(|| swatch::NONE.into()),
first_tint: tint(e.num(&format!("Start{kind}FillTint"))).unwrap_or(1.0),
next: e.num(&format!("End{kind}FillCount")).unwrap_or(1.0) as u32,
next_color: e.get(&format!("End{kind}FillColor")).map(|c| self.swatch_ref(c)).unwrap_or_else(|| swatch::NONE.into()),
next_tint: tint(e.num(&format!("End{kind}FillTint"))).unwrap_or(1.0),
skip_first: e.num(&format!("SkipFirstAlternatingFill{kind}s")).unwrap_or(0.0) as u32,
skip_last: e.num(&format!("SkipLastAlternatingFill{kind}s")).unwrap_or(0.0) as u32,
};
if kind == "Row" {
t.options.alt_rows = Some(alt);
} else {
t.options.alt_cols = Some(alt);
}
}
t.style = e
.get("AppliedTableStyle")
.map(|r| names::style_name_in(names::TABLE_BUILTINS, &unescape_id(r.trim_start_matches("TableStyle/"))))
.filter(|n| n != designcraft_doc::BASIC_TABLE && n != "[No table style]")
.unwrap_or_default();
// A local count may enable a pattern disabled by its parent style. Merge before
// materialization so the parent's colors/tints are still available in that case.
let mut row_fills = style.row_fills.clone();
row_fills.merge(&self.alt_fill_attrs(e, "Row"));
row_fills.apply_to(&mut t.options.alt_rows);
let mut column_fills = style.column_fills.clone();
column_fills.merge(&self.alt_fill_attrs(e, "Column"));
column_fills.apply_to(&mut t.options.alt_cols);
let mut row_strokes = style.row_strokes.clone();
row_strokes.merge(&self.alt_stroke_attrs(e, "Row"));
row_strokes.apply_to(&mut t.options.row_strokes);
let mut column_strokes = style.column_strokes.clone();
column_strokes.merge(&self.alt_stroke_attrs(e, "Column"));
column_strokes.apply_to(&mut t.options.column_strokes);
let mut regions = Vec::new();
for ce in e.find_all("Cell") {
let Some((c, r)) =
@ -1544,36 +1600,39 @@ impl<'r> Importer<'r> {
if r >= nr || c >= nc {
continue;
}
let rs = ce.num("RowSpan").unwrap_or(1.0).max(1.0) as usize;
let cs = ce.num("ColumnSpan").unwrap_or(1.0).max(1.0) as usize;
let text = self.story(StoryId(0), ce);
let mut cell = Cell { text, ..Default::default() };
cell.style = ce
.get("AppliedCellStyle")
.map(|r| names::style_name_in(names::CELL_BUILTINS, &unescape_id(r.trim_start_matches("CellStyle/"))))
.filter(|n| n != designcraft_doc::NO_CELL_STYLE)
.unwrap_or_default();
let rs = (ce.num("RowSpan").unwrap_or(1.0).max(1.0) as usize).min(nr - r);
let cs = (ce.num("ColumnSpan").unwrap_or(1.0).max(1.0) as usize).min(nc - c);
let mut cell = t.cell(r, c).cloned().unwrap_or_default();
let cell_style = ce.get("AppliedCellStyle").map(|r| self.cell_style_ref(r)).unwrap_or_else(|| designcraft_doc::NO_CELL_STYLE.into());
if cell_style != designcraft_doc::NO_CELL_STYLE {
designcraft_doc::resolve_cell_style(&self.styles.cell, &cell_style).apply_to(&mut cell);
}
cell.style = if cell_style == designcraft_doc::NO_CELL_STYLE { String::new() } else { cell_style };
let default_para = cell.text.paras.first().cloned().unwrap_or_default();
cell.text = self.story_with_para(StoryId(0), ce, default_para);
if let Some(fc) = ce.get("FillColor") {
cell.fill = self.swatch_ref(fc);
}
cell.fill_tint = tint(ce.num("FillTint")).unwrap_or(1.0);
if let Some(tint) = tint(ce.num("FillTint")) {
cell.fill_tint = tint;
}
for (i, k) in ["TopInset", "LeftInset", "BottomInset", "RightInset"].iter().enumerate() {
if let Some(v) = ce.num(k) {
cell.insets[i] = v.max(0.0);
}
}
cell.vj = match ce.get("VerticalJustification") {
Some("CenterAlign") => VerticalJustification::Center,
Some("BottomAlign") => VerticalJustification::Bottom,
Some("JustifyAlign") => VerticalJustification::Justify,
_ => VerticalJustification::Top,
};
if let Some(vj) = ce.get("VerticalJustification") {
cell.vj = names::vj_in(vj);
}
cell.rotation = ce.num("RotationAngle").unwrap_or(cell.rotation);
for (i, side) in ["TopEdge", "LeftEdge", "BottomEdge", "RightEdge"].iter().enumerate() {
let base = cell.strokes[i].clone();
cell.strokes[i] = stroke(self, ce, side, &base);
// A positive edge priority records a local cell override, including an
// explicit None stroke. Keep it when this edge meets the table perimeter.
cell.border_overrides[i] = ce.num(&format!("{side}StrokePriority")).is_some_and(|priority| priority > 0.0);
let attrs = self.cell_stroke_attrs(ce, side);
attrs.apply_to(&mut cell.strokes[i]);
cell.stroke_defined[i] |= !attrs.is_empty();
let priority = ce.prop(&format!("{side}StrokePriority")).and_then(|v| v.trim().parse::<i32>().ok()).unwrap_or(0);
cell.stroke_priorities[i] = priority;
// Preserve style-defined edges and #102's positive-priority local overrides.
cell.border_overrides[i] |= priority > 0;
}
if let Some(slot) = t.cell_mut(r, c) {
*slot = cell;
@ -1593,7 +1652,7 @@ impl<'r> Importer<'r> {
match n {
Node::El(c) => match c.local() {
"ParagraphStyleRange" => {
let style = c.get("AppliedParagraphStyle").map(|r| self.para_style_ref(r)).unwrap_or_else(|| st::BASIC_PARAGRAPH.into());
let style = c.get("AppliedParagraphStyle").map(|r| self.para_style_ref(r)).unwrap_or_else(|| pf.style.clone());
let para = self.para_attrs(c);
let chars = self.char_attrs(c);
let npf = ParaFormat { style, para, chars: CharAttrs::default(), table: None };

View File

@ -85,7 +85,7 @@ pub fn unescape_id(s: &str) -> String {
pub const PARA_BUILTINS: &[(&str, &str)] = &[("[No Paragraph Style]", "$ID/[No paragraph style]"), ("[Basic Paragraph]", "$ID/NormalParagraphStyle")];
pub const CHAR_BUILTINS: &[(&str, &str)] = &[("[None]", "$ID/[No character style]")];
pub const CELL_BUILTINS: &[(&str, &str)] = &[("[None]", "$ID/[None]")];
pub const TABLE_BUILTINS: &[(&str, &str)] = &[("[Basic Table]", "$ID/[Basic Table]")];
pub const TABLE_BUILTINS: &[(&str, &str)] = &[("[Basic Table]", "$ID/[Basic Table]"), ("[No table style]", "$ID/[No table style]")];
pub const OBJECT_BUILTINS: &[(&str, &str)] =
&[("[None]", "$ID/[None]"), ("[Basic Graphics Frame]", "$ID/[Normal Graphics Frame]"), ("[Basic Text Frame]", "$ID/[Normal Text Frame]")];

View File

@ -928,6 +928,336 @@ fn arabic_story_and_table_directions_survive_idml_export() {
assert_eq!(st.direction, designcraft_doc::TextDirection::RightToLeft);
assert_eq!(st.tables.values().next().unwrap().options.direction, designcraft_doc::TextDirection::RightToLeft);
}
/// Fully synthetic style-only table, including sparse based-on attributes and local overrides.
const CASCADE_STYLES: &str = r#"<idPkg:Styles xmlns:idPkg="http://ns.adobe.com/AdobeInDesign/idml/1.0/packaging">
<RootCellStyleGroup>
<CellStyle Self="CellStyle/$ID/[None]" Name="$ID/[None]"/>
<CellStyle Self="cs-base" Name="Base" FillColor="Color/Brand" FillTint="25"
TopInset="2" LeftInset="3" BottomInset="4" RightInset="5" VerticalJustification="CenterAlign"
TopEdgeStrokeWeight="2" TopEdgeStrokeColor="Color/Brand" TopEdgeStrokeTint="40" TopEdgeStrokeType="StrokeStyle/$ID/Dashed"
LeftEdgeStrokeWeight="3" BottomEdgeStrokeWeight="4" RightEdgeStrokeWeight="5"
AppliedParagraphStyle="ParagraphStyle/CellParagraph"/>
<CellStyle Self="cs-body" Name="Body" FillTint="50"><Properties><BasedOn type="object">cs-base</BasedOn></Properties></CellStyle>
<CellStyle Self="cs-left" Name="Left" FillColor="Color/Black" LeftEdgeStrokeWeight="0"><Properties><BasedOn type="object">cs-body</BasedOn></Properties></CellStyle>
<CellStyle Self="cs-header" Name="Header" FillColor="Color/Paper" BottomEdgeStrokeColor="Color/Brand" BottomEdgeStrokeWeight="6">
<Properties><BasedOn type="object">cs-body</BasedOn></Properties>
</CellStyle>
<CellStyle Self="cs-explicit" Name="Explicit" FillColor="Color/Brand" FillTint="75" TopInset="0"
TopEdgeStrokeColor="Swatch/None" RightEdgeStrokeTint="30"/>
</RootCellStyleGroup>
<RootParagraphStyleGroup><ParagraphStyle Self="ParagraphStyle/CellParagraph" Name="CellParagraph" PointSize="9"/></RootParagraphStyleGroup>
<RootTableStyleGroup>
<TableStyle Self="TableStyle/$ID/[No table style]" Name="$ID/[No table style]"
TopBorderStrokeWeight="7" LeftBorderStrokeWeight="8" BottomBorderStrokeWeight="9" RightBorderStrokeWeight="10"
StartRowFillCount="0" StartRowFillColor="Color/Brand" StartRowFillTint="20" EndRowFillCount="0" EndRowFillColor="Swatch/None"
HeaderRegionSameAsBodyRegion="true" FooterRegionSameAsBodyRegion="true"
LeftColumnRegionSameAsBodyRegion="true" RightColumnRegionSameAsBodyRegion="true"/>
<TableStyle Self="ts-base" Name="BaseTable" BodyRegionCellStyle="cs-body" HeaderRegionCellStyle="cs-header"
LeftColumnRegionCellStyle="cs-left" HeaderRegionSameAsBodyRegion="false" LeftColumnRegionSameAsBodyRegion="false">
<Properties><BasedOn type="string">$ID/[No table style]</BasedOn></Properties>
</TableStyle>
<TableStyle Self="ts-child" Name="Table" SpaceBefore="12" StartRowFillCount="1" EndRowFillCount="1">
<Properties><BasedOn type="object">ts-base</BasedOn></Properties>
</TableStyle>
<TableStyle Self="TableStyle/$ID/[Basic Table]" Name="$ID/[Basic Table]" SpaceAfter="17">
<Properties><BasedOn type="object">ts-child</BasedOn></Properties>
</TableStyle>
</RootTableStyleGroup></idPkg:Styles>"#;
const CASCADE_STORY: &str = r#"<idPkg:Story xmlns:idPkg="http://ns.adobe.com/AdobeInDesign/idml/1.0/packaging">
<Story Self="s1"><ParagraphStyleRange><CharacterStyleRange><Table AppliedTableStyle="ts-child" HeaderRowCount="1" SpaceBefore="19" RightBorderStrokeColor="Color/Brand">
<Row Name="0" MinimumHeight="24"/><Row Name="1" MinimumHeight="24"/>
<Column Name="0" SingleColumnWidth="60"/><Column Name="1" SingleColumnWidth="60"/><Column Name="2" SingleColumnWidth="60"/>
<Cell Name="0:0" AppliedCellStyle="CellStyle/$ID/[None]"><ParagraphStyleRange><CharacterStyleRange><Content>Header left</Content></CharacterStyleRange></ParagraphStyleRange></Cell>
<Cell Name="1:0" AppliedCellStyle="cs-explicit"><ParagraphStyleRange><CharacterStyleRange><Content>Explicit</Content></CharacterStyleRange></ParagraphStyleRange></Cell>
<Cell Name="2:0" AppliedCellStyle="cs-explicit" FillColor="Swatch/None" FillTint="0" LeftInset="0" VerticalJustification="BottomAlign"
TopEdgeStrokeWeight="0" RightEdgeStrokeColor="Swatch/None" RightEdgeStrokePriority="7">
<ParagraphStyleRange AppliedParagraphStyle="ParagraphStyle/$ID/NormalParagraphStyle"><CharacterStyleRange><Content>Local</Content></CharacterStyleRange></ParagraphStyleRange>
</Cell>
<Cell Name="0:1" AppliedCellStyle="CellStyle/$ID/[None]"><ParagraphStyleRange><CharacterStyleRange><Content>Left</Content></CharacterStyleRange></ParagraphStyleRange></Cell>
<Cell Name="1:1"><ParagraphStyleRange><CharacterStyleRange><Content>Body</Content></CharacterStyleRange></ParagraphStyleRange></Cell>
<Cell Name="2:1"><ParagraphStyleRange><CharacterStyleRange><Content>Body right</Content></CharacterStyleRange></ParagraphStyleRange></Cell>
</Table></CharacterStyleRange></ParagraphStyleRange></Story></idPkg:Story>"#;
fn fixture_with_table_styles(styles: &str, story: &str) -> Vec<u8> {
zip_files(&[
("designmap.xml", DESIGNMAP),
("Resources/Graphic.xml", GRAPHIC),
("Resources/Styles.xml", styles),
("Resources/Preferences.xml", PREFS),
("MasterSpreads/MasterSpread_m1.xml", MASTER),
("Spreads/Spread_sp1.xml", SPREAD),
("Stories/Story_s1.xml", story),
])
}
#[test]
fn table_style_cascade_preserves_sparse_edges_and_local_overrides() {
let d = import_idml(&fixture_with_table_styles(CASCADE_STYLES, CASCADE_STORY)).unwrap();
d.check().unwrap();
let table = d.stories.values().flat_map(|s| s.tables.values()).next().unwrap();
assert_eq!((table.nrows(), table.ncols()), (2, 3));
assert_eq!(table.style, "Table");
assert_eq!(table.options.space_before, 19.0);
assert_eq!(std::array::from_fn::<_, 4, _>(|i| table.options.border_for(i).weight), [7.0, 8.0, 9.0, 10.0]);
assert_eq!(table.options.border_for(3).color, "Brand");
let fills = table.options.alt_rows.as_ref().unwrap();
assert_eq!((fills.first, fills.next, fills.first_tint), (1, 1, 0.2));
assert_eq!(fills.first_color, "Brand");
let header = table.cell(0, 0).unwrap();
assert_eq!(header.fill, designcraft_color::swatch::PAPER, "header takes precedence over left region");
assert!(header.style.is_empty(), "a table region does not become AppliedCellStyle");
assert_eq!(header.insets, [2.0, 3.0, 4.0, 5.0]);
assert_eq!(header.vj, designcraft_doc::VerticalJustification::Center);
assert_eq!(header.text.paras[0].style, "CellParagraph");
assert_eq!(header.strokes[0].weight, 2.0);
assert_eq!(header.strokes[0].tint, 0.4);
assert_ne!(header.strokes[0].kind, designcraft_doc::StrokeType::Solid);
assert_eq!(header.strokes[2].weight, 6.0);
assert_eq!(header.border_overrides, [true; 4]);
assert_eq!(header.stroke_defined, [true; 4]);
assert_eq!(header.stroke_priorities, [0; 4]);
let explicit = table.cell(0, 1).unwrap();
assert_eq!(explicit.fill, "Brand");
assert_eq!(explicit.fill_tint, 0.75);
assert_eq!(explicit.insets, [0.0, 3.0, 4.0, 5.0]);
assert_eq!(explicit.strokes[0].weight, 2.0, "color-only override inherits weight");
assert_eq!(explicit.strokes[0].color, designcraft_color::swatch::NONE);
assert_eq!(explicit.strokes[3].weight, 5.0);
assert_eq!(explicit.strokes[3].tint, 0.3);
let local = table.cell(0, 2).unwrap();
assert_eq!(local.fill, designcraft_color::swatch::NONE);
assert_eq!(local.fill_tint, 0.0);
assert_eq!(local.insets[1], 0.0);
assert_eq!(local.vj, designcraft_doc::VerticalJustification::Bottom);
assert_eq!(local.strokes[0].weight, 0.0);
assert_eq!(local.strokes[3].color, designcraft_color::swatch::NONE);
assert_eq!(local.stroke_priorities, [0, 0, 0, 7]);
assert_eq!(local.text.paras[0].style, st::BASIC_PARAGRAPH, "explicit paragraph style wins");
assert_eq!(table.cell(1, 0).unwrap().fill, designcraft_color::swatch::BLACK);
assert_eq!(table.cell(1, 0).unwrap().strokes[1].weight, 0.0);
assert_eq!(table.cell(1, 1).unwrap().fill_tint, 0.5);
assert_eq!(table.cell(1, 2).unwrap().fill, "Brand");
let back = import_idml(&export_idml(&d)).unwrap();
let roundtrip = back.stories.values().flat_map(|s| s.tables.values()).next().unwrap();
for (before, after) in table.cells.iter().zip(&roundtrip.cells) {
assert_eq!(after.strokes, before.strokes);
assert_eq!(after.border_overrides, before.border_overrides);
assert_eq!(after.stroke_priorities, before.stroke_priorities);
assert_eq!(after.style, before.style);
assert_eq!(after.fill, before.fill);
assert_eq!(after.insets, before.insets);
}
for i in 0..4 {
assert_eq!(roundtrip.options.border_for(i), table.options.border_for(i));
}
let style = back.styles.cell.iter().find(|s| s.name == "Explicit").unwrap();
assert_eq!(style.strokes[0].weight, None, "style export keeps absence distinct from zero");
assert_eq!(style.strokes[0].color.as_deref(), Some(designcraft_color::swatch::NONE));
assert_eq!(back.styles.table.iter().find(|s| s.name == "Table").unwrap().based_on.as_deref(), Some("BaseTable"));
}
#[test]
fn table_style_same_as_body_flags_and_editable_basic_table_are_inherited() {
let styles = CASCADE_STYLES.replace(
"Name=\"Table\" SpaceBefore",
"Name=\"Table\" HeaderRegionSameAsBodyRegion=\"true\" LeftColumnRegionSameAsBodyRegion=\"true\" SpaceBefore",
);
let story = CASCADE_STORY.replace("AppliedTableStyle=\"ts-child\"", "AppliedTableStyle=\"TableStyle/$ID/[Basic Table]\"");
let d = import_idml(&fixture_with_table_styles(&styles, &story)).unwrap();
let table = d.stories.values().flat_map(|s| s.tables.values()).next().unwrap();
assert!(table.style.is_empty());
assert_eq!(table.options.space_after, 17.0);
assert_eq!(table.cell(0, 0).unwrap().fill, "Brand");
assert_eq!(table.cell(0, 0).unwrap().strokes[2].weight, 4.0);
assert_eq!(table.cell(1, 0).unwrap().fill, "Brand");
assert_eq!(table.cell(1, 0).unwrap().strokes[1].weight, 3.0);
}
#[test]
fn table_style_cycles_and_missing_bases_terminate_without_losing_nearest_values() {
let styles = r#"<idPkg:Styles xmlns:idPkg="http://ns.adobe.com/AdobeInDesign/idml/1.0/packaging">
<RootCellStyleGroup>
<CellStyle Self="CellStyle/A" Name="A" TopEdgeStrokeWeight="0"><Properties><BasedOn type="object">CellStyle/B</BasedOn></Properties></CellStyle>
<CellStyle Self="CellStyle/B" Name="B" TopEdgeStrokeColor="Color/Brand"><Properties><BasedOn type="object">CellStyle/A</BasedOn></Properties></CellStyle>
<CellStyle Self="CellStyle/Missing" Name="Missing" LeftInset="0"><Properties><BasedOn type="object">CellStyle/Unknown</BasedOn></Properties></CellStyle>
</RootCellStyleGroup><RootTableStyleGroup>
<TableStyle Self="ts-child" Name="A" BodyRegionCellStyle="CellStyle/A"><Properties><BasedOn type="object">TableStyle/B</BasedOn></Properties></TableStyle>
<TableStyle Self="TableStyle/B" Name="B" SpaceAfter="20"><Properties><BasedOn type="object">ts-child</BasedOn></Properties></TableStyle>
</RootTableStyleGroup></idPkg:Styles>"#;
let d = import_idml(&fixture_with_table_styles(styles, CASCADE_STORY)).unwrap();
let table = d.stories.values().flat_map(|s| s.tables.values()).next().unwrap();
assert_eq!(table.options.space_after, 20.0);
assert_eq!(table.cell(1, 1).unwrap().strokes[0].weight, 0.0);
assert_eq!(table.cell(1, 1).unwrap().strokes[0].color, "Brand");
let missing = designcraft_doc::resolve_cell_style(&d.styles.cell, "Missing");
assert_eq!(missing.inset_overrides[1], Some(0.0));
}
#[test]
fn table_local_alternating_fill_off_survives_named_style_roundtrip() {
let styles = CASCADE_STYLES.replace(
"Name=\"Table\" SpaceBefore",
"Name=\"Table\" StartColumnFillCount=\"1\" EndColumnFillCount=\"1\" StartColumnFillColor=\"Color/Brand\" SpaceBefore",
);
let story = CASCADE_STORY.replace("HeaderRowCount=\"1\"", "HeaderRowCount=\"1\" StartRowFillCount=\"0\" StartColumnFillCount=\"0\"");
let document = import_idml(&fixture_with_table_styles(&styles, &story)).unwrap();
for document in [&document, &import_idml(&export_idml(&document)).unwrap()] {
let table = document.stories.values().flat_map(|s| s.tables.values()).next().unwrap();
assert!(table.options.alt_rows.is_none());
assert!(table.options.alt_cols.is_none());
}
}
#[test]
fn table_local_fill_counts_enable_inherited_disabled_pattern_colors() {
let story =
CASCADE_STORY.replace("AppliedTableStyle=\"ts-child\"", "AppliedTableStyle=\"ts-base\" StartRowFillCount=\"1\" EndRowFillCount=\"1\"");
let document = import_idml(&fixture_with_table_styles(CASCADE_STYLES, &story)).unwrap();
let table = document.stories.values().flat_map(|s| s.tables.values()).next().unwrap();
let fills = table.options.alt_rows.as_ref().unwrap();
assert_eq!((fills.first, fills.next), (1, 1));
assert_eq!(fills.first_color, "Brand");
assert_eq!(fills.first_tint, 0.2);
}
#[test]
fn table_absent_and_explicit_default_edges_remain_distinct_on_idml_roundtrip() {
let story = CASCADE_STORY
.replace("AppliedTableStyle=\"ts-child\"", "AppliedTableStyle=\"Missing\"")
.replace("Name=\"1:1\"", "Name=\"1:1\" TopEdgeStrokeWeight=\"1\" TopEdgeStrokePriority=\"0\"");
let document = import_idml(&fixture_with_table_styles(STYLES, &story)).unwrap();
for document in [&document, &import_idml(&export_idml(&document)).unwrap()] {
let table = document.stories.values().flat_map(|s| s.tables.values()).next().unwrap();
assert_eq!(table.cell(1, 0).unwrap().stroke_defined, [false; 4]);
assert_eq!(table.cell(1, 1).unwrap().stroke_defined, [true, false, false, false]);
assert_eq!(table.cell(1, 1).unwrap().border_overrides, [false; 4]);
assert_eq!(table.cell(1, 1).unwrap().stroke_priorities, [0; 4]);
}
}
#[test]
fn multiline_table_and_cell_style_bases_resolve() {
let styles = CASCADE_STYLES.replace(">cs-base</BasedOn>", ">\n cs-base \n</BasedOn>").replace(">ts-base</BasedOn>", ">\n ts-base \n</BasedOn>");
let document = import_idml(&fixture_with_table_styles(&styles, CASCADE_STORY)).unwrap();
let table = document.stories.values().flat_map(|s| s.tables.values()).next().unwrap();
assert_eq!(table.cell(1, 1).unwrap().strokes[0].weight, 2.0);
assert_eq!(table.options.border_for(0).weight, 7.0);
}
#[test]
fn legacy_native_documents_export_both_builtin_table_styles_when_missing() {
let story = CASCADE_STORY.replace("AppliedTableStyle=\"ts-child\"", "AppliedTableStyle=\"TableStyle/$ID/[Basic Table]\"");
let mut document = import_idml(&fixture_with_story(&story)).unwrap();
document.styles_mut().table.clear();
let bytes = export_idml(&document);
let mut zip = zip::ZipArchive::new(std::io::Cursor::new(&bytes)).unwrap();
let mut styles = String::new();
std::io::Read::read_to_string(&mut zip.by_name("Resources/Styles.xml").unwrap(), &mut styles).unwrap();
assert_eq!(styles.matches("Self=\"TableStyle/$ID/[Basic Table]\"").count(), 1);
assert_eq!(styles.matches("Self=\"TableStyle/$ID/[No table style]\"").count(), 1);
let back = import_idml(&bytes).unwrap();
assert!(back.styles.table.iter().any(|s| s.name == designcraft_doc::BASIC_TABLE));
}
#[test]
fn hostile_table_spans_are_clamped_before_arithmetic() {
let story = CASCADE_STORY.replace("Name=\"1:1\"", "Name=\"1:1\" RowSpan=\"inf\" ColumnSpan=\"inf\"");
let document = import_idml(&fixture_with_table_styles(CASCADE_STYLES, &story)).unwrap();
document.check().unwrap();
let table = document.stories.values().flat_map(|s| s.tables.values()).next().unwrap();
assert_eq!(table.cell(1, 1).unwrap().row_span, 1);
assert_eq!(table.cell(1, 1).unwrap().col_span, 2);
}
#[test]
fn signed_idml_edge_priorities_roundtrip_without_clamping() {
let story = CASCADE_STORY.replace(
"Name=\"1:1\"",
"Name=\"1:1\" TopEdgeStrokePriority=\"-5\" LeftEdgeStrokePriority=\"-2147483648\" RightEdgeStrokePriority=\"2147483647\"",
);
let document = import_idml(&fixture_with_table_styles(CASCADE_STYLES, &story)).unwrap();
for document in [&document, &import_idml(&export_idml(&document)).unwrap()] {
let table = document.stories.values().flat_map(|s| s.tables.values()).next().unwrap();
assert_eq!(table.cell(1, 1).unwrap().stroke_priorities, [-5, i32::MIN, 0, i32::MAX]);
}
}
#[test]
fn alternating_table_strokes_inherit_and_roundtrip_without_flattening_cell_edges() {
let styles = CASCADE_STYLES.replace(
"Name=\"BaseTable\"",
"Name=\"BaseTable\" StartRowStrokeCount=\"1\" EndRowStrokeCount=\"1\" StartRowStrokeWeight=\"0\" EndRowStrokeWeight=\"0\" StartRowStrokeColor=\"Color/Paper\" EndRowStrokeColor=\"Color/Brand\" StartColumnStrokeCount=\"2\" EndColumnStrokeCount=\"1\" StartColumnStrokeWeight=\"1\" EndColumnStrokeWeight=\"2\" EndColumnLineStyle=\"StrokeStyle/$ID/Dotted\" SkipFirstAlternatingStrokeColumns=\"1\"",
).replace("Name=\"Table\"", "Name=\"Table\" StartRowStrokeTint=\"25\"");
let story = CASCADE_STORY.replace("HeaderRowCount=\"1\"", "HeaderRowCount=\"1\" EndRowStrokeColor=\"Swatch/None\"");
let document = import_idml(&fixture_with_table_styles(&styles, &story)).unwrap();
let before = document.stories.values().flat_map(|s| s.tables.values()).next().unwrap();
let row = before.options.row_strokes.as_ref().unwrap();
assert_eq!((row.first, row.next), (1, 1));
assert_eq!(row.first_stroke.tint, 0.25);
assert_eq!(row.first_stroke.color, designcraft_color::swatch::PAPER);
assert_eq!(row.next_stroke.color, designcraft_color::swatch::NONE);
assert!(!row.uniform_stroke().unwrap().is_visible(), "zero patterns are uniform even when their colors differ");
let column = before.options.column_strokes.as_ref().unwrap();
assert_eq!((column.first, column.next, column.skip_first), (2, 1, 1));
assert_eq!(column.next_stroke.kind, designcraft_doc::StrokeType::Dotted);
assert!(column.uniform_stroke().is_none(), "nonuniform/skipped patterns are preserved without guessing their phase");
assert_eq!(before.cell(0, 0).unwrap().strokes[2].weight, 6.0, "cell styles remain higher-precedence edges");
let back = import_idml(&export_idml(&document)).unwrap();
let after = back.stories.values().flat_map(|s| s.tables.values()).next().unwrap();
assert_eq!(after.options.row_strokes, before.options.row_strokes);
assert_eq!(after.options.column_strokes, before.options.column_strokes);
let style = back.styles.table.iter().find(|s| s.name == "Table").unwrap();
assert_eq!(style.row_strokes.first_stroke.weight, None);
assert_eq!(style.row_strokes.first_stroke.tint, Some(0.25));
}
#[test]
fn alternating_table_strokes_local_off_overrides_named_style_on_roundtrip() {
let styles = CASCADE_STYLES.replace(
"Name=\"Table\"",
"Name=\"Table\" StartRowStrokeCount=\"1\" EndRowStrokeCount=\"1\" StartColumnStrokeCount=\"1\" EndColumnStrokeCount=\"1\"",
);
let story = CASCADE_STORY.replace(
"HeaderRowCount=\"1\"",
"HeaderRowCount=\"1\" StartRowStrokeCount=\"0\" EndRowStrokeCount=\"0\" StartRowStrokeWeight=\"4\" EndRowStrokeColor=\"Color/Brand\" StartColumnStrokeCount=\"0\" EndColumnStrokeCount=\"0\" SkipFirstAlternatingStrokeColumns=\"3\" EndColumnLineStyle=\"StrokeStyle/$ID/Dotted\"",
);
let document = import_idml(&fixture_with_table_styles(&styles, &story)).unwrap();
for document in [&document, &import_idml(&export_idml(&document)).unwrap()] {
let table = document.stories.values().flat_map(|s| s.tables.values()).next().unwrap();
let row = table.options.row_strokes.as_ref().unwrap();
let column = table.options.column_strokes.as_ref().unwrap();
assert_eq!((row.first, row.next, column.first, column.next), (0, 0, 0, 0));
assert!(row.uniform_stroke().is_none());
assert!(column.uniform_stroke().is_none());
assert_eq!(row.first_stroke.weight, 4.0);
assert_eq!(row.next_stroke.color, "Brand");
assert_eq!(column.skip_first, 3);
assert_eq!(column.next_stroke.kind, designcraft_doc::StrokeType::Dotted);
}
}
#[test]
fn complete_uniform_native_style_fields_keep_legacy_identity_on_idml_roundtrip() {
let mut document = import_idml(&fixture()).unwrap();
let stroke = designcraft_doc::CellStroke { weight: 3.0, tint: 0.5, ..Default::default() };
document.styles_mut().cell.push(designcraft_doc::CellStyle {
name: "Uniform".into(),
insets: Some([5.0; 4]),
stroke: Some(stroke.clone()),
..Default::default()
});
document.styles_mut().table.push(designcraft_doc::TableStyle { name: "Uniform".into(), border: Some(stroke.clone()), ..Default::default() });
let back = import_idml(&export_idml(&document)).unwrap();
let cell = back.styles.cell.iter().find(|s| s.name == "Uniform").unwrap();
assert_eq!(cell.insets, Some([5.0; 4]));
assert_eq!(cell.stroke.as_ref(), Some(&stroke));
assert!(cell.strokes.iter().all(designcraft_doc::CellStrokeAttrs::is_empty));
let table = back.styles.table.iter().find(|s| s.name == "Uniform").unwrap();
assert_eq!(table.border.as_ref(), Some(&stroke));
assert!(table.borders.iter().all(designcraft_doc::CellStrokeAttrs::is_empty));
}
#[test]
fn imports_column_rules_from_frames_and_object_styles() {

View File

@ -171,11 +171,10 @@ pub fn check(crates: &[Crate]) -> Vec<Violation> {
match classify(&d.name) {
// Unregistered deps are reported on their own entry.
None => {}
Some(Class::Testkit) => {
if d.kind != DepKind::Dev {
Some(Class::Testkit) if d.kind != DepKind::Dev => {
out.push(Violation::TestkitAsNormalDep { krate: c.name.clone() });
}
}
Some(Class::Testkit) => {}
Some(dc) => {
let to = dc.layer().unwrap_or(u8::MAX);
if to >= layer && !(to == layer && intra_layer_allowed(&c.name, &d.name)) {