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