//! Save PDF settings: the serde model a preset stores and `document.exportPdf` takes (camelCase //! JSON; every field is optional and falls back to its default), the checks that refuse settings //! the writer can't honour, and the warnings for options it accepts but doesn't apply yet. use serde::{Deserialize, Serialize}; use crate::PdfError; /// A closed list of choices (a settings enum), for UIs that show it as a dropdown. pub trait Choice: Copy + 'static { /// Every choice, in display order. const ALL: &'static [Self]; /// The JSON ids, in display order. const IDS: &'static [&'static str]; /// The labels shown in the PDF dialogs, in display order. const LABELS: &'static [&'static str]; } /// A closed list of choices: a serde enum (its JSON ids) with a UI label per variant. macro_rules! choice { ($(#[$m:meta])* $name:ident { $($(#[$vm:meta])* $v:ident = $id:literal, $label:literal;)+ } default $d:ident) => { $(#[$m])* #[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize)] pub enum $name { $($(#[$vm])* #[serde(rename = $id)] $v,)+ } impl $crate::Choice for $name { const ALL: &'static [Self] = &[$(Self::$v),+]; const IDS: &'static [&'static str] = &[$($id),+]; const LABELS: &'static [&'static str] = &[$($label),+]; } impl $name { /// The label shown in the PDF dialogs. pub fn label(self) -> &'static str { match self { $(Self::$v => $label,)+ } } /// The JSON id. pub fn id(self) -> &'static str { match self { $(Self::$v => $id,)+ } } } impl Default for $name { fn default() -> Self { Self::$d } } }; } pub(crate) use choice; choice! { /// The PDF standard the file conforms to. Standard { None = "none", "None"; /// PDF/A-2b (archival), validated by the writer. PdfA2b = "pdfA2b", "PDF/A-2b"; PdfX1a = "pdfX1a", "PDF/X-1a:2001"; PdfX3 = "pdfX3", "PDF/X-3:2002"; PdfX4 = "pdfX4", "PDF/X-4:2010"; } default None } impl Standard { /// The writer can produce this standard (PDF/X is not supported yet). pub fn supported(self) -> bool { matches!(self, Self::None | Self::PdfA2b) } /// A file of this standard can declare PDF version `c` (PDF/A-2 is based on PDF 1.7, so it /// can't be PDF 2.0). pub fn allows(self, c: Compatibility) -> bool { !(self == Self::PdfA2b && c == Compatibility::Pdf20) } } choice! { /// The PDF version written in the file header. Compatibility { Pdf14 = "1.4", "PDF 1.4"; Pdf15 = "1.5", "PDF 1.5"; Pdf16 = "1.6", "PDF 1.6"; Pdf17 = "1.7", "PDF 1.7"; Pdf20 = "2.0", "PDF 2.0"; } default Pdf17 } choice! { /// How images above the threshold resolution are resampled. Downsample { None = "none", "No downsampling"; Average = "average", "Average"; Subsample = "subsample", "Subsample"; Bicubic = "bicubic", "Bicubic"; } default None } choice! { /// Compression of colour and greyscale images. ImageCodec { None = "none", "None"; Zip = "zip", "ZIP"; Jpeg = "jpeg", "JPEG"; Jpeg2000 = "jpeg2000", "JPEG 2000"; /// JPEG images stay JPEG, the others are compressed losslessly. Auto = "auto", "Automatic"; } default Auto } choice! { /// JPEG image quality. JpegQuality { Minimum = "minimum", "Minimum"; Low = "low", "Low"; Medium = "medium", "Medium"; High = "high", "High"; Maximum = "maximum", "Maximum"; } default Maximum } choice! { /// Compression of 1-bit (monochrome) images. MonoCodec { None = "none", "None"; CcittG3 = "ccittG3", "CCITT Group 3"; CcittG4 = "ccittG4", "CCITT Group 4"; Zip = "zip", "ZIP"; RunLength = "runLength", "Run Length"; } default Zip } choice! { /// The style of printer's marks. MarkKind { Roman = "roman", "Roman"; Japanese = "japanese", "Japanese"; } default Roman } choice! { /// Colour conversion on output. ColorConversion { None = "none", "No conversion"; Destination = "destination", "Convert to destination"; /// Convert only colours whose profile differs from the destination's. PreserveNumbers = "preserveNumbers", "Convert to destination (preserve numbers)"; } default None } choice! { /// Which ICC profiles the file embeds. ProfileInclusion { None = "none", "Don't include profiles"; All = "all", "Include all profiles"; Destination = "destination", "Include destination profile"; TaggedSource = "taggedSource", "Include tagged source profiles"; } default None } choice! { /// What happens to overprinting fills and strokes. Overprint { Preserve = "preserve", "Preserve"; Discard = "discard", "Discard"; } default Preserve } choice! { /// Printing allowed by the permissions. Printing { None = "none", "None"; Low = "low", "Low resolution (150 ppi)"; High = "high", "High resolution"; } default High } choice! { /// Changes allowed by the permissions. Changes { None = "none", "None"; Pages = "pages", "Inserting, deleting and rotating pages"; Forms = "forms", "Filling in form fields and signing"; Comments = "comments", "Commenting, filling in form fields and signing"; Any = "any", "Any except extracting pages"; } default Any } /// Save PDF settings (the dialog's sections; what a preset stores). #[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)] #[serde(default, rename_all = "camelCase")] pub struct PdfSettings { pub standard: Standard, pub compatibility: Compatibility, // General. /// Embed the native document so the PDF reopens editable. pub preserve_editing: bool, /// Embed a thumbnail image per page. pub thumbnails: bool, /// Write a linearised file that displays page by page while it downloads. pub fast_web_view: bool, /// The app opens the written file (frontends only; the writer ignores it). pub view_after_saving: bool, /// Write top-level layers as PDF layers (optional content). pub create_layers: bool, pub compression: CompressionSettings, pub marks: MarkSettings, pub bleed: BleedSettings, pub output: OutputSettings, pub advanced: AdvancedSettings, pub security: SecuritySettings, } /// Resampling and compression of one kind of image. #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] #[serde(default, rename_all = "camelCase")] pub struct ImageCompression { pub downsample: Downsample, /// Target resolution (ppi). pub ppi: f64, /// Only images above this resolution (ppi) are downsampled. pub above_ppi: f64, pub compression: ImageCodec, pub quality: JpegQuality, } impl Default for ImageCompression { fn default() -> Self { Self { downsample: Downsample::None, ppi: 300.0, above_ppi: 450.0, compression: ImageCodec::Auto, quality: JpegQuality::Maximum } } } /// Resampling and compression of monochrome images. #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] #[serde(default, rename_all = "camelCase")] pub struct MonoCompression { pub downsample: Downsample, pub ppi: f64, pub above_ppi: f64, pub compression: MonoCodec, } impl Default for MonoCompression { fn default() -> Self { Self { downsample: Downsample::None, ppi: 1200.0, above_ppi: 1800.0, compression: MonoCodec::Zip } } } /// The Compression section. #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] #[serde(default, rename_all = "camelCase")] pub struct CompressionSettings { pub color: ImageCompression, pub gray: ImageCompression, pub mono: MonoCompression, /// Compress text and line art (Flate content streams). pub compress_text: bool, } impl Default for CompressionSettings { fn default() -> Self { Self { color: ImageCompression::default(), gray: ImageCompression::default(), mono: MonoCompression::default(), compress_text: true } } } /// Printer's marks (Marks and Bleeds section). #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] #[serde(default, rename_all = "camelCase")] pub struct MarkSettings { pub trim: bool, pub registration: bool, pub color_bars: bool, pub page_info: bool, pub kind: MarkKind, /// Trim mark weight (pt). pub weight: f64, /// Distance of the marks from the artboard (pt). pub offset: f64, } impl Default for MarkSettings { fn default() -> Self { Self { trim: false, registration: false, color_bars: false, page_info: false, kind: MarkKind::Roman, weight: 0.25, offset: 6.0 } } } impl MarkSettings { /// Any mark is on. pub fn any(&self) -> bool { self.trim || self.registration || self.color_bars || self.page_info } } /// Bleed (Marks and Bleeds section), in points. #[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)] #[serde(default, rename_all = "camelCase")] pub struct BleedSettings { /// Use the document's bleed instead of the values below. pub use_document: bool, pub top: f64, pub bottom: f64, pub left: f64, pub right: f64, } impl BleedSettings { fn values(&self) -> [f64; 4] { [self.top, self.bottom, self.left, self.right] } } /// The Output section. #[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)] #[serde(default, rename_all = "camelCase")] pub struct OutputSettings { pub conversion: ColorConversion, /// Destination profile name (empty: the document's profile). pub destination: String, pub profiles: ProfileInclusion, /// PDF/X output intent profile name. pub output_intent: String, pub output_condition: String, pub output_condition_id: String, pub registry: String, /// Mark the file as trapped. pub trapped: bool, } /// The Advanced section. #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] #[serde(default, rename_all = "camelCase")] pub struct AdvancedSettings { /// Embed whole fonts when more than this share (%) of their characters is used. pub font_subset_percent: f64, /// Text as glyph outlines (the only text the writer produces today). pub outline_text: bool, pub overprint: Overprint, } impl Default for AdvancedSettings { fn default() -> Self { Self { font_subset_percent: 100.0, outline_text: true, overprint: Overprint::Preserve } } } /// The Security section. Passwords are never serialized (presets and summaries leave them out). #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] #[serde(default, rename_all = "camelCase")] pub struct SecuritySettings { #[serde(skip_serializing)] pub open_password: String, #[serde(skip_serializing)] pub permissions_password: String, pub printing: Printing, pub changes: Changes, /// Allow copying text, images and other content. pub copy: bool, /// Allow screen readers to read the text. pub screen_reader: bool, /// Leave the metadata unencrypted. pub plaintext_metadata: bool, } impl Default for SecuritySettings { fn default() -> Self { Self { open_password: String::new(), permissions_password: String::new(), printing: Printing::High, changes: Changes::Any, copy: true, screen_reader: true, plaintext_metadata: true, } } } /// `v` lies in `lo..=hi` (and is a number). fn within(name: &str, v: f64, lo: f64, hi: f64, unit: &str) -> Result<(), PdfError> { if (lo..=hi).contains(&v) { Ok(()) } else { Err(PdfError::BadSetting(format!("{name} must be {lo}–{hi}{unit} (got {v})"))) } } impl PdfSettings { /// Refuse settings the writer can't honour: an unsupported standard, a password (the file /// would not be protected), or what [`Self::check_values`] refuses. pub fn check(&self) -> Result<(), PdfError> { if !self.standard.supported() { return Err(PdfError::Unsupported(format!("{} output is not supported yet", self.standard.label()))); } if !self.security.open_password.is_empty() || !self.security.permissions_password.is_empty() { return Err(PdfError::Unsupported("password protection is not supported yet (the PDF would not be encrypted)".into())); } self.check_values() } /// The part of [`Self::check`] a preset must pass (it may name a standard the writer can't /// produce yet): a standard with a PDF version it doesn't allow, PDF/A with editing data, or /// an out-of-range value. pub fn check_values(&self) -> Result<(), PdfError> { if !self.standard.allows(self.compatibility) { return Err(PdfError::BadSetting(format!("{} files can't be {}", self.standard.label(), self.compatibility.label()))); } if self.preserve_editing && self.standard == Standard::PdfA2b { return Err(PdfError::BadSetting(format!("{} files can't carry editing data: turn off preserveEditing", self.standard.label()))); } let c = &self.compression; for (name, img) in [("color", (c.color.ppi, c.color.above_ppi)), ("gray", (c.gray.ppi, c.gray.above_ppi)), ("mono", (c.mono.ppi, c.mono.above_ppi))] { within(&format!("compression.{name}.ppi"), img.0, 9.0, 2400.0, " ppi")?; within(&format!("compression.{name}.abovePpi"), img.1, 9.0, 2400.0, " ppi")?; } within("marks.weight", self.marks.weight, 0.05, 2.0, " pt")?; within("marks.offset", self.marks.offset, 0.0, 72.0, " pt")?; for (side, v) in ["top", "bottom", "left", "right"].iter().zip(self.bleed.values()) { within(&format!("bleed.{side}"), v, 0.0, 72.0, " pt")?; } within("advanced.fontSubsetPercent", self.advanced.font_subset_percent, 0.0, 100.0, "%") } /// Forget the passwords (presets never store them). pub fn clear_passwords(&mut self) { self.security.open_password.clear(); self.security.permissions_password.clear(); } /// Options that are accepted but not applied by the writer yet, one warning each. pub fn warnings(&self) -> Vec { let d = Self::default(); let c = &self.compression; let o = &self.output; let s = &self.security; [ (self.thumbnails, "page thumbnails are not embedded yet"), (self.fast_web_view, "fast web view (a linearised file) is not written yet"), (self.create_layers, "PDF layers are not written yet: every layer is plain page content"), ( c.color != d.compression.color || c.gray != d.compression.gray || c.mono != d.compression.mono, "image downsampling and compression settings are not applied yet: images are embedded unchanged", ), (self.marks.any(), "printer's marks are not drawn yet"), (!self.bleed.use_document && self.bleed.values().iter().any(|v| *v > 0.0), "bleed is not added yet: each page is its artboard"), (o.conversion != ColorConversion::None, "colour conversion is not applied yet: colours are written as they are"), (o.profiles != ProfileInclusion::None, "ICC profiles are not embedded yet"), ( !o.output_intent.is_empty() || !o.output_condition.is_empty() || !o.output_condition_id.is_empty() || !o.registry.is_empty() || o.trapped, "output intent and trapped entries are not written yet", ), (!self.advanced.outline_text, "text is exported as outlines: real, selectable text is not written yet"), ( s.printing != d.security.printing || s.changes != d.security.changes || s.copy != d.security.copy || s.screen_reader != d.security.screen_reader || s.plaintext_metadata != d.security.plaintext_metadata, "permissions need a permissions password, which is not supported yet: they are not applied", ), ] .into_iter() .filter(|(on, _)| *on) .map(|(_, w)| w.to_string()) .collect() } }