Skip to main content

stella_docx_kernel/projection/
structure.rs

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