Skip to main content

stella_docx_kernel/projection/
structure.rs

1use std::collections::{HashMap, HashSet};
2
3use crate::ProjectionError;
4use crate::projection::numbering::NumberingCatalog;
5use crate::projection::review::ReviewPoint;
6
7#[derive(Clone, Copy, Debug, Eq, PartialEq)]
8pub enum StructuralFactUnknownReason {
9    ParagraphFragment,
10    DocumentPartOnly,
11    StylesPartUnavailable,
12    UnsupportedStyles,
13    UnsupportedNumbering,
14    IncompleteBookmarkRanges,
15    UnsupportedInternalReferences,
16}
17
18#[derive(Clone, Debug, Eq, PartialEq)]
19pub enum StructuralFactSet<T> {
20    Known(Vec<T>),
21    Unknown(StructuralFactUnknownReason),
22}
23
24impl<T> StructuralFactSet<T> {
25    pub fn known(items: impl IntoIterator<Item = T>) -> Self {
26        Self::Known(items.into_iter().collect())
27    }
28}
29
30#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
31pub struct ParagraphIndentation {
32    pub first_line_twips: Option<i32>,
33    pub hanging_twips: Option<i32>,
34    pub left_twips: Option<i32>,
35    pub right_twips: Option<i32>,
36    pub start_twips: Option<i32>,
37    pub end_twips: Option<i32>,
38    pub first_line_chars_hundredths: Option<i32>,
39    pub hanging_chars_hundredths: Option<i32>,
40    pub left_chars_hundredths: Option<i32>,
41    pub right_chars_hundredths: Option<i32>,
42    pub start_chars_hundredths: Option<i32>,
43    pub end_chars_hundredths: Option<i32>,
44}
45
46impl ParagraphIndentation {
47    fn is_empty(self) -> bool {
48        self == Self::default()
49    }
50
51    fn inherit(self, child: Self) -> Self {
52        let child_has_hanging =
53            child.hanging_twips.is_some() || child.hanging_chars_hundredths.is_some();
54        let child_has_first_line = !child_has_hanging
55            && (child.first_line_twips.is_some() || child.first_line_chars_hundredths.is_some());
56        let parent_first_line = if child_has_hanging {
57            (None, None)
58        } else {
59            (self.first_line_twips, self.first_line_chars_hundredths)
60        };
61        let parent_hanging = if child_has_first_line {
62            (None, None)
63        } else {
64            (self.hanging_twips, self.hanging_chars_hundredths)
65        };
66        let child_first_line = if child_has_hanging {
67            (None, None)
68        } else {
69            (child.first_line_twips, child.first_line_chars_hundredths)
70        };
71        let (first_line_twips, first_line_chars_hundredths) = inherit_character_indent(
72            parent_first_line.0,
73            parent_first_line.1,
74            child_first_line.0,
75            child_first_line.1,
76        );
77        let (hanging_twips, hanging_chars_hundredths) = inherit_character_indent(
78            parent_hanging.0,
79            parent_hanging.1,
80            child.hanging_twips,
81            child.hanging_chars_hundredths,
82        );
83        let (mut left_twips, left_chars_hundredths) = inherit_character_indent(
84            self.left_twips,
85            self.left_chars_hundredths,
86            child.left_twips,
87            child.left_chars_hundredths,
88        );
89        let (mut right_twips, right_chars_hundredths) = inherit_character_indent(
90            self.right_twips,
91            self.right_chars_hundredths,
92            child.right_twips,
93            child.right_chars_hundredths,
94        );
95        let (mut start_twips, start_chars_hundredths) = inherit_character_indent(
96            self.start_twips,
97            self.start_chars_hundredths,
98            child.start_twips,
99            child.start_chars_hundredths,
100        );
101        let (mut end_twips, end_chars_hundredths) = inherit_character_indent(
102            self.end_twips,
103            self.end_chars_hundredths,
104            child.end_twips,
105            child.end_chars_hundredths,
106        );
107        if left_chars_hundredths.is_some() || start_chars_hundredths.is_some() {
108            left_twips = None;
109            start_twips = None;
110        }
111        if right_chars_hundredths.is_some() || end_chars_hundredths.is_some() {
112            right_twips = None;
113            end_twips = None;
114        }
115        Self {
116            first_line_twips,
117            hanging_twips,
118            left_twips,
119            right_twips,
120            start_twips,
121            end_twips,
122            first_line_chars_hundredths,
123            hanging_chars_hundredths,
124            left_chars_hundredths,
125            right_chars_hundredths,
126            start_chars_hundredths,
127            end_chars_hundredths,
128        }
129    }
130
131    fn inherit_numbered_direct(self, mut direct: Self) -> Self {
132        if direct.first_line_twips == Some(0) {
133            direct.first_line_twips = None;
134        }
135        if direct.hanging_twips == Some(0) {
136            direct.hanging_twips = None;
137        }
138        self.inherit(direct)
139    }
140}
141
142fn inherit_character_indent(
143    parent_twips: Option<i32>,
144    parent_chars: Option<i32>,
145    child_twips: Option<i32>,
146    child_chars: Option<i32>,
147) -> (Option<i32>, Option<i32>) {
148    match child_chars {
149        Some(0) => (child_twips.or(parent_twips), None),
150        Some(value) => (None, Some(value)),
151        None => parent_chars.filter(|value| *value != 0).map_or_else(
152            || (child_twips.or(parent_twips), None),
153            |value| (None, Some(value)),
154        ),
155    }
156}
157
158#[derive(Clone, Debug, Eq, PartialEq)]
159pub struct ParagraphIndentationFact {
160    pub paragraph_ordinal: usize,
161    pub value: ParagraphIndentation,
162}
163
164/// Projected `w:jc/@w:val` tokens.
165#[derive(Clone, Copy, Debug, Eq, PartialEq)]
166pub enum ParagraphAlignmentValue {
167    Center,
168    Justify,
169    Left,
170    Right,
171}
172
173/// Cascade level that supplied a paragraph's effective `w:jc`.
174#[derive(Clone, Copy, Debug, Eq, PartialEq)]
175pub enum ParagraphAlignmentSource {
176    /// `w:pPr/w:jc` on the paragraph itself.
177    Direct,
178    /// The `w:pStyle` chain, or `w:docDefaults/w:pPrDefault/w:pPr/w:jc`.
179    Style,
180}
181
182/// Effective paragraph alignment resolved for one paragraph.
183#[derive(Clone, Copy, Debug, Eq, PartialEq)]
184pub struct ParagraphAlignmentFact {
185    pub value: ParagraphAlignmentValue,
186    pub source: ParagraphAlignmentSource,
187}
188
189/// A `w:jc` element observed at one cascade level.
190#[derive(Clone, Copy, Debug, Eq, PartialEq)]
191pub(super) enum ParagraphAlignmentSetting {
192    Supported(ParagraphAlignmentValue),
193    /// A `w:val` token outside the projected set, or a `w:jc` without a usable
194    /// `w:val`. The element is present, so it still shadows lower cascade
195    /// levels; it projects no value, because a lower level's value would be a
196    /// guess at what the token renders as.
197    Unsupported,
198}
199
200#[derive(Clone, Copy, Debug, Eq, PartialEq)]
201pub struct ParagraphOutlineLevelFact {
202    pub paragraph_ordinal: usize,
203    pub outline_level: u8,
204}
205
206#[derive(Clone, Debug, Eq, PartialEq)]
207pub struct NumberingHierarchyFact {
208    pub paragraph_ordinal: usize,
209    pub parent_paragraph_ordinal: Option<usize>,
210    pub child_paragraph_ordinals: Vec<usize>,
211}
212
213#[derive(Clone, Copy, Debug, Eq, Ord, PartialEq, PartialOrd)]
214pub enum SpanCoverage {
215    Complete,
216    ContinuesBefore,
217    ContinuesAfter,
218    ContinuesBeforeAndAfter,
219}
220
221#[derive(Clone, Copy, Debug, Eq, Ord, PartialEq, PartialOrd)]
222pub struct StructuralSpan {
223    pub start_utf8: u32,
224    pub end_utf8: u32,
225    pub start_utf16: u32,
226    pub end_utf16: u32,
227    pub coverage: SpanCoverage,
228}
229
230#[derive(Clone, Debug, Eq, PartialEq)]
231pub struct BookmarkFact {
232    pub paragraph_ordinal: usize,
233    pub bookmark_id: u32,
234    pub name: String,
235    pub span: StructuralSpan,
236}
237
238#[derive(Clone, Copy, Debug, Eq, Ord, PartialEq, PartialOrd)]
239pub enum InternalReferenceRole {
240    Source,
241    Target,
242}
243
244#[derive(Clone, Debug, Eq, PartialEq)]
245pub struct InternalReferenceFact {
246    pub paragraph_ordinal: usize,
247    pub reference_id: String,
248    pub role: InternalReferenceRole,
249    pub span: StructuralSpan,
250}
251
252#[derive(Clone, Debug, Eq, PartialEq)]
253pub struct DocumentStructureFacts {
254    pub indentation: StructuralFactSet<ParagraphIndentationFact>,
255    pub numbering_hierarchy: StructuralFactSet<NumberingHierarchyFact>,
256    pub bookmarks: StructuralFactSet<BookmarkFact>,
257    pub internal_references: StructuralFactSet<InternalReferenceFact>,
258    pub outline_levels: StructuralFactSet<ParagraphOutlineLevelFact>,
259}
260
261#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
262pub(super) struct NumberingProperties {
263    pub num_id: Option<u32>,
264    pub level: Option<u8>,
265    pub present: bool,
266}
267
268impl NumberingProperties {
269    fn inherit(self, child: Self) -> Self {
270        if !child.present {
271            return self;
272        }
273        Self {
274            num_id: child.num_id.or(self.num_id),
275            level: child.level.or(self.level),
276            present: true,
277        }
278    }
279}
280
281#[derive(Clone, Debug, Default, Eq, PartialEq)]
282pub(super) enum TableStyleSelection {
283    #[default]
284    OutsideTable,
285    Default,
286    Explicit(String),
287}
288
289#[derive(Clone, Debug, Default, Eq, PartialEq)]
290pub(super) struct ParagraphProperties {
291    pub style_id: Option<String>,
292    pub table_style: TableStyleSelection,
293    pub outline_level: Option<u8>,
294    pub indentation: ParagraphIndentation,
295    pub numbering: NumberingProperties,
296    pub alignment: Option<ParagraphAlignmentSetting>,
297}
298
299#[derive(Clone, Debug)]
300pub(super) struct RawBookmarkRange {
301    pub id: u32,
302    pub name: String,
303    pub start: ReviewPoint,
304    pub end: ReviewPoint,
305}
306
307#[derive(Clone, Debug)]
308pub(super) struct RawInternalReference {
309    pub reference_id: String,
310    pub source: ReviewPoint,
311}
312
313#[derive(Clone, Debug)]
314pub(super) struct StyleDefinition {
315    pub based_on: Option<String>,
316    pub properties: ParagraphProperties,
317    pub text_properties: TextProperties,
318    pub kind: StyleKind,
319    pub table_formatting: TableStyleFormatting,
320}
321
322#[derive(Clone, Copy, Debug, Eq, PartialEq)]
323pub(super) enum StyleKind {
324    Character,
325    Paragraph,
326    Table,
327}
328
329#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
330pub(super) enum TableStyleFormatting {
331    #[default]
332    Base,
333    Conditional,
334}
335
336pub(super) struct TextStyleInput<'a> {
337    pub paragraph: &'a ParagraphProperties,
338    pub character_style_id: Option<&'a str>,
339    pub direct: TextProperties,
340}
341
342#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
343pub(super) struct TextProperties {
344    pub bold: Option<bool>,
345    pub complex_script_bold: Option<bool>,
346    pub force_complex_script: Option<bool>,
347    pub right_to_left: Option<bool>,
348    pub highlighted: Option<bool>,
349    pub superscript: Option<bool>,
350}
351
352impl TextProperties {
353    const fn inherit(self, child: Self) -> Self {
354        Self {
355            bold: if child.bold.is_some() {
356                child.bold
357            } else {
358                self.bold
359            },
360            complex_script_bold: if child.complex_script_bold.is_some() {
361                child.complex_script_bold
362            } else {
363                self.complex_script_bold
364            },
365            force_complex_script: if child.force_complex_script.is_some() {
366                child.force_complex_script
367            } else {
368                self.force_complex_script
369            },
370            right_to_left: if child.right_to_left.is_some() {
371                child.right_to_left
372            } else {
373                self.right_to_left
374            },
375            highlighted: if child.highlighted.is_some() {
376                child.highlighted
377            } else {
378                self.highlighted
379            },
380            superscript: if child.superscript.is_some() {
381                child.superscript
382            } else {
383                self.superscript
384            },
385        }
386    }
387
388    fn inherit_style(self, child: Self) -> Self {
389        let mut inherited = self.inherit(child);
390        inherited.bold = inherit_style_toggle(self.bold, child.bold);
391        inherited.complex_script_bold =
392            inherit_style_toggle(self.complex_script_bold, child.complex_script_bold);
393        inherited
394    }
395}
396
397fn inherit_style_toggle(inherited: Option<bool>, child: Option<bool>) -> Option<bool> {
398    match child {
399        Some(true) => Some(!inherited.unwrap_or(false)),
400        Some(false) | None => inherited,
401    }
402}
403
404#[derive(Clone, Debug, Default)]
405pub(super) struct StyleSheet {
406    pub default_style_id: Option<String>,
407    pub default_table_style_id: Option<String>,
408    pub document_defaults: ParagraphProperties,
409    pub document_text_defaults: TextProperties,
410    pub styles: HashMap<String, StyleDefinition>,
411    resolved_character_text: HashMap<String, TextProperties>,
412    resolved_paragraph_styles: HashMap<String, ResolvedParagraphStyle>,
413    resolved_table_styles: HashMap<String, ResolvedParagraphStyle>,
414}
415
416#[derive(Clone, Copy, Debug, Default)]
417struct ResolvedParagraphStyle {
418    text: TextProperties,
419    properties: ResolvedParagraphStyleProperties,
420}
421
422impl ResolvedParagraphStyle {
423    fn inherit(self, child: &StyleDefinition) -> Self {
424        Self {
425            text: self.text.inherit_style(child.text_properties),
426            properties: self.properties.inherit(&child.properties),
427        }
428    }
429}
430
431#[derive(Clone, Copy, Debug, Default)]
432struct ResolvedParagraphStyleProperties {
433    indentation: ParagraphIndentation,
434    numbering: NumberingProperties,
435    outline_level: Option<u8>,
436    alignment: Option<ParagraphAlignmentSetting>,
437}
438
439impl ResolvedParagraphStyleProperties {
440    fn inherit(self, child: &ParagraphProperties) -> Self {
441        Self {
442            indentation: self.indentation.inherit(child.indentation),
443            numbering: self.numbering.inherit(child.numbering),
444            outline_level: child.outline_level.or(self.outline_level),
445            alignment: child.alignment.or(self.alignment),
446        }
447    }
448}
449
450#[derive(Clone, Debug)]
451struct ResolvedParagraphProperties {
452    indentation: Result<ParagraphIndentation, StructuralFactUnknownReason>,
453    numbering: NumberingProperties,
454    outline_level: Option<u8>,
455}
456
457impl StyleSheet {
458    pub(super) fn prepare_styles(&mut self) {
459        (self.resolved_character_text, _) = self.resolve_character_text_styles();
460        (self.resolved_paragraph_styles, _) = self.resolve_block_styles(StyleKind::Paragraph);
461        (self.resolved_table_styles, _) = self.resolve_block_styles(StyleKind::Table);
462    }
463
464    pub(super) fn resolve_text(
465        &self,
466        TextStyleInput {
467            paragraph: properties,
468            character_style_id,
469            direct,
470        }: TextStyleInput<'_>,
471    ) -> Result<TextProperties, ()> {
472        let table = self
473            .table_style(&properties.table_style)?
474            .map(|style| style.text)
475            .unwrap_or_default();
476        let paragraph = table.inherit_style(
477            self.paragraph_style(properties.style_id.as_deref())?
478                .map(|style| style.text)
479                .unwrap_or_default(),
480        );
481        let character = character_style_id
482            .filter(|style_id| {
483                self.styles
484                    .get(*style_id)
485                    .is_some_and(|style| style.kind == StyleKind::Character)
486            })
487            .map(|style_id| {
488                self.resolved_character_text
489                    .get(style_id)
490                    .copied()
491                    .ok_or(())
492            })
493            .transpose()?
494            .unwrap_or_default();
495        let mut effective = self
496            .document_text_defaults
497            .inherit(paragraph)
498            .inherit(character)
499            .inherit(direct);
500        effective.bold = Some(resolve_word_toggle(
501            self.document_text_defaults.bold,
502            paragraph.bold,
503            character.bold,
504            direct.bold,
505        ));
506        effective.complex_script_bold = Some(resolve_word_toggle(
507            self.document_text_defaults.complex_script_bold,
508            paragraph.complex_script_bold,
509            character.complex_script_bold,
510            direct.complex_script_bold,
511        ));
512        Ok(effective)
513    }
514
515    fn resolve_character_text_styles(&self) -> (HashMap<String, TextProperties>, usize) {
516        let mut resolved = HashMap::new();
517        let mut unresolved = HashSet::new();
518        let mut resolution_steps = 0usize;
519        for root_id in self.styles.iter().filter_map(|(style_id, style)| {
520            (style.kind == StyleKind::Character).then_some(style_id)
521        }) {
522            if resolved.contains_key(root_id) || unresolved.contains(root_id) {
523                continue;
524            }
525            let mut chain = Vec::new();
526            let mut seen = HashSet::new();
527            let mut current = Some(root_id.as_str());
528            let mut inherited = TextProperties::default();
529            let mut valid = true;
530            while let Some(style_id) = current {
531                resolution_steps = resolution_steps.saturating_add(1);
532                if let Some(properties) = resolved.get(style_id).copied() {
533                    inherited = properties;
534                    break;
535                }
536                if unresolved.contains(style_id) {
537                    valid = false;
538                    break;
539                }
540                if !seen.insert(style_id) {
541                    valid = false;
542                    break;
543                }
544                let Some(style) = self.styles.get(style_id) else {
545                    // A missing base contributes no inherited properties.
546                    valid = !chain.is_empty();
547                    break;
548                };
549                if style.kind != StyleKind::Character {
550                    // Inheritance cannot cross style kinds; the current style
551                    // remains a root with its own properties.
552                    valid = !chain.is_empty();
553                    break;
554                }
555                chain.push(style_id);
556                current = style.based_on.as_deref();
557            }
558            if !valid {
559                unresolved.extend(chain.into_iter().map(str::to_owned));
560                continue;
561            }
562            for style_id in chain.into_iter().rev() {
563                // Every ID in the chain was validated above.
564                if let Some(style) = self.styles.get(style_id) {
565                    inherited = inherited.inherit_style(style.text_properties);
566                    resolved.insert(style_id.to_owned(), inherited);
567                }
568            }
569        }
570        debug_assert!(
571            resolution_steps <= self.styles.len().saturating_mul(2).saturating_add(1),
572            "memoized style resolution exceeded its linear work budget"
573        );
574        (resolved, resolution_steps)
575    }
576
577    fn resolve_block_styles(
578        &self,
579        kind: StyleKind,
580    ) -> (HashMap<String, ResolvedParagraphStyle>, usize) {
581        let mut resolved = HashMap::new();
582        let mut unresolved = HashSet::new();
583        let mut resolution_steps = 0usize;
584        for root_id in self
585            .styles
586            .iter()
587            .filter_map(|(style_id, style)| (style.kind == kind).then_some(style_id))
588        {
589            if resolved.contains_key(root_id) || unresolved.contains(root_id) {
590                continue;
591            }
592            let mut chain = Vec::new();
593            let mut seen = HashSet::new();
594            let mut current = Some(root_id.as_str());
595            let mut inherited = ResolvedParagraphStyle::default();
596            let mut valid = true;
597            while let Some(style_id) = current {
598                resolution_steps = resolution_steps.saturating_add(1);
599                if let Some(properties) = resolved.get(style_id).copied() {
600                    inherited = properties;
601                    break;
602                }
603                if unresolved.contains(style_id) || !seen.insert(style_id) {
604                    valid = false;
605                    break;
606                }
607                let Some(style) = self.styles.get(style_id) else {
608                    valid = !chain.is_empty();
609                    break;
610                };
611                if style.kind != kind {
612                    valid = !chain.is_empty();
613                    break;
614                }
615                chain.push(style_id);
616                if kind == StyleKind::Table
617                    && style.table_formatting == TableStyleFormatting::Conditional
618                {
619                    valid = false;
620                    break;
621                }
622                current = style.based_on.as_deref();
623            }
624            if !valid {
625                unresolved.extend(chain.into_iter().map(str::to_owned));
626                continue;
627            }
628            for style_id in chain.into_iter().rev() {
629                if let Some(style) = self.styles.get(style_id) {
630                    inherited = inherited.inherit(style);
631                    resolved.insert(style_id.to_owned(), inherited);
632                }
633            }
634        }
635        debug_assert!(
636            resolution_steps <= self.styles.len().saturating_mul(2).saturating_add(1),
637            "memoized paragraph style resolution exceeded its linear work budget"
638        );
639        (resolved, resolution_steps)
640    }
641
642    fn table_style(
643        &self,
644        selection: &TableStyleSelection,
645    ) -> Result<Option<&ResolvedParagraphStyle>, ()> {
646        let style_id = match selection {
647            TableStyleSelection::OutsideTable => return Ok(None),
648            TableStyleSelection::Default => self.default_table_style_id.as_deref(),
649            TableStyleSelection::Explicit(id) => Some(id.as_str()),
650        };
651        let Some(style_id) = style_id.filter(|id| {
652            self.styles
653                .get(*id)
654                .is_some_and(|style| style.kind == StyleKind::Table)
655        }) else {
656            return Ok(None);
657        };
658        self.resolved_table_styles.get(style_id).map(Some).ok_or(())
659    }
660
661    fn paragraph_style(
662        &self,
663        explicit_style_id: Option<&str>,
664    ) -> Result<Option<&ResolvedParagraphStyle>, ()> {
665        let Some(style_id) = explicit_style_id
666            .filter(|id| {
667                self.styles
668                    .get(*id)
669                    .is_some_and(|style| style.kind == StyleKind::Paragraph)
670            })
671            .or(self.default_style_id.as_deref())
672        else {
673            return Ok(None);
674        };
675        self.resolved_paragraph_styles
676            .get(style_id)
677            .map(Some)
678            .ok_or(())
679    }
680
681    /// Missing, wrong-kind, and omitted paragraph references select the default.
682    /// Cyclic inheritance cannot supply an effective alignment.
683    fn style_alignment(&self, direct: &ParagraphProperties) -> Option<ParagraphAlignmentSetting> {
684        let table = self.table_style(&direct.table_style).ok()?;
685        self.paragraph_style(direct.style_id.as_deref())
686            .ok()?
687            .and_then(|style| style.properties.alignment)
688            .or_else(|| table.and_then(|style| style.properties.alignment))
689            .or(self.document_defaults.alignment)
690    }
691
692    fn resolve(
693        &self,
694        direct: &ParagraphProperties,
695        numbering_catalog: Result<&NumberingCatalog, StructuralFactUnknownReason>,
696    ) -> Result<ResolvedParagraphProperties, StructuralFactUnknownReason> {
697        let table = self
698            .table_style(&direct.table_style)
699            .map_err(|()| StructuralFactUnknownReason::UnsupportedStyles)?
700            .map(|style| style.properties)
701            .unwrap_or_default();
702        let style = self
703            .paragraph_style(direct.style_id.as_deref())
704            .map_err(|()| StructuralFactUnknownReason::UnsupportedStyles)?
705            .map(|style| style.properties)
706            .unwrap_or_default();
707        let style = ResolvedParagraphStyleProperties {
708            indentation: table.indentation.inherit(style.indentation),
709            numbering: table.numbering.inherit(style.numbering),
710            outline_level: style.outline_level.or(table.outline_level),
711            alignment: style.alignment.or(table.alignment),
712        };
713        let mut numbering = self.document_defaults.numbering.inherit(style.numbering);
714        let mut outline_level = style.outline_level.or(self.document_defaults.outline_level);
715        let inherited_numbering = numbering;
716        numbering = numbering.inherit(direct.numbering);
717        outline_level = direct.outline_level.or(outline_level);
718
719        let numbered = numbering.present && numbering.num_id != Some(0);
720        let numbering_is_direct = direct.numbering.present;
721        let numbering_removed = numbering_is_direct
722            && direct.numbering.num_id == Some(0)
723            && inherited_numbering.present
724            && inherited_numbering.num_id != Some(0);
725        let indentation = if numbered {
726            numbering
727                .num_id
728                .ok_or(StructuralFactUnknownReason::UnsupportedNumbering)
729                .and_then(|num_id| {
730                    numbering_catalog.and_then(|catalog| {
731                        catalog.indentation(num_id, numbering.level.unwrap_or(0))
732                    })
733                })
734        } else {
735            Ok(ParagraphIndentation::default())
736        }
737        .map(|level_indentation| {
738            let mut indentation = if numbering_removed {
739                ParagraphIndentation::default()
740            } else {
741                self.document_defaults.indentation
742            };
743            if !numbering_is_direct && !numbering_removed {
744                indentation = indentation.inherit(level_indentation);
745            }
746            if !numbering_removed {
747                indentation = indentation.inherit(style.indentation);
748            }
749            if numbering_is_direct {
750                indentation = indentation.inherit(level_indentation);
751            }
752            if numbered {
753                indentation.inherit_numbered_direct(direct.indentation)
754            } else {
755                indentation.inherit(direct.indentation)
756            }
757        });
758        Ok(ResolvedParagraphProperties {
759            indentation,
760            numbering,
761            outline_level,
762        })
763    }
764}
765
766/// Resolves the effective paragraph alignment for one paragraph.
767///
768/// Direct `w:pPr/w:jc` wins; otherwise the paragraph style chain and
769/// `w:docDefaults` apply. Without a style sheet only direct alignment is
770/// projected, which never claims a style-dependent value.
771///
772/// Two cascade tiers are out of scope: a table style's `w:tblStyle` ->
773/// `w:pPr/w:jc` and a numbering level's `w:lvl/w:pPr/w:jc`. Neither is
774/// consulted, so a paragraph whose effective alignment comes from either tier
775/// projects the lower-tier `w:docDefaults` or style value instead.
776pub(super) fn resolve_paragraph_alignment(
777    styles: Result<&StyleSheet, StructuralFactUnknownReason>,
778    direct: &ParagraphProperties,
779) -> Option<ParagraphAlignmentFact> {
780    if let Some(setting) = direct.alignment {
781        return alignment_fact(setting, ParagraphAlignmentSource::Direct);
782    }
783    styles
784        .ok()?
785        .style_alignment(direct)
786        .and_then(|setting| alignment_fact(setting, ParagraphAlignmentSource::Style))
787}
788
789const fn alignment_fact(
790    setting: ParagraphAlignmentSetting,
791    source: ParagraphAlignmentSource,
792) -> Option<ParagraphAlignmentFact> {
793    match setting {
794        ParagraphAlignmentSetting::Supported(value) => {
795            Some(ParagraphAlignmentFact { value, source })
796        }
797        ParagraphAlignmentSetting::Unsupported => None,
798    }
799}
800
801fn resolve_word_toggle(
802    document_default: Option<bool>,
803    paragraph: Option<bool>,
804    character: Option<bool>,
805    direct: Option<bool>,
806) -> bool {
807    if let Some(value) = direct {
808        return value;
809    }
810    document_default.unwrap_or(false) ^ paragraph.unwrap_or(false) ^ character.unwrap_or(false)
811}
812
813#[derive(Clone, Copy)]
814pub(super) struct RawStructureInput<'a> {
815    pub paragraph_texts: &'a [&'a str],
816    pub properties: &'a [&'a ParagraphProperties],
817    pub bookmarks: Result<&'a [RawBookmarkRange], StructuralFactUnknownReason>,
818    pub references: Result<&'a [RawInternalReference], StructuralFactUnknownReason>,
819}
820
821struct StructuralFactBudget {
822    remaining: usize,
823}
824
825impl StructuralFactBudget {
826    const fn new(maximum_facts: usize) -> Self {
827        Self {
828            remaining: maximum_facts,
829        }
830    }
831
832    fn consume(&mut self, facts: usize) -> Result<(), ProjectionError> {
833        self.remaining = self
834            .remaining
835            .checked_sub(facts)
836            .ok_or(ProjectionError::TooManyStructuralFacts)?;
837        Ok(())
838    }
839}
840
841pub(super) fn materialize_structure(
842    input: RawStructureInput<'_>,
843    styles: Result<&StyleSheet, StructuralFactUnknownReason>,
844    numbering: Result<&NumberingCatalog, StructuralFactUnknownReason>,
845    maximum_facts: usize,
846) -> Result<DocumentStructureFacts, ProjectionError> {
847    let mut fact_budget = StructuralFactBudget::new(maximum_facts);
848    let resolved = styles.and_then(|styles| {
849        input
850            .properties
851            .iter()
852            .map(|properties| styles.resolve(properties, numbering))
853            .collect::<Result<Vec<_>, _>>()
854    });
855
856    let (indentation, numbering_hierarchy, outline_levels) = match resolved {
857        Ok(properties) => {
858            let indentation = properties
859                .iter()
860                .map(|properties| properties.indentation)
861                .collect::<Result<Vec<_>, _>>()
862                .map_or_else(StructuralFactSet::Unknown, |indentations| {
863                    StructuralFactSet::Known(
864                        indentations
865                            .into_iter()
866                            .enumerate()
867                            .filter(|(_, indentation)| !indentation.is_empty())
868                            .map(|(paragraph_ordinal, value)| ParagraphIndentationFact {
869                                paragraph_ordinal,
870                                value,
871                            })
872                            .collect(),
873                    )
874                });
875            (
876                indentation,
877                materialize_numbering(&properties),
878                StructuralFactSet::Known(
879                    properties
880                        .iter()
881                        .enumerate()
882                        .filter_map(|(paragraph_ordinal, properties)| {
883                            properties.outline_level.map(|outline_level| {
884                                ParagraphOutlineLevelFact {
885                                    paragraph_ordinal,
886                                    outline_level,
887                                }
888                            })
889                        })
890                        .collect(),
891                ),
892            )
893        }
894        Err(reason) => (
895            StructuralFactSet::Unknown(reason),
896            StructuralFactSet::Unknown(reason),
897            StructuralFactSet::Unknown(reason),
898        ),
899    };
900    if let StructuralFactSet::Known(facts) = &indentation {
901        fact_budget.consume(facts.len())?;
902    }
903    if let StructuralFactSet::Known(facts) = &numbering_hierarchy {
904        fact_budget.consume(facts.len())?;
905    }
906    if let StructuralFactSet::Known(facts) = &outline_levels {
907        fact_budget.consume(facts.len())?;
908    }
909
910    let bookmarks = match input.bookmarks {
911        Ok(ranges) => StructuralFactSet::Known(materialize_bookmarks(
912            input.paragraph_texts,
913            ranges,
914            &mut fact_budget,
915        )?),
916        Err(reason) => StructuralFactSet::Unknown(reason),
917    };
918    let internal_references = match (input.references, &bookmarks) {
919        (Ok([]), _) => StructuralFactSet::Known(Vec::new()),
920        (Ok(references), StructuralFactSet::Known(bookmark_facts)) => StructuralFactSet::Known(
921            materialize_references(references, bookmark_facts, &mut fact_budget)?,
922        ),
923        (Err(reason), _) => StructuralFactSet::Unknown(reason),
924        (_, StructuralFactSet::Unknown(_)) => {
925            StructuralFactSet::Unknown(StructuralFactUnknownReason::IncompleteBookmarkRanges)
926        }
927    };
928
929    Ok(DocumentStructureFacts {
930        indentation,
931        numbering_hierarchy,
932        bookmarks,
933        internal_references,
934        outline_levels,
935    })
936}
937
938fn materialize_numbering(
939    properties: &[ResolvedParagraphProperties],
940) -> StructuralFactSet<NumberingHierarchyFact> {
941    let mut stacks: HashMap<u32, [Option<usize>; 9]> = HashMap::new();
942    let mut parents = vec![None; properties.len()];
943    let mut children = vec![Vec::new(); properties.len()];
944
945    for (ordinal, paragraph_properties) in properties.iter().enumerate() {
946        if !paragraph_properties.numbering.present
947            || paragraph_properties.numbering.num_id == Some(0)
948        {
949            continue;
950        }
951        let Some(num_id) = paragraph_properties.numbering.num_id else {
952            return StructuralFactSet::Unknown(StructuralFactUnknownReason::UnsupportedNumbering);
953        };
954        let level = usize::from(paragraph_properties.numbering.level.unwrap_or(0));
955        if level >= 9 {
956            return StructuralFactSet::Unknown(StructuralFactUnknownReason::UnsupportedNumbering);
957        }
958        let stack = stacks.entry(num_id).or_insert([None; 9]);
959        if level > 0
960            && let Some(parent) = stack
961                .get(..level)
962                .and_then(|ancestors| ancestors.iter().rev().flatten().next().copied())
963        {
964            let Some(parent_slot) = parents.get_mut(ordinal) else {
965                return StructuralFactSet::Unknown(
966                    StructuralFactUnknownReason::UnsupportedNumbering,
967                );
968            };
969            *parent_slot = Some(parent);
970            let Some(parent_children) = children.get_mut(parent) else {
971                return StructuralFactSet::Unknown(
972                    StructuralFactUnknownReason::UnsupportedNumbering,
973                );
974            };
975            parent_children.push(ordinal);
976        }
977        let Some(level_slot) = stack.get_mut(level) else {
978            return StructuralFactSet::Unknown(StructuralFactUnknownReason::UnsupportedNumbering);
979        };
980        *level_slot = Some(ordinal);
981        let descendant_start = level.saturating_add(1);
982        let Some(descendant_slots) = stack.get_mut(descendant_start..) else {
983            return StructuralFactSet::Unknown(StructuralFactUnknownReason::UnsupportedNumbering);
984        };
985        for slot in descendant_slots {
986            *slot = None;
987        }
988    }
989
990    StructuralFactSet::Known(
991        properties
992            .iter()
993            .enumerate()
994            .filter_map(|(ordinal, _)| {
995                let parent = parents.get(ordinal).copied().flatten();
996                let paragraph_children = children.get(ordinal)?;
997                (parent.is_some() || !paragraph_children.is_empty()).then(|| {
998                    NumberingHierarchyFact {
999                        paragraph_ordinal: ordinal,
1000                        parent_paragraph_ordinal: parent,
1001                        child_paragraph_ordinals: paragraph_children.clone(),
1002                    }
1003                })
1004            })
1005            .collect(),
1006    )
1007}
1008
1009fn materialize_bookmarks(
1010    texts: &[&str],
1011    ranges: &[RawBookmarkRange],
1012    fact_budget: &mut StructuralFactBudget,
1013) -> Result<Vec<BookmarkFact>, ProjectionError> {
1014    let mut facts = Vec::new();
1015    for range in ranges {
1016        segment_range(texts, &range.start, &range.end, |paragraph, span| {
1017            fact_budget.consume(1)?;
1018            facts.push(BookmarkFact {
1019                paragraph_ordinal: paragraph,
1020                bookmark_id: range.id,
1021                name: range.name.clone(),
1022                span,
1023            });
1024            Ok(())
1025        })?;
1026    }
1027    facts.sort_by(|left, right| {
1028        (
1029            left.paragraph_ordinal,
1030            left.span.start_utf8,
1031            left.span.end_utf8,
1032            left.bookmark_id,
1033            left.name.as_str(),
1034            left.span.coverage,
1035        )
1036            .cmp(&(
1037                right.paragraph_ordinal,
1038                right.span.start_utf8,
1039                right.span.end_utf8,
1040                right.bookmark_id,
1041                right.name.as_str(),
1042                right.span.coverage,
1043            ))
1044    });
1045    Ok(facts)
1046}
1047
1048fn materialize_references(
1049    references: &[RawInternalReference],
1050    bookmarks: &[BookmarkFact],
1051    fact_budget: &mut StructuralFactBudget,
1052) -> Result<Vec<InternalReferenceFact>, ProjectionError> {
1053    let mut facts = Vec::new();
1054    let mut targets_by_name: HashMap<&str, Vec<&BookmarkFact>> = HashMap::new();
1055    for bookmark in bookmarks {
1056        targets_by_name
1057            .entry(&bookmark.name)
1058            .or_default()
1059            .push(bookmark);
1060    }
1061    let mut emitted_target_names = HashSet::new();
1062    for reference in references {
1063        fact_budget.consume(1)?;
1064        facts.push(InternalReferenceFact {
1065            paragraph_ordinal: reference.source.paragraph_ordinal,
1066            reference_id: reference.reference_id.clone(),
1067            role: InternalReferenceRole::Source,
1068            span: StructuralSpan {
1069                start_utf8: reference.source.utf8,
1070                end_utf8: reference.source.utf8,
1071                start_utf16: reference.source.utf16,
1072                end_utf16: reference.source.utf16,
1073                coverage: SpanCoverage::Complete,
1074            },
1075        });
1076        if emitted_target_names.insert(reference.reference_id.as_str())
1077            && let Some(targets) = targets_by_name.get(reference.reference_id.as_str())
1078        {
1079            for bookmark in targets {
1080                fact_budget.consume(1)?;
1081                facts.push(InternalReferenceFact {
1082                    paragraph_ordinal: bookmark.paragraph_ordinal,
1083                    reference_id: reference.reference_id.clone(),
1084                    role: InternalReferenceRole::Target,
1085                    span: bookmark.span,
1086                });
1087            }
1088        }
1089    }
1090    facts.sort_by(|left, right| {
1091        (
1092            left.paragraph_ordinal,
1093            left.span.start_utf8,
1094            left.span.end_utf8,
1095            left.reference_id.as_str(),
1096            left.role,
1097            left.span.coverage,
1098        )
1099            .cmp(&(
1100                right.paragraph_ordinal,
1101                right.span.start_utf8,
1102                right.span.end_utf8,
1103                right.reference_id.as_str(),
1104                right.role,
1105                right.span.coverage,
1106            ))
1107    });
1108    Ok(facts)
1109}
1110
1111fn segment_range(
1112    texts: &[&str],
1113    start: &ReviewPoint,
1114    end: &ReviewPoint,
1115    mut visit: impl FnMut(usize, StructuralSpan) -> Result<(), ProjectionError>,
1116) -> Result<(), ProjectionError> {
1117    if start.paragraph_ordinal > end.paragraph_ordinal || end.paragraph_ordinal >= texts.len() {
1118        return Err(ProjectionError::InvalidDocumentXml);
1119    }
1120    if start.paragraph_ordinal == end.paragraph_ordinal
1121        && (start.utf8 > end.utf8 || start.utf16 > end.utf16)
1122    {
1123        return Err(ProjectionError::InvalidDocumentXml);
1124    }
1125    for (paragraph, text) in texts
1126        .iter()
1127        .enumerate()
1128        .take(end.paragraph_ordinal.saturating_add(1))
1129        .skip(start.paragraph_ordinal)
1130    {
1131        let text_utf8 =
1132            u32::try_from(text.len()).map_err(|_| ProjectionError::InvalidDocumentXml)?;
1133        let text_utf16 = u32::try_from(text.encode_utf16().count())
1134            .map_err(|_| ProjectionError::InvalidDocumentXml)?;
1135        let (start_utf8, start_utf16) = if paragraph == start.paragraph_ordinal {
1136            (start.utf8, start.utf16)
1137        } else {
1138            (0, 0)
1139        };
1140        let (end_utf8, end_utf16) = if paragraph == end.paragraph_ordinal {
1141            (end.utf8, end.utf16)
1142        } else {
1143            (text_utf8, text_utf16)
1144        };
1145        if start_utf8 > text_utf8
1146            || end_utf8 > text_utf8
1147            || start_utf16 > text_utf16
1148            || end_utf16 > text_utf16
1149        {
1150            return Err(ProjectionError::InvalidDocumentXml);
1151        }
1152        let continues_before = paragraph > start.paragraph_ordinal;
1153        let continues_after = paragraph < end.paragraph_ordinal;
1154        let coverage = match (continues_before, continues_after) {
1155            (false, false) => SpanCoverage::Complete,
1156            (true, false) => SpanCoverage::ContinuesBefore,
1157            (false, true) => SpanCoverage::ContinuesAfter,
1158            (true, true) => SpanCoverage::ContinuesBeforeAndAfter,
1159        };
1160        visit(
1161            paragraph,
1162            StructuralSpan {
1163                start_utf8,
1164                end_utf8,
1165                start_utf16,
1166                end_utf16,
1167                coverage,
1168            },
1169        )?;
1170    }
1171    Ok(())
1172}
1173
1174#[cfg(test)]
1175mod tests {
1176    use super::{
1177        ParagraphProperties, StyleDefinition, StyleKind, StyleSheet, TableStyleFormatting,
1178        TextProperties, inherit_style_toggle,
1179    };
1180
1181    const WORD_STYLE_LIMIT: usize = 4_079;
1182
1183    fn style(based_on: Option<String>, kind: StyleKind) -> StyleDefinition {
1184        StyleDefinition {
1185            based_on,
1186            properties: ParagraphProperties::default(),
1187            text_properties: TextProperties {
1188                bold: Some(true),
1189                ..TextProperties::default()
1190            },
1191            kind,
1192            table_formatting: TableStyleFormatting::Base,
1193        }
1194    }
1195
1196    #[test]
1197    fn style_toggle_truth_table_preserves_false_and_toggles_true() {
1198        let cases = [
1199            (&[][..], None),
1200            (&[false][..], None),
1201            (&[true][..], Some(true)),
1202            (&[true, false][..], Some(true)),
1203            (&[true, true][..], Some(false)),
1204            (&[true, false, true][..], Some(false)),
1205            (&[true, true, true][..], Some(true)),
1206        ];
1207
1208        for (levels, expected) in cases {
1209            let actual = levels.iter().fold(None, |inherited, &level| {
1210                inherit_style_toggle(inherited, Some(level))
1211            });
1212            assert_eq!(actual, expected, "style levels {levels:?}");
1213        }
1214    }
1215
1216    #[test]
1217    fn style_resolution_stays_within_its_linear_work_budget() -> Result<(), &'static str> {
1218        let ids = (0..WORD_STYLE_LIMIT)
1219            .map(|index| format!("style-{index}"))
1220            .collect::<Vec<_>>();
1221
1222        let mut paragraph_chain = StyleSheet::default();
1223        let mut paragraph_parent = None;
1224        for id in &ids {
1225            paragraph_chain.styles.insert(
1226                id.clone(),
1227                style(paragraph_parent.clone(), StyleKind::Paragraph),
1228            );
1229            paragraph_parent = Some(id.clone());
1230        }
1231        let (resolved_paragraphs, paragraph_steps) =
1232            paragraph_chain.resolve_block_styles(StyleKind::Paragraph);
1233        assert_eq!(resolved_paragraphs.len(), WORD_STYLE_LIMIT);
1234        assert!(paragraph_steps <= WORD_STYLE_LIMIT.saturating_mul(2).saturating_add(1));
1235
1236        let mut character_chain = StyleSheet::default();
1237        let mut character_parent = None;
1238        for id in &ids {
1239            character_chain.styles.insert(
1240                id.clone(),
1241                style(character_parent.clone(), StyleKind::Character),
1242            );
1243            character_parent = Some(id.clone());
1244        }
1245        let (resolved_characters, character_steps) =
1246            character_chain.resolve_character_text_styles();
1247        assert_eq!(resolved_characters.len(), WORD_STYLE_LIMIT);
1248        assert!(character_steps <= WORD_STYLE_LIMIT.saturating_mul(2).saturating_add(1));
1249
1250        let mut cycle = StyleSheet::default();
1251        let first = ids.first().ok_or("the fixture must be non-empty")?;
1252        for (index, id) in ids.iter().enumerate() {
1253            let successor = ids.get(index.saturating_add(1)).unwrap_or(first);
1254            cycle.styles.insert(
1255                id.clone(),
1256                style(Some(successor.clone()), StyleKind::Paragraph),
1257            );
1258        }
1259        let (cycle_resolved, cycle_steps) = cycle.resolve_block_styles(StyleKind::Paragraph);
1260        assert!(cycle_resolved.is_empty());
1261        assert!(cycle_steps <= WORD_STYLE_LIMIT.saturating_add(1));
1262        Ok(())
1263    }
1264}