//! Style resolution: document defaults → paragraph style chain → character style chain → direct //! formatting, into concrete values for layout. use serde::{Deserialize, Serialize}; use crate::props::{ Align, Border, Borders, CharProps, Highlight, LineSpacing, NumRef, ParaProps, Rgb, TabAlign, TabStop, TextColor, Underline, VertAlign, }; use crate::styles::{StyleKind, StyleSheet}; /// Concrete character formatting. #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct ResolvedChar { pub font: String, pub size: f32, pub bold: bool, pub italic: bool, pub underline: Underline, pub underline_color: Option, pub strike: bool, pub double_strike: bool, pub color: TextColor, pub highlight: Option, pub shading: Option, /// Visible character border only (`nil`/`none` resolve to `None`). pub border: Option, pub vert_align: VertAlign, pub caps: bool, pub small_caps: bool, pub hidden: bool, pub spacing: f32, pub scale: f32, pub position: f32, pub outline: bool, pub shadow: bool, pub emboss: bool, pub engrave: bool, /// WordArt text effects (sanitized; `None` when there are none). #[serde(default, skip_serializing_if = "Option::is_none")] pub text_effects: Option>, pub link: Option, pub ins: Option, pub del: Option, /// The revision of a tracked formatting change (`w:rPrChange`) of the run. pub fmt: Option, pub no_proof: bool, pub lang: Option, /// Right-to-left run (`w:rtl`). pub rtl: bool, /// The whole run uses the complex-script properties (`w:cs`). pub cs: bool, /// Complex-script font, size, bold and italic (Arabic, Persian, Hebrew… text). pub font_cs: String, /// East Asian font (Chinese, Japanese and Korean text), when one is set. #[serde(skip_serializing_if = "Option::is_none")] pub font_ea: Option, pub size_cs: f32, pub bold_cs: bool, pub italic_cs: bool, } impl ResolvedChar { fn from(c: &CharProps) -> ResolvedChar { ResolvedChar { font: c.font.clone().unwrap_or_else(|| crate::styles::BODY_FONT.to_string()), size: c.size.unwrap_or(11.0).clamp(1.0, 1638.0), bold: c.bold.unwrap_or(false), italic: c.italic.unwrap_or(false), underline: c.underline.unwrap_or_default(), underline_color: c.underline_color, strike: c.strike.unwrap_or(false), double_strike: c.double_strike.unwrap_or(false), color: c.color.unwrap_or_default(), highlight: c.highlight.and_then(Highlight::rgb), shading: c.shading, border: c.border.filter(Border::is_visible), vert_align: c.vert_align.unwrap_or_default(), caps: c.caps.unwrap_or(false), small_caps: c.small_caps.unwrap_or(false), hidden: c.hidden.unwrap_or(false), spacing: c.spacing.unwrap_or(0.0).clamp(-100.0, 100.0), scale: c.scale.unwrap_or(100.0).clamp(1.0, 600.0), position: c.position.unwrap_or(0.0).clamp(-800.0, 800.0), outline: c.outline.unwrap_or(false), shadow: c.shadow.unwrap_or(false), emboss: c.emboss.unwrap_or(false), engrave: c.engrave.unwrap_or(false), text_effects: c.text_effects.as_deref().map(crate::wordart::TextEffects::sanitized).filter(|t| !t.is_empty()).map(Box::new), link: c.link.clone(), ins: c.ins, del: c.del, fmt: c.fmt_change.as_ref().map(|f| f.rev), no_proof: c.no_proof.unwrap_or(false), lang: c.lang.clone(), rtl: c.rtl.unwrap_or(false), cs: c.cs.unwrap_or(false), font_cs: c.font_cs.clone().or_else(|| c.font.clone()).unwrap_or_else(|| crate::styles::BODY_FONT.to_string()), font_ea: c.font_ea.clone(), size_cs: c.size_cs.or(c.size).unwrap_or(11.0).clamp(1.0, 1638.0), bold_cs: c.bold_cs.or(c.bold).unwrap_or(false), italic_cs: c.italic_cs.or(c.italic).unwrap_or(false), } } /// The formatting complex-script text in this run is drawn with: the complex-script font, /// size, bold and italic in place of the others (Word formats Arabic, Persian and Hebrew /// characters, and every character of an `rtl`/`cs` run, this way). pub fn complex(&self) -> ResolvedChar { ResolvedChar { font: self.font_cs.clone(), size: self.size_cs, bold: self.bold_cs, italic: self.italic_cs, ..self.clone() } } /// Does a character of this run use the complex-script properties? pub fn uses_complex(&self, c: char) -> bool { self.rtl || self.cs || crate::bidi::is_complex_script(c) } /// The size glyphs are drawn at (super/subscript shrink to ~2/3). pub fn draw_size(&self) -> f32 { if self.vert_align == VertAlign::Baseline { self.size } else { self.size * 0.65 } } /// Baseline shift, points (positive = up). pub fn baseline_shift(&self) -> f32 { self.position + match self.vert_align { VertAlign::Baseline => 0.0, VertAlign::Superscript => self.size * 0.33, VertAlign::Subscript => -self.size * 0.14, } } } /// Concrete paragraph formatting. #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct ResolvedPara { pub style: String, pub align: Align, pub indent_left: f32, pub indent_right: f32, pub indent_first: f32, pub space_before: f32, pub space_after: f32, pub line_spacing: LineSpacing, pub contextual_spacing: bool, /// Left/right indents are inside/outside: they swap on even pages. pub mirror_indents: bool, pub keep_next: bool, pub keep_lines: bool, pub page_break_before: bool, pub widow_control: bool, pub outline_level: Option, pub numbering: Option, pub tabs: Vec, pub shading: Option, pub borders: Option, pub bidi: bool, pub drop_cap: u8, pub suppress_hyphens: bool, pub suppress_line_numbers: bool, /// Asian typography (see `ParaProps`): East Asian line-breaking rules. pub kinsoku: bool, /// Latin words wrap whole (`false`: they may break at any character). pub word_wrap: bool, pub overflow_punct: bool, pub top_line_punct: bool, pub auto_space_de: bool, pub auto_space_dn: bool, /// Lines snap to the document grid, when the section has one. pub snap_to_grid: bool, /// Indents in characters (left, right, first line; 0 = in points), which win over the /// point values; see [`ResolvedPara::set_char_unit`]. #[serde(default)] pub indent_chars: [f32; 3], } const MAX_INDENT: f32 = 1584.0; // 22" impl ResolvedPara { /// Visual (left, right) indents. Indents, like alignment, are logical: `indent_left` is the /// start (leading) edge, which is the right edge of a right-to-left paragraph (ISO 29500 /// `w:ind/@w:start`, read from `w:left` too). pub fn visual_indents(&self) -> (f32, f32) { if self.bidi { (self.indent_right, self.indent_left) } else { (self.indent_left, self.indent_right) } } /// Turn the indents given in characters into points, one character being `size` points: the /// size of the paragraph mark's font (as Word measures `w:firstLineChars` and friends). pub fn set_char_unit(&mut self, size: f32) { let size = if size.is_finite() { size.clamp(1.0, 1638.0) } else { 12.0 }; let [l, r, f] = self.indent_chars; for (chars, pts) in [(l, &mut self.indent_left), (r, &mut self.indent_right), (f, &mut self.indent_first)] { if chars != 0.0 { *pts = (chars * size).clamp(-MAX_INDENT, MAX_INDENT); } } } } impl ResolvedPara { fn from(p: &ParaProps, style: &str) -> ResolvedPara { let clampi = |v: Option| v.unwrap_or(0.0).clamp(-MAX_INDENT, MAX_INDENT); let mut tabs: Vec = p.tabs.clone().unwrap_or_default().into_iter().filter(|t| t.align != TabAlign::Clear && t.pos.is_finite()).collect(); tabs.sort_by(|a, b| a.pos.total_cmp(&b.pos)); ResolvedPara { style: style.to_string(), align: p.align.unwrap_or_default(), indent_left: clampi(p.indent_left), indent_right: clampi(p.indent_right), indent_first: clampi(p.indent_first), space_before: p.space_before.unwrap_or(0.0).clamp(0.0, 1584.0), space_after: p.space_after.unwrap_or(0.0).clamp(0.0, 1584.0), line_spacing: match p.line_spacing.unwrap_or_default() { LineSpacing::Multiple(m) => LineSpacing::Multiple(if m.is_finite() { m.clamp(0.06, 132.0) } else { 1.0 }), LineSpacing::AtLeast(v) => LineSpacing::AtLeast(if v.is_finite() { v.clamp(0.0, 1584.0) } else { 0.0 }), LineSpacing::Exactly(v) => LineSpacing::Exactly(if v.is_finite() { v.clamp(0.7, 1584.0) } else { 12.0 }), }, contextual_spacing: p.contextual_spacing.unwrap_or(false), mirror_indents: p.mirror_indents.unwrap_or(false), keep_next: p.keep_next.unwrap_or(false), keep_lines: p.keep_lines.unwrap_or(false), page_break_before: p.page_break_before.unwrap_or(false), widow_control: p.widow_control.unwrap_or(true), outline_level: p.outline_level.filter(|l| *l < 9), numbering: p.numbering.filter(|n| n.num != 0), tabs, shading: p.shading, borders: p.borders.filter(Borders::any_visible), bidi: p.bidi.unwrap_or(false), drop_cap: p.drop_cap.unwrap_or(0).min(10), suppress_hyphens: p.suppress_hyphens.unwrap_or(false), suppress_line_numbers: p.suppress_line_numbers.unwrap_or(false), kinsoku: p.kinsoku.unwrap_or(true), word_wrap: p.word_wrap.unwrap_or(true), overflow_punct: p.overflow_punct.unwrap_or(true), top_line_punct: p.top_line_punct.unwrap_or(false), auto_space_de: p.auto_space_de.unwrap_or(true), auto_space_dn: p.auto_space_dn.unwrap_or(true), snap_to_grid: p.snap_to_grid.unwrap_or(true), indent_chars: [p.indent_left_chars, p.indent_right_chars, p.indent_first_chars] .map(|c| c.filter(|c| c.is_finite()).unwrap_or(0.0).clamp(-MAX_CHARS, MAX_CHARS)), } } } /// The largest indent in characters (Word allows indents up to 22"). const MAX_CHARS: f32 = 1584.0; impl StyleSheet { /// Paragraph properties after defaults and the style chain (without direct formatting). pub fn para_style_props(&self, style: &str) -> (ParaProps, CharProps) { self.para_style_props_over(style, self.default_para.clone(), self.default_chr.clone()) } /// The paragraph style chain of `style` applied over `pp` / `cp`. fn para_style_props_over(&self, style: &str, mut pp: ParaProps, mut cp: CharProps) -> (ParaProps, CharProps) { for s in self.chain(style) { if s.kind == StyleKind::Paragraph { let mut sp = s.para.clone(); // Tabs accumulate along the chain (with Clear removing inherited stops). if let (Some(base), Some(add)) = (pp.tabs.clone(), sp.tabs.clone()) { let mut merged: Vec = base.into_iter().filter(|t| !add.iter().any(|a| (a.pos - t.pos).abs() < 0.5)).collect(); merged.extend(add); sp.tabs = Some(merged); } pp.overlay(&sp); cp.overlay(&s.chr); } } pp.style = None; (pp, cp) } /// Resolve a paragraph's properties. pub fn resolve_para(&self, p: &ParaProps) -> ResolvedPara { self.resolve_para_in(p, None) } /// Resolve a paragraph's properties; `table` is the paragraph formatting of the table style of /// the table the paragraph is in. The order is that of ECMA-376 §17.7.2: document defaults, the /// table style, the paragraph style chain, direct formatting. So the table style replaces /// document defaults (Table Grid's single spacing beats the defaults' 8 pt after), but any /// property the paragraph style chain sets, Normal included, beats the table style. pub fn resolve_para_in(&self, p: &ParaProps, table: Option<&ParaProps>) -> ResolvedPara { let style = p.style.clone().unwrap_or_else(|| "Normal".into()); let mut defaults = self.default_para.clone(); if let Some(t) = table { defaults.overlay(&ParaProps { style: None, ..t.clone() }); } let (mut base, _) = self.para_style_props_over(&style, defaults, CharProps::default()); let mut direct = p.clone(); if let (Some(b), Some(d)) = (base.tabs.clone(), direct.tabs.clone()) { let mut merged: Vec = b.into_iter().filter(|t| !d.iter().any(|a| (a.pos - t.pos).abs() < 0.5)).collect(); merged.extend(d); direct.tabs = Some(merged); } base.overlay(&direct); let mut rp = ResolvedPara::from(&base, &style); // Indents in characters: measured in the style's font size here; layout measures them in // the paragraph mark's (`set_char_unit`), which direct formatting may change. if rp.indent_chars.iter().any(|c| *c != 0.0) { rp.set_char_unit(self.resolve_char(Some(&style), &CharProps::default()).size); } rp } /// Resolve character formatting for a run in a paragraph of style `para_style`. pub fn resolve_char(&self, para_style: Option<&str>, c: &CharProps) -> ResolvedChar { self.resolve_char_in(para_style, None, c) } /// Resolve character formatting for a run in a paragraph of style `para_style`; `table` is the /// run formatting of the table style (with the cell's conditional formatting) of the table the /// paragraph is in. Order: document defaults, the table style, the paragraph style chain, the /// character style chain, direct formatting (ECMA-376 §17.7.2). pub fn resolve_char_in(&self, para_style: Option<&str>, table: Option<&CharProps>, c: &CharProps) -> ResolvedChar { let mut defaults = self.default_chr.clone(); if let Some(t) = table { defaults.overlay(&CharProps { style: None, ..t.clone() }); } let (_, mut cp) = self.para_style_props_over(para_style.unwrap_or("Normal"), ParaProps::default(), defaults); if let Some(cs) = c.style.as_deref() { for s in self.chain(cs) { if s.kind == StyleKind::Character || s.kind == StyleKind::Paragraph { cp.overlay(&s.chr); } } } let mut direct = c.clone(); direct.style = None; cp.overlay(&direct); ResolvedChar::from(&cp) } } #[cfg(test)] mod tests { use super::*; #[test] fn heading_resolves() { let s = StyleSheet::builtin(); let p = s.resolve_para(&ParaProps { style: Some("Heading1".into()), ..Default::default() }); assert!(p.keep_next); assert_eq!(p.outline_level, Some(0)); assert_eq!(p.space_after, 4.0); let c = s.resolve_char(Some("Heading1"), &CharProps::default()); assert_eq!(c.size, 20.0); assert_eq!(c.font, crate::styles::HEADING_FONT); let c2 = s.resolve_char(Some("Heading1"), &CharProps { size: Some(30.0), ..Default::default() }); assert_eq!(c2.size, 30.0); } #[test] fn char_style_then_direct() { let s = StyleSheet::builtin(); let c = s.resolve_char(None, &CharProps { style: Some("Strong".into()), italic: Some(true), ..Default::default() }); assert!(c.bold && c.italic); assert_eq!(c.size, 12.0); let unknown = s.resolve_char(Some("NoSuchStyle"), &CharProps::default()); assert_eq!(unknown.size, 12.0); } #[test] fn hostile_values_clamped() { let s = StyleSheet::builtin(); let p = s.resolve_para(&ParaProps { indent_left: Some(f32::INFINITY), line_spacing: Some(LineSpacing::Multiple(f32::NAN)), ..Default::default() }); assert_eq!(p.indent_left, MAX_INDENT); assert_eq!(p.line_spacing, LineSpacing::Multiple(1.0)); let c = s.resolve_char(None, &CharProps { size: Some(-4.0), ..Default::default() }); assert_eq!(c.size, 1.0); } /// ECMA-376 §17.7.2 for text in a table: document defaults, then the table style, then the /// paragraph style chain (Normal included), then direct formatting. #[test] fn table_style_sits_between_defaults_and_paragraph_style() { let mut s = StyleSheet::builtin(); s.styles.push(crate::styles::Style { id: "Spaced".into(), based_on: Some("Normal".into()), para: ParaProps { space_after: Some(12.0), ..Default::default() }, chr: CharProps { color: Some(TextColor::Rgb(Rgb(0, 0, 0xFF))), ..Default::default() }, ..Default::default() }); let table_p = ParaProps { space_after: Some(0.0), line_spacing: Some(LineSpacing::Multiple(1.0)), ..Default::default() }; let red = TextColor::Rgb(Rgb(0xC0, 0, 0)); let table_c = CharProps { bold: Some(true), color: Some(red), size: Some(9.0), ..Default::default() }; // Document defaults alone (outside a table). let out = s.resolve_para(&ParaProps::default()); assert_eq!((out.space_after, out.line_spacing), (8.0, LineSpacing::Multiple(1.15))); // The table style replaces the defaults. let p = s.resolve_para_in(&ParaProps::default(), Some(&table_p)); assert_eq!((p.space_after, p.line_spacing), (0.0, LineSpacing::Multiple(1.0))); let c = s.resolve_char_in(None, Some(&table_c), &CharProps::default()); assert!(c.bold); assert_eq!((c.color, c.size), (red, 9.0)); // A paragraph style beats it, for what the style sets only. let spaced = ParaProps { style: Some("Spaced".into()), ..Default::default() }; let p = s.resolve_para_in(&spaced, Some(&table_p)); assert_eq!((p.space_after, p.line_spacing), (12.0, LineSpacing::Multiple(1.0))); let c = s.resolve_char_in(Some("Spaced"), Some(&table_c), &CharProps::default()); assert!(c.bold); assert_eq!((c.color, c.size), (TextColor::Rgb(Rgb(0, 0, 0xFF)), 9.0)); // A character style and direct formatting beat both. let c = s.resolve_char_in(Some("Spaced"), Some(&table_c), &CharProps { style: Some("Emphasis".into()), bold: Some(false), ..Default::default() }); assert!(!c.bold && c.italic); let p = s.resolve_para_in(&ParaProps { space_after: Some(3.0), ..spaced }, Some(&table_p)); assert_eq!(p.space_after, 3.0); // Normal setting a property itself beats the table style too (Word draws such rows tall). if let Some(n) = s.get_mut("Normal") { n.para.space_after = Some(10.0); n.chr.size = Some(11.0); } assert_eq!(s.resolve_para_in(&ParaProps::default(), Some(&table_p)).space_after, 10.0); assert_eq!(s.resolve_char_in(None, Some(&table_c), &CharProps::default()).size, 11.0); } #[test] fn header_tabs_inherit() { let s = StyleSheet::builtin(); let p = s.resolve_para(&ParaProps { style: Some("Header".into()), tabs: Some(vec![TabStop { pos: 100.0, align: TabAlign::Left, leader: Default::default() }]), ..Default::default() }); assert_eq!(p.tabs.len(), 3); assert_eq!(p.tabs[0].pos, 100.0); } }