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