M3.93: Edit > Transparency Flattener Presets… manages built-in and saved flattener presets

Saved presets live in Prefs.flattener_presets (persisted with the preferences) beside the
built-in High, Medium and Low Resolution ones, which stay read-only. New commands:
flattener.presets.list / save / delete / import / export (a .vcflattener JSON file; opening one
imports it). Any saved preset name works as `preset` wherever flattener options are taken
(Session::flatten_options). The manager dialog lists the presets and edits the selected saved
one in place; the Flatten Transparency dialog lists saved presets and gains Save Preset….

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
bflatastic
2026-10-05 02:36:28 +07:00
co-authored by Claude Opus 5.5
parent 936721fbf5
commit 1dc158cafe
15 changed files with 863 additions and 57 deletions
+2 -1
View File
@@ -243,11 +243,12 @@ pub const OPEN_EXTS: &[&str] =
&["vectorcraft", "drawcraft", "svg", "svgz", "pdf", "ai", "ait", "png", "jpg", "jpeg", "gif", "webp", "tif", "tiff", "bmp"];
/// Open-dialog filters: "All readable files" first, then one per readable format, then swatch
/// libraries (which open in the library panel).
/// libraries (which open in the library panel) and flattener presets (imported).
pub fn open_filters() -> impl Iterator<Item = (&'static str, &'static [&'static str])> {
std::iter::once(("All readable files", OPEN_EXTS))
.chain(FORMATS.iter().filter(|f| f.read).map(|f| (f.label, f.extensions)))
.chain(std::iter::once(("Swatch libraries", super::swatchlib::LIBRARY_EXTS)))
.chain(std::iter::once(("Flattener presets", super::flatten::PRESET_EXTS)))
}
/// A format by id or extension (any case, leading dot allowed; `jpeg` finds `jpg`).
+1 -1
View File
@@ -148,7 +148,7 @@ fn open_exts_cover_every_readable_format() {
}
let filters: Vec<_> = open_filters().collect();
assert_eq!(filters[0], ("All readable files", OPEN_EXTS));
assert_eq!(filters.len(), 2 + FORMATS.iter().filter(|f| f.read).count(), "and swatch libraries");
assert_eq!(filters.len(), 3 + FORMATS.iter().filter(|f| f.read).count(), "and swatch libraries and flattener presets");
}
#[test]
+258 -13
View File
@@ -11,6 +11,10 @@
//! clipped to them with Clip Complex Regions (else the image is a rectangle that takes in the
//! regions it overlaps). The raster/vector balance rasterizes whole groups that split into more
//! regions than it allows (all of them at 0).
//!
//! Edit → Transparency Flattener Presets: the built-in presets plus the user's, saved with the
//! preferences ([`crate::Prefs::flattener_presets`]); a `preset` named anywhere options are taken
//! finds both.
use std::sync::Arc;
@@ -30,15 +34,62 @@ use super::pathops::{node_path, outline_strokes, outline_strokes_under, shape_no
use super::*;
pub fn specs() -> Vec<CommandSpec> {
vec![cmd!(
"object.flattenTransparency",
"Flatten Transparency…",
["Object"],
None,
"{preset?: \"high\"|\"medium\"|\"low\" (default medium), balance?: 0..100 (raster/vector balance; 0 rasterizes everything), lineArtPpi?: 1..2400, gradientPpi?: 1..2400 (areas only gradients and meshes reach), textToOutlines?, strokesToOutlines?, clipComplexRegions? (clip images to the region outlines, else rectangles), antiAlias?, preserveAlpha? (composite over nothing instead of white), preserveOverprints? (areas showing one paint keep its colour and overprint; false clears overprints), options?: {the same keys}, ids?} overlapping transparent objects become one group of flat-colour regions, plus an image where gradients, patterns, images, masks or raster effects reach; objects without transparency stay → {ids, rasterized: images made, vector: regions made, options}",
has_doc,
flatten
)]
vec![
cmd!(
"object.flattenTransparency",
"Flatten Transparency…",
["Object"],
None,
"{preset?: \"high\"|\"medium\"|\"low\" or a saved preset's name (see flattener.presets.list; default medium), balance?: 0..100 (raster/vector balance; 0 rasterizes everything), lineArtPpi?: 1..2400, gradientPpi?: 1..2400 (areas only gradients and meshes reach), textToOutlines?, strokesToOutlines?, clipComplexRegions? (clip images to the region outlines, else rectangles), antiAlias?, preserveAlpha? (composite over nothing instead of white), preserveOverprints? (areas showing one paint keep its colour and overprint; false clears overprints), options?: {the same keys}, ids?} overlapping transparent objects become one group of flat-colour regions, plus an image where gradients, patterns, images, masks or raster effects reach; objects without transparency stay → {ids, rasterized: images made, vector: regions made, options}",
has_doc,
flatten
),
cmd!(
query "flattener.presets.list",
"Transparency Flattener Presets",
[],
None,
"{} → {presets: [{name, builtIn, options}]} the built-in presets (High, Medium and Low Resolution), then the saved ones; any of these names works as `preset` wherever flattener options are taken",
always,
presets_list
),
cmd!(
query "flattener.presets.save",
"Save Transparency Flattener Preset",
[],
None,
"{name?: (default: a new \"Flattener Preset N\"), newName?: rename it, preset?: the preset to start from (default: the saved preset `name`, else medium), …options (the keys of object.flattenTransparency, at the top level or in `options`)} create or change a saved preset (built-in ones can't change) → {name, options, created}",
always,
presets_save
),
cmd!(
query "flattener.presets.delete",
"Delete Transparency Flattener Preset",
[],
None,
"{name} delete a saved preset (built-in ones stay) → {deleted: name}",
always,
presets_delete
),
cmd!(
query "flattener.presets.import",
"Import Transparency Flattener Presets",
[],
None,
"{path? | data?: file text | dataBase64?, replace?: false (replace saved presets of the same names; else the imported ones get a number)} add the presets of a .vcflattener file (as flattener.presets.export writes) to the saved ones → {imported: [names]}",
always,
presets_import
),
cmd!(
query "flattener.presets.export",
"Export Transparency Flattener Presets",
[],
None,
"{names?: [preset names, built-in ones too] (default: every saved preset), path?} write the presets as a .vcflattener file (JSON) → {path, count}; without path → {data: the file's text, count}",
always,
presets_export
),
]
}
/// Object → Flatten Transparency settings: a preset's, adjusted by the command's params.
@@ -109,11 +160,30 @@ impl FlattenOptions {
})
}
/// The options `p` asks for: `preset` (default medium) adjusted by the option keys given at the
/// top level or in `options`.
/// The built-in presets, finest first.
pub fn builtin_presets() -> Vec<FlattenerPreset> {
Self::PRESETS
.into_iter()
.filter_map(|id| Some(FlattenerPreset { name: Self::preset_label(id)?.into(), options: Self::preset(id)? }))
.collect()
}
/// A built-in preset (by id or display name) or one of `saved` (by name), any case.
pub fn find_preset(name: &str, saved: &[FlattenerPreset]) -> Option<Self> {
Self::preset(name).or_else(|| saved_preset(saved, name).map(|p| p.options.clone()))
}
/// The options `p` asks for, with the built-in presets only ([`Self::from_params_with`]).
pub fn from_params(p: &Value) -> std::result::Result<Self, String> {
Self::from_params_with(p, &[])
}
/// The options `p` asks for: `preset` (built-in or one of `saved`; default medium) adjusted by
/// the option keys given at the top level or in `options`.
pub fn from_params_with(p: &Value, saved: &[FlattenerPreset]) -> std::result::Result<Self, String> {
let base = match str_param(p, "preset") {
Some(name) => Self::preset(name).ok_or_else(|| format!("unknown preset `{name}` (high, medium or low)"))?,
Some(name) => Self::find_preset(name, saved)
.ok_or_else(|| format!("unknown preset `{name}` (high, medium, low or a saved one: see flattener.presets.list)"))?,
None => Self::default(),
};
let mut v = serde_json::to_value(&base).map_err(|e| e.to_string())?;
@@ -142,7 +212,7 @@ impl FlattenOptions {
fn flatten(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "object.flattenTransparency";
let o = FlattenOptions::from_params(p).map_err(|m| bad(C, m))?;
let o = s.flatten_options(p).map_err(|m| bad(C, m))?;
let roots = target_roots(s, p)?;
if roots.is_empty() {
return Err(EngineError::Other("Flatten Transparency: select objects to flatten".into()));
@@ -157,6 +227,181 @@ fn flatten(s: &mut Session, p: &Value) -> Result<Value> {
Ok(json!({ "ids": ids.iter().map(|i| i.0).collect::<Vec<_>>(), "rasterized": rasterized, "vector": vector, "options": options }))
}
// ---------- presets ----------
/// A named set of flattener options: a built-in preset or a saved one
/// ([`crate::Prefs::flattener_presets`]).
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct FlattenerPreset {
pub name: String,
#[serde(default)]
pub options: FlattenOptions,
}
/// What `flattener.presets.export` writes and `flattener.presets.import` reads.
#[derive(Serialize, Deserialize)]
struct PresetFile {
format: String,
#[serde(default)]
presets: Vec<FlattenerPreset>,
}
/// The `format` of a presets file, also its extension.
pub const PRESET_FORMAT: &str = "vcflattener";
/// The extensions of presets files (opening one imports it).
pub const PRESET_EXTS: &[&str] = &[PRESET_FORMAT];
fn saved_preset<'a>(saved: &'a [FlattenerPreset], name: &str) -> Option<&'a FlattenerPreset> {
saved.iter().find(|p| p.name.eq_ignore_ascii_case(name.trim()))
}
impl Session {
/// Every flattener preset: the built-in ones, then the saved ones.
pub fn flattener_presets(&self) -> Vec<FlattenerPreset> {
let mut v = FlattenOptions::builtin_presets();
v.extend(self.prefs.flattener_presets.iter().cloned());
v
}
/// The flattener options `p` asks for: a `preset` by name, built-in or saved, adjusted by the
/// option keys ([`FlattenOptions::from_params_with`]).
pub fn flatten_options(&self, p: &Value) -> std::result::Result<FlattenOptions, String> {
FlattenOptions::from_params_with(p, &self.prefs.flattener_presets)
}
/// The first free "Flattener Preset N": the name a new preset gets.
pub fn new_preset_name(&self) -> String {
(1..).map(|i| format!("Flattener Preset {i}")).find(|n| !self.preset_taken(n, None)).unwrap_or_default()
}
/// The index of the saved preset `name`.
fn saved_index(&self, name: &str) -> Option<usize> {
self.prefs.flattener_presets.iter().position(|q| q.name.eq_ignore_ascii_case(name.trim()))
}
/// Whether `name` is taken by a built-in preset or a saved one other than the one at `except`.
fn preset_taken(&self, name: &str, except: Option<usize>) -> bool {
FlattenOptions::preset(name).is_some() || self.saved_index(name).is_some_and(|i| Some(i) != except)
}
}
fn presets_list(s: &mut Session, _: &Value) -> Result<Value> {
let rows = FlattenOptions::builtin_presets()
.into_iter()
.map(|p| (p, true))
.chain(s.prefs.flattener_presets.iter().cloned().map(|p| (p, false)))
.map(|(p, builtin)| json!({"name": p.name, "builtIn": builtin, "options": p.options}));
Ok(json!({ "presets": rows.collect::<Vec<_>>() }))
}
fn presets_save(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "flattener.presets.save";
let name = match str_param(p, "name").map(str::trim) {
Some("") => return Err(bad(C, "`name` is empty")),
Some(n) => n.to_string(),
None => s.new_preset_name(),
};
if FlattenOptions::preset(&name).is_some() {
return Err(bad(C, format!("`{name}` is a built-in preset and can't change: save it under another name")));
}
let at = s.saved_index(&name);
// A saved preset changes from its own options unless `preset` says where to start.
let mut q = p.clone();
if let (Some(i), Some(o), None) = (at, q.as_object_mut(), str_param(p, "preset")) {
o.insert("preset".into(), json!(s.prefs.flattener_presets[i].name));
}
let options = s.flatten_options(&q).map_err(|m| bad(C, m))?;
let name = match str_param(p, "newName").map(str::trim) {
Some("") => return Err(bad(C, "`newName` is empty")),
Some(n) if s.preset_taken(n, at) => return Err(bad(C, format!("a preset named `{n}` exists"))),
Some(n) => n.to_string(),
None => at.map_or(name, |i| s.prefs.flattener_presets[i].name.clone()),
};
let preset = FlattenerPreset { name: name.clone(), options: options.clone() };
match at {
Some(i) => s.prefs.flattener_presets[i] = preset,
None => s.prefs.flattener_presets.push(preset),
}
Ok(json!({"name": name, "options": options, "created": at.is_none()}))
}
fn presets_delete(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "flattener.presets.delete";
let name = str_param(p, "name").ok_or_else(|| bad(C, "missing `name`"))?;
if FlattenOptions::preset(name).is_some() {
return Err(bad(C, format!("`{name}` is a built-in preset and stays")));
}
let i = s.saved_index(name).ok_or_else(|| bad(C, format!("no saved preset named `{name}`")))?;
Ok(json!({ "deleted": s.prefs.flattener_presets.remove(i).name }))
}
fn presets_export(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "flattener.presets.export";
let presets = match p.get("names").and_then(Value::as_array) {
Some(names) => {
let all = s.flattener_presets();
let find = |n: &Value| {
let n = n.as_str().unwrap_or_default().trim();
// Built-in ones also by id (`high`).
let key = FlattenOptions::preset_label(&n.to_ascii_lowercase()).unwrap_or(n);
all.iter().find(|q| q.name.eq_ignore_ascii_case(key)).cloned().ok_or_else(|| bad(C, format!("no preset named `{n}`")))
};
names.iter().map(find).collect::<Result<Vec<_>>>()?
}
None => s.prefs.flattener_presets.clone(),
};
if presets.is_empty() {
return Err(bad(C, "no saved presets to export (name built-in ones in `names`)"));
}
let count = presets.len();
let text = serde_json::to_string_pretty(&PresetFile { format: PRESET_FORMAT.into(), presets }).map_err(|e| bad(C, e.to_string()))?;
match str_param(p, "path") {
Some(path) => {
super::fileio::write_file(path, text.as_bytes())?;
Ok(json!({"path": path, "count": count}))
}
None => Ok(json!({"data": text, "count": count})),
}
}
fn presets_import(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "flattener.presets.import";
let bytes = match (str_param(p, "path"), str_param(p, "data"), str_param(p, "dataBase64")) {
(Some(path), ..) => super::fileio::read_file(path)?,
(None, Some(text), _) => text.as_bytes().to_vec(),
(None, None, Some(b64)) => vectorcraft_format::base64_decode(b64).ok_or_else(|| bad(C, "bad dataBase64"))?,
_ => return Err(bad(C, "give `path`, `data` or `dataBase64`")),
};
let file: PresetFile = serde_json::from_slice(&bytes).map_err(|e| bad(C, format!("not a flattener presets file: {e}")))?;
if file.format != PRESET_FORMAT {
return Err(bad(C, format!("not a flattener presets file (format `{}`)", file.format)));
}
// Values a hand-edited file may carry are rejected as the commands reject them.
for q in &file.presets {
FlattenOptions::from_params(&json!({ "options": q.options })).map_err(|m| bad(C, format!("`{}`: {m}", q.name)))?;
}
let replace = bool_or(p, "replace", false);
let mut imported = vec![];
for mut preset in file.presets {
let base = Some(preset.name.trim()).filter(|n| !n.is_empty()).unwrap_or("Flattener Preset").to_string();
match s.saved_index(&base) {
Some(i) if replace => {
preset.name = s.prefs.flattener_presets[i].name.clone();
imported.push(preset.name.clone());
s.prefs.flattener_presets[i] = preset;
}
_ => {
preset.name = unique_name(&base, |n| s.preset_taken(n, None));
imported.push(preset.name.clone());
s.prefs.flattener_presets.push(preset);
}
}
}
Ok(json!({ "imported": imported }))
}
/// The top-level objects among `ids` (or the selection), in paint order: layers stand for their
/// contents; hidden and locked objects and clipping paths are left alone.
fn target_roots(s: &Session, p: &Value) -> Result<Vec<NodeId>> {
+4 -1
View File
@@ -14,7 +14,7 @@ mod draw2;
mod edit;
mod effectcmd;
pub mod fileio;
pub(crate) mod flatten;
pub mod flatten;
mod fonts;
pub(crate) mod freeform;
pub(crate) mod gradient;
@@ -363,3 +363,6 @@ pub use overprint::AttributesInfo;
/// Object → Flatten Transparency settings (`object.flattenTransparency`).
pub use flatten::FlattenOptions;
/// A built-in or saved flattener preset ([`crate::Prefs::flattener_presets`]).
pub use flatten::FlattenerPreset;