Files
designcraft/crates/doc/src/styles.rs
T
Aykut FiratandClaude Opus 5.5 08ce444054 Tables: cell First Baseline offset (Cell Options › Text › First Baseline)
Cells and cell styles had no first-baseline setting, so every cell set its first line by the
Ascent offset (the font's typographic ascender). IDML documents that set FirstBaselineOffset on a
cell style (CapHeight is common, often through a table style's body region) composed every row
taller than the document asked for. With a font whose typographic ascender is a full em (Avenir),
each row grew about 2.6 pt and long tables overset, losing their last rows.

- doc: Cell.first_baseline / first_baseline_min; CellStyle.first_baseline / first_baseline_min,
  applied by CellStyle::apply_to and carried through resolve_cell_style's BasedOn chain.
- idml: FirstBaselineOffset and MinimumFirstBaselineOffset read and written on cells and cell styles.
- compose: a cell's text frame uses the cell's first baseline offset and minimum.
- engine: table.setCell, style.cell.create/edit take firstBaseline / firstBaselineMin; table.get
  reports them; style.cell.create fromSelection captures them.
- tests: import through BasedOn and a table style's body region, a cell's own override, IDML round
  trip; composer cap height vs ascent and a fixed minimum; command parameters and validation.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-17 15:07:46 -04:00

1026 lines
40 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! Paragraph, character and object styles with based-on inheritance.
use serde::{Deserialize, Serialize};
use crate::attrs::{CharAttrs, CharProps, ParaAttrs, ParaProps};
use crate::item::{Effects, Fill, Item, Stroke, TextFrameOptions};
use crate::story::{BASIC_PARAGRAPH, CharFormat, NO_CHAR_STYLE, ParaFormat};
pub const NO_PARA_STYLE: &str = "[No Paragraph Style]";
pub const BASIC_GRAPHICS_FRAME: &str = "[Basic Graphics Frame]";
pub const BASIC_TEXT_FRAME: &str = "[Basic Text Frame]";
pub const NO_OBJECT_STYLE: &str = "[None]";
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ParagraphStyle {
/// Unique name; groups are expressed as `Group/Name`.
pub name: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub based_on: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub next_style: Option<String>,
#[serde(default)]
pub para: ParaAttrs,
#[serde(default)]
pub chars: CharAttrs,
#[serde(default, skip_serializing_if = "String::is_empty")]
pub shortcut: String,
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct CharacterStyle {
pub name: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub based_on: Option<String>,
#[serde(default)]
pub chars: CharAttrs,
#[serde(default, skip_serializing_if = "String::is_empty")]
pub shortcut: String,
}
/// Object style: each attribute group is optional ("not included in style" when `None`).
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase", default)]
pub struct ObjectStyle {
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<Fill>,
#[serde(skip_serializing_if = "Option::is_none")]
pub stroke: Option<Stroke>,
#[serde(skip_serializing_if = "Option::is_none")]
pub paragraph_style: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub text_frame: Option<TextFrameOptions>,
/// Effects (drop shadow, feather, glows…). An item using the style follows them until its own
/// effects differ from the style's (a local override).
#[serde(skip_serializing_if = "Option::is_none")]
pub effects: Option<Effects>,
}
impl ObjectStyle {
/// Gives `it` the fill, stroke, effects and text frame options this style sets.
pub fn apply_to(&self, it: &mut Item) {
if let Some(f) = &self.fill {
it.fill = f.clone();
}
if let Some(st) = &self.stroke {
it.stroke = st.clone();
}
if let Some(e) = &self.effects {
it.effects = e.clone();
}
if let (Some(tf), Some(o)) = (it.text_frame_mut(), &self.text_frame) {
tf.options = o.clone();
}
}
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct Styles {
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub composite_fonts: Vec<crate::cjk::CompositeFont>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub mojikumi_tables: Vec<crate::cjk::MojikumiTable>,
/// Export Tagging per style (`p:Name` or `c:Name`): the HTML tag and class for EPUB/HTML.
#[serde(default, skip_serializing_if = "std::collections::BTreeMap::is_empty")]
pub export_tags: std::collections::BTreeMap<String, ExportTag>,
pub paragraph: Vec<ParagraphStyle>,
pub character: Vec<CharacterStyle>,
pub object: Vec<ObjectStyle>,
/// Cell and table styles (Table › Cell Styles / Table Styles).
#[serde(default)]
pub cell: Vec<CellStyle>,
#[serde(default)]
pub table: Vec<TableStyle>,
/// Default styles for new text/frames (the style selected with nothing selected).
pub default_paragraph: String,
pub default_character: String,
pub default_text_frame: String,
pub default_graphic_frame: String,
}
/// A style's Export Tagging.
#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase", default)]
pub struct ExportTag {
/// HTML element (`p`, `h1`…`h6`, `blockquote`, `pre`, `li` for paragraphs; `span`, `em`,
/// `strong`, `code`, `sup`, `sub` for characters). Empty = automatic.
pub tag: String,
/// CSS class (empty = from the style name).
pub class: String,
}
impl Styles {
/// Resolve either a display name or a normalized IDML composite-font reference.
pub fn composite_font(&self, family: &str) -> Option<&crate::cjk::CompositeFont> {
let name = family.strip_prefix("CompositeFont/").unwrap_or(family);
self.composite_fonts.iter().find(|f| f.name == name)
}
/// Export tagging of a paragraph (`character` false) or character style.
pub fn export_tag(&self, name: &str, character: bool) -> Option<&ExportTag> {
self.export_tags.get(&format!("{}:{name}", if character { "c" } else { "p" }))
}
}
impl Default for Styles {
fn default() -> Self {
Styles {
composite_fonts: Vec::new(),
mojikumi_tables: Vec::new(),
export_tags: Default::default(),
paragraph: vec![
ParagraphStyle {
name: NO_PARA_STYLE.into(),
based_on: None,
next_style: None,
para: ParaAttrs::default(),
chars: CharAttrs::default(),
shortcut: String::new(),
},
ParagraphStyle {
name: BASIC_PARAGRAPH.into(),
based_on: None,
next_style: Some(BASIC_PARAGRAPH.into()),
para: ParaAttrs::default(),
chars: CharAttrs::default(),
shortcut: String::new(),
},
],
character: vec![CharacterStyle { name: NO_CHAR_STYLE.into(), based_on: None, chars: CharAttrs::default(), shortcut: String::new() }],
object: vec![
ObjectStyle { name: NO_OBJECT_STYLE.into(), ..Default::default() },
ObjectStyle { name: BASIC_GRAPHICS_FRAME.into(), stroke: Some(Stroke::default()), ..Default::default() },
ObjectStyle {
name: BASIC_TEXT_FRAME.into(),
fill: Some(Fill::none()),
stroke: Some(Stroke::none()),
paragraph_style: Some(BASIC_PARAGRAPH.into()),
..Default::default()
},
],
cell: vec![CellStyle { name: NO_CELL_STYLE.into(), ..Default::default() }],
table: vec![TableStyle { name: BASIC_TABLE.into(), ..Default::default() }],
default_paragraph: BASIC_PARAGRAPH.into(),
default_character: NO_CHAR_STYLE.into(),
default_text_frame: BASIC_TEXT_FRAME.into(),
default_graphic_frame: BASIC_GRAPHICS_FRAME.into(),
}
}
}
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>,
#[serde(skip_serializing_if = "Option::is_none")]
pub gap_color: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub gap_tint: Option<f32>,
}
impl CellStrokeAttrs {
pub fn is_empty(&self) -> bool {
self.weight.is_none()
&& self.color.is_none()
&& self.tint.is_none()
&& self.kind.is_none()
&& self.gap_color.is_none()
&& self.gap_tint.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();
}
if let Some(v) = &self.gap_color {
stroke.gap_color = v.clone();
}
if let Some(v) = self.gap_tint {
stroke.gap_tint = v;
}
}
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();
}
if other.gap_color.is_some() {
self.gap_color = other.gap_color.clone();
}
if other.gap_tint.is_some() {
self.gap_tint = other.gap_tint;
}
}
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()),
gap_color: Some(stroke.gap_color.clone()),
gap_tint: Some(stroke.gap_tint),
}
}
}
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.
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase", default)]
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>,
/// Cell Options › Text › First Baseline, and its minimum.
#[serde(skip_serializing_if = "Option::is_none")]
pub first_baseline: Option<crate::item::FirstBaseline>,
#[serde(skip_serializing_if = "Option::is_none")]
pub first_baseline_min: Option<f64>,
/// 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>,
}
/// A table style: cell styles per region, the border, alternating row fills and spacing.
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase", default)]
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>,
#[serde(skip_serializing_if = "Option::is_none")]
pub footer: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub left_column: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
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")]
pub space_after: Option<f64>,
}
impl CellStyle {
/// Write this style's attributes into `cell`.
pub fn apply_to(&self, cell: &mut crate::table::Cell) {
if let Some(v) = &self.fill {
cell.fill = v.clone();
}
if let Some(v) = self.fill_tint {
cell.fill_tint = v;
}
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.first_baseline {
cell.first_baseline = v;
}
if let Some(v) = self.first_baseline_min {
cell.first_baseline_min = 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 {
p.style = ps.clone();
}
cell.text.rev = cell.text.rev.wrapping_add(1);
}
cell.style = if self.name == NO_CELL_STYLE { String::new() } else { self.name.clone() };
}
}
impl TableStyle {
/// 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;
}
if let Some(v) = self.space_after {
t.options.space_after = v;
}
let (nr, nc) = (t.rows.len(), t.columns.len());
for r in 0..nr {
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 same_or_region = |same, region: &Option<String>| {
if same == Some(true) { self.body.clone() } else { region.clone() }
};
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 style.first_baseline.is_some() {
out.first_baseline = style.first_baseline;
}
if style.first_baseline_min.is_some() {
out.first_baseline_min = style.first_baseline_min;
}
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)
}
pub fn para_mut(&mut self, name: &str) -> Option<&mut ParagraphStyle> {
self.paragraph.iter_mut().find(|s| s.name == name)
}
pub fn char_style(&self, name: &str) -> Option<&CharacterStyle> {
self.character.iter().find(|s| s.name == name)
}
pub fn char_style_mut(&mut self, name: &str) -> Option<&mut CharacterStyle> {
self.character.iter_mut().find(|s| s.name == name)
}
pub fn object_style(&self, name: &str) -> Option<&ObjectStyle> {
self.object.iter().find(|s| s.name == name)
}
/// The effects object style `name` sets, if any (for new items that get a default style).
pub fn object_effects(&self, name: &str) -> Option<&Effects> {
self.object_style(name)?.effects.as_ref()
}
/// The paragraph style chain from root to `name` (cycle-safe): resolution starts at its first.
pub fn para_chain(&self, name: &str) -> Vec<&ParagraphStyle> {
let mut chain = Vec::new();
let mut cur = self.para(name);
while let Some(s) = cur {
if chain.len() >= MAX_DEPTH || chain.iter().any(|c: &&ParagraphStyle| c.name == s.name) {
break;
}
chain.push(s);
cur = s.based_on.as_deref().and_then(|b| self.para(b));
}
chain.reverse();
chain
}
fn char_chain(&self, name: &str) -> Vec<&CharacterStyle> {
let mut chain = Vec::new();
let mut cur = self.char_style(name);
while let Some(s) = cur {
if chain.len() >= MAX_DEPTH || chain.iter().any(|c: &&CharacterStyle| c.name == s.name) {
break;
}
chain.push(s);
cur = s.based_on.as_deref().and_then(|b| self.char_style(b));
}
chain.reverse();
chain
}
/// Resolved paragraph attributes and the paragraph's base character attributes.
pub fn resolve_para(&self, p: &ParaFormat) -> (ParaProps, CharProps) {
let (mut pp, mut cp) = (ParaProps::default(), CharProps::default());
for s in self.para_chain(&p.style) {
pp.apply(&s.para);
cp.apply(&s.chars);
}
pp.apply(&p.para);
cp.apply(&p.chars);
(pp, cp)
}
/// Paragraph-style-only resolution (no local overrides).
pub fn resolve_para_style(&self, name: &str) -> (ParaProps, CharProps) {
self.resolve_para(&ParaFormat { style: name.into(), ..Default::default() })
}
/// Character attributes for a run: paragraph base ← character style chain ← local overrides.
pub fn resolve_char(&self, para_chars: &CharProps, f: &CharFormat) -> CharProps {
let mut cp = para_chars.clone();
for s in self.char_chain(&f.style) {
cp.apply(&s.chars);
}
cp.apply(&f.over);
cp
}
/// The attributes character style `name` sets, with those of its Based On chain beneath them.
pub fn char_style_attrs(&self, name: &str) -> CharAttrs {
let mut a = CharAttrs::default();
for s in self.char_chain(name) {
a.merge(&s.chars);
}
a
}
/// Would making `name` based on `parent` create a cycle?
pub fn para_based_on_cycles(&self, name: &str, parent: &str) -> bool {
name == parent || self.para_chain(parent).iter().any(|s| s.name == name)
}
pub fn char_based_on_cycles(&self, name: &str, parent: &str) -> bool {
name == parent || self.char_chain(parent).iter().any(|s| s.name == name)
}
/// A free name `base`, `base 2`, `base 3`…
pub fn unique_name(existing: impl Fn(&str) -> bool, base: &str) -> String {
if !existing(base) {
return base.to_string();
}
(2..=usize::MAX).map(|i| format!("{base} {i}")).find(|n| !existing(n)).unwrap_or_else(|| format!("{base} copy"))
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn based_on_chain_resolves_in_order() {
let mut st = Styles::default();
st.paragraph.push(ParagraphStyle {
name: "Body".into(),
based_on: Some(BASIC_PARAGRAPH.into()),
next_style: None,
para: ParaAttrs { space_after: Some(6.0), ..Default::default() },
chars: CharAttrs { size: Some(10.0), ..Default::default() },
shortcut: String::new(),
});
st.paragraph.push(ParagraphStyle {
name: "Body First".into(),
based_on: Some("Body".into()),
next_style: Some("Body".into()),
para: ParaAttrs { first_line_indent: Some(0.0), ..Default::default() },
chars: CharAttrs { size: Some(11.0), ..Default::default() },
shortcut: String::new(),
});
let (pp, cp) = st.resolve_para_style("Body First");
assert_eq!(pp.space_after, 6.0);
assert_eq!(cp.size, 11.0);
let f = CharFormat { style: NO_CHAR_STYLE.into(), over: CharAttrs { tracking: Some(10.0), ..Default::default() } };
let r = st.resolve_char(&cp, &f);
assert_eq!((r.size, r.tracking), (11.0, 10.0));
}
#[test]
fn cycles_are_detected_and_safe() {
let mut st = Styles::default();
for (n, b) in [("A", "B"), ("B", "A")] {
st.paragraph.push(ParagraphStyle {
name: n.into(),
based_on: Some(b.into()),
next_style: None,
para: ParaAttrs::default(),
chars: CharAttrs::default(),
shortcut: String::new(),
});
}
let _ = st.resolve_para_style("A"); // terminates
assert!(st.para_based_on_cycles("A", "B"));
assert!(!st.para_based_on_cycles(BASIC_PARAGRAPH, NO_PARA_STYLE));
}
#[test]
fn character_style_attrs_include_its_based_on_chain_only() {
let mut st = Styles::default();
let cs = |name: &str, based_on: Option<&str>, chars| CharacterStyle {
name: name.into(),
based_on: based_on.map(Into::into),
chars,
shortcut: String::new(),
};
st.character.push(cs("Base", None, CharAttrs { font_style: Some("Bold".into()), size: Some(9.0), ..Default::default() }));
st.character.push(cs("Strong", Some("Base"), CharAttrs { size: Some(14.0), ..Default::default() }));
st.character.push(cs("Loop", Some("Loop"), CharAttrs { tracking: Some(5.0), ..Default::default() }));
assert_eq!(st.char_style_attrs("Strong"), CharAttrs { font_style: Some("Bold".into()), size: Some(14.0), ..Default::default() });
assert_eq!(st.char_style_attrs("Loop"), CharAttrs { tracking: Some(5.0), ..Default::default() });
assert!(st.char_style_attrs(NO_CHAR_STYLE).is_empty());
assert!(st.char_style_attrs("Missing").is_empty());
}
#[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 unique_names() {
let names = ["Body", "Body 2"];
assert_eq!(Styles::unique_name(|n| names.contains(&n), "Body"), "Body 3");
assert_eq!(Styles::unique_name(|n| names.contains(&n), "Head"), "Head");
}
}