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::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 (document, revisions) = project_document_xml_with_limit_and_review(
254        &parts.document,
255        limits.maximum_paragraphs,
256        limits.maximum_structural_facts,
257        options,
258        styles.as_ref().map_err(|reason| *reason),
259        Some(review_limits.maximum_facts_per_family),
260        allocate_id,
261    )?;
262    let review_facts = review::project_review_facts(
263        revisions.unwrap_or(ReviewFactSet::Unknown(
264            ReviewFactUnknownReason::InvalidDocument,
265        )),
266        parts.comments,
267        parts.comments_extended,
268        review_limits,
269    );
270    Ok(DocumentPackageProjection {
271        document,
272        review_facts,
273    })
274}
275
276/// Projects an uncompressed main OOXML document part with default options.
277///
278/// # Errors
279///
280/// Returns [`ProjectionError`] for invalid or unsupported XML, a resource-limit
281/// violation, or an invalid identity allocated by the caller.
282pub fn project_document_xml<F>(
283    xml: &[u8],
284    allocate_id: F,
285) -> Result<DocumentProjection, ProjectionError>
286where
287    F: FnMut(ParagraphIdentityFacts<'_>) -> Result<InternalParagraphId, ProjectionError>,
288{
289    project_document_xml_with_options(xml, ProjectionOptions::default(), allocate_id)
290}
291
292/// Projects an uncompressed main OOXML document part with explicit options.
293///
294/// # Errors
295///
296/// Returns [`ProjectionError`] for invalid or unsupported XML, a resource-limit
297/// violation, or an invalid identity allocated by the caller.
298pub fn project_document_xml_with_options<F>(
299    xml: &[u8],
300    options: ProjectionOptions,
301    allocate_id: F,
302) -> Result<DocumentProjection, ProjectionError>
303where
304    F: FnMut(ParagraphIdentityFacts<'_>) -> Result<InternalParagraphId, ProjectionError>,
305{
306    project_document_xml_with_limit(
307        xml,
308        DocxLimits::default().maximum_paragraphs,
309        DocxLimits::default().maximum_structural_facts,
310        options,
311        Err(StructuralFactUnknownReason::DocumentPartOnly),
312        allocate_id,
313    )
314}
315
316fn project_document_xml_with_limit<F>(
317    xml: &[u8],
318    maximum_paragraphs: usize,
319    maximum_structural_facts: usize,
320    options: ProjectionOptions,
321    styles: Result<&structure::StyleSheet, StructuralFactUnknownReason>,
322    allocate_id: F,
323) -> Result<DocumentProjection, ProjectionError>
324where
325    F: FnMut(ParagraphIdentityFacts<'_>) -> Result<InternalParagraphId, ProjectionError>,
326{
327    project_document_xml_with_limit_and_review(
328        xml,
329        maximum_paragraphs,
330        maximum_structural_facts,
331        options,
332        styles,
333        None,
334        allocate_id,
335    )
336    .map(|(projection, _)| projection)
337}
338
339fn project_document_xml_with_limit_and_review<F>(
340    xml: &[u8],
341    maximum_paragraphs: usize,
342    maximum_structural_facts: usize,
343    options: ProjectionOptions,
344    styles: Result<&structure::StyleSheet, StructuralFactUnknownReason>,
345    maximum_review_facts: Option<usize>,
346    mut allocate_id: F,
347) -> Result<
348    (
349        DocumentProjection,
350        Option<ReviewFactSet<AttributedRevision>>,
351    ),
352    ProjectionError,
353>
354where
355    F: FnMut(ParagraphIdentityFacts<'_>) -> Result<InternalParagraphId, ProjectionError>,
356{
357    let projected = ooxml::project_document_xml(
358        xml,
359        maximum_paragraphs,
360        options.revision_view,
361        options.text_materialization,
362        maximum_review_facts,
363    )?;
364    let review_revisions = projected.review_revisions;
365    let mut seen_ids = HashSet::with_capacity(projected.paragraphs.len());
366    let mut ids = Vec::with_capacity(projected.paragraphs.len());
367    for paragraph in &projected.paragraphs {
368        let facts = ParagraphIdentityFacts {
369            ordinal: paragraph.ordinal,
370            package_paragraph_id: paragraph.package_paragraph_id,
371            text: &paragraph.text,
372        };
373        let id = allocate_id(facts)?;
374        if !seen_ids.insert(id.clone()) {
375            return Err(ProjectionError::DuplicateInternalParagraphId);
376        }
377        ids.push(id);
378    }
379    let texts = projected
380        .paragraphs
381        .iter()
382        .map(|paragraph| paragraph.text.as_str())
383        .collect::<Vec<_>>();
384    let properties = projected
385        .paragraphs
386        .iter()
387        .map(|paragraph| paragraph.properties.clone())
388        .collect::<Vec<_>>();
389    let structural_facts = structure::materialize_structure(
390        structure::RawStructureInput {
391            paragraph_texts: &texts,
392            properties: &properties,
393            bookmarks: projected.bookmarks.as_deref().map_err(|reason| *reason),
394            references: projected.references.as_deref().map_err(|reason| *reason),
395        },
396        styles,
397        maximum_structural_facts,
398    )?;
399    let mut paragraphs = Vec::with_capacity(projected.paragraphs.len());
400    for (id, paragraph) in ids.into_iter().zip(projected.paragraphs) {
401        paragraphs.push(ProjectedParagraph {
402            id,
403            ordinal: paragraph.ordinal,
404            package_paragraph_id: paragraph.package_paragraph_id,
405            text: paragraph.text,
406            formatting: paragraph.formatting,
407            structure: paragraph.structure,
408        });
409    }
410    Ok((
411        DocumentProjection {
412            paragraphs,
413            revision_status: projected.revision_status,
414            structural_facts,
415        },
416        review_revisions,
417    ))
418}