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