979 lines
44 KiB
Rust
979 lines
44 KiB
Rust
//! File → Save as PDF: the Save PDF dialog. Preset, Standard and Compatibility on top, then the
|
||
//! sections General, Compression, Marks and Bleeds, Output, Advanced, Security and Summary.
|
||
//!
|
||
//! The fields are the `document.exportPdf` options (sections are objects such as `compression`),
|
||
//! plus `preset`, `range`, `path?` and UI-only `__` keys, so agents fill the dialog with
|
||
//! `ui.dialog.set`. OK runs `document.exportPdf` through [`io::export_pdf`]; the Summary is
|
||
//! `document.pdfSettings`. Save Preset… names the settings as a preset (`pdf.preset.save`).
|
||
//!
|
||
//! The same dialog edits a preset (kind `pdfPreset`, from Edit → PDF Presets): a `name` and
|
||
//! `description` instead of the preset and artboards, and OK saves the preset.
|
||
|
||
use std::sync::LazyLock;
|
||
|
||
use serde_json::{Map, Value, json};
|
||
use vectorcraft_doc::Unit;
|
||
use vectorcraft_engine::cmd::fileio::{SaveMode, pdf};
|
||
use vectorcraft_pdf::{
|
||
Changes, Choice, ColorConversion, Compatibility, Downsample, Encryption, ImageCodec, JpegQuality, MarkKind, MonoCodec, Overprint, PdfSettings,
|
||
Printing, ProfileInclusion, Standard,
|
||
};
|
||
|
||
use super::{DialogSpec, form};
|
||
use crate::state::Dialog;
|
||
use crate::theme::{self, Tokens};
|
||
use crate::{VectorcraftApp, io, widgets};
|
||
|
||
pub(super) const KIND: &str = "savePdf";
|
||
|
||
pub(super) const SPEC: DialogSpec = DialogSpec { heading: |_| "Save PDF".into(), body, confirm, ok: Some("Save PDF"), ..DialogSpec::FORM };
|
||
|
||
/// The dialog kind of the preset editor (New / Edit in Edit → PDF Presets).
|
||
pub(super) const PRESET_KIND: &str = "pdfPreset";
|
||
|
||
pub(super) const PRESET_SPEC: DialogSpec = DialogSpec {
|
||
heading: |d| if d.str(EDITING).is_empty() { "New PDF Preset" } else { "Edit PDF Preset" }.into(),
|
||
body,
|
||
confirm: confirm_preset,
|
||
ok: Some("Save Preset"),
|
||
..DialogSpec::FORM
|
||
};
|
||
|
||
/// The preset editor's field holding the name of the saved preset it edits (empty: a new one).
|
||
const EDITING: &str = "__editing";
|
||
/// Save PDF's field holding the name typed for Save Preset… (present while that row shows).
|
||
const SAVE_AS: &str = "__savePresetAs";
|
||
|
||
pub(super) const SECTIONS: [&str; 7] = ["General", "Compression", "Marks and Bleeds", "Output", "Advanced", "Security", "Summary"];
|
||
|
||
/// The default settings as JSON: what a field an agent left out reads as.
|
||
static DEFAULTS: LazyLock<Value> = LazyLock::new(|| serde_json::to_value(PdfSettings::default()).unwrap_or_default());
|
||
|
||
/// Trim mark weights offered (pt).
|
||
const WEIGHTS: [f64; 3] = [0.125, 0.25, 0.5];
|
||
const WEIGHT_LABELS: [&str; 3] = ["0.125 pt", "0.25 pt", "0.5 pt"];
|
||
|
||
/// The Destination entry for the document's own profile (stored as "").
|
||
const DOCUMENT_PROFILE: &str = "Document profile";
|
||
/// The Output Intent Profile entry for no output intent (stored as "").
|
||
const NO_PROFILE: &str = "None";
|
||
/// That entry with a PDF/X standard, whose files always have an output intent.
|
||
const PDFX_INTENT: &str = "CMYK profile in effect";
|
||
|
||
pub(super) const LABEL_WIDTH: f32 = 150.0;
|
||
/// Width of the section list (its frame adds 6 px a side).
|
||
pub(super) const LIST_WIDTH: f32 = 150.0;
|
||
/// The top rows' labels end where the section content starts, so their dropdowns line up with it.
|
||
pub(super) const TOP_LABEL_WIDTH: f32 = LIST_WIDTH + 26.0;
|
||
|
||
/// Open the dialog with `params` (`document.exportPdf` options and `path?`) applied over their
|
||
/// preset.
|
||
pub fn open(app: &mut VectorcraftApp, params: &Value) -> Result<Value, String> {
|
||
let mut fields = settings_fields(app, params)?;
|
||
let s = |k: &str| params.get(k).and_then(Value::as_str);
|
||
fields.insert("preset".into(), json!(s("preset").unwrap_or(pdf::DEFAULT_PRESET)));
|
||
fields.insert("__presets".into(), json!(pdf::presets(&app.session)));
|
||
fields.insert("__section".into(), json!(SECTIONS[0]));
|
||
fields.insert("__allArtboards".into(), json!(s("range").is_none()));
|
||
fields.insert("range".into(), json!(s("range").unwrap_or("")));
|
||
if let Some(path) = s("path") {
|
||
fields.insert("path".into(), json!(path));
|
||
}
|
||
app.ui.dialog = Some(Dialog { kind: KIND.into(), fields });
|
||
Ok(Value::Null)
|
||
}
|
||
|
||
/// The settings `params` ask for (their preset, built-in or saved, with their options applied) as
|
||
/// dialog fields. A standard the writer can't produce yet still shows (Save PDF then says so).
|
||
fn settings_fields(app: &VectorcraftApp, params: &Value) -> Result<Map<String, Value>, String> {
|
||
let settings = pdf::resolve("ui.savePdfDialog", params, &app.session.prefs.pdf_presets).map_err(|e| e.to_string())?;
|
||
match serde_json::to_value(settings).map_err(|e| e.to_string())? {
|
||
Value::Object(fields) => Ok(fields),
|
||
_ => Err("PDF settings are not an object".into()),
|
||
}
|
||
}
|
||
|
||
/// The `document.exportPdf` params the dialog stands for.
|
||
fn params(d: &Dialog) -> Value {
|
||
let mut p = form::params(d);
|
||
if (d.bool("__allArtboards") || d.str("range").trim().is_empty())
|
||
&& let Some(o) = p.as_object_mut()
|
||
{
|
||
o.remove("range");
|
||
}
|
||
p
|
||
}
|
||
|
||
/// Save PDF: write the file (a PDF copy, or the PDF a Save As / Save a Copy asked for, `__save`);
|
||
/// the dialog stays open when that fails (bad range, cancelled save…). While Save Preset… asks for
|
||
/// a name, OK (and Enter) saves the preset instead.
|
||
fn confirm(app: &mut VectorcraftApp, d: &Dialog) -> Result<Value, String> {
|
||
if d.fields.contains_key(SAVE_AS) {
|
||
let mut d = d.clone();
|
||
let r = save_preset(app, &mut d);
|
||
app.ui.dialog = Some(d);
|
||
return r;
|
||
}
|
||
let r = match SaveMode::of(&d.str("__save")) {
|
||
Some(mode) => io::save_pdf(app, mode, params(d)),
|
||
None => io::export_pdf(app, params(d)),
|
||
};
|
||
if r.is_ok() {
|
||
app.ui.dialog = None;
|
||
}
|
||
r
|
||
}
|
||
|
||
/// The settings as `pdf.preset.save` params (without the artboards and path).
|
||
fn preset_params(d: &Dialog) -> Value {
|
||
let mut p = form::params(d);
|
||
if let Some(o) = p.as_object_mut() {
|
||
for k in ["range", "path"] {
|
||
o.remove(k);
|
||
}
|
||
}
|
||
p
|
||
}
|
||
|
||
/// Save Preset…: save the settings under the typed name and pick that preset.
|
||
fn save_preset(app: &mut VectorcraftApp, d: &mut Dialog) -> Result<Value, String> {
|
||
let mut p = preset_params(d);
|
||
p["name"] = json!(d.str(SAVE_AS));
|
||
let r = app.run("pdf.preset.save", p)?;
|
||
d.fields.remove(SAVE_AS);
|
||
d.fields.insert("preset".into(), r["name"].clone());
|
||
d.fields.insert("__presets".into(), json!(pdf::presets(&app.session)));
|
||
Ok(r)
|
||
}
|
||
|
||
/// Open the preset editor on the saved preset `name`, or on a new preset starting from `preset`
|
||
/// (default: the app default).
|
||
pub fn open_preset(app: &mut VectorcraftApp, params: &Value) -> Result<Value, String> {
|
||
let s = |k: &str| params.get(k).and_then(Value::as_str);
|
||
let (base, name, description, editing) = match s("name") {
|
||
Some(name) => {
|
||
let p = app.session.prefs.pdf_presets.iter().find(|p| p.name.eq_ignore_ascii_case(name.trim()));
|
||
let p = p.ok_or_else(|| format!("no saved PDF preset named `{name}` (built-in presets are read-only: start a new one from them)"))?;
|
||
(p.name.clone(), p.name.clone(), p.description.clone(), p.name.clone())
|
||
}
|
||
None => (s("preset").unwrap_or(pdf::DEFAULT_PRESET).to_string(), app.session.new_pdf_preset_name(), String::new(), String::new()),
|
||
};
|
||
let mut fields = settings_fields(app, &json!({ "preset": base }))?;
|
||
fields.insert("name".into(), json!(name));
|
||
fields.insert("description".into(), json!(description));
|
||
fields.insert(EDITING.into(), json!(editing));
|
||
fields.insert("__section".into(), json!(SECTIONS[0]));
|
||
app.ui.dialog = Some(Dialog { kind: PRESET_KIND.into(), fields });
|
||
Ok(Value::Null)
|
||
}
|
||
|
||
/// Save Preset (the preset editor's OK): `pdf.preset.save`, then back to Edit → PDF Presets on it.
|
||
/// A new preset may not take the name of another one.
|
||
fn confirm_preset(app: &mut VectorcraftApp, d: &Dialog) -> Result<Value, String> {
|
||
let mut p = preset_params(d);
|
||
let (name, editing) = (d.str("name"), d.str(EDITING));
|
||
if editing.is_empty() {
|
||
if app.session.pdf_presets().iter().any(|q| q.name.eq_ignore_ascii_case(name.trim())) {
|
||
return Err(format!("a preset named `{}` exists", name.trim()));
|
||
}
|
||
} else {
|
||
p["name"] = json!(editing);
|
||
p["newName"] = json!(name);
|
||
}
|
||
let r = app.run("pdf.preset.save", p)?;
|
||
super::pdf_presets::open(app, r["name"].as_str());
|
||
Ok(r)
|
||
}
|
||
|
||
// ---------- fields by path (`compression.color.ppi`) ----------
|
||
|
||
fn lookup<'a>(m: &'a Map<String, Value>, path: &str) -> Option<&'a Value> {
|
||
let mut keys = path.split('.');
|
||
let first = m.get(keys.next()?)?;
|
||
keys.try_fold(first, |v, k| v.get(k))
|
||
}
|
||
|
||
/// The value at `path`: the dialog's, else the default of the settings it edits (the PDF
|
||
/// settings, or the print settings of the Print dialogs).
|
||
pub(super) fn get<'a>(d: &'a Dialog, path: &str) -> &'a Value {
|
||
let defaults = super::print::defaults(&d.kind).unwrap_or(&DEFAULTS);
|
||
lookup(&d.fields, path).or_else(|| defaults.as_object().and_then(|m| lookup(m, path))).unwrap_or(&Value::Null)
|
||
}
|
||
|
||
pub(super) fn set(d: &mut Dialog, path: &str, value: Value) {
|
||
let (parents, key) = path.rsplit_once('.').unwrap_or(("", path));
|
||
let mut map = &mut d.fields;
|
||
for k in parents.split('.').filter(|k| !k.is_empty()) {
|
||
let e = map.entry(k).or_insert(Value::Null);
|
||
if !e.is_object() {
|
||
*e = Value::Object(Map::new());
|
||
}
|
||
let Value::Object(m) = e else { return };
|
||
map = m;
|
||
}
|
||
map.insert(key.into(), value);
|
||
}
|
||
|
||
// ---------- widgets bound to a path ----------
|
||
|
||
pub(super) fn heading(ui: &mut egui::Ui, text: &str) {
|
||
let t = Tokens::get(ui.ctx());
|
||
ui.add_space(6.0);
|
||
ui.label(egui::RichText::new(text).font(theme::semibold(12.5)).color(t.text_strong));
|
||
ui.add_space(2.0);
|
||
}
|
||
|
||
pub(super) fn note(ui: &mut egui::Ui, text: &str) {
|
||
let t = Tokens::get(ui.ctx());
|
||
ui.label(egui::RichText::new(text).size(11.5).color(t.text_dim));
|
||
}
|
||
|
||
/// A labelled row.
|
||
pub(super) fn row(ui: &mut egui::Ui, label: &str, add: impl FnOnce(&mut egui::Ui)) {
|
||
row_with(ui, label, LABEL_WIDTH, add);
|
||
}
|
||
|
||
pub(super) fn row_with(ui: &mut egui::Ui, label: &str, width: f32, add: impl FnOnce(&mut egui::Ui)) {
|
||
let t = Tokens::get(ui.ctx());
|
||
ui.horizontal(|ui| {
|
||
ui.allocate_ui_with_layout(egui::vec2(width, 24.0), egui::Layout::left_to_right(egui::Align::Center), |ui| {
|
||
ui.set_min_width(width);
|
||
ui.label(egui::RichText::new(label).color(t.text));
|
||
});
|
||
add(ui);
|
||
});
|
||
}
|
||
|
||
pub(super) fn flag(ui: &mut egui::Ui, d: &mut Dialog, path: &str, label: &str, enabled: bool) {
|
||
let on = get(d, path).as_bool().unwrap_or(false);
|
||
if widgets::check(ui, label, on, enabled) {
|
||
set(d, path, json!(!on));
|
||
}
|
||
}
|
||
|
||
/// The index in `T`'s choices of the value at `path`.
|
||
fn choice_index<T: Choice>(d: &Dialog, path: &str) -> Option<usize> {
|
||
get(d, path).as_str().and_then(|s| T::IDS.iter().position(|i| *i == s))
|
||
}
|
||
|
||
/// The choice at `path`.
|
||
pub(super) fn choice<T: Choice>(d: &Dialog, path: &str) -> Option<T> {
|
||
choice_index::<T>(d, path).and_then(|i| T::ALL.get(i).copied())
|
||
}
|
||
|
||
/// A dropdown of the choices `T` (options for which `enabled` is false are greyed).
|
||
pub(super) fn pick<T: Choice>(ui: &mut egui::Ui, d: &mut Dialog, path: &str, width: f32, enabled: impl Fn(T) -> bool) -> bool {
|
||
let current = choice_index::<T>(d, path);
|
||
let label = current.and_then(|i| T::LABELS.get(i)).copied().unwrap_or_default();
|
||
let chosen = widgets::dropdown_with(ui, path, label, T::LABELS, width, |i| T::ALL.get(i).is_some_and(|c| enabled(*c)));
|
||
let Some(id) = chosen.and_then(|i| T::IDS.get(i)) else { return false };
|
||
set(d, path, json!(id));
|
||
true
|
||
}
|
||
|
||
pub(super) fn number(ui: &mut egui::Ui, d: &mut Dialog, path: &str, suffix: &str, enabled: bool) {
|
||
let v = get(d, path).as_f64().unwrap_or(0.0);
|
||
ui.add_enabled_ui(enabled, |ui| {
|
||
if let Some(x) = widgets::plain_field(ui, path, v, suffix, 3, 64.0) {
|
||
set(d, path, json!(x));
|
||
}
|
||
});
|
||
}
|
||
|
||
/// A length in points, shown in the document's units.
|
||
pub(super) fn length(ui: &mut egui::Ui, d: &mut Dialog, path: &str, unit: Unit, enabled: bool) {
|
||
let v = get(d, path).as_f64();
|
||
ui.add_enabled_ui(enabled, |ui| {
|
||
if let Some(x) = widgets::num_field(ui, path, v, unit, 80.0) {
|
||
set(d, path, json!(x));
|
||
}
|
||
});
|
||
}
|
||
|
||
pub(super) fn text(ui: &mut egui::Ui, d: &mut Dialog, path: &str, enabled: bool) {
|
||
let mut s = get(d, path).as_str().unwrap_or_default().to_string();
|
||
if ui.add_enabled(enabled, egui::TextEdit::singleline(&mut s).desired_width(240.0)).changed() {
|
||
set(d, path, json!(s));
|
||
}
|
||
}
|
||
|
||
// ---------- the dialog ----------
|
||
|
||
fn body(app: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) -> bool {
|
||
let t = Tokens::get(ui.ctx());
|
||
let editor = d.kind == PRESET_KIND;
|
||
if editor {
|
||
row_with(ui, "Preset name:", TOP_LABEL_WIDTH, |ui| {
|
||
form::text_edit(ui, d, "name", 288.0);
|
||
});
|
||
} else {
|
||
preset_rows(app, ui, d);
|
||
}
|
||
row_with(ui, "Standard:", TOP_LABEL_WIDTH, |ui| {
|
||
let picked = pick::<Standard>(ui, d, "standard", 170.0, |_| true);
|
||
let standard = choice::<Standard>(d, "standard").unwrap_or_default();
|
||
if picked {
|
||
standard_chosen(d, standard);
|
||
}
|
||
ui.add_space(16.0);
|
||
ui.label(egui::RichText::new("Compatibility:").color(t.text));
|
||
pick::<Compatibility>(ui, d, "compatibility", 110.0, |c| standard.allows(c));
|
||
});
|
||
ui.add_space(10.0);
|
||
let section = current_section(d, &SECTIONS);
|
||
ui.horizontal_top(|ui| {
|
||
section_frame(ui, |ui| section_list(ui, d, &SECTIONS, section));
|
||
ui.add_space(14.0);
|
||
ui.vertical(|ui| {
|
||
ui.set_width(500.0);
|
||
ui.label(egui::RichText::new(section).font(theme::semibold(14.0)).color(t.text_strong));
|
||
egui::ScrollArea::vertical().id_salt(("save-pdf", section)).max_height(360.0).auto_shrink([false, false]).show(ui, |ui| match section {
|
||
"Compression" => compression(ui, d),
|
||
"Marks and Bleeds" => marks_and_bleeds(app, ui, d),
|
||
"Output" => output(ui, d),
|
||
"Advanced" => advanced(ui, d),
|
||
"Security" => security(ui, d),
|
||
"Summary" => summary(app, ui, d),
|
||
_ => general(ui, d, editor),
|
||
});
|
||
});
|
||
});
|
||
false
|
||
}
|
||
|
||
/// The section `__section` names (default: the first).
|
||
pub(super) fn current_section(d: &Dialog, sections: &[&'static str]) -> &'static str {
|
||
sections.iter().copied().find(|s| *s == d.str("__section")).or_else(|| sections.first().copied()).unwrap_or_default()
|
||
}
|
||
|
||
/// The frame of the section list column, at least as tall as the section content.
|
||
pub(super) fn section_frame(ui: &mut egui::Ui, add: impl FnOnce(&mut egui::Ui)) {
|
||
let t = Tokens::get(ui.ctx());
|
||
egui::Frame::NONE.fill(t.panel_darker).corner_radius(egui::CornerRadius::same(4)).inner_margin(egui::Margin::same(6)).show(ui, |ui| {
|
||
ui.set_width(LIST_WIDTH);
|
||
ui.set_min_height(380.0);
|
||
ui.vertical(add);
|
||
});
|
||
}
|
||
|
||
/// The section list: clicking a section shows it (`__section`).
|
||
pub(super) fn section_list(ui: &mut egui::Ui, d: &mut Dialog, sections: &[&str], section: &str) {
|
||
let t = Tokens::get(ui.ctx());
|
||
ui.spacing_mut().item_spacing.y = 1.0;
|
||
for s in sections {
|
||
let sel = *s == section;
|
||
let label = egui::RichText::new(*s).size(12.5).color(if sel { t.text_strong } else { t.text });
|
||
if ui.add(egui::Button::selectable(sel, label).frame_when_inactive(false).min_size(egui::vec2(LIST_WIDTH - 12.0, 24.0))).clicked() {
|
||
d.fields.insert("__section".into(), json!(s));
|
||
}
|
||
}
|
||
}
|
||
|
||
/// What choosing `standard` changes: the compatibility, to the latest version it allows, and off
|
||
/// what files of a standard can't have (editing data, passwords; layers in PDF/X-1a and PDF/X-3).
|
||
fn standard_chosen(d: &mut Dialog, standard: Standard) {
|
||
if choice::<Compatibility>(d, "compatibility").is_some_and(|c| !standard.allows(c)) {
|
||
set(d, "compatibility", json!(standard.version().id()));
|
||
}
|
||
if standard != Standard::None {
|
||
set(d, "preserveEditing", json!(false));
|
||
for p in [OPEN_PASSWORD, PERMISSIONS_PASSWORD] {
|
||
set(d, p, json!(""));
|
||
}
|
||
}
|
||
if !standard.allows_layers() {
|
||
set(d, "createLayers", json!(false));
|
||
}
|
||
}
|
||
|
||
/// The Preset row (the presets, built-in and saved, and Save Preset…) and, while Save Preset…
|
||
/// asks for a name, the row taking it.
|
||
fn preset_rows(app: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) {
|
||
row_with(ui, "Preset:", TOP_LABEL_WIDTH, |ui| {
|
||
let presets = pdf::presets(&app.session);
|
||
let names: Vec<&str> = presets.iter().map(String::as_str).collect();
|
||
let chosen = widgets::dropdown(ui, "pdf-preset", &d.str("preset"), &names, 300.0).and_then(|i| names.get(i)).map(|p| p.to_string());
|
||
if let Some(name) = chosen {
|
||
apply_preset(app, d, &name);
|
||
}
|
||
let asking = d.fields.contains_key(SAVE_AS);
|
||
if ui
|
||
.add_enabled_ui(!asking, |ui| widgets::flat_button(ui, "Save Preset…", 96.0))
|
||
.inner
|
||
.on_hover_text("Save these settings as a preset")
|
||
.clicked()
|
||
{
|
||
d.fields.insert(SAVE_AS.into(), json!(app.session.new_pdf_preset_name()));
|
||
}
|
||
});
|
||
if d.fields.contains_key(SAVE_AS) {
|
||
row_with(ui, "Save as preset:", TOP_LABEL_WIDTH, |ui| {
|
||
form::text_edit(ui, d, SAVE_AS, 200.0);
|
||
if widgets::flat_button(ui, "Save", 52.0).clicked()
|
||
&& let Err(e) = save_preset(app, d)
|
||
{
|
||
app.status(e);
|
||
}
|
||
if widgets::flat_button(ui, "Cancel", 60.0).clicked() {
|
||
d.fields.remove(SAVE_AS);
|
||
}
|
||
});
|
||
}
|
||
}
|
||
|
||
/// Replace the settings with preset `name`'s (the artboard choice and path stay).
|
||
fn apply_preset(app: &mut VectorcraftApp, d: &mut Dialog, name: &str) {
|
||
match settings_fields(app, &json!({ "preset": name })) {
|
||
Ok(settings) => {
|
||
d.fields.extend(settings);
|
||
d.fields.insert("preset".into(), json!(name));
|
||
}
|
||
Err(e) => app.status(e),
|
||
}
|
||
}
|
||
|
||
/// General: the options, then the artboards (Save PDF) or the description (preset editor).
|
||
fn general(ui: &mut egui::Ui, d: &mut Dialog, editor: bool) {
|
||
heading(ui, "Options");
|
||
let standard = choice::<Standard>(d, "standard").unwrap_or_default();
|
||
flag(ui, d, "preserveEditing", "Preserve editing capabilities", standard == Standard::None);
|
||
flag(ui, d, "thumbnails", "Embed page thumbnails", true);
|
||
flag(ui, d, "fastWebView", "Optimize for fast web view", true);
|
||
flag(ui, d, "viewAfterSaving", "View PDF after saving", true);
|
||
flag(ui, d, "createLayers", "Create PDF layers from top-level layers", standard.allows_layers());
|
||
flag(ui, d, "includeNonPrinting", "Include non-printing layers", true);
|
||
if editor {
|
||
heading(ui, "Description");
|
||
if let Some(text) = widgets::text_field(ui, "pdf-preset-description", Some(&d.str("description")), 460.0, 3) {
|
||
d.fields.insert("description".into(), json!(text));
|
||
}
|
||
return;
|
||
}
|
||
heading(ui, "Artboards");
|
||
let mut all = d.bool("__allArtboards");
|
||
ui.horizontal(|ui| {
|
||
let changed = ui.radio_value(&mut all, true, "All").changed() | ui.radio_value(&mut all, false, "Range:").changed();
|
||
if changed {
|
||
d.fields.insert("__allArtboards".into(), json!(all));
|
||
}
|
||
let mut range = d.str("range");
|
||
if ui.add_enabled(!all, egui::TextEdit::singleline(&mut range).desired_width(140.0).hint_text("1-3, 5")).changed() {
|
||
d.fields.insert("range".into(), json!(range));
|
||
}
|
||
});
|
||
}
|
||
|
||
fn image_rows(ui: &mut egui::Ui, d: &mut Dialog, key: &str, title: &str) {
|
||
heading(ui, title);
|
||
let base = format!("compression.{key}");
|
||
let on = get(d, &format!("{base}.downsample")) != Downsample::None.id();
|
||
ui.horizontal(|ui| {
|
||
pick::<Downsample>(ui, d, &format!("{base}.downsample"), 150.0, |_| true);
|
||
ui.label("to");
|
||
number(ui, d, &format!("{base}.ppi"), " ppi", on);
|
||
ui.label("above");
|
||
number(ui, d, &format!("{base}.abovePpi"), " ppi", on);
|
||
});
|
||
ui.horizontal(|ui| {
|
||
ui.label("Compression:");
|
||
if key == "mono" {
|
||
pick::<MonoCodec>(ui, d, &format!("{base}.compression"), 150.0, |_| true);
|
||
} else {
|
||
pick::<ImageCodec>(ui, d, &format!("{base}.compression"), 120.0, |_| true);
|
||
let lossy = get(d, &format!("{base}.compression")).as_str().is_some_and(|c| c != ImageCodec::None.id() && c != ImageCodec::Zip.id());
|
||
ui.label("Quality:");
|
||
ui.add_enabled_ui(lossy, |ui| {
|
||
pick::<JpegQuality>(ui, d, &format!("{base}.quality"), 110.0, |_| true);
|
||
});
|
||
}
|
||
});
|
||
}
|
||
|
||
fn compression(ui: &mut egui::Ui, d: &mut Dialog) {
|
||
image_rows(ui, d, "color", "Color images");
|
||
image_rows(ui, d, "gray", "Grayscale images");
|
||
image_rows(ui, d, "mono", "Monochrome images");
|
||
ui.add_space(8.0);
|
||
flag(ui, d, "compression.compressText", "Compress text and line art", true);
|
||
}
|
||
|
||
pub(super) fn marks_and_bleeds(app: &VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) {
|
||
let unit = app.session.general_unit();
|
||
const MARKS: [(&str, &str); 4] = [
|
||
("marks.trim", "Trim marks"),
|
||
("marks.registration", "Registration marks"),
|
||
("marks.colorBars", "Color bars"),
|
||
("marks.pageInfo", "Page information"),
|
||
];
|
||
heading(ui, "Marks");
|
||
let all = MARKS.iter().all(|(p, _)| get(d, p).as_bool() == Some(true));
|
||
if widgets::check(ui, "All printer's marks", all, true) {
|
||
for (p, _) in MARKS {
|
||
set(d, p, json!(!all));
|
||
}
|
||
}
|
||
egui::Grid::new("pdf-marks").num_columns(2).spacing([24.0, 4.0]).show(ui, |ui| {
|
||
for pair in MARKS.chunks(2) {
|
||
for (p, label) in pair {
|
||
flag(ui, d, p, label, true);
|
||
}
|
||
ui.end_row();
|
||
}
|
||
});
|
||
row(ui, "Printer mark type:", |ui| {
|
||
pick::<MarkKind>(ui, d, "marks.kind", 130.0, |_| true);
|
||
});
|
||
row(ui, "Trim mark weight:", |ui| {
|
||
let w = get(d, "marks.weight").as_f64().unwrap_or(0.25);
|
||
let current =
|
||
WEIGHTS.iter().zip(WEIGHT_LABELS).find(|(x, _)| (*x - w).abs() < 1e-9).map_or_else(|| format!("{w} pt"), |(_, l)| l.to_string());
|
||
if let Some(x) = widgets::dropdown(ui, "marks.weight", ¤t, &WEIGHT_LABELS, 130.0).and_then(|i| WEIGHTS.get(i)) {
|
||
set(d, "marks.weight", json!(x));
|
||
}
|
||
});
|
||
row(ui, "Offset:", |ui| length(ui, d, "marks.offset", unit, true));
|
||
heading(ui, "Bleeds");
|
||
flag(ui, d, "bleed.useDocument", "Use document bleed settings", true);
|
||
// With the document's bleed the fields show it (Document Setup's `[top, bottom, left, right]`).
|
||
let document = (get(d, "bleed.useDocument").as_bool() == Some(true)).then(|| app.session.active().map_or([0.0; 4], |s| s.doc.setup.bleed));
|
||
egui::Grid::new("pdf-bleed").num_columns(4).spacing([10.0, 6.0]).show(ui, |ui| {
|
||
for pair in [[(0, "bleed.top", "Top:"), (1, "bleed.bottom", "Bottom:")], [(2, "bleed.left", "Left:"), (3, "bleed.right", "Right:")]] {
|
||
for (i, p, label) in pair {
|
||
ui.label(label);
|
||
match document {
|
||
Some(b) => {
|
||
ui.add_enabled_ui(false, |ui| widgets::num_field(ui, p, b.get(i).copied(), unit, 80.0));
|
||
}
|
||
None => length(ui, d, p, unit, true),
|
||
}
|
||
}
|
||
ui.end_row();
|
||
}
|
||
});
|
||
}
|
||
|
||
/// A dropdown of the colour profiles (`names`, the first one standing for "" at `path`), showing
|
||
/// the profile at `path` (a name that isn't one of them too).
|
||
fn profile_pick(ui: &mut egui::Ui, d: &mut Dialog, path: &str, names: &[&str], enabled: bool) {
|
||
let blank = names.first().copied().unwrap_or_default();
|
||
let current = get(d, path).as_str().filter(|s| !s.is_empty()).unwrap_or(blank).to_string();
|
||
ui.add_enabled_ui(enabled, |ui| {
|
||
if let Some(i) = widgets::dropdown(ui, path, ¤t, names, 300.0) {
|
||
set(d, path, json!(if i == 0 { "" } else { names.get(i).copied().unwrap_or_default() }));
|
||
}
|
||
});
|
||
}
|
||
|
||
fn output(ui: &mut egui::Ui, d: &mut Dialog) {
|
||
let profiles = vectorcraft_color::cms::profiles();
|
||
let names = |blank: &'static str| -> Vec<&str> { std::iter::once(blank).chain(profiles.iter().map(|p| p.name.as_str())).collect() };
|
||
let standard = choice::<Standard>(d, "standard").unwrap_or_default();
|
||
heading(ui, "Color");
|
||
row(ui, "Color conversion:", |ui| {
|
||
pick::<ColorConversion>(ui, d, "output.conversion", 300.0, |_| true);
|
||
});
|
||
// PDF/X-1a converts to CMYK even without a conversion.
|
||
let converting = get(d, "output.conversion") != ColorConversion::None.id() || standard.cmyk_only();
|
||
row(ui, "Destination:", |ui| profile_pick(ui, d, "output.destination", &names(DOCUMENT_PROFILE), converting));
|
||
row(ui, "Profile inclusion:", |ui| {
|
||
// A standard decides whether colours are tagged.
|
||
ui.add_enabled_ui(standard == Standard::None, |ui| pick::<ProfileInclusion>(ui, d, "output.profiles", 300.0, |_| true));
|
||
});
|
||
if standard.cmyk_only() {
|
||
note(ui, "PDF/X-1a files are CMYK: every colour is converted to the destination (blank: the output intent's CMYK profile), untagged.");
|
||
} else if standard != Standard::None {
|
||
note(ui, "Files of this standard tag their colours with ICC profiles.");
|
||
}
|
||
heading(ui, "Output Intent");
|
||
// PDF/A files carry their own output intent; PDF/X files always have one.
|
||
let own = standard != Standard::PdfA2b;
|
||
let blank = if standard.is_pdfx() { PDFX_INTENT } else { NO_PROFILE };
|
||
row(ui, "Output intent profile:", |ui| profile_pick(ui, d, "output.outputIntent", &names(blank), own));
|
||
for (p, label) in [
|
||
("output.outputCondition", "Output condition:"),
|
||
("output.outputConditionId", "Condition identifier:"),
|
||
("output.registry", "Registry name:"),
|
||
] {
|
||
row(ui, label, |ui| text(ui, d, p, own));
|
||
}
|
||
flag(ui, d, "output.trapped", "Mark as trapped", own);
|
||
if !own {
|
||
note(ui, "PDF/A files carry their own output intent.");
|
||
}
|
||
}
|
||
|
||
fn advanced(ui: &mut egui::Ui, d: &mut Dialog) {
|
||
heading(ui, "Fonts");
|
||
let outline = get(d, "advanced.outlineText").as_bool() == Some(true);
|
||
row(ui, "Subset fonts below:", |ui| {
|
||
number(ui, d, "advanced.fontSubsetPercent", "%", !outline);
|
||
ui.label("of characters used");
|
||
});
|
||
flag(ui, d, "advanced.outlineText", "Convert text to outlines", true);
|
||
heading(ui, "Overprint");
|
||
row(ui, "Overprint:", |ui| {
|
||
pick::<Overprint>(ui, d, "advanced.overprint", 130.0, |_| true);
|
||
});
|
||
}
|
||
|
||
/// The password fields (never stored in presets).
|
||
const OPEN_PASSWORD: &str = "security.openPassword";
|
||
const PERMISSIONS_PASSWORD: &str = "security.permissionsPassword";
|
||
|
||
/// A checkbox turning a password on (UI-only `flag`) or off (clearing it); checked while the
|
||
/// password is set. Returns whether the password applies.
|
||
fn password_check(ui: &mut egui::Ui, d: &mut Dialog, path: &str, flag: &str, label: &str, enabled: bool) -> bool {
|
||
let on = d.bool(flag) || get(d, path).as_str().is_some_and(|s| !s.is_empty());
|
||
if widgets::check(ui, label, on, enabled) {
|
||
d.fields.insert(flag.into(), json!(!on));
|
||
if on {
|
||
set(d, path, json!(""));
|
||
}
|
||
}
|
||
on && enabled
|
||
}
|
||
|
||
fn password_field(ui: &mut egui::Ui, d: &mut Dialog, path: &str, enabled: bool) {
|
||
let mut s = get(d, path).as_str().unwrap_or_default().to_string();
|
||
if ui.add_enabled(enabled, egui::TextEdit::singleline(&mut s).password(true).desired_width(200.0)).changed() {
|
||
set(d, path, json!(s));
|
||
}
|
||
}
|
||
|
||
fn security(ui: &mut egui::Ui, d: &mut Dialog) {
|
||
let plain = choice::<Standard>(d, "standard").unwrap_or_default() == Standard::None;
|
||
if !plain {
|
||
note(ui, "Files of a PDF standard can't be password-protected.");
|
||
}
|
||
heading(ui, "Document open password");
|
||
let open = password_check(ui, d, OPEN_PASSWORD, "__requireOpenPassword", "Require a password to open the document", plain);
|
||
row(ui, "Open password:", |ui| password_field(ui, d, OPEN_PASSWORD, open));
|
||
heading(ui, "Permissions");
|
||
let restrict = password_check(ui, d, PERMISSIONS_PASSWORD, "__restrictPermissions", "Restrict printing, editing and other tasks", plain);
|
||
row(ui, "Permissions password:", |ui| password_field(ui, d, PERMISSIONS_PASSWORD, restrict));
|
||
ui.add_enabled_ui(restrict, |ui| {
|
||
row(ui, "Printing allowed:", |ui| {
|
||
pick::<Printing>(ui, d, "security.printing", 260.0, |_| true);
|
||
});
|
||
row(ui, "Changes allowed:", |ui| {
|
||
pick::<Changes>(ui, d, "security.changes", 260.0, |_| true);
|
||
});
|
||
});
|
||
flag(ui, d, "security.copy", "Enable copying of text, images and other content", restrict);
|
||
let reader = "Enable text access for screen readers";
|
||
if get(d, "security.copy").as_bool() == Some(true) {
|
||
// What can be copied can be read out.
|
||
widgets::check(ui, reader, true, false);
|
||
} else {
|
||
flag(ui, d, "security.screenReader", reader, restrict);
|
||
}
|
||
let compatibility = choice::<Compatibility>(d, "compatibility").unwrap_or_default();
|
||
flag(ui, d, "security.plaintextMetadata", "Enable plaintext metadata", (open || restrict) && compatibility != Compatibility::Pdf14);
|
||
ui.add_space(6.0);
|
||
note(ui, &format!("Encryption: {} (set by Compatibility)", Encryption::for_compatibility(compatibility).label()));
|
||
}
|
||
|
||
/// The section a changed option belongs to (for the Summary's order).
|
||
pub(super) fn section_of(option: &str) -> usize {
|
||
match option.split('.').next().unwrap_or_default() {
|
||
"compression" => 1,
|
||
"marks" | "bleed" => 2,
|
||
"output" => 3,
|
||
"advanced" => 4,
|
||
"security" => 5,
|
||
_ => 0,
|
||
}
|
||
}
|
||
|
||
/// `compression.color.abovePpi` → "Compression › Color › Above Ppi": the section, then the keys
|
||
/// (without the section's own object), `thumbnails` → "General › Thumbnails".
|
||
pub(super) fn option_label(option: &str) -> String {
|
||
let section = SECTIONS.get(section_of(option)).copied().unwrap_or(SECTIONS[0]);
|
||
let mut keys = option.split('.').peekable();
|
||
keys.next_if(|k| k.eq_ignore_ascii_case(section));
|
||
std::iter::once(section.to_string()).chain(keys.map(|k| form::humanize(k).trim_end_matches(':').to_string())).collect::<Vec<_>>().join(" › ")
|
||
}
|
||
|
||
/// `document.pdfSettings` for the dialog with the document's warnings (an export in memory),
|
||
/// cached until a field or the document changes.
|
||
fn summary_of(app: &mut VectorcraftApp, ctx: &egui::Context, p: &Value) -> Result<Value, String> {
|
||
let revision = app.session.active().map_or(0, |s| s.revision);
|
||
let stamp = egui::Id::new((p.to_string(), revision));
|
||
let key = egui::Id::new("save-pdf-summary");
|
||
if let Some((s, v)) = ctx.data(|m| m.get_temp::<(egui::Id, Result<Value, String>)>(key))
|
||
&& s == stamp
|
||
{
|
||
return v;
|
||
}
|
||
let mut q = p.clone();
|
||
q["includeDocument"] = json!(true);
|
||
let v = app.session.execute("document.pdfSettings", &q).map_err(|e| e.to_string());
|
||
ctx.data_mut(|m| m.insert_temp(key, (stamp, v.clone())));
|
||
v
|
||
}
|
||
|
||
fn summary(app: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) {
|
||
let t = Tokens::get(ui.ctx());
|
||
let v = match summary_of(app, ui.ctx(), ¶ms(d)) {
|
||
Ok(v) => v,
|
||
Err(e) => {
|
||
heading(ui, "Error");
|
||
ui.label(egui::RichText::new(format!("⚠ {e}")).color(t.text));
|
||
return;
|
||
}
|
||
};
|
||
heading(ui, "Options");
|
||
let mut changed: Vec<&Value> = v["changed"].as_array().map(|a| a.iter().collect()).unwrap_or_default();
|
||
changed.sort_by_key(|c| section_of(c["option"].as_str().unwrap_or_default()));
|
||
if changed.is_empty() {
|
||
let base = if d.kind == PRESET_KIND { pdf::DEFAULT_PRESET } else { "the preset" };
|
||
note(ui, &format!("Every option matches {base}."));
|
||
}
|
||
option_rows(ui, changed, option_label);
|
||
heading(ui, "Warnings");
|
||
warning_rows(ui, v["warnings"].as_array().map(Vec::as_slice).unwrap_or_default());
|
||
}
|
||
|
||
/// A Summary line per changed option (`{option, value}`), labelled by `label`.
|
||
pub(super) fn option_rows<'a>(ui: &mut egui::Ui, changed: impl IntoIterator<Item = &'a Value>, label: impl Fn(&str) -> String) {
|
||
let t = Tokens::get(ui.ctx());
|
||
for c in changed {
|
||
let value = match &c["value"] {
|
||
Value::Bool(b) => if *b { "On" } else { "Off" }.to_string(),
|
||
Value::String(s) => s.clone(),
|
||
other => other.to_string(),
|
||
};
|
||
ui.label(egui::RichText::new(format!("{}: {value}", label(c["option"].as_str().unwrap_or_default()))).color(t.text));
|
||
}
|
||
}
|
||
|
||
/// The Summary's warnings ("None." without any).
|
||
pub(super) fn warning_rows(ui: &mut egui::Ui, warnings: &[Value]) {
|
||
let t = Tokens::get(ui.ctx());
|
||
if warnings.is_empty() {
|
||
note(ui, "None.");
|
||
}
|
||
for w in warnings {
|
||
ui.label(egui::RichText::new(format!("⚠ {}", w.as_str().unwrap_or_default())).color(t.text));
|
||
}
|
||
}
|
||
|
||
#[cfg(test)]
|
||
mod tests {
|
||
use std::cell::RefCell;
|
||
use std::rc::Rc;
|
||
|
||
use vectorcraft_engine::Session;
|
||
|
||
use super::*;
|
||
use crate::Services;
|
||
|
||
type Log = Rc<RefCell<Vec<(String, Vec<u8>)>>>;
|
||
|
||
/// An app with two artboards whose writer and URL opener record what they get.
|
||
fn app() -> (VectorcraftApp, Log, Log) {
|
||
let (written, opened) = (Log::default(), Log::default());
|
||
let (w, o) = (written.clone(), opened.clone());
|
||
let services = Services {
|
||
write: Some(Box::new(move |p: &str, b: &[u8]| {
|
||
w.borrow_mut().push((p.to_string(), b.to_vec()));
|
||
Ok(())
|
||
})),
|
||
open_url: Some(Box::new(move |u: &str| o.borrow_mut().push((u.to_string(), vec![])))),
|
||
..Default::default()
|
||
};
|
||
let mut app = VectorcraftApp::new(Session::new(), services);
|
||
app.run("file.new", json!({"width": 120, "height": 90, "artboards": 3})).unwrap();
|
||
app.run("shape.rectangle", json!({"x": 10, "y": 10, "width": 50, "height": 40})).unwrap();
|
||
(app, written, opened)
|
||
}
|
||
|
||
/// One headless frame of the dialog layer.
|
||
fn frame(app: &mut VectorcraftApp) {
|
||
let ctx = egui::Context::default();
|
||
theme::install_fonts(&ctx);
|
||
let mut out = ctx.run_ui(egui::RawInput::default(), |ui| super::super::show(app, ui.ctx()));
|
||
out.textures_delta.clear();
|
||
}
|
||
|
||
fn set_field(app: &mut VectorcraftApp, path: &str, v: Value) {
|
||
set(app.ui.dialog.as_mut().expect("dialog open"), path, v);
|
||
}
|
||
|
||
#[test]
|
||
fn every_section_draws() {
|
||
let (mut app, _, _) = app();
|
||
app.run("file.export.pdf", json!({})).unwrap();
|
||
assert_eq!(app.ui.dialog.as_ref().map(|d| d.kind.as_str()), Some(KIND));
|
||
assert_eq!(super::super::DialogKind::of(KIND), Some(super::super::DialogKind::SavePdf));
|
||
for s in SECTIONS {
|
||
set_field(&mut app, "__section", json!(s));
|
||
frame(&mut app);
|
||
assert!(app.ui.dialog.is_some(), "{s} closed the dialog");
|
||
}
|
||
// The Summary lists what changed and the warnings for options not applied yet.
|
||
set_field(&mut app, "thumbnails", json!(true));
|
||
let p = params(app.ui.dialog.as_ref().unwrap());
|
||
let v = summary_of(&mut app, &egui::Context::default(), &p).unwrap();
|
||
assert_eq!(v["changed"][0]["option"], "thumbnails");
|
||
assert!(v["warnings"][0].as_str().unwrap().contains("thumbnails"));
|
||
}
|
||
|
||
#[test]
|
||
fn confirm_runs_export_pdf_with_the_dialog_options() {
|
||
let (mut app, written, opened) = app();
|
||
app.run("ui.savePdfDialog", json!({"path": "/tmp/out.pdf", "compatibility": "1.5"})).unwrap();
|
||
set_field(&mut app, "__allArtboards", json!(false));
|
||
set_field(&mut app, "range", json!("1,3"));
|
||
set_field(&mut app, "compression.compressText", json!(false));
|
||
set_field(&mut app, "viewAfterSaving", json!(true));
|
||
let r = super::super::confirm(&mut app).unwrap();
|
||
assert!(app.ui.dialog.is_none(), "closes after saving");
|
||
assert_eq!(r["path"], "/tmp/out.pdf");
|
||
assert!(r.get("dataBase64").is_none());
|
||
let w = written.borrow();
|
||
assert_eq!(w.len(), 1);
|
||
assert_eq!(w[0].0, "/tmp/out.pdf");
|
||
assert!(w[0].1.starts_with(b"%PDF-1.5"), "compatibility reaches the writer");
|
||
assert_eq!(vectorcraft_pdf::import(&w[0].1).unwrap().artboards.len(), 2, "range 1,3");
|
||
assert!(!String::from_utf8_lossy(&w[0].1).contains("/FlateDecode"), "uncompressed content");
|
||
assert_eq!(opened.borrow().len(), 1, "View PDF after Saving opens the file once");
|
||
let url = &opened.borrow()[0].0;
|
||
assert!(url.starts_with("file:///") && url.ends_with("/tmp/out.pdf"), "the written file as a URL: {url}");
|
||
assert!(app.ui.status.starts_with("Saved /tmp/out.pdf"), "{}", app.ui.status);
|
||
}
|
||
|
||
#[test]
|
||
fn warnings_reach_the_status_and_errors_keep_the_dialog_open() {
|
||
let (mut app, written, opened) = app();
|
||
app.run("ui.savePdfDialog", json!({"path": "/tmp/w.pdf"})).unwrap();
|
||
set_field(&mut app, "thumbnails", json!(true));
|
||
super::super::confirm(&mut app).unwrap();
|
||
assert!(app.ui.status.contains("1 note(s)") && app.ui.status.contains("thumbnails"), "{}", app.ui.status);
|
||
assert!(opened.borrow().is_empty(), "not opened unless asked");
|
||
app.run("ui.savePdfDialog", json!({"path": "/tmp/bad.pdf"})).unwrap();
|
||
set_field(&mut app, "__allArtboards", json!(false));
|
||
set_field(&mut app, "range", json!("7"));
|
||
assert!(super::super::confirm(&mut app).is_err());
|
||
assert!(app.ui.dialog.is_some(), "a bad range keeps the dialog open");
|
||
assert_eq!(written.borrow().len(), 1);
|
||
// Without a path, bad options are refused before a save dialog asks for one.
|
||
let asked = Rc::new(RefCell::new(0));
|
||
let a = asked.clone();
|
||
app.services.pick_save = Some(Box::new(move |pick: &crate::FilePick| {
|
||
*a.borrow_mut() += 1;
|
||
Some(format!("/tmp/picked-{}", pick.name))
|
||
}));
|
||
app.run("ui.savePdfDialog", json!({"range": "7"})).unwrap();
|
||
assert!(super::super::confirm(&mut app).is_err());
|
||
assert_eq!(*asked.borrow(), 0, "no save dialog for a bad range");
|
||
set_field(&mut app, "range", json!("2"));
|
||
let r = super::super::confirm(&mut app).unwrap();
|
||
assert_eq!(*asked.borrow(), 1);
|
||
assert!(r["path"].as_str().is_some_and(|p| p.starts_with("/tmp/picked-") && p.ends_with(".pdf")), "{r}");
|
||
// Agents can export without the dialog.
|
||
let r = app.run("file.export.pdf", json!({"path": "/tmp/direct.pdf", "range": "2"})).unwrap();
|
||
assert_eq!(r["path"], "/tmp/direct.pdf");
|
||
assert!(r["warnings"].is_array());
|
||
}
|
||
|
||
#[test]
|
||
fn presets_reset_the_settings_and_fields_fall_back_to_defaults() {
|
||
let (mut app, _, _) = app();
|
||
app.run("ui.savePdfDialog", json!({"compatibility": "1.4", "marks": {"trim": true}})).unwrap();
|
||
let d = app.ui.dialog.as_mut().unwrap();
|
||
assert_eq!(get(d, "compatibility"), "1.4");
|
||
assert_eq!(get(d, "marks.weight"), 0.25, "the rest of a section keeps its defaults");
|
||
d.fields.remove("compression");
|
||
assert_eq!(get(d, "compression.color.ppi"), 300.0, "a field an agent dropped reads as its default");
|
||
let mut d = d.clone();
|
||
apply_preset(&mut app, &mut d, pdf::DEFAULT_PRESET);
|
||
assert_eq!(get(&d, "compatibility"), "1.7");
|
||
assert_eq!(get(&d, "marks.trim"), false);
|
||
assert!(app.run("ui.savePdfDialog", json!({"compatibility": "1.0"})).is_err(), "bad options are refused");
|
||
}
|
||
|
||
#[test]
|
||
fn marks_and_bleeds_reach_the_pdf() {
|
||
let (mut app, written, _) = app();
|
||
app.run("document.setup", json!({"bleed": 9})).unwrap();
|
||
let notes = app.run("layer.new", json!({})).unwrap()["id"].clone();
|
||
app.run("layer.setProps", json!({"id": notes, "printable": false})).unwrap();
|
||
app.run("shape.ellipse", json!({"x": 60, "y": 40, "width": 20, "height": 20})).unwrap();
|
||
app.run("ui.savePdfDialog", json!({"path": "/tmp/marks.pdf"})).unwrap();
|
||
set_field(&mut app, "__section", json!("Marks and Bleeds"));
|
||
set_field(&mut app, "bleed.useDocument", json!(true));
|
||
set_field(&mut app, "marks.trim", json!(true));
|
||
frame(&mut app);
|
||
set_field(&mut app, "__section", json!("General"));
|
||
set_field(&mut app, "includeNonPrinting", json!(true));
|
||
frame(&mut app);
|
||
let r = super::super::confirm(&mut app).unwrap();
|
||
assert_eq!(r["warnings"], json!([]), "marks and bleed are applied");
|
||
let bytes = &written.borrow()[0].1;
|
||
let page = &vectorcraft_pdf::info(bytes, None).unwrap().pages[0];
|
||
let trim = page.boxes.iter().find(|(b, _)| *b == vectorcraft_pdf::CropTo::Trim).unwrap().1;
|
||
assert_eq!((trim.width(), trim.x0), (120.0, 9.0 + 18.0 + 0.25), "the trim box is the artboard, inside the bleed and marks");
|
||
let mut ellipse = false;
|
||
vectorcraft_pdf::import(bytes).unwrap().walk(|n| {
|
||
let b = n.path_data().and_then(|p| p.bounds()).unwrap_or_default();
|
||
ellipse |= (b.width() - 20.0).abs() < 0.1 && (b.height() - 20.0).abs() < 0.1;
|
||
});
|
||
assert!(ellipse, "the non-printing layer is included");
|
||
}
|
||
|
||
#[test]
|
||
fn option_labels_read_well() {
|
||
assert_eq!(option_label("compression.color.abovePpi"), "Compression › Color › Above Ppi");
|
||
assert_eq!(option_label("compression.compressText"), "Compression › Compress Text");
|
||
assert_eq!(option_label("thumbnails"), "General › Thumbnails");
|
||
assert_eq!(option_label("bleed.top"), "Marks and Bleeds › Bleed › Top");
|
||
assert_eq!(section_of("bleed.top"), 2);
|
||
assert_eq!(SECTIONS[section_of("standard")], "General");
|
||
}
|
||
|
||
#[test]
|
||
fn the_security_section_takes_passwords_that_presets_drop() {
|
||
let (mut app, _, _) = app();
|
||
app.run("ui.savePdfDialog", json!({})).unwrap();
|
||
set_field(&mut app, "__section", json!("Security"));
|
||
set_field(&mut app, OPEN_PASSWORD, json!("open"));
|
||
set_field(&mut app, PERMISSIONS_PASSWORD, json!("own"));
|
||
set_field(&mut app, "security.copy", json!(false));
|
||
frame(&mut app);
|
||
let p = params(app.ui.dialog.as_ref().unwrap());
|
||
assert_eq!((p["security"]["openPassword"].as_str(), p["security"]["permissionsPassword"].as_str()), (Some("open"), Some("own")));
|
||
assert_eq!(p["security"]["copy"], false);
|
||
// With a standard the section still draws (greyed); a preset brings its own security.
|
||
set_field(&mut app, "standard", json!(Standard::PdfA2b.id()));
|
||
frame(&mut app);
|
||
let mut d = app.ui.dialog.clone().unwrap();
|
||
apply_preset(&mut app, &mut d, pdf::DEFAULT_PRESET);
|
||
assert_eq!(get(&d, OPEN_PASSWORD), &Value::Null, "presets carry no passwords");
|
||
assert_eq!(get(&d, "security.copy"), true);
|
||
}
|
||
|
||
#[test]
|
||
fn choosing_a_pdfx_standard_sets_its_version_and_writes_the_standard() {
|
||
let (mut app, written, _) = app();
|
||
app.run("ui.savePdfDialog", json!({"path": "/tmp/x.pdf", "createLayers": true})).unwrap();
|
||
let d = app.ui.dialog.as_mut().unwrap();
|
||
set(d, "standard", json!(Standard::PdfX1a.id()));
|
||
standard_chosen(d, Standard::PdfX1a);
|
||
assert_eq!((get(d, "compatibility"), get(d, "createLayers"), get(d, "preserveEditing")), (&json!("1.4"), &json!(false), &json!(false)));
|
||
for s in SECTIONS {
|
||
set_field(&mut app, "__section", json!(s));
|
||
frame(&mut app);
|
||
}
|
||
super::super::confirm(&mut app).unwrap();
|
||
let pdf = String::from_utf8_lossy(&written.borrow()[0].1).into_owned();
|
||
assert!(pdf.starts_with("%PDF-1.3") && pdf.contains("/GTS_PDFXConformance(PDF/X-1a:2001)") && pdf.contains("/S/GTS_PDFX"));
|
||
// PDF/X-4 keeps layers and is PDF 1.6.
|
||
app.run("ui.savePdfDialog", json!({"compatibility": "2.0", "createLayers": true})).unwrap();
|
||
let d = app.ui.dialog.as_mut().unwrap();
|
||
set(d, "standard", json!(Standard::PdfX4.id()));
|
||
standard_chosen(d, Standard::PdfX4);
|
||
assert_eq!((get(d, "compatibility"), get(d, "createLayers")), (&json!("1.6"), &json!(true)));
|
||
}
|
||
}
|