Skip to main content

stella_docx_kernel/projection/
mod.rs

1//! Bounded, host-independent projection of the main OOXML document part.
2//!
3//! This crate deliberately knows nothing about host-specific paragraph identifiers.
4//! Callers allocate opaque application identities from package facts; `w14:paraId`
5//! remains optional package metadata and is never treated as a host navigation ID.
6
7mod archive;
8mod compatibility;
9mod container;
10mod flat_opc;
11mod namespaces;
12mod numbering;
13mod ooxml;
14mod relationships;
15mod review;
16mod structure;
17mod styles;
18
19use std::collections::{HashMap, HashSet};
20use std::fmt;
21
22pub use archive::{DocumentParts, DocxLimits, extract_document_parts, extract_document_xml};
23pub use container::ParagraphContainer;
24pub use ooxml::{
25    PackageParagraphId, ParagraphStructure, RevisionProjectionStatus, RevisionUnsupportedReason,
26    RevisionView, TextFormattingSpan, TextMaterialization, TextStyle,
27};
28pub use review::{
29    AttributedComment, AttributedRevision, CommentContent, DocumentReviewFacts, ReviewDetail,
30    ReviewFactLimits, ReviewFactSet, ReviewFactUnknownReason, ReviewPoint, ReviewSpan,
31    RevisionContent, RevisionFactKind, RevisionPayload,
32};
33pub use structure::{
34    BookmarkFact, DocumentStructureFacts, InternalReferenceFact, InternalReferenceRole,
35    NumberingHierarchyFact, ParagraphAlignmentFact, ParagraphAlignmentSource,
36    ParagraphAlignmentValue, ParagraphIndentation, ParagraphIndentationFact,
37    ParagraphOutlineLevelFact, SpanCoverage, StructuralFactSet, StructuralFactUnknownReason,
38    StructuralSpan,
39};
40pub use styles::is_semantic_highlight_color;
41
42#[derive(Clone, Debug, Eq, Hash, PartialEq)]
43pub struct InternalParagraphId(String);
44
45impl InternalParagraphId {
46    /// Creates a bounded, non-empty application paragraph identity.
47    ///
48    /// # Errors
49    ///
50    /// Returns [`ProjectionError::InvalidInternalParagraphId`] when the value
51    /// is empty or exceeds the identity length limit.
52    pub fn new(value: impl Into<String>) -> Result<Self, ProjectionError> {
53        let value = value.into();
54        if value.is_empty() || value.len() > 128 {
55            return Err(ProjectionError::InvalidInternalParagraphId);
56        }
57        Ok(Self(value))
58    }
59
60    #[must_use]
61    pub fn as_str(&self) -> &str {
62        &self.0
63    }
64}
65
66#[derive(Clone, Copy, Debug)]
67pub struct ParagraphIdentityFacts<'a> {
68    pub ordinal: usize,
69    pub package_paragraph_id: Option<PackageParagraphId>,
70    pub text: &'a str,
71}
72
73#[derive(Clone, Debug, Eq, PartialEq)]
74pub struct ProjectedParagraph {
75    pub id: InternalParagraphId,
76    pub ordinal: usize,
77    pub package_paragraph_id: Option<PackageParagraphId>,
78    pub style_id: Option<String>,
79    pub text: String,
80    pub formatting: Vec<TextFormattingSpan>,
81    pub structure: Option<ParagraphStructure>,
82    pub container: ParagraphContainer,
83    pub alignment: Option<ParagraphAlignmentFact>,
84}
85
86#[derive(Clone, Copy, Debug, Eq, Ord, PartialEq, PartialOrd)]
87pub enum FormattingUnknownReason {
88    DocumentPartOnly,
89    StylesPartUnavailable,
90    UnsupportedStyles,
91    UnsupportedAlignment,
92}
93
94#[derive(Clone, Copy, Debug, Eq, PartialEq)]
95pub enum FormattingFactStatus {
96    Known,
97    Unknown(FormattingUnknownReason),
98}
99
100#[derive(Clone, Copy, Debug, Eq, PartialEq)]
101pub struct FormattingCompleteness {
102    pub alignment: FormattingFactStatus,
103    pub bold: FormattingFactStatus,
104    pub highlight: FormattingFactStatus,
105    pub superscript: FormattingFactStatus,
106}
107
108impl FormattingCompleteness {
109    fn from_styles(styles: Result<(), FormattingUnknownReason>) -> Self {
110        let status = |family| match family {
111            // Highlight facts report direct markup, independently of the cascade.
112            TextStyle::Highlight => FormattingFactStatus::Known,
113            TextStyle::Bold | TextStyle::Superscript => styles
114                .map_or_else(FormattingFactStatus::Unknown, |()| {
115                    FormattingFactStatus::Known
116                }),
117        };
118        Self {
119            alignment: styles.map_or_else(FormattingFactStatus::Unknown, |()| {
120                FormattingFactStatus::Known
121            }),
122            bold: status(TextStyle::Bold),
123            highlight: status(TextStyle::Highlight),
124            superscript: status(TextStyle::Superscript),
125        }
126    }
127
128    fn mark_styles_unread(&mut self) {
129        let unread = Self::from_styles(Err(FormattingUnknownReason::UnsupportedStyles));
130        self.bold = unread.bold;
131        self.superscript = unread.superscript;
132    }
133
134    const fn mark_formatting_unread(&mut self) {
135        let unknown = FormattingFactStatus::Unknown(FormattingUnknownReason::UnsupportedStyles);
136        self.bold = unknown;
137        self.highlight = unknown;
138        self.superscript = unknown;
139    }
140}
141
142impl Default for FormattingCompleteness {
143    fn default() -> Self {
144        Self::from_styles(Err(FormattingUnknownReason::DocumentPartOnly))
145    }
146}
147
148#[derive(Clone, Debug, Eq, PartialEq)]
149pub struct DocumentProjection {
150    pub paragraphs: Vec<ProjectedParagraph>,
151    pub formatting_completeness: FormattingCompleteness,
152    pub revision_status: RevisionProjectionStatus,
153    pub structural_facts: DocumentStructureFacts,
154}
155
156#[derive(Clone, Debug, Eq, PartialEq)]
157pub struct DocumentPackageProjection {
158    pub document: DocumentProjection,
159    pub review_facts: DocumentReviewFacts,
160}
161
162#[derive(Clone, Copy, Debug, Eq, PartialEq)]
163pub struct ProjectionOptions {
164    pub revision_view: RevisionView,
165    pub text_materialization: TextMaterialization,
166}
167
168impl Default for ProjectionOptions {
169    fn default() -> Self {
170        Self {
171            revision_view: RevisionView::Current,
172            text_materialization: TextMaterialization::WordHost,
173        }
174    }
175}
176
177#[derive(Clone, Debug, Eq, PartialEq)]
178pub enum ProjectionError {
179    ArchiveTooLarge,
180    InvalidArchive,
181    InvalidFlatOpcPackage,
182    TooManyArchiveEntries,
183    InvalidPackageRelationships,
184    PackageRelationshipsTooLarge,
185    MissingDocumentXml,
186    DuplicateDocumentXml,
187    DuplicateStylesXml,
188    DuplicateNumberingXml,
189    EncryptedDocumentXml,
190    UnsupportedCompression(u16),
191    DocumentXmlTooLarge,
192    SuspiciousCompressionRatio,
193    InvalidDocumentXmlEntry,
194    DocumentXmlIntegrity,
195    StylesXmlTooLarge,
196    InvalidStylesXmlEntry,
197    StylesXmlIntegrity,
198    NumberingXmlTooLarge,
199    InvalidNumberingXmlEntry,
200    NumberingXmlIntegrity,
201    InvalidDocumentXml,
202    InvalidStylesXml,
203    InvalidNumberingXml,
204    MissingDocumentBody,
205    TooManyParagraphs,
206    TooManyStructuralFacts,
207    TooManyNumberingItems,
208    InvalidInternalParagraphId,
209    DuplicateInternalParagraphId,
210}
211
212impl fmt::Display for ProjectionError {
213    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
214        let message = match self {
215            Self::ArchiveTooLarge => "DOCX archive exceeds the configured size limit",
216            Self::InvalidFlatOpcPackage => "Flat OPC package has invalid structure",
217            Self::InvalidArchive => "DOCX archive is invalid",
218            Self::TooManyArchiveEntries => "DOCX archive has too many entries",
219            Self::InvalidPackageRelationships => "DOCX archive has invalid package relationships",
220            Self::PackageRelationshipsTooLarge => {
221                "DOCX package relationships exceed the configured size limit"
222            }
223            Self::MissingDocumentXml => "DOCX archive has no main document part",
224            Self::DuplicateDocumentXml => "DOCX archive has duplicate main document parts",
225            Self::DuplicateStylesXml => "DOCX archive has duplicate entries for its styles part",
226            Self::DuplicateNumberingXml => {
227                "DOCX archive has duplicate entries for its numbering part"
228            }
229            Self::EncryptedDocumentXml => "DOCX main document part is encrypted",
230            Self::UnsupportedCompression(_) => {
231                "selected DOCX package part uses unsupported compression"
232            }
233            Self::DocumentXmlTooLarge => {
234                "DOCX main document part exceeds the configured size limit"
235            }
236            Self::SuspiciousCompressionRatio => {
237                "selected DOCX package part exceeds the compression-ratio limit"
238            }
239            Self::InvalidDocumentXmlEntry => "DOCX main document part has an invalid ZIP entry",
240            Self::DocumentXmlIntegrity => "DOCX main document part failed size or CRC validation",
241            Self::StylesXmlTooLarge => "DOCX styles part exceeds the configured size limit",
242            Self::InvalidStylesXmlEntry => "DOCX styles part has an invalid ZIP entry",
243            Self::StylesXmlIntegrity => "DOCX styles part failed size or CRC validation",
244            Self::NumberingXmlTooLarge => "DOCX numbering part exceeds the configured size limit",
245            Self::InvalidNumberingXmlEntry => "DOCX numbering part has an invalid ZIP entry",
246            Self::NumberingXmlIntegrity => "DOCX numbering part failed size or CRC validation",
247            Self::InvalidDocumentXml => "DOCX main document part is invalid XML",
248            Self::InvalidStylesXml => "DOCX styles part is invalid XML",
249            Self::InvalidNumberingXml => "DOCX numbering part is invalid XML",
250            Self::MissingDocumentBody => "DOCX main document part has no document body",
251            Self::TooManyParagraphs => "DOCX main document part has too many paragraphs",
252            Self::TooManyStructuralFacts => {
253                "DOCX main document part produces too many structural facts"
254            }
255            Self::TooManyNumberingItems => "DOCX numbering part exceeds the configured item limit",
256            Self::InvalidInternalParagraphId => "application paragraph ID is invalid",
257            Self::DuplicateInternalParagraphId => "application paragraph IDs are not unique",
258        };
259        formatter.write_str(message)
260    }
261}
262
263impl std::error::Error for ProjectionError {}
264
265/// Projects a bounded DOCX package using default projection options.
266///
267/// # Errors
268///
269/// Returns [`ProjectionError`] for invalid or unsupported package input, a
270/// resource-limit violation, or an invalid identity allocated by the caller.
271pub fn project_docx<F>(
272    bytes: &[u8],
273    limits: DocxLimits,
274    allocate_id: F,
275) -> Result<DocumentProjection, ProjectionError>
276where
277    F: FnMut(ParagraphIdentityFacts<'_>) -> Result<InternalParagraphId, ProjectionError>,
278{
279    project_docx_with_options(bytes, limits, ProjectionOptions::default(), allocate_id)
280}
281
282/// Projects a bounded DOCX package using explicit projection options.
283///
284/// # Errors
285///
286/// Returns [`ProjectionError`] for invalid or unsupported package input, a
287/// resource-limit violation, or an invalid identity allocated by the caller.
288pub fn project_docx_with_options<F>(
289    bytes: &[u8],
290    limits: DocxLimits,
291    options: ProjectionOptions,
292    allocate_id: F,
293) -> Result<DocumentProjection, ProjectionError>
294where
295    F: FnMut(ParagraphIdentityFacts<'_>) -> Result<InternalParagraphId, ProjectionError>,
296{
297    let parts = extract_document_parts(bytes, limits)?;
298    project_parts(&parts, limits, options, allocate_id)
299}
300
301// Both package entry points must share dependency preparation and projection.
302#[inline(never)]
303fn project_parts<F>(
304    parts: &DocumentParts,
305    limits: DocxLimits,
306    options: ProjectionOptions,
307    allocate_id: F,
308) -> Result<DocumentProjection, ProjectionError>
309where
310    F: FnMut(ParagraphIdentityFacts<'_>) -> Result<InternalParagraphId, ProjectionError>,
311{
312    let styles = parts.styles_xml.as_deref().map_or(
313        Err(StructuralFactUnknownReason::StylesPartUnavailable),
314        |styles| {
315            styles::parse_styles(styles, limits.maximum_styles)
316                .map_err(|_| StructuralFactUnknownReason::UnsupportedStyles)
317        },
318    );
319    let numbering = parse_optional_numbering(
320        parts.numbering_xml.as_deref(),
321        limits.maximum_numbering_items,
322    )?;
323    project_document_xml_with_limit(
324        &parts.document_xml,
325        limits.maximum_paragraphs,
326        limits.maximum_structural_facts,
327        options,
328        ProjectionDependencies {
329            styles: styles.as_ref().map_err(|reason| *reason),
330            numbering: numbering
331                .as_ref()
332                .ok_or(StructuralFactUnknownReason::UnsupportedNumbering),
333        },
334        allocate_id,
335    )
336}
337
338/// Projects a bounded DOCX package and its attributed review facts in one
339/// package-directory scan.
340///
341/// Invalid optional review parts produce an explicit unknown fact family; they
342/// do not discard an otherwise valid document projection.
343///
344/// # Errors
345///
346/// Returns [`ProjectionError`] for an invalid document projection or package
347/// boundary. Optional comments-part failures remain represented in
348/// [`DocumentReviewFacts`].
349pub fn project_docx_with_review_facts<F>(
350    bytes: &[u8],
351    limits: DocxLimits,
352    review_limits: ReviewFactLimits,
353    options: ProjectionOptions,
354    allocate_id: F,
355) -> Result<DocumentPackageProjection, ProjectionError>
356where
357    F: FnMut(ParagraphIdentityFacts<'_>) -> Result<InternalParagraphId, ProjectionError>,
358{
359    let parts = archive::extract_projection_parts(bytes, limits, review_limits)?;
360    let styles = parts.styles.as_deref().map_or(
361        Err(StructuralFactUnknownReason::StylesPartUnavailable),
362        |styles| {
363            styles::parse_styles(styles, limits.maximum_styles)
364                .map_err(|_| StructuralFactUnknownReason::UnsupportedStyles)
365        },
366    );
367    let numbering =
368        parse_optional_numbering(parts.numbering.as_deref(), limits.maximum_numbering_items)?;
369    let ProjectedDocumentWithReview {
370        document,
371        revisions,
372        comment_anchors,
373    } = project_document_xml_with_limit_and_review(
374        &parts.document,
375        limits.maximum_paragraphs,
376        limits.maximum_structural_facts,
377        options,
378        ProjectionDependencies {
379            styles: styles.as_ref().map_err(|reason| *reason),
380            numbering: numbering
381                .as_ref()
382                .ok_or(StructuralFactUnknownReason::UnsupportedNumbering),
383        },
384        Some(ooxml::ReviewProjectionLimits {
385            maximum_facts: review_limits.maximum_facts_per_family,
386            maximum_detail_bytes: review_limits.maximum_review_detail_bytes,
387        }),
388        allocate_id,
389    )?;
390    let review_facts = review::project_review_facts(
391        revisions.unwrap_or(ReviewFactSet::Unknown(
392            ReviewFactUnknownReason::InvalidDocument,
393        )),
394        comment_anchors.as_ref(),
395        &document,
396        parts.comments,
397        parts.comments_extended,
398        review_limits,
399        options.text_materialization,
400    );
401    Ok(DocumentPackageProjection {
402        document,
403        review_facts,
404    })
405}
406
407fn parse_optional_numbering(
408    xml: Option<&[u8]>,
409    maximum_items: usize,
410) -> Result<Option<numbering::NumberingCatalog>, ProjectionError> {
411    let Some(xml) = xml else {
412        return Ok(None);
413    };
414    match numbering::parse_numbering(xml, maximum_items) {
415        Ok(catalog) => Ok(Some(catalog)),
416        Err(ProjectionError::TooManyNumberingItems) => Err(ProjectionError::TooManyNumberingItems),
417        Err(_) => Ok(None),
418    }
419}
420
421/// Projects a bounded standalone main document or relationship-selected Flat OPC package.
422///
423/// Package payloads are indexed without scanning their bodies. Only the selected
424/// document and its style/numbering dependencies enter the shared projection.
425/// Standalone input retains unknown dependency-derived evidence.
426///
427/// # Errors
428/// Returns [`ProjectionError`] for malformed input, resource limits or allocator failures.
429pub fn project_main_document_xml<F>(
430    xml: &[u8],
431    limits: DocxLimits,
432    allocate_id: F,
433) -> Result<DocumentProjection, ProjectionError>
434where
435    F: FnMut(ParagraphIdentityFacts<'_>) -> Result<InternalParagraphId, ProjectionError>,
436{
437    if xml.len() > limits.maximum_archive_bytes {
438        return Err(ProjectionError::ArchiveTooLarge);
439    }
440    match flat_opc::input_kind(xml)? {
441        flat_opc::XmlInputKind::Document => {
442            if xml.len() > limits.maximum_document_xml_bytes {
443                return Err(ProjectionError::DocumentXmlTooLarge);
444            }
445            project_document_xml_with_limit(
446                xml,
447                limits.maximum_paragraphs,
448                limits.maximum_structural_facts,
449                ProjectionOptions::default(),
450                ProjectionDependencies {
451                    styles: Err(StructuralFactUnknownReason::DocumentPartOnly),
452                    numbering: Err(StructuralFactUnknownReason::DocumentPartOnly),
453                },
454                allocate_id,
455            )
456        }
457        flat_opc::XmlInputKind::Package => project_parts(
458            &flat_opc::extract_parts(xml, limits)?,
459            limits,
460            ProjectionOptions::default(),
461            allocate_id,
462        ),
463    }
464}
465
466/// Projects exactly one paragraph from a relationship-selected Flat OPC package.
467///
468/// The caller's package, XML, dependency and fact limits still apply; the paragraph
469/// count is additionally capped at one. Namespace context and related styles use
470/// the same package selection as full-document projection. This is partial evidence:
471/// all document structural fact families are unknown, and table coordinates are
472/// unavailable because fragment-local positions cannot identify document locations.
473///
474/// # Errors
475/// Returns [`ProjectionError`] for malformed input, zero or multiple paragraphs,
476/// resource limits or allocator failures. Standalone XML is not a package fragment.
477pub fn project_paragraph_fragment<F>(
478    xml: &[u8],
479    mut limits: DocxLimits,
480    allocate_id: F,
481) -> Result<DocumentProjection, ProjectionError>
482where
483    F: FnMut(ParagraphIdentityFacts<'_>) -> Result<InternalParagraphId, ProjectionError>,
484{
485    limits.maximum_paragraphs = limits.maximum_paragraphs.min(1);
486    let mut projection = project_parts(
487        &flat_opc::extract_parts(xml, limits)?,
488        limits,
489        ProjectionOptions::default(),
490        allocate_id,
491    )?;
492    let [paragraph] = projection.paragraphs.as_mut_slice() else {
493        return Err(ProjectionError::InvalidDocumentXml);
494    };
495    paragraph.structure = None;
496    projection.structural_facts = DocumentStructureFacts {
497        indentation: StructuralFactSet::Unknown(StructuralFactUnknownReason::ParagraphFragment),
498        numbering_hierarchy: StructuralFactSet::Unknown(
499            StructuralFactUnknownReason::ParagraphFragment,
500        ),
501        bookmarks: StructuralFactSet::Unknown(StructuralFactUnknownReason::ParagraphFragment),
502        internal_references: StructuralFactSet::Unknown(
503            StructuralFactUnknownReason::ParagraphFragment,
504        ),
505        outline_levels: StructuralFactSet::Unknown(StructuralFactUnknownReason::ParagraphFragment),
506    };
507    Ok(projection)
508}
509
510/// Projects an uncompressed main OOXML document part with default options.
511///
512/// # Errors
513///
514/// Returns [`ProjectionError`] for invalid or unsupported XML, a resource-limit
515/// violation, or an invalid identity allocated by the caller.
516pub fn project_document_xml<F>(
517    xml: &[u8],
518    allocate_id: F,
519) -> Result<DocumentProjection, ProjectionError>
520where
521    F: FnMut(ParagraphIdentityFacts<'_>) -> Result<InternalParagraphId, ProjectionError>,
522{
523    project_document_xml_with_options(xml, ProjectionOptions::default(), allocate_id)
524}
525
526/// Projects an uncompressed main OOXML document part with explicit options.
527///
528/// # Errors
529///
530/// Returns [`ProjectionError`] for invalid or unsupported XML, a resource-limit
531/// violation, or an invalid identity allocated by the caller.
532pub fn project_document_xml_with_options<F>(
533    xml: &[u8],
534    options: ProjectionOptions,
535    allocate_id: F,
536) -> Result<DocumentProjection, ProjectionError>
537where
538    F: FnMut(ParagraphIdentityFacts<'_>) -> Result<InternalParagraphId, ProjectionError>,
539{
540    project_document_xml_with_limit(
541        xml,
542        DocxLimits::default().maximum_paragraphs,
543        DocxLimits::default().maximum_structural_facts,
544        options,
545        ProjectionDependencies {
546            styles: Err(StructuralFactUnknownReason::DocumentPartOnly),
547            numbering: Err(StructuralFactUnknownReason::DocumentPartOnly),
548        },
549        allocate_id,
550    )
551}
552
553fn project_document_xml_with_limit<F>(
554    xml: &[u8],
555    maximum_paragraphs: usize,
556    maximum_structural_facts: usize,
557    options: ProjectionOptions,
558    dependencies: ProjectionDependencies<'_>,
559    allocate_id: F,
560) -> Result<DocumentProjection, ProjectionError>
561where
562    F: FnMut(ParagraphIdentityFacts<'_>) -> Result<InternalParagraphId, ProjectionError>,
563{
564    project_document_xml_with_limit_and_review(
565        xml,
566        maximum_paragraphs,
567        maximum_structural_facts,
568        options,
569        dependencies,
570        None,
571        allocate_id,
572    )
573    .map(|projection| projection.document)
574}
575
576struct ProjectedDocumentWithReview {
577    document: DocumentProjection,
578    revisions: Option<ReviewFactSet<AttributedRevision>>,
579    comment_anchors: Option<HashMap<String, ReviewSpan>>,
580}
581
582#[derive(Clone, Copy)]
583struct ProjectionDependencies<'a> {
584    styles: Result<&'a structure::StyleSheet, StructuralFactUnknownReason>,
585    numbering: Result<&'a numbering::NumberingCatalog, StructuralFactUnknownReason>,
586}
587
588fn project_document_xml_with_limit_and_review<F>(
589    xml: &[u8],
590    maximum_paragraphs: usize,
591    maximum_structural_facts: usize,
592    options: ProjectionOptions,
593    dependencies: ProjectionDependencies<'_>,
594    review_limits: Option<ooxml::ReviewProjectionLimits>,
595    mut allocate_id: F,
596) -> Result<ProjectedDocumentWithReview, ProjectionError>
597where
598    F: FnMut(ParagraphIdentityFacts<'_>) -> Result<InternalParagraphId, ProjectionError>,
599{
600    let projected = ooxml::project_document_xml(
601        xml,
602        maximum_paragraphs,
603        options.revision_view,
604        options.text_materialization,
605        dependencies.styles.map_err(formatting_unknown_reason),
606        review_limits,
607    )?;
608    let review_revisions = projected.review_revisions;
609    let review_comment_anchors = review_limits
610        .is_some()
611        .then_some(projected.review_comment_anchors);
612    let mut seen_ids = HashSet::with_capacity(projected.paragraphs.len());
613    let mut ids = Vec::with_capacity(projected.paragraphs.len());
614    for paragraph in &projected.paragraphs {
615        let facts = ParagraphIdentityFacts {
616            ordinal: paragraph.ordinal,
617            package_paragraph_id: paragraph.package_paragraph_id,
618            text: &paragraph.text,
619        };
620        let id = allocate_id(facts)?;
621        if !seen_ids.insert(id.clone()) {
622            return Err(ProjectionError::DuplicateInternalParagraphId);
623        }
624        ids.push(id);
625    }
626    let texts = projected
627        .paragraphs
628        .iter()
629        .map(|paragraph| paragraph.text.as_str())
630        .collect::<Vec<_>>();
631    let properties = projected
632        .paragraphs
633        .iter()
634        .map(|paragraph| &paragraph.properties)
635        .collect::<Vec<_>>();
636    let structural_facts = structure::materialize_structure(
637        structure::RawStructureInput {
638            paragraph_texts: &texts,
639            properties: &properties,
640            bookmarks: projected.bookmarks.as_deref().map_err(|reason| *reason),
641            references: projected.references.as_deref().map_err(|reason| *reason),
642        },
643        dependencies.styles,
644        dependencies.numbering,
645        maximum_structural_facts,
646    )?;
647    let mut formatting_completeness = projected.formatting_completeness;
648    formatting_completeness.alignment = FormattingFactStatus::Known;
649    let mut paragraphs = Vec::with_capacity(projected.paragraphs.len());
650    for (id, paragraph) in ids.into_iter().zip(projected.paragraphs) {
651        let alignment =
652            structure::resolve_paragraph_alignment(dependencies.styles, &paragraph.properties);
653        let alignment = match alignment {
654            Ok(fact) => fact,
655            Err(reason) => {
656                formatting_completeness.alignment = FormattingFactStatus::Unknown(reason);
657                None
658            }
659        };
660        paragraphs.push(ProjectedParagraph {
661            id,
662            ordinal: paragraph.ordinal,
663            package_paragraph_id: paragraph.package_paragraph_id,
664            style_id: paragraph.properties.style_id,
665            text: paragraph.text,
666            formatting: paragraph.formatting,
667            structure: paragraph.structure,
668            container: paragraph.container,
669            alignment,
670        });
671    }
672    Ok(ProjectedDocumentWithReview {
673        document: DocumentProjection {
674            paragraphs,
675            formatting_completeness,
676            revision_status: projected.revision_status,
677            structural_facts,
678        },
679        revisions: review_revisions,
680        comment_anchors: review_comment_anchors,
681    })
682}
683
684const fn formatting_unknown_reason(reason: StructuralFactUnknownReason) -> FormattingUnknownReason {
685    match reason {
686        StructuralFactUnknownReason::DocumentPartOnly
687        | StructuralFactUnknownReason::ParagraphFragment => {
688            FormattingUnknownReason::DocumentPartOnly
689        }
690        StructuralFactUnknownReason::StylesPartUnavailable => {
691            FormattingUnknownReason::StylesPartUnavailable
692        }
693        StructuralFactUnknownReason::UnsupportedStyles
694        | StructuralFactUnknownReason::UnsupportedNumbering
695        | StructuralFactUnknownReason::IncompleteBookmarkRanges
696        | StructuralFactUnknownReason::UnsupportedInternalReferences => {
697            FormattingUnknownReason::UnsupportedStyles
698        }
699    }
700}