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 structure;
13mod styles;
14
15use std::collections::HashSet;
16use std::fmt;
17
18pub use archive::{DocumentParts, DocxLimits, extract_document_parts, extract_document_xml};
19pub use ooxml::{
20    PackageParagraphId, ParagraphStructure, RevisionProjectionStatus, RevisionUnsupportedReason,
21    RevisionView, TextFormattingSpan, TextMaterialization, TextStyle, is_semantic_highlight_color,
22};
23pub use structure::{
24    BookmarkFact, DocumentStructureFacts, InternalReferenceFact, InternalReferenceRole,
25    NumberingHierarchyFact, ParagraphIndentation, ParagraphIndentationFact, SpanCoverage,
26    StructuralFactSet, StructuralFactUnknownReason, StructuralSpan,
27};
28
29#[derive(Clone, Debug, Eq, Hash, PartialEq)]
30pub struct InternalParagraphId(String);
31
32impl InternalParagraphId {
33    /// Creates a bounded, non-empty application paragraph identity.
34    ///
35    /// # Errors
36    ///
37    /// Returns [`ProjectionError::InvalidInternalParagraphId`] when the value
38    /// is empty or exceeds the identity length limit.
39    pub fn new(value: impl Into<String>) -> Result<Self, ProjectionError> {
40        let value = value.into();
41        if value.is_empty() || value.len() > 128 {
42            return Err(ProjectionError::InvalidInternalParagraphId);
43        }
44        Ok(Self(value))
45    }
46
47    #[must_use]
48    pub fn as_str(&self) -> &str {
49        &self.0
50    }
51}
52
53#[derive(Clone, Copy, Debug)]
54pub struct ParagraphIdentityFacts<'a> {
55    pub ordinal: usize,
56    pub package_paragraph_id: Option<PackageParagraphId>,
57    pub text: &'a str,
58}
59
60#[derive(Clone, Debug, Eq, PartialEq)]
61pub struct ProjectedParagraph {
62    pub id: InternalParagraphId,
63    pub ordinal: usize,
64    pub package_paragraph_id: Option<PackageParagraphId>,
65    pub text: String,
66    pub formatting: Vec<TextFormattingSpan>,
67    pub structure: Option<ParagraphStructure>,
68}
69
70#[derive(Clone, Debug, Eq, PartialEq)]
71pub struct DocumentProjection {
72    pub paragraphs: Vec<ProjectedParagraph>,
73    pub revision_status: RevisionProjectionStatus,
74    pub structural_facts: DocumentStructureFacts,
75}
76
77#[derive(Clone, Copy, Debug, Eq, PartialEq)]
78pub struct ProjectionOptions {
79    pub revision_view: RevisionView,
80    pub text_materialization: TextMaterialization,
81}
82
83impl Default for ProjectionOptions {
84    fn default() -> Self {
85        Self {
86            revision_view: RevisionView::Current,
87            text_materialization: TextMaterialization::WordHost,
88        }
89    }
90}
91
92#[derive(Clone, Debug, Eq, PartialEq)]
93pub enum ProjectionError {
94    ArchiveTooLarge,
95    InvalidArchive,
96    TooManyArchiveEntries,
97    InvalidPackageRelationships,
98    PackageRelationshipsTooLarge,
99    MissingDocumentXml,
100    DuplicateDocumentXml,
101    DuplicateStylesXml,
102    EncryptedDocumentXml,
103    UnsupportedCompression(u16),
104    DocumentXmlTooLarge,
105    SuspiciousCompressionRatio,
106    InvalidDocumentXmlEntry,
107    DocumentXmlIntegrity,
108    StylesXmlTooLarge,
109    InvalidStylesXmlEntry,
110    StylesXmlIntegrity,
111    InvalidDocumentXml,
112    InvalidStylesXml,
113    MissingDocumentBody,
114    TooManyParagraphs,
115    TooManyStructuralFacts,
116    InvalidInternalParagraphId,
117    DuplicateInternalParagraphId,
118}
119
120impl fmt::Display for ProjectionError {
121    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
122        let message = match self {
123            Self::ArchiveTooLarge => "DOCX archive exceeds the configured size limit",
124            Self::InvalidArchive => "DOCX archive is invalid",
125            Self::TooManyArchiveEntries => "DOCX archive has too many entries",
126            Self::InvalidPackageRelationships => "DOCX archive has invalid package relationships",
127            Self::PackageRelationshipsTooLarge => {
128                "DOCX package relationships exceed the configured size limit"
129            }
130            Self::MissingDocumentXml => "DOCX archive has no main document part",
131            Self::DuplicateDocumentXml => "DOCX archive has duplicate main document parts",
132            Self::DuplicateStylesXml => "DOCX archive has duplicate word/styles.xml entries",
133            Self::EncryptedDocumentXml => "DOCX main document part is encrypted",
134            Self::UnsupportedCompression(_) => {
135                "selected DOCX package part uses unsupported compression"
136            }
137            Self::DocumentXmlTooLarge => {
138                "DOCX main document part exceeds the configured size limit"
139            }
140            Self::SuspiciousCompressionRatio => {
141                "selected DOCX package part exceeds the compression-ratio limit"
142            }
143            Self::InvalidDocumentXmlEntry => "DOCX main document part has an invalid ZIP entry",
144            Self::DocumentXmlIntegrity => "DOCX main document part failed size or CRC validation",
145            Self::StylesXmlTooLarge => "word/styles.xml exceeds the configured size limit",
146            Self::InvalidStylesXmlEntry => "word/styles.xml has an invalid ZIP entry",
147            Self::StylesXmlIntegrity => "word/styles.xml failed size or CRC validation",
148            Self::InvalidDocumentXml => "DOCX main document part is invalid XML",
149            Self::InvalidStylesXml => "word/styles.xml is invalid XML",
150            Self::MissingDocumentBody => "DOCX main document part has no document body",
151            Self::TooManyParagraphs => "DOCX main document part has too many paragraphs",
152            Self::TooManyStructuralFacts => {
153                "DOCX main document part produces too many structural facts"
154            }
155            Self::InvalidInternalParagraphId => "application paragraph ID is invalid",
156            Self::DuplicateInternalParagraphId => "application paragraph IDs are not unique",
157        };
158        formatter.write_str(message)
159    }
160}
161
162impl std::error::Error for ProjectionError {}
163
164/// Projects a bounded DOCX package using default projection options.
165///
166/// # Errors
167///
168/// Returns [`ProjectionError`] for invalid or unsupported package input, a
169/// resource-limit violation, or an invalid identity allocated by the caller.
170pub fn project_docx<F>(
171    bytes: &[u8],
172    limits: DocxLimits,
173    allocate_id: F,
174) -> Result<DocumentProjection, ProjectionError>
175where
176    F: FnMut(ParagraphIdentityFacts<'_>) -> Result<InternalParagraphId, ProjectionError>,
177{
178    project_docx_with_options(bytes, limits, ProjectionOptions::default(), allocate_id)
179}
180
181/// Projects a bounded DOCX package using explicit projection options.
182///
183/// # Errors
184///
185/// Returns [`ProjectionError`] for invalid or unsupported package input, a
186/// resource-limit violation, or an invalid identity allocated by the caller.
187pub fn project_docx_with_options<F>(
188    bytes: &[u8],
189    limits: DocxLimits,
190    options: ProjectionOptions,
191    allocate_id: F,
192) -> Result<DocumentProjection, ProjectionError>
193where
194    F: FnMut(ParagraphIdentityFacts<'_>) -> Result<InternalParagraphId, ProjectionError>,
195{
196    let parts = extract_document_parts(bytes, limits)?;
197    let styles = parts.styles_xml.as_deref().map_or(
198        Err(StructuralFactUnknownReason::StylesPartUnavailable),
199        |styles| {
200            styles::parse_styles(styles).map_err(|_| StructuralFactUnknownReason::UnsupportedStyles)
201        },
202    );
203    project_document_xml_with_limit(
204        &parts.document_xml,
205        limits.maximum_paragraphs,
206        limits.maximum_structural_facts,
207        options,
208        styles.as_ref().map_err(|reason| *reason),
209        allocate_id,
210    )
211}
212
213/// Projects an uncompressed main OOXML document part with default options.
214///
215/// # Errors
216///
217/// Returns [`ProjectionError`] for invalid or unsupported XML, a resource-limit
218/// violation, or an invalid identity allocated by the caller.
219pub fn project_document_xml<F>(
220    xml: &[u8],
221    allocate_id: F,
222) -> Result<DocumentProjection, ProjectionError>
223where
224    F: FnMut(ParagraphIdentityFacts<'_>) -> Result<InternalParagraphId, ProjectionError>,
225{
226    project_document_xml_with_options(xml, ProjectionOptions::default(), allocate_id)
227}
228
229/// Projects an uncompressed main OOXML document part with explicit options.
230///
231/// # Errors
232///
233/// Returns [`ProjectionError`] for invalid or unsupported XML, a resource-limit
234/// violation, or an invalid identity allocated by the caller.
235pub fn project_document_xml_with_options<F>(
236    xml: &[u8],
237    options: ProjectionOptions,
238    allocate_id: F,
239) -> Result<DocumentProjection, ProjectionError>
240where
241    F: FnMut(ParagraphIdentityFacts<'_>) -> Result<InternalParagraphId, ProjectionError>,
242{
243    project_document_xml_with_limit(
244        xml,
245        DocxLimits::default().maximum_paragraphs,
246        DocxLimits::default().maximum_structural_facts,
247        options,
248        Err(StructuralFactUnknownReason::DocumentPartOnly),
249        allocate_id,
250    )
251}
252
253fn project_document_xml_with_limit<F>(
254    xml: &[u8],
255    maximum_paragraphs: usize,
256    maximum_structural_facts: usize,
257    options: ProjectionOptions,
258    styles: Result<&structure::StyleSheet, StructuralFactUnknownReason>,
259    mut allocate_id: F,
260) -> Result<DocumentProjection, ProjectionError>
261where
262    F: FnMut(ParagraphIdentityFacts<'_>) -> Result<InternalParagraphId, ProjectionError>,
263{
264    let projected = ooxml::project_document_xml(
265        xml,
266        maximum_paragraphs,
267        options.revision_view,
268        options.text_materialization,
269    )?;
270    let mut seen_ids = HashSet::with_capacity(projected.paragraphs.len());
271    let mut ids = Vec::with_capacity(projected.paragraphs.len());
272    for paragraph in &projected.paragraphs {
273        let facts = ParagraphIdentityFacts {
274            ordinal: paragraph.ordinal,
275            package_paragraph_id: paragraph.package_paragraph_id,
276            text: &paragraph.text,
277        };
278        let id = allocate_id(facts)?;
279        if !seen_ids.insert(id.clone()) {
280            return Err(ProjectionError::DuplicateInternalParagraphId);
281        }
282        ids.push(id);
283    }
284    let texts = projected
285        .paragraphs
286        .iter()
287        .map(|paragraph| paragraph.text.as_str())
288        .collect::<Vec<_>>();
289    let properties = projected
290        .paragraphs
291        .iter()
292        .map(|paragraph| paragraph.properties.clone())
293        .collect::<Vec<_>>();
294    let structural_facts = structure::materialize_structure(
295        structure::RawStructureInput {
296            paragraph_texts: &texts,
297            properties: &properties,
298            bookmarks: projected.bookmarks.as_deref().map_err(|reason| *reason),
299            references: projected.references.as_deref().map_err(|reason| *reason),
300        },
301        styles,
302        maximum_structural_facts,
303    )?;
304    let mut paragraphs = Vec::with_capacity(projected.paragraphs.len());
305    for (id, paragraph) in ids.into_iter().zip(projected.paragraphs) {
306        paragraphs.push(ProjectedParagraph {
307            id,
308            ordinal: paragraph.ordinal,
309            package_paragraph_id: paragraph.package_paragraph_id,
310            text: paragraph.text,
311            formatting: paragraph.formatting,
312            structure: paragraph.structure,
313        });
314    }
315    Ok(DocumentProjection {
316        paragraphs,
317        revision_status: projected.revision_status,
318        structural_facts,
319    })
320}