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