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