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