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 namespaces;
10mod numbering;
11mod ooxml;
12mod relationships;
13mod review;
14mod structure;
15mod styles;
16
17use std::collections::{HashMap, HashSet};
18use std::fmt;
19
20pub use archive::{DocumentParts, DocxLimits, extract_document_parts, extract_document_xml};
21pub use ooxml::{
22    PackageParagraphId, ParagraphStructure, RevisionProjectionStatus, RevisionUnsupportedReason,
23    RevisionView, TextFormattingSpan, TextMaterialization, TextStyle,
24};
25pub use review::{
26    AttributedComment, AttributedRevision, CommentContent, DocumentReviewFacts, ReviewDetail,
27    ReviewFactLimits, ReviewFactSet, ReviewFactUnknownReason, ReviewPoint, ReviewSpan,
28    RevisionContent, RevisionFactKind,
29};
30pub use structure::{
31    BookmarkFact, DocumentStructureFacts, InternalReferenceFact, InternalReferenceRole,
32    NumberingHierarchyFact, ParagraphAlignmentFact, ParagraphAlignmentSource,
33    ParagraphAlignmentValue, ParagraphIndentation, ParagraphIndentationFact,
34    ParagraphOutlineLevelFact, SpanCoverage, StructuralFactSet, StructuralFactUnknownReason,
35    StructuralSpan,
36};
37pub use styles::is_semantic_highlight_color;
38
39#[derive(Clone, Debug, Eq, Hash, PartialEq)]
40pub struct InternalParagraphId(String);
41
42impl InternalParagraphId {
43    /// Creates a bounded, non-empty application paragraph identity.
44    ///
45    /// # Errors
46    ///
47    /// Returns [`ProjectionError::InvalidInternalParagraphId`] when the value
48    /// is empty or exceeds the identity length limit.
49    pub fn new(value: impl Into<String>) -> Result<Self, ProjectionError> {
50        let value = value.into();
51        if value.is_empty() || value.len() > 128 {
52            return Err(ProjectionError::InvalidInternalParagraphId);
53        }
54        Ok(Self(value))
55    }
56
57    #[must_use]
58    pub fn as_str(&self) -> &str {
59        &self.0
60    }
61}
62
63#[derive(Clone, Copy, Debug)]
64pub struct ParagraphIdentityFacts<'a> {
65    pub ordinal: usize,
66    pub package_paragraph_id: Option<PackageParagraphId>,
67    pub text: &'a str,
68}
69
70#[derive(Clone, Debug, Eq, PartialEq)]
71pub struct ProjectedParagraph {
72    pub id: InternalParagraphId,
73    pub ordinal: usize,
74    pub package_paragraph_id: Option<PackageParagraphId>,
75    pub style_id: Option<String>,
76    pub text: String,
77    pub formatting: Vec<TextFormattingSpan>,
78    pub structure: Option<ParagraphStructure>,
79    pub alignment: Option<ParagraphAlignmentFact>,
80}
81
82#[derive(Clone, Copy, Debug, Eq, PartialEq)]
83pub enum FormattingUnknownReason {
84    DocumentPartOnly,
85    StylesPartUnavailable,
86    UnsupportedStyles,
87}
88
89#[derive(Clone, Copy, Debug, Eq, PartialEq)]
90pub enum FormattingProjectionStatus {
91    Complete,
92    Incomplete(FormattingUnknownReason),
93}
94
95impl Default for FormattingProjectionStatus {
96    fn default() -> Self {
97        Self::Incomplete(FormattingUnknownReason::DocumentPartOnly)
98    }
99}
100
101#[derive(Clone, Debug, Eq, PartialEq)]
102pub struct DocumentProjection {
103    pub paragraphs: Vec<ProjectedParagraph>,
104    pub formatting_status: FormattingProjectionStatus,
105    pub revision_status: RevisionProjectionStatus,
106    pub structural_facts: DocumentStructureFacts,
107}
108
109#[derive(Clone, Debug, Eq, PartialEq)]
110pub struct DocumentPackageProjection {
111    pub document: DocumentProjection,
112    pub review_facts: DocumentReviewFacts,
113}
114
115#[derive(Clone, Copy, Debug, Eq, PartialEq)]
116pub struct ProjectionOptions {
117    pub revision_view: RevisionView,
118    pub text_materialization: TextMaterialization,
119}
120
121impl Default for ProjectionOptions {
122    fn default() -> Self {
123        Self {
124            revision_view: RevisionView::Current,
125            text_materialization: TextMaterialization::WordHost,
126        }
127    }
128}
129
130#[derive(Clone, Debug, Eq, PartialEq)]
131pub enum ProjectionError {
132    ArchiveTooLarge,
133    InvalidArchive,
134    TooManyArchiveEntries,
135    InvalidPackageRelationships,
136    PackageRelationshipsTooLarge,
137    MissingDocumentXml,
138    DuplicateDocumentXml,
139    DuplicateStylesXml,
140    DuplicateNumberingXml,
141    EncryptedDocumentXml,
142    UnsupportedCompression(u16),
143    DocumentXmlTooLarge,
144    SuspiciousCompressionRatio,
145    InvalidDocumentXmlEntry,
146    DocumentXmlIntegrity,
147    StylesXmlTooLarge,
148    InvalidStylesXmlEntry,
149    StylesXmlIntegrity,
150    NumberingXmlTooLarge,
151    InvalidNumberingXmlEntry,
152    NumberingXmlIntegrity,
153    InvalidDocumentXml,
154    InvalidStylesXml,
155    InvalidNumberingXml,
156    MissingDocumentBody,
157    TooManyParagraphs,
158    TooManyStructuralFacts,
159    TooManyNumberingItems,
160    InvalidInternalParagraphId,
161    DuplicateInternalParagraphId,
162}
163
164impl fmt::Display for ProjectionError {
165    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
166        let message = match self {
167            Self::ArchiveTooLarge => "DOCX archive exceeds the configured size limit",
168            Self::InvalidArchive => "DOCX archive is invalid",
169            Self::TooManyArchiveEntries => "DOCX archive has too many entries",
170            Self::InvalidPackageRelationships => "DOCX archive has invalid package relationships",
171            Self::PackageRelationshipsTooLarge => {
172                "DOCX package relationships exceed the configured size limit"
173            }
174            Self::MissingDocumentXml => "DOCX archive has no main document part",
175            Self::DuplicateDocumentXml => "DOCX archive has duplicate main document parts",
176            Self::DuplicateStylesXml => "DOCX archive has duplicate entries for its styles part",
177            Self::DuplicateNumberingXml => {
178                "DOCX archive has duplicate entries for its numbering part"
179            }
180            Self::EncryptedDocumentXml => "DOCX main document part is encrypted",
181            Self::UnsupportedCompression(_) => {
182                "selected DOCX package part uses unsupported compression"
183            }
184            Self::DocumentXmlTooLarge => {
185                "DOCX main document part exceeds the configured size limit"
186            }
187            Self::SuspiciousCompressionRatio => {
188                "selected DOCX package part exceeds the compression-ratio limit"
189            }
190            Self::InvalidDocumentXmlEntry => "DOCX main document part has an invalid ZIP entry",
191            Self::DocumentXmlIntegrity => "DOCX main document part failed size or CRC validation",
192            Self::StylesXmlTooLarge => "DOCX styles part exceeds the configured size limit",
193            Self::InvalidStylesXmlEntry => "DOCX styles part has an invalid ZIP entry",
194            Self::StylesXmlIntegrity => "DOCX styles part failed size or CRC validation",
195            Self::NumberingXmlTooLarge => "DOCX numbering part exceeds the configured size limit",
196            Self::InvalidNumberingXmlEntry => "DOCX numbering part has an invalid ZIP entry",
197            Self::NumberingXmlIntegrity => "DOCX numbering part failed size or CRC validation",
198            Self::InvalidDocumentXml => "DOCX main document part is invalid XML",
199            Self::InvalidStylesXml => "DOCX styles part is invalid XML",
200            Self::InvalidNumberingXml => "DOCX numbering part is invalid XML",
201            Self::MissingDocumentBody => "DOCX main document part has no document body",
202            Self::TooManyParagraphs => "DOCX main document part has too many paragraphs",
203            Self::TooManyStructuralFacts => {
204                "DOCX main document part produces too many structural facts"
205            }
206            Self::TooManyNumberingItems => "DOCX numbering part exceeds the configured item limit",
207            Self::InvalidInternalParagraphId => "application paragraph ID is invalid",
208            Self::DuplicateInternalParagraphId => "application paragraph IDs are not unique",
209        };
210        formatter.write_str(message)
211    }
212}
213
214impl std::error::Error for ProjectionError {}
215
216/// Projects a bounded DOCX package using default projection options.
217///
218/// # Errors
219///
220/// Returns [`ProjectionError`] for invalid or unsupported package input, a
221/// resource-limit violation, or an invalid identity allocated by the caller.
222pub fn project_docx<F>(
223    bytes: &[u8],
224    limits: DocxLimits,
225    allocate_id: F,
226) -> Result<DocumentProjection, ProjectionError>
227where
228    F: FnMut(ParagraphIdentityFacts<'_>) -> Result<InternalParagraphId, ProjectionError>,
229{
230    project_docx_with_options(bytes, limits, ProjectionOptions::default(), allocate_id)
231}
232
233/// Projects a bounded DOCX package using explicit projection options.
234///
235/// # Errors
236///
237/// Returns [`ProjectionError`] for invalid or unsupported package input, a
238/// resource-limit violation, or an invalid identity allocated by the caller.
239pub fn project_docx_with_options<F>(
240    bytes: &[u8],
241    limits: DocxLimits,
242    options: ProjectionOptions,
243    allocate_id: F,
244) -> Result<DocumentProjection, ProjectionError>
245where
246    F: FnMut(ParagraphIdentityFacts<'_>) -> Result<InternalParagraphId, ProjectionError>,
247{
248    let parts = extract_document_parts(bytes, limits)?;
249    let styles = parts.styles_xml.as_deref().map_or(
250        Err(StructuralFactUnknownReason::StylesPartUnavailable),
251        |styles| {
252            styles::parse_styles(styles, limits.maximum_styles)
253                .map_err(|_| StructuralFactUnknownReason::UnsupportedStyles)
254        },
255    );
256    let numbering = parse_optional_numbering(
257        parts.numbering_xml.as_deref(),
258        limits.maximum_numbering_items,
259    )?;
260    project_document_xml_with_limit(
261        &parts.document_xml,
262        limits.maximum_paragraphs,
263        limits.maximum_structural_facts,
264        options,
265        ProjectionDependencies {
266            styles: styles.as_ref().map_err(|reason| *reason),
267            numbering: numbering
268                .as_ref()
269                .ok_or(StructuralFactUnknownReason::UnsupportedNumbering),
270        },
271        allocate_id,
272    )
273}
274
275/// Projects a bounded DOCX package and its attributed review facts in one
276/// package-directory scan.
277///
278/// Invalid optional review parts produce an explicit unknown fact family; they
279/// do not discard an otherwise valid document projection.
280///
281/// # Errors
282///
283/// Returns [`ProjectionError`] for an invalid document projection or package
284/// boundary. Optional comments-part failures remain represented in
285/// [`DocumentReviewFacts`].
286pub fn project_docx_with_review_facts<F>(
287    bytes: &[u8],
288    limits: DocxLimits,
289    review_limits: ReviewFactLimits,
290    options: ProjectionOptions,
291    allocate_id: F,
292) -> Result<DocumentPackageProjection, ProjectionError>
293where
294    F: FnMut(ParagraphIdentityFacts<'_>) -> Result<InternalParagraphId, ProjectionError>,
295{
296    let parts = archive::extract_projection_parts(bytes, limits, review_limits)?;
297    let styles = parts.styles.as_deref().map_or(
298        Err(StructuralFactUnknownReason::StylesPartUnavailable),
299        |styles| {
300            styles::parse_styles(styles, limits.maximum_styles)
301                .map_err(|_| StructuralFactUnknownReason::UnsupportedStyles)
302        },
303    );
304    let numbering =
305        parse_optional_numbering(parts.numbering.as_deref(), limits.maximum_numbering_items)?;
306    let ProjectedDocumentWithReview {
307        document,
308        revisions,
309        comment_anchors,
310    } = project_document_xml_with_limit_and_review(
311        &parts.document,
312        limits.maximum_paragraphs,
313        limits.maximum_structural_facts,
314        options,
315        ProjectionDependencies {
316            styles: styles.as_ref().map_err(|reason| *reason),
317            numbering: numbering
318                .as_ref()
319                .ok_or(StructuralFactUnknownReason::UnsupportedNumbering),
320        },
321        Some(ooxml::ReviewProjectionLimits {
322            maximum_facts: review_limits.maximum_facts_per_family,
323            maximum_detail_bytes: review_limits.maximum_review_detail_bytes,
324        }),
325        allocate_id,
326    )?;
327    let review_facts = review::project_review_facts(
328        revisions.unwrap_or(ReviewFactSet::Unknown(
329            ReviewFactUnknownReason::InvalidDocument,
330        )),
331        comment_anchors.as_ref(),
332        &document,
333        parts.comments,
334        parts.comments_extended,
335        review_limits,
336        options.text_materialization,
337    );
338    Ok(DocumentPackageProjection {
339        document,
340        review_facts,
341    })
342}
343
344fn parse_optional_numbering(
345    xml: Option<&[u8]>,
346    maximum_items: usize,
347) -> Result<Option<numbering::NumberingCatalog>, ProjectionError> {
348    let Some(xml) = xml else {
349        return Ok(None);
350    };
351    match numbering::parse_numbering(xml, maximum_items) {
352        Ok(catalog) => Ok(Some(catalog)),
353        Err(ProjectionError::TooManyNumberingItems) => Err(ProjectionError::TooManyNumberingItems),
354        Err(_) => Ok(None),
355    }
356}
357
358/// Projects an uncompressed main OOXML document part with default options.
359///
360/// # Errors
361///
362/// Returns [`ProjectionError`] for invalid or unsupported XML, a resource-limit
363/// violation, or an invalid identity allocated by the caller.
364pub fn project_document_xml<F>(
365    xml: &[u8],
366    allocate_id: F,
367) -> Result<DocumentProjection, ProjectionError>
368where
369    F: FnMut(ParagraphIdentityFacts<'_>) -> Result<InternalParagraphId, ProjectionError>,
370{
371    project_document_xml_with_options(xml, ProjectionOptions::default(), allocate_id)
372}
373
374/// Projects an uncompressed main OOXML document part with explicit options.
375///
376/// # Errors
377///
378/// Returns [`ProjectionError`] for invalid or unsupported XML, a resource-limit
379/// violation, or an invalid identity allocated by the caller.
380pub fn project_document_xml_with_options<F>(
381    xml: &[u8],
382    options: ProjectionOptions,
383    allocate_id: F,
384) -> Result<DocumentProjection, ProjectionError>
385where
386    F: FnMut(ParagraphIdentityFacts<'_>) -> Result<InternalParagraphId, ProjectionError>,
387{
388    project_document_xml_with_limit(
389        xml,
390        DocxLimits::default().maximum_paragraphs,
391        DocxLimits::default().maximum_structural_facts,
392        options,
393        ProjectionDependencies {
394            styles: Err(StructuralFactUnknownReason::DocumentPartOnly),
395            numbering: Err(StructuralFactUnknownReason::DocumentPartOnly),
396        },
397        allocate_id,
398    )
399}
400
401fn project_document_xml_with_limit<F>(
402    xml: &[u8],
403    maximum_paragraphs: usize,
404    maximum_structural_facts: usize,
405    options: ProjectionOptions,
406    dependencies: ProjectionDependencies<'_>,
407    allocate_id: F,
408) -> Result<DocumentProjection, ProjectionError>
409where
410    F: FnMut(ParagraphIdentityFacts<'_>) -> Result<InternalParagraphId, ProjectionError>,
411{
412    project_document_xml_with_limit_and_review(
413        xml,
414        maximum_paragraphs,
415        maximum_structural_facts,
416        options,
417        dependencies,
418        None,
419        allocate_id,
420    )
421    .map(|projection| projection.document)
422}
423
424struct ProjectedDocumentWithReview {
425    document: DocumentProjection,
426    revisions: Option<ReviewFactSet<AttributedRevision>>,
427    comment_anchors: Option<HashMap<String, ReviewSpan>>,
428}
429
430#[derive(Clone, Copy)]
431struct ProjectionDependencies<'a> {
432    styles: Result<&'a structure::StyleSheet, StructuralFactUnknownReason>,
433    numbering: Result<&'a numbering::NumberingCatalog, StructuralFactUnknownReason>,
434}
435
436fn project_document_xml_with_limit_and_review<F>(
437    xml: &[u8],
438    maximum_paragraphs: usize,
439    maximum_structural_facts: usize,
440    options: ProjectionOptions,
441    dependencies: ProjectionDependencies<'_>,
442    review_limits: Option<ooxml::ReviewProjectionLimits>,
443    mut allocate_id: F,
444) -> Result<ProjectedDocumentWithReview, ProjectionError>
445where
446    F: FnMut(ParagraphIdentityFacts<'_>) -> Result<InternalParagraphId, ProjectionError>,
447{
448    let projected = ooxml::project_document_xml(
449        xml,
450        maximum_paragraphs,
451        options.revision_view,
452        options.text_materialization,
453        dependencies.styles.map_err(formatting_unknown_reason),
454        review_limits,
455    )?;
456    let review_revisions = projected.review_revisions;
457    let review_comment_anchors = review_limits
458        .is_some()
459        .then_some(projected.review_comment_anchors);
460    let mut seen_ids = HashSet::with_capacity(projected.paragraphs.len());
461    let mut ids = Vec::with_capacity(projected.paragraphs.len());
462    for paragraph in &projected.paragraphs {
463        let facts = ParagraphIdentityFacts {
464            ordinal: paragraph.ordinal,
465            package_paragraph_id: paragraph.package_paragraph_id,
466            text: &paragraph.text,
467        };
468        let id = allocate_id(facts)?;
469        if !seen_ids.insert(id.clone()) {
470            return Err(ProjectionError::DuplicateInternalParagraphId);
471        }
472        ids.push(id);
473    }
474    let texts = projected
475        .paragraphs
476        .iter()
477        .map(|paragraph| paragraph.text.as_str())
478        .collect::<Vec<_>>();
479    let properties = projected
480        .paragraphs
481        .iter()
482        .map(|paragraph| &paragraph.properties)
483        .collect::<Vec<_>>();
484    let structural_facts = structure::materialize_structure(
485        structure::RawStructureInput {
486            paragraph_texts: &texts,
487            properties: &properties,
488            bookmarks: projected.bookmarks.as_deref().map_err(|reason| *reason),
489            references: projected.references.as_deref().map_err(|reason| *reason),
490        },
491        dependencies.styles,
492        dependencies.numbering,
493        maximum_structural_facts,
494    )?;
495    let mut paragraphs = Vec::with_capacity(projected.paragraphs.len());
496    for (id, paragraph) in ids.into_iter().zip(projected.paragraphs) {
497        let alignment =
498            structure::resolve_paragraph_alignment(dependencies.styles, &paragraph.properties);
499        paragraphs.push(ProjectedParagraph {
500            id,
501            ordinal: paragraph.ordinal,
502            package_paragraph_id: paragraph.package_paragraph_id,
503            style_id: paragraph.properties.style_id,
504            text: paragraph.text,
505            formatting: paragraph.formatting,
506            structure: paragraph.structure,
507            alignment,
508        });
509    }
510    Ok(ProjectedDocumentWithReview {
511        document: DocumentProjection {
512            paragraphs,
513            formatting_status: projected.formatting_status,
514            revision_status: projected.revision_status,
515            structural_facts,
516        },
517        revisions: review_revisions,
518        comment_anchors: review_comment_anchors,
519    })
520}
521
522const fn formatting_unknown_reason(reason: StructuralFactUnknownReason) -> FormattingUnknownReason {
523    match reason {
524        StructuralFactUnknownReason::DocumentPartOnly => FormattingUnknownReason::DocumentPartOnly,
525        StructuralFactUnknownReason::StylesPartUnavailable => {
526            FormattingUnknownReason::StylesPartUnavailable
527        }
528        StructuralFactUnknownReason::UnsupportedStyles
529        | StructuralFactUnknownReason::UnsupportedNumbering
530        | StructuralFactUnknownReason::IncompleteBookmarkRanges
531        | StructuralFactUnknownReason::UnsupportedInternalReferences => {
532            FormattingUnknownReason::UnsupportedStyles
533        }
534    }
535}