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