Skip to main content

pdf_annot/
stamp.rs

1//! Stamp, FileAttachment, FreeText, Sound, and Movie annotations.
2
3// Copyright (c) 2026 Innovation Trigger B.V.
4//
5// PDFluent is available under two licences, at your option: the GNU AGPLv3, or
6// the PDFluent Commercial Licence. See the LICENSE file in this repository --
7// that file travels with the copy you received, which a URL does not.
8
9extern crate alloc;
10
11use crate::annotation::{self, Annotation};
12use crate::types::LineEnding;
13use pdf_syntax::object::dict::keys::*;
14use pdf_syntax::object::{Array, Dict, Name, Rect, Stream};
15
16/// Standard rubber-stamp names per ISO 32000-2 Table 181.
17///
18/// Used for both reading (parser output) and writing (annotation builder).
19/// PDF viewers ship built-in appearances for the standard variants; using
20/// a recognized name produces consistent visuals across viewers. A
21/// non-standard name is preserved verbatim in [`Self::Custom`] so it is
22/// never silently lost.  [`Self::to_pdf_name`] converts back to the PDF
23/// name string for the write path.
24#[derive(Debug, Clone, PartialEq, Eq)]
25pub enum StampName {
26    /// "APPROVED" — green check-style stamp.
27    Approved,
28    /// "EXPERIMENTAL" — orange/red stamp.
29    Experimental,
30    /// "NOT APPROVED" — red rejection-style stamp.
31    NotApproved,
32    /// "AS IS" — neutral stamp meaning "submitted without warranty".
33    AsIs,
34    /// "EXPIRED" — red stamp indicating a document has lapsed.
35    Expired,
36    /// "NOT FOR PUBLIC RELEASE" — red restriction stamp.
37    NotForPublicRelease,
38    /// "CONFIDENTIAL" — red restriction stamp for sensitive material.
39    Confidential,
40    /// "FINAL" — green completion stamp.
41    Final,
42    /// "SOLD" — typically used in real-estate or auction contexts.
43    Sold,
44    /// "DEPARTMENTAL" — internal-use restriction stamp.
45    Departmental,
46    /// "FOR COMMENT" — review-cycle stamp.
47    ForComment,
48    /// "TOP SECRET" — strongest standard restriction stamp.
49    TopSecret,
50    /// "DRAFT" — work-in-progress stamp. Also the fallback default
51    /// when no `/Name` entry is present on the input annotation.
52    Draft,
53    /// "FOR PUBLIC RELEASE" — explicit clearance stamp.
54    ForPublicRelease,
55    /// Any non-standard stamp name found in the input PDF, preserved
56    /// verbatim. Custom stamps may render with viewer-default
57    /// appearance only.
58    Custom(alloc::string::String),
59}
60
61impl StampName {
62    /// Return the PDF `/Name` string for this stamp, e.g. `"Approved"`.
63    ///
64    /// Used by the write path (`AnnotationBuilder::stamp`) to embed the
65    /// correct name into the annotation dictionary.
66    pub fn to_pdf_name(&self) -> &str {
67        match self {
68            Self::Approved => "Approved",
69            Self::Experimental => "Experimental",
70            Self::NotApproved => "NotApproved",
71            Self::AsIs => "AsIs",
72            Self::Expired => "Expired",
73            Self::NotForPublicRelease => "NotForPublicRelease",
74            Self::Confidential => "Confidential",
75            Self::Final => "Final",
76            Self::Sold => "Sold",
77            Self::Departmental => "Departmental",
78            Self::ForComment => "ForComment",
79            Self::TopSecret => "TopSecret",
80            Self::Draft => "Draft",
81            Self::ForPublicRelease => "ForPublicRelease",
82            Self::Custom(s) => s.as_str(),
83        }
84    }
85
86    /// Parse from a PDF name.
87    pub fn from_name(name: &[u8]) -> Self {
88        match name {
89            b"Approved" => Self::Approved,
90            b"Experimental" => Self::Experimental,
91            b"NotApproved" => Self::NotApproved,
92            b"AsIs" => Self::AsIs,
93            b"Expired" => Self::Expired,
94            b"NotForPublicRelease" => Self::NotForPublicRelease,
95            b"Confidential" => Self::Confidential,
96            b"Final" => Self::Final,
97            b"Sold" => Self::Sold,
98            b"Departmental" => Self::Departmental,
99            b"ForComment" => Self::ForComment,
100            b"TopSecret" => Self::TopSecret,
101            b"Draft" => Self::Draft,
102            b"ForPublicRelease" => Self::ForPublicRelease,
103            other => {
104                let s = core::str::from_utf8(other).unwrap_or("Unknown").to_owned();
105                Self::Custom(s)
106            }
107        }
108    }
109}
110
111/// A Stamp annotation.
112#[derive(Debug)]
113pub struct StampAnnotation {
114    /// The stamp name.
115    pub name: StampName,
116}
117
118impl StampAnnotation {
119    /// Extract stamp annotation properties.
120    pub fn from_annot(annot: &Annotation<'_>) -> Self {
121        let name = annot
122            .dict()
123            .get::<Name>(NAME)
124            .map(|n: Name| StampName::from_name(n.as_ref()))
125            .unwrap_or(StampName::Draft);
126        Self { name }
127    }
128}
129
130/// A FileAttachment annotation.
131#[derive(Debug)]
132pub struct FileAttachmentAnnotation<'a> {
133    dict: Dict<'a>,
134    /// The icon name.
135    pub icon: alloc::string::String,
136}
137
138impl<'a> FileAttachmentAnnotation<'a> {
139    /// Extract file attachment annotation properties.
140    pub fn from_annot(annot: &Annotation<'a>) -> Self {
141        let dict = annot.dict().clone();
142        let icon = dict
143            .get::<Name>(NAME)
144            .map(|n: Name| alloc::string::String::from(n.as_str()))
145            .unwrap_or_else(|| alloc::string::String::from("PushPin"));
146        Self { dict, icon }
147    }
148
149    /// Return the file specification dictionary.
150    pub fn file_spec(&self) -> Option<Dict<'a>> {
151        self.dict.get::<Dict<'_>>(FS)
152    }
153
154    /// Return the embedded file stream.
155    pub fn embedded_file(&self) -> Option<Stream<'a>> {
156        let fs = self.file_spec()?;
157        let ef = fs.get::<Dict<'_>>(EF)?;
158        ef.get::<Stream<'_>>(F).or_else(|| ef.get::<Stream<'_>>(UF))
159    }
160
161    /// Return the filename.
162    pub fn filename(&self) -> Option<alloc::string::String> {
163        let fs = self.file_spec()?;
164        fs.get::<pdf_syntax::object::String>(UF)
165            .or_else(|| fs.get::<pdf_syntax::object::String>(F))
166            .map(|s| annotation::pdf_string_to_string(&s))
167    }
168}
169
170/// A FreeText annotation.
171#[derive(Debug)]
172pub struct FreeTextAnnotation {
173    /// The default appearance string.
174    pub default_appearance: alloc::string::String,
175    /// Text justification (0=left, 1=center, 2=right).
176    pub justification: u32,
177    /// Default style string.
178    pub default_style: Option<alloc::string::String>,
179    /// Rich text content.
180    pub rich_content: Option<alloc::string::String>,
181    /// Callout line points.
182    pub callout_line: Option<Vec<f32>>,
183    /// Intent.
184    pub intent: Option<alloc::string::String>,
185    /// Line ending style for callout.
186    pub line_ending: LineEnding,
187    /// Rectangle differences.
188    pub rd: Option<Rect>,
189}
190
191impl FreeTextAnnotation {
192    /// Extract free text annotation properties.
193    pub fn from_annot(annot: &Annotation<'_>) -> Self {
194        let dict = annot.dict();
195        let default_appearance = dict
196            .get::<pdf_syntax::object::String>(DA)
197            .map(|s| annotation::pdf_string_to_string(&s))
198            .unwrap_or_default();
199        let justification = dict.get::<u32>(Q).unwrap_or(0);
200        let default_style = dict
201            .get::<pdf_syntax::object::String>(DS)
202            .map(|s| annotation::pdf_string_to_string(&s));
203        let rich_content = dict
204            .get::<pdf_syntax::object::String>(RC)
205            .map(|s| annotation::pdf_string_to_string(&s));
206        let callout_line: Option<Vec<f32>> = dict
207            .get::<Array<'_>>(CL)
208            .map(|arr: Array<'_>| arr.iter::<f32>().collect());
209        let intent = dict
210            .get::<Name>(IT)
211            .map(|n: Name| alloc::string::String::from(n.as_str()));
212        let line_ending = dict
213            .get::<Name>(LE)
214            .map(|n: Name| LineEnding::from_name(n.as_ref()))
215            .unwrap_or(LineEnding::None);
216        let rd = dict.get::<Rect>(RD);
217        Self {
218            default_appearance,
219            justification,
220            default_style,
221            rich_content,
222            callout_line,
223            intent,
224            line_ending,
225            rd,
226        }
227    }
228}
229
230/// A Sound annotation.
231#[derive(Debug)]
232pub struct SoundAnnotation {
233    /// The icon name.
234    pub icon: alloc::string::String,
235}
236
237impl SoundAnnotation {
238    /// Extract sound annotation properties.
239    pub fn from_annot(annot: &Annotation<'_>) -> Self {
240        let icon = annot
241            .dict()
242            .get::<Name>(NAME)
243            .map(|n: Name| alloc::string::String::from(n.as_str()))
244            .unwrap_or_else(|| alloc::string::String::from("Speaker"));
245        Self { icon }
246    }
247}
248
249/// A Movie annotation.
250#[derive(Debug)]
251pub struct MovieAnnotation {
252    /// The movie title.
253    pub title: Option<alloc::string::String>,
254}
255
256impl MovieAnnotation {
257    /// Extract movie annotation properties.
258    pub fn from_annot(annot: &Annotation<'_>) -> Self {
259        let title = annot
260            .dict()
261            .get::<pdf_syntax::object::String>(T)
262            .map(|s| annotation::pdf_string_to_string(&s));
263        Self { title }
264    }
265}