Skip to main content

stella_docx_kernel/projection/
structure.rs

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