Skip to main content

rdocx_layout/
style_resolver.rs

1//! Style resolution: cascade styles and generate numbering markers.
2//!
3//! Ports the logic from `crates/rdocx/src/style.rs` since rdocx-layout
4//! depends on rdocx-oxml directly (not rdocx).
5
6use std::collections::{HashMap, HashSet};
7
8use rdocx_oxml::numbering::{CT_Numbering, ST_NumberFormat};
9use rdocx_oxml::properties::{CT_PPr, CT_RPr};
10use rdocx_oxml::styles::{CT_Style, CT_Styles, StyleType};
11
12/// A fully resolved paragraph with merged properties and numbering info.
13#[derive(Debug, Clone)]
14pub struct ResolvedParagraph {
15    /// Merged paragraph properties (style chain + direct formatting).
16    pub ppr: CT_PPr,
17    /// Resolved runs with merged run properties.
18    pub runs: Vec<ResolvedRun>,
19    /// Numbering marker info (if paragraph is part of a list).
20    pub numbering: Option<ResolvedNumbering>,
21}
22
23/// A run with fully resolved properties.
24#[derive(Debug, Clone)]
25pub struct ResolvedRun {
26    /// Merged run properties.
27    pub rpr: CT_RPr,
28    /// Run content items.
29    pub content: Vec<rdocx_oxml::text::RunContent>,
30}
31
32/// Resolved numbering marker for a list paragraph.
33#[derive(Debug, Clone)]
34pub struct ResolvedNumbering {
35    /// The text of the marker (e.g., "1.", "a)", bullet char).
36    pub marker_text: String,
37    /// Run properties for the marker.
38    pub marker_rpr: CT_RPr,
39}
40
41/// Tracks numbering counters across paragraphs.
42pub struct NumberingState {
43    /// (numId, ilvl) → current count
44    counters: HashMap<(u32, u32), u32>,
45}
46
47impl Default for NumberingState {
48    fn default() -> Self {
49        Self::new()
50    }
51}
52
53impl NumberingState {
54    pub fn new() -> Self {
55        NumberingState {
56            counters: HashMap::new(),
57        }
58    }
59
60    /// Advance the counter for the given numId/ilvl and return the new value.
61    /// Also resets any deeper levels.
62    pub fn advance(&mut self, num_id: u32, ilvl: u32, start: u32) -> u32 {
63        let key = (num_id, ilvl);
64        let counter = self.counters.entry(key).or_insert(start - 1);
65        *counter += 1;
66        let value = *counter;
67
68        // Reset deeper levels
69        for deeper in (ilvl + 1)..=8 {
70            self.counters.remove(&(num_id, deeper));
71        }
72
73        value
74    }
75
76    /// Get the current count for a level (without advancing).
77    pub fn current(&self, num_id: u32, ilvl: u32) -> u32 {
78        self.counters.get(&(num_id, ilvl)).copied().unwrap_or(0)
79    }
80}
81
82/// Resolve paragraph properties by walking the style inheritance chain.
83pub fn resolve_paragraph_properties(style_id: Option<&str>, styles: &CT_Styles) -> CT_PPr {
84    let mut effective = CT_PPr::default();
85
86    // 1. Start from docDefaults
87    if let Some(ref defaults) = styles.doc_defaults
88        && let Some(ref ppr) = defaults.ppr
89    {
90        effective.merge_from(ppr);
91    }
92
93    // 2. Walk the basedOn chain
94    if let Some(sid) = style_id {
95        let chain = collect_style_chain(sid, styles);
96        // Apply from most-base to most-derived
97        for style in chain.iter().rev() {
98            if let Some(ref ppr) = style.ppr {
99                effective.merge_from(ppr);
100            }
101        }
102    } else {
103        // Apply the default paragraph style
104        if let Some(default_style) = styles.get_default(StyleType::Paragraph)
105            && let Some(ref ppr) = default_style.ppr
106        {
107            effective.merge_from(ppr);
108        }
109    }
110
111    effective
112}
113
114/// Resolve run properties by walking paragraph and character style chains.
115pub fn resolve_run_properties(
116    para_style_id: Option<&str>,
117    run_style_id: Option<&str>,
118    styles: &CT_Styles,
119) -> CT_RPr {
120    let mut effective = CT_RPr::default();
121
122    // 1. docDefaults run properties
123    if let Some(ref defaults) = styles.doc_defaults
124        && let Some(ref rpr) = defaults.rpr
125    {
126        effective.merge_from(rpr);
127    }
128
129    // 2. paragraph style's rpr (following basedOn chain)
130    let para_sid = para_style_id.or_else(|| {
131        styles
132            .get_default(StyleType::Paragraph)
133            .map(|s| s.style_id.as_str())
134    });
135    if let Some(sid) = para_sid {
136        let chain = collect_style_chain(sid, styles);
137        for style in chain.iter().rev() {
138            if let Some(ref rpr) = style.rpr {
139                effective.merge_from(rpr);
140            }
141        }
142    }
143
144    // 3. character style's rpr (following basedOn chain)
145    if let Some(sid) = run_style_id {
146        let chain = collect_style_chain(sid, styles);
147        for style in chain.iter().rev() {
148            if let Some(ref rpr) = style.rpr {
149                effective.merge_from(rpr);
150            }
151        }
152    }
153
154    effective
155}
156
157/// The paragraph properties a numbering level carries, mainly its indentation.
158///
159/// In the property chain these sit between the paragraph style and direct
160/// formatting, so the indent for a list level applies unless the paragraph
161/// sets its own.
162pub fn level_paragraph_properties(
163    num_id: u32,
164    ilvl: u32,
165    numbering: &CT_Numbering,
166) -> Option<&CT_PPr> {
167    let abs = numbering.get_abstract_num_for(num_id)?;
168    abs.levels.iter().find(|l| l.ilvl == ilvl)?.ppr.as_ref()
169}
170
171/// Generate the marker text for a numbered/bulleted list item.
172pub fn generate_marker(
173    num_id: u32,
174    ilvl: u32,
175    numbering: &CT_Numbering,
176    state: &mut NumberingState,
177) -> Option<ResolvedNumbering> {
178    let abs = numbering.get_abstract_num_for(num_id)?;
179    let lvl = abs.levels.iter().find(|l| l.ilvl == ilvl)?;
180
181    // Counters belong to the abstract definition, not the numbering instance.
182    // Writers such as Pandoc and LibreOffice emit a separate w:num per list
183    // block while pointing them all at one w:abstractNum, and readers are
184    // expected to carry the count across them. Keying on num_id restarted the
185    // sequence at every block, so a two item list rendered as "1." twice.
186    let counter_id = abs.abstract_num_id;
187
188    let num_fmt = lvl.num_fmt.unwrap_or(ST_NumberFormat::Decimal);
189    let start = lvl.start.unwrap_or(1);
190    let lvl_text = lvl.lvl_text.as_deref().unwrap_or("%1.");
191
192    let marker_text = if num_fmt == ST_NumberFormat::Bullet {
193        lvl_text.to_string()
194    } else {
195        let count = state.advance(counter_id, ilvl, start);
196        format_lvl_text(lvl_text, num_id, counter_id, ilvl, count, numbering, state)
197    };
198
199    let marker_rpr = lvl.rpr.clone().unwrap_or_default();
200
201    Some(ResolvedNumbering {
202        marker_text,
203        marker_rpr,
204    })
205}
206
207/// Format level text by substituting %1, %2, etc. with formatted counters.
208fn format_lvl_text(
209    template: &str,
210    num_id: u32,
211    counter_id: u32,
212    current_ilvl: u32,
213    current_count: u32,
214    numbering: &CT_Numbering,
215    state: &NumberingState,
216) -> String {
217    let abs = match numbering.get_abstract_num_for(num_id) {
218        Some(a) => a,
219        None => return template.to_string(),
220    };
221
222    let mut result = template.to_string();
223    for lvl_idx in 0..=8u32 {
224        let placeholder = format!("%{}", lvl_idx + 1);
225        if result.contains(&placeholder) {
226            let count = if lvl_idx == current_ilvl {
227                current_count
228            } else {
229                state.current(counter_id, lvl_idx)
230            };
231            let fmt = abs
232                .levels
233                .iter()
234                .find(|l| l.ilvl == lvl_idx)
235                .and_then(|l| l.num_fmt)
236                .unwrap_or(ST_NumberFormat::Decimal);
237            let formatted = format_number(count, fmt);
238            result = result.replace(&placeholder, &formatted);
239        }
240    }
241    result
242}
243
244/// Format a number according to ST_NumberFormat.
245fn format_number(n: u32, fmt: ST_NumberFormat) -> String {
246    match fmt {
247        ST_NumberFormat::Decimal => n.to_string(),
248        ST_NumberFormat::UpperRoman => to_roman(n, true),
249        ST_NumberFormat::LowerRoman => to_roman(n, false),
250        ST_NumberFormat::UpperLetter => to_letter(n, true),
251        ST_NumberFormat::LowerLetter => to_letter(n, false),
252        ST_NumberFormat::Ordinal => format!("{n}"),
253        ST_NumberFormat::Bullet | ST_NumberFormat::None => String::new(),
254    }
255}
256
257fn to_roman(mut n: u32, upper: bool) -> String {
258    let vals = [
259        (1000, "M"),
260        (900, "CM"),
261        (500, "D"),
262        (400, "CD"),
263        (100, "C"),
264        (90, "XC"),
265        (50, "L"),
266        (40, "XL"),
267        (10, "X"),
268        (9, "IX"),
269        (5, "V"),
270        (4, "IV"),
271        (1, "I"),
272    ];
273    let mut result = String::new();
274    for &(value, numeral) in &vals {
275        while n >= value {
276            result.push_str(numeral);
277            n -= value;
278        }
279    }
280    if upper { result } else { result.to_lowercase() }
281}
282
283fn to_letter(n: u32, upper: bool) -> String {
284    if n == 0 {
285        return String::new();
286    }
287    let base = if upper { b'A' } else { b'a' };
288    let idx = ((n - 1) % 26) as u8;
289    String::from(char::from(base + idx))
290}
291
292/// Collect the style chain from the given style up through basedOn ancestors.
293fn collect_style_chain<'a>(style_id: &str, styles: &'a CT_Styles) -> Vec<&'a CT_Style> {
294    let mut chain = Vec::new();
295    let mut current_id = Some(style_id.to_string());
296    let mut seen = HashSet::new();
297
298    while let Some(ref sid) = current_id {
299        if !seen.insert(sid.clone()) {
300            break; // Prevent cycles
301        }
302        if let Some(style) = styles.get_by_id(sid) {
303            chain.push(style);
304            current_id = style.based_on.clone();
305        } else {
306            break;
307        }
308    }
309
310    chain
311}
312
313#[cfg(test)]
314mod tests {
315    use super::*;
316    use rdocx_oxml::units::{HalfPoint, Twips};
317
318    fn test_styles() -> CT_Styles {
319        let mut styles = CT_Styles::new_default();
320        styles.styles.push(CT_Style {
321            style_id: "Heading2".to_string(),
322            style_type: StyleType::Paragraph,
323            name: Some("heading 2".to_string()),
324            based_on: Some("Heading1".to_string()),
325            next_style: Some("Normal".to_string()),
326            is_default: false,
327            ppr: Some(CT_PPr {
328                space_before: Some(Twips(40)),
329                ..Default::default()
330            }),
331            rpr: Some(CT_RPr {
332                sz: Some(HalfPoint(26)),
333                color: Some("2E74B5".to_string()),
334                ..Default::default()
335            }),
336        });
337        styles
338    }
339
340    #[test]
341    fn resolve_normal_paragraph() {
342        let styles = test_styles();
343        let ppr = resolve_paragraph_properties(Some("Normal"), &styles);
344        assert_eq!(ppr.space_after, Some(Twips(160)));
345    }
346
347    #[test]
348    fn resolve_heading1() {
349        let styles = test_styles();
350        let ppr = resolve_paragraph_properties(Some("Heading1"), &styles);
351        assert_eq!(ppr.keep_next, Some(true));
352        assert_eq!(ppr.space_before, Some(Twips(240)));
353        assert_eq!(ppr.space_after, Some(Twips(0)));
354    }
355
356    #[test]
357    fn resolve_heading2_inherits_heading1() {
358        let styles = test_styles();
359        let ppr = resolve_paragraph_properties(Some("Heading2"), &styles);
360        assert_eq!(ppr.keep_next, Some(true));
361        assert_eq!(ppr.space_before, Some(Twips(40)));
362    }
363
364    #[test]
365    fn resolve_heading2_rpr() {
366        let styles = test_styles();
367        let rpr = resolve_run_properties(Some("Heading2"), None, &styles);
368        assert_eq!(rpr.font_ascii, Some("Calibri".to_string()));
369        assert_eq!(rpr.sz, Some(HalfPoint(26)));
370        assert_eq!(rpr.bold, Some(true));
371        assert_eq!(rpr.color, Some("2E74B5".to_string()));
372    }
373
374    #[test]
375    fn numbering_decimal_marker() {
376        let mut numbering = CT_Numbering::new();
377        let num_id = numbering.add_numbered_list();
378
379        let mut state = NumberingState::new();
380        let marker1 = generate_marker(num_id, 0, &numbering, &mut state).unwrap();
381        assert_eq!(marker1.marker_text, "1.");
382        let marker2 = generate_marker(num_id, 0, &numbering, &mut state).unwrap();
383        assert_eq!(marker2.marker_text, "2.");
384    }
385
386    #[test]
387    fn numbering_bullet_marker() {
388        let mut numbering = CT_Numbering::new();
389        let num_id = numbering.add_bullet_list();
390
391        let mut state = NumberingState::new();
392        let marker = generate_marker(num_id, 0, &numbering, &mut state).unwrap();
393        assert_eq!(marker.marker_text, "\u{2022}");
394    }
395
396    #[test]
397    fn numbering_sub_level_reset() {
398        let mut numbering = CT_Numbering::new();
399        let num_id = numbering.add_numbered_list();
400
401        let mut state = NumberingState::new();
402        // Level 0: 1, 2
403        generate_marker(num_id, 0, &numbering, &mut state);
404        generate_marker(num_id, 0, &numbering, &mut state);
405        // Level 1: a
406        let sub = generate_marker(num_id, 1, &numbering, &mut state).unwrap();
407        assert_eq!(sub.marker_text, "a.");
408        // Back to level 0: 3 — this should reset level 1
409        generate_marker(num_id, 0, &numbering, &mut state);
410        let sub2 = generate_marker(num_id, 1, &numbering, &mut state).unwrap();
411        assert_eq!(sub2.marker_text, "a."); // reset
412    }
413
414    #[test]
415    fn roman_numeral_formatting() {
416        assert_eq!(to_roman(1, true), "I");
417        assert_eq!(to_roman(4, true), "IV");
418        assert_eq!(to_roman(9, true), "IX");
419        assert_eq!(to_roman(14, false), "xiv");
420    }
421
422    #[test]
423    fn letter_formatting() {
424        assert_eq!(to_letter(1, false), "a");
425        assert_eq!(to_letter(26, false), "z");
426        assert_eq!(to_letter(27, false), "a"); // wraps
427        assert_eq!(to_letter(1, true), "A");
428    }
429
430    /// Two numbering instances that share one abstract definition are one
431    /// list and must keep counting.
432    ///
433    /// Pandoc and LibreOffice both emit a separate `w:num` per list block
434    /// pointing at the same `w:abstractNum`. Keying counters on num_id made
435    /// every block restart, so a two item list rendered as "1." twice.
436    #[test]
437    fn shared_abstract_definition_continues_the_count() {
438        let mut numbering = CT_Numbering::new();
439        let first = numbering.add_numbered_list();
440        let abstract_id = numbering
441            .nums
442            .iter()
443            .find(|n| n.num_id == first)
444            .unwrap()
445            .abstract_num_id;
446
447        // A second instance pointing at the same abstract definition.
448        let second = numbering.nums.iter().map(|n| n.num_id).max().unwrap() + 1;
449        numbering.nums.push(rdocx_oxml::numbering::CT_Num {
450            num_id: second,
451            abstract_num_id: abstract_id,
452            extra_xml: Vec::new(),
453            extra_attributes: Vec::new(),
454        });
455
456        let mut state = NumberingState::new();
457        let a = generate_marker(first, 0, &numbering, &mut state).unwrap();
458        let b = generate_marker(second, 0, &numbering, &mut state).unwrap();
459        assert_eq!(a.marker_text, "1.");
460        assert_eq!(b.marker_text, "2.", "the count must carry across instances");
461    }
462
463    /// Separate abstract definitions are separate lists and each restarts.
464    #[test]
465    fn separate_abstract_definitions_count_independently() {
466        let mut numbering = CT_Numbering::new();
467        let first = numbering.add_numbered_list();
468        let second = numbering.add_numbered_list();
469
470        let mut state = NumberingState::new();
471        let a = generate_marker(first, 0, &numbering, &mut state).unwrap();
472        let b = generate_marker(second, 0, &numbering, &mut state).unwrap();
473        assert_eq!(a.marker_text, "1.");
474        assert_eq!(b.marker_text, "1.");
475    }
476
477    /// Each level carries its own indent, and deeper levels step further in.
478    #[test]
479    fn level_paragraph_properties_expose_per_level_indent() {
480        let mut numbering = CT_Numbering::new();
481        let num_id = numbering.add_numbered_list();
482
483        let lvl0 = level_paragraph_properties(num_id, 0, &numbering)
484            .expect("level 0 should carry paragraph properties");
485        let lvl1 = level_paragraph_properties(num_id, 1, &numbering)
486            .expect("level 1 should carry paragraph properties");
487
488        let left0 = lvl0.ind_left.expect("level 0 indent").0;
489        let left1 = lvl1.ind_left.expect("level 1 indent").0;
490        assert!(
491            left1 > left0,
492            "level 1 must indent further than level 0, got {left0} then {left1}"
493        );
494    }
495}