Skip to main content

ooxml_core/opc/
part.rs

1//! `Part` 与 `PartName`:OPC 包内的逻辑条目。
2//!
3//! 本文件定义 OPC 容器层最细粒度的"零件"——**Part**。一个 `.pptx`
4//! 文件本质上是若干个 Part 组成的扁平命名空间,由 Content-Types 描述
5//! 媒体类型,由 `.rels` 描述相互链接。
6//!
7//! # 设计要点
8//!
9//! - `PartName` 是对 OPC 规范的"PartName"(绝对路径)语义的 NewType 包装;
10//! - `Part` 自身**只**持字节内容(`blob`),不解析 XML;
11//! - 解析与序列化交由 `oxml::parser` / `oxml::writer` 负责。
12//!
13//! 这与 python-pptx 中 `Part` 是"基类 + 各 part 子类"的 OOP 风格不同——
14//! 本库采用 **trait-object free** 的 Rust 风格:调用方持 `Part`,在需要
15//! 时按 `content_type` 自行反序列化为具体 `oxml::*` 结构体。
16
17use std::borrow::Cow;
18use std::fmt;
19
20use super::content_types::ContentTypes;
21
22/// Part 逻辑路径,形式如 `/ppt/slides/slide1.xml`。
23///
24/// 字符串以 `/` 开头,不含 `..`,以 `/` 分隔。NewType 包装确保
25/// 调用方不会"误用相对路径"。
26#[derive(Clone, Eq, PartialEq, Ord, PartialOrd, Hash)]
27pub struct PartName(String);
28
29impl PartName {
30    /// 用裸路径字符串构造,不做合法性校验。
31    ///
32    /// 适用于内部已知合法的场景;外部输入请用 [`PartName::new`]。
33    pub fn from_unchecked(s: impl Into<String>) -> Self {
34        let s = s.into();
35        PartName(if s.starts_with('/') {
36            s
37        } else {
38            format!("/{}", s)
39        })
40    }
41
42    /// 校验并构造。
43    ///
44    /// # 错误
45    /// - [`PartNameError::NotAbsolute`]:不以 `/` 开头;
46    /// - [`PartNameError::EmptySegment`]:含空段(双斜杠);
47    /// - [`PartNameError::RelSegment`]:含 `.` 或 `..` 段。
48    pub fn new(s: &str) -> Result<Self, PartNameError> {
49        if !s.starts_with('/') {
50            return Err(PartNameError::NotAbsolute);
51        }
52        for seg in s.split('/').skip(1) {
53            if seg.is_empty() {
54                return Err(PartNameError::EmptySegment);
55            }
56            if seg == "." || seg == ".." {
57                return Err(PartNameError::RelSegment);
58            }
59        }
60        Ok(PartName(s.to_string()))
61    }
62
63    /// 字符串视图。
64    #[inline]
65    pub fn as_str(&self) -> &str {
66        &self.0
67    }
68    /// 取出内部字符串(消费 self)。
69    #[inline]
70    pub fn into_string(self) -> String {
71        self.0
72    }
73
74    /// 转成 zip 内的相对路径(去掉前导 `/`)。
75    #[inline]
76    pub fn to_zip_path(&self) -> &str {
77        &self.0[1..]
78    }
79
80    /// 取扩展名(含 `.`,小写),无扩展名时为空串。
81    pub fn ext(&self) -> String {
82        match self.0.rfind('.') {
83            Some(i) => self.0[i..].to_ascii_lowercase(),
84            None => String::new(),
85        }
86    }
87
88    /// 同级目录下新文件名:取父目录,拼上 `filename`。
89    pub fn sibling(&self, filename: &str) -> PartName {
90        let p = self.0.rfind('/').unwrap_or(0);
91        PartName(format!("{}/{}", &self.0[..p], filename))
92    }
93
94    /// 父目录 part name。已为根时返回 `None`。
95    pub fn parent(&self) -> Option<PartName> {
96        let p = self.0.rfind('/')?;
97        if p == 0 {
98            return None;
99        }
100        Some(PartName(self.0[..p].to_string()))
101    }
102}
103
104impl fmt::Debug for PartName {
105    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
106        write!(f, "PartName({})", self.0)
107    }
108}
109
110impl fmt::Display for PartName {
111    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
112        f.write_str(&self.0)
113    }
114}
115
116impl std::str::FromStr for PartName {
117    type Err = PartNameError;
118    fn from_str(s: &str) -> Result<Self, Self::Err> {
119        PartName::new(s)
120    }
121}
122
123/// Part 名称错误。
124#[derive(Debug, thiserror::Error)]
125pub enum PartNameError {
126    /// 必须以 `/` 开头。
127    #[error("part name must start with '/'")]
128    NotAbsolute,
129    /// 包含空段(`//`)。
130    #[error("part name contains empty segment")]
131    EmptySegment,
132    /// 包含相对段 `.` 或 `..`。
133    #[error("part name contains relative segment '.' or '..'")]
134    RelSegment,
135}
136
137/// 快速构造一个 PartName:未做合法性校验。
138///
139/// 等价于 [`PartName::from_unchecked`]。为方便函数签名紧凑,单独提供。
140pub fn new_part_name(s: &str) -> PartName {
141    PartName::from_unchecked(s)
142}
143
144/// 一个 OPC part。
145///
146/// Part 包含:
147/// - 名称 [`PartName`](绝对路径);
148/// - 内容类型 `content_type`(如
149///   `application/vnd.openxmlformats-officedocument.presentationml.slide+xml`);
150/// - `blob`(字节内容,未解析)。
151#[derive(Debug, Clone)]
152pub struct Part {
153    /// Part 名称(绝对路径)。
154    pub partname: PartName,
155    /// Content-Type 字符串。
156    pub content_type: String,
157    /// 原始字节内容。
158    pub blob: Vec<u8>,
159}
160
161impl Part {
162    /// 构造一个 part。
163    pub fn new<N, C, B>(partname: N, content_type: C, blob: B) -> Self
164    where
165        N: Into<PartName>,
166        C: Into<String>,
167        B: Into<Vec<u8>>,
168    {
169        Part {
170            partname: partname.into(),
171            content_type: content_type.into(),
172            blob: blob.into(),
173        }
174    }
175
176    /// blob 转字符串(UTF-8)。
177    ///
178    /// 若不是合法 UTF-8 返回 `None`,调用方应自行决定是否报错。
179    pub fn blob_text(&self) -> Option<Cow<'_, str>> {
180        match std::str::from_utf8(&self.blob) {
181            Ok(s) => Some(Cow::Borrowed(s)),
182            Err(_) => None,
183        }
184    }
185
186    /// blob 长度(字节)。
187    pub fn len(&self) -> usize {
188        self.blob.len()
189    }
190    /// blob 是否为空。
191    pub fn is_empty(&self) -> bool {
192        self.blob.is_empty()
193    }
194
195    /// 通知 [`ContentTypes`] 自己存在:根据扩展名或 override 添加相应记录。
196    ///
197    /// - `.rels` 由 default ContentType 处理(`application/vnd...relationships+xml`)
198    /// - `.xml` 与其它扩展都显式注册 override,PowerPoint 严格要求每个 XML part 都有显式 Content-Type。
199    pub fn contribute_to(&self, ct: &mut ContentTypes) {
200        let ext = self.partname.ext();
201        if ext == ".rels" {
202            // 使用 default 中已经注册的 relationships Content-Type
203            return;
204        }
205        // .xml 与其它扩展都显式注册 override
206        ct.add_override(self.partname.as_str(), &self.content_type);
207    }
208}
209
210/// 允许 `PathBuf::from(part)` 直接把 Part 转成本地路径(zip 内相对路径)。
211impl From<Part> for std::path::PathBuf {
212    fn from(p: Part) -> std::path::PathBuf {
213        std::path::PathBuf::from(p.partname.to_zip_path())
214    }
215}