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