Merge pull request #46 from storytold/pr/m3-flattener-ui
M3: Flatten Transparency dialog, flattener presets, Flattener Preview panel (M3.92–M3.94)
This commit is contained in:
commit
c73aa380e1
|
|
@ -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`).
|
||||
|
|
|
|||
|
|
@ -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]
|
||||
|
|
|
|||
|
|
@ -11,6 +11,13 @@
|
|||
//! 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.
|
||||
//!
|
||||
//! Window → Flattener Preview: the same plan, worked out without rendering, reports what flattening
|
||||
//! the document would touch ([`FlattenReport`], `flattener.preview`).
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
|
|
@ -30,15 +37,71 @@ 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
|
||||
),
|
||||
cmd!(
|
||||
query "flattener.preview",
|
||||
"Flattener Preview",
|
||||
[],
|
||||
None,
|
||||
"{highlight?: \"none\"|\"rasterizedRegions\" (areas the raster/vector balance rasterizes whole)|\"transparentObjects\"|\"allAffected\"|\"expandedPatterns\" (pattern art taking part)|\"outlinedStrokes\"|\"outlinedText\"|\"allRasterized\" (default none), overprints?: \"preserve\"|\"simulate\"|\"discard\" (simulate and discard flatten without preserveOverprints), preset?, …options (as object.flattenTransparency), ids?: (default: the whole document)} what flattening would do, without changing anything → {highlight, regions: [{bounds: {x, y, width, height}, id?}] (the highlighted objects, or areas), counts: {transparentObjects, allAffected, expandedPatterns, outlinedStrokes, outlinedText, rasterizedRegions, allRasterized, vectorRegions}, options}",
|
||||
has_doc,
|
||||
preview
|
||||
),
|
||||
]
|
||||
}
|
||||
|
||||
/// Object → Flatten Transparency settings: a preset's, adjusted by the command's params.
|
||||
|
|
@ -109,11 +172,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,13 +224,13 @@ 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()));
|
||||
}
|
||||
let src = s.doc()?.doc.clone();
|
||||
let plan = plan(&src, &roots, &o);
|
||||
let plan = plan(&src, &roots, &o, true);
|
||||
let options = serde_json::to_value(&o).unwrap_or_default();
|
||||
if plan.flat.is_empty() && !plan.kept.iter().any(|id| src.node(*id).is_some_and(|n| kept_work(n, &o))) {
|
||||
return Ok(json!({ "ids": roots.iter().map(|i| i.0).collect::<Vec<_>>(), "rasterized": 0, "vector": 0, "options": options }));
|
||||
|
|
@ -157,6 +239,185 @@ 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 preset `name` names: a built-in one (by id or display name) or a saved one, any case.
|
||||
pub fn flattener_preset(&self, name: &str) -> Option<FlattenerPreset> {
|
||||
let key = name.trim();
|
||||
let key = FlattenOptions::preset_label(&key.to_ascii_lowercase()).unwrap_or(key);
|
||||
self.flattener_presets().into_iter().find(|p| p.name.eq_ignore_ascii_case(key))
|
||||
}
|
||||
|
||||
/// 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 find = |n: &Value| {
|
||||
let n = n.as_str().unwrap_or_default();
|
||||
s.flattener_preset(n).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>> {
|
||||
|
|
@ -180,6 +441,284 @@ fn target_roots(s: &Session, p: &Value) -> Result<Vec<NodeId>> {
|
|||
Ok(roots.into_iter().filter(|id| d.is_visible(*id) && d.is_editable(*id) && !clipping(*id)).collect())
|
||||
}
|
||||
|
||||
// ---------- preview ----------
|
||||
|
||||
/// What the Flattener Preview highlights.
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub enum Highlight {
|
||||
/// Nothing: the art in colour.
|
||||
#[default]
|
||||
None,
|
||||
/// Areas the raster/vector balance rasterizes whole.
|
||||
RasterizedRegions,
|
||||
/// Objects with transparency of their own.
|
||||
TransparentObjects,
|
||||
/// Every object flattening changes.
|
||||
AllAffected,
|
||||
/// Objects with pattern paint in the flattened art.
|
||||
ExpandedPatterns,
|
||||
/// Strokes that become filled outlines.
|
||||
OutlinedStrokes,
|
||||
/// Type that becomes outlines.
|
||||
OutlinedText,
|
||||
/// Every area that becomes an image.
|
||||
AllRasterized,
|
||||
}
|
||||
|
||||
impl Highlight {
|
||||
pub const ALL: [Self; 8] = [
|
||||
Self::None,
|
||||
Self::RasterizedRegions,
|
||||
Self::TransparentObjects,
|
||||
Self::AllAffected,
|
||||
Self::ExpandedPatterns,
|
||||
Self::OutlinedStrokes,
|
||||
Self::OutlinedText,
|
||||
Self::AllRasterized,
|
||||
];
|
||||
|
||||
/// The id `flattener.preview` takes.
|
||||
pub fn id(self) -> &'static str {
|
||||
match self {
|
||||
Self::None => "none",
|
||||
Self::RasterizedRegions => "rasterizedRegions",
|
||||
Self::TransparentObjects => "transparentObjects",
|
||||
Self::AllAffected => "allAffected",
|
||||
Self::ExpandedPatterns => "expandedPatterns",
|
||||
Self::OutlinedStrokes => "outlinedStrokes",
|
||||
Self::OutlinedText => "outlinedText",
|
||||
Self::AllRasterized => "allRasterized",
|
||||
}
|
||||
}
|
||||
|
||||
pub fn label(self) -> &'static str {
|
||||
match self {
|
||||
Self::None => "None (Color Preview)",
|
||||
Self::RasterizedRegions => "Rasterized Complex Regions",
|
||||
Self::TransparentObjects => "Transparent Objects",
|
||||
Self::AllAffected => "All Affected Objects",
|
||||
Self::ExpandedPatterns => "Expanded Patterns",
|
||||
Self::OutlinedStrokes => "Outlined Strokes",
|
||||
Self::OutlinedText => "Outlined Text",
|
||||
Self::AllRasterized => "All Rasterized Regions",
|
||||
}
|
||||
}
|
||||
|
||||
/// A highlight by id or label, any case.
|
||||
pub fn parse(s: &str) -> Option<Self> {
|
||||
let s = s.trim();
|
||||
Self::ALL.into_iter().find(|h| h.id().eq_ignore_ascii_case(s) || h.label().eq_ignore_ascii_case(s))
|
||||
}
|
||||
}
|
||||
|
||||
/// How the Flattener Preview treats overprints: kept as they are, simulated in the preview, or
|
||||
/// dropped; only Preserve flattens with Preserve Overprints and Spot Colors.
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub enum Overprints {
|
||||
#[default]
|
||||
Preserve,
|
||||
Simulate,
|
||||
Discard,
|
||||
}
|
||||
|
||||
impl Overprints {
|
||||
pub const ALL: [Self; 3] = [Self::Preserve, Self::Simulate, Self::Discard];
|
||||
|
||||
pub fn id(self) -> &'static str {
|
||||
match self {
|
||||
Self::Preserve => "preserve",
|
||||
Self::Simulate => "simulate",
|
||||
Self::Discard => "discard",
|
||||
}
|
||||
}
|
||||
|
||||
pub fn label(self) -> &'static str {
|
||||
match self {
|
||||
Self::Preserve => "Preserve",
|
||||
Self::Simulate => "Simulate",
|
||||
Self::Discard => "Discard",
|
||||
}
|
||||
}
|
||||
|
||||
pub fn parse(s: &str) -> Option<Self> {
|
||||
Self::ALL.into_iter().find(|o| o.id().eq_ignore_ascii_case(s.trim()) || o.label().eq_ignore_ascii_case(s.trim()))
|
||||
}
|
||||
}
|
||||
|
||||
/// What flattening would do (Window → Flattener Preview, `flattener.preview`).
|
||||
#[derive(Clone, Debug, Default)]
|
||||
pub struct FlattenReport {
|
||||
/// Top-level objects with transparency of their own.
|
||||
pub transparent: Vec<NodeId>,
|
||||
/// Top-level objects flattening changes: the overlapping groups with transparency, and those
|
||||
/// the outline and overprint options change.
|
||||
pub affected: Vec<NodeId>,
|
||||
/// Objects with pattern paint in the flattened art.
|
||||
pub patterns: Vec<NodeId>,
|
||||
/// Paths whose strokes become filled outlines.
|
||||
pub strokes: Vec<NodeId>,
|
||||
/// Type that becomes outlines.
|
||||
pub text: Vec<NodeId>,
|
||||
/// Areas the raster/vector balance rasterizes whole.
|
||||
pub complex: Vec<PathData>,
|
||||
/// Every area that becomes an image.
|
||||
pub rasterized: Vec<PathData>,
|
||||
/// Flat-colour regions made.
|
||||
pub vector: usize,
|
||||
}
|
||||
|
||||
impl FlattenReport {
|
||||
/// The objects `h` highlights.
|
||||
pub fn objects(&self, h: Highlight) -> &[NodeId] {
|
||||
match h {
|
||||
Highlight::TransparentObjects => &self.transparent,
|
||||
Highlight::AllAffected => &self.affected,
|
||||
Highlight::ExpandedPatterns => &self.patterns,
|
||||
Highlight::OutlinedStrokes => &self.strokes,
|
||||
Highlight::OutlinedText => &self.text,
|
||||
_ => &[],
|
||||
}
|
||||
}
|
||||
|
||||
/// The areas `h` highlights.
|
||||
pub fn areas(&self, h: Highlight) -> &[PathData] {
|
||||
match h {
|
||||
Highlight::RasterizedRegions => &self.complex,
|
||||
Highlight::AllRasterized => &self.rasterized,
|
||||
_ => &[],
|
||||
}
|
||||
}
|
||||
|
||||
/// How many objects or areas each highlight shows.
|
||||
pub fn counts(&self) -> Value {
|
||||
json!({
|
||||
"transparentObjects": self.transparent.len(),
|
||||
"allAffected": self.affected.len(),
|
||||
"expandedPatterns": self.patterns.len(),
|
||||
"outlinedStrokes": self.strokes.len(),
|
||||
"outlinedText": self.text.len(),
|
||||
"rasterizedRegions": self.complex.len(),
|
||||
"allRasterized": self.rasterized.len(),
|
||||
"vectorRegions": self.vector,
|
||||
})
|
||||
}
|
||||
|
||||
/// The report as `flattener.preview` answers: the highlighted objects (or areas) of `doc` and
|
||||
/// the counts.
|
||||
pub fn to_json(&self, doc: &Document, h: Highlight) -> Value {
|
||||
let rect = |r: Rect| json!({"x": r.x0, "y": r.y0, "width": r.width(), "height": r.height()});
|
||||
let objects = self.objects(h).iter().filter_map(|id| Some(json!({"id": id.0, "bounds": rect(doc.node(*id)?.visual_bounds()?)})));
|
||||
let areas = self.areas(h).iter().filter_map(|p| Some(json!({ "bounds": rect(p.bounds()?) })));
|
||||
json!({"highlight": h.id(), "regions": objects.chain(areas).collect::<Vec<_>>(), "counts": self.counts()})
|
||||
}
|
||||
|
||||
/// The art `h` highlights, opaque, alone in a copy of `doc`: the objects and the areas (filled
|
||||
/// black). Its coverage is the highlight. `None` when nothing is highlighted.
|
||||
pub fn highlight_art(&self, doc: &Document, h: Highlight) -> Option<Document> {
|
||||
let (ids, areas) = (self.objects(h), self.areas(h));
|
||||
if ids.is_empty() && areas.is_empty() {
|
||||
return None;
|
||||
}
|
||||
let mut tmp = doc.clone();
|
||||
let mut art: Vec<Node> = ids.iter().filter_map(|id| doc.node(*id).cloned()).collect();
|
||||
art.iter_mut().for_each(opaque);
|
||||
for path in areas {
|
||||
let mut n = shape_node(&mut tmp, path.clone(), None);
|
||||
n.appearance = Appearance::basic(Paint::solid(Color::rgb(0.0, 0.0, 0.0)), Paint::None, 0.0);
|
||||
art.push(n);
|
||||
}
|
||||
Some(isolated_doc(&tmp, art))
|
||||
}
|
||||
}
|
||||
|
||||
/// What flattening `roots` of `doc` with `o` would do.
|
||||
pub fn report(doc: &Document, roots: &[NodeId], o: &FlattenOptions) -> FlattenReport {
|
||||
let plan = plan(doc, roots, o, false);
|
||||
let mut r = FlattenReport { transparent: plan.transparent, ..Default::default() };
|
||||
// Every object under `id` (but a compound path's own pieces), visible ones only.
|
||||
fn visit(n: &Node, f: &mut dyn FnMut(&Node)) {
|
||||
if !n.visible {
|
||||
return;
|
||||
}
|
||||
f(n);
|
||||
if !matches!(n.kind, NodeKind::Compound { .. }) {
|
||||
for c in n.children().into_iter().flatten() {
|
||||
visit(c, f);
|
||||
}
|
||||
}
|
||||
}
|
||||
let pattern = |n: &Node| n.appearance.items.iter().any(|i| i.visible() && matches!(i.paint(), Paint::Pattern { .. }));
|
||||
for f in &plan.flat {
|
||||
r.affected.extend(&f.roots);
|
||||
r.vector += f.regions.len();
|
||||
for root in f.roots.iter().filter_map(|id| doc.node(*id)) {
|
||||
visit(root, &mut |n| {
|
||||
// Flattened art is outlined throughout and its patterns become part of the image.
|
||||
if matches!(n.kind, NodeKind::Text(_)) {
|
||||
r.text.push(n.id);
|
||||
} else if stroked(n) {
|
||||
r.strokes.push(n.id);
|
||||
}
|
||||
if pattern(n) {
|
||||
r.patterns.push(n.id);
|
||||
}
|
||||
});
|
||||
}
|
||||
if let Some(a) = &f.area {
|
||||
if a.whole {
|
||||
r.complex.push(a.outline());
|
||||
}
|
||||
r.rasterized.push(a.outline());
|
||||
}
|
||||
}
|
||||
for root in plan.kept.iter().filter_map(|id| doc.node(*id)).filter(|n| kept_work(n, o)) {
|
||||
r.affected.push(root.id);
|
||||
visit(root, &mut |n| {
|
||||
if o.text_to_outlines && matches!(n.kind, NodeKind::Text(_)) {
|
||||
r.text.push(n.id);
|
||||
} else if o.strokes_to_outlines && stroked(n) {
|
||||
r.strokes.push(n.id);
|
||||
}
|
||||
});
|
||||
}
|
||||
r.affected.sort_by_key(|id| doc.index_path(*id));
|
||||
r
|
||||
}
|
||||
|
||||
impl Session {
|
||||
/// What flattening would do with the options `p` asks for (as `flattener.preview`): the
|
||||
/// options and the report.
|
||||
pub fn flattener_report(&self, p: &Value) -> Result<(FlattenOptions, FlattenReport)> {
|
||||
const C: &str = "flattener.preview";
|
||||
let mut o = self.flatten_options(p).map_err(|m| bad(C, m))?;
|
||||
if let Some(v) = str_param(p, "overprints") {
|
||||
let ov = Overprints::parse(v).ok_or_else(|| bad(C, format!("unknown overprints `{v}` (preserve, simulate or discard)")))?;
|
||||
o.preserve_overprints = ov == Overprints::Preserve;
|
||||
}
|
||||
let roots = if p.get("ids").is_some() {
|
||||
target_roots(self, p)?
|
||||
} else {
|
||||
let layers: Vec<u64> = self.doc()?.doc.layers.iter().map(|l| l.id.0).collect();
|
||||
target_roots(self, &json!({ "ids": layers }))?
|
||||
};
|
||||
let r = report(&self.doc()?.doc, &roots, &o);
|
||||
Ok((o, r))
|
||||
}
|
||||
}
|
||||
|
||||
fn preview(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let h = match str_param(p, "highlight") {
|
||||
Some(v) => Highlight::parse(v).ok_or_else(|| bad("flattener.preview", format!("unknown highlight `{v}`")))?,
|
||||
None => Highlight::None,
|
||||
};
|
||||
let (o, r) = s.flattener_report(p)?;
|
||||
let mut out = r.to_json(&s.doc()?.doc, h);
|
||||
out["options"] = serde_json::to_value(&o).unwrap_or_default();
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
// ---------- plan ----------
|
||||
|
||||
/// What the flattening does, worked out before the document changes.
|
||||
|
|
@ -187,16 +726,40 @@ struct Plan {
|
|||
flat: Vec<Flat>,
|
||||
/// Objects without transparency (they stay, but for the outline options).
|
||||
kept: Vec<NodeId>,
|
||||
/// The objects with transparency of their own.
|
||||
transparent: Vec<NodeId>,
|
||||
}
|
||||
|
||||
/// One group of overlapping objects with transparency and what replaces it.
|
||||
struct Flat {
|
||||
/// Its objects in paint order; the result takes the place of the last.
|
||||
roots: Vec<NodeId>,
|
||||
regions: Vec<(PathData, RegionFill)>,
|
||||
regions: Regions,
|
||||
/// Where its image goes, and the image (once rendered).
|
||||
area: Option<Area>,
|
||||
raster: Option<Raster>,
|
||||
}
|
||||
|
||||
/// Where a group's image goes.
|
||||
struct Area {
|
||||
rect: Rect,
|
||||
ppi: f64,
|
||||
/// The complex regions it is clipped to (Clip Complex Regions).
|
||||
clip: Option<PathData>,
|
||||
/// The raster/vector balance rasterizes the whole group (it splits into too many regions).
|
||||
whole: bool,
|
||||
}
|
||||
|
||||
impl Area {
|
||||
/// The area's outline.
|
||||
fn outline(&self) -> PathData {
|
||||
self.clip.clone().unwrap_or_else(|| shapes::rectangle(self.rect))
|
||||
}
|
||||
}
|
||||
|
||||
/// Flat-colour regions: their outlines and paints.
|
||||
type Regions = Vec<(PathData, RegionFill)>;
|
||||
|
||||
/// A flat-colour region's paint.
|
||||
struct RegionFill {
|
||||
paint: Paint,
|
||||
|
|
@ -213,7 +776,9 @@ struct Raster {
|
|||
clip: Option<PathData>,
|
||||
}
|
||||
|
||||
fn plan(src: &Document, roots: &[NodeId], o: &FlattenOptions) -> Plan {
|
||||
/// The flattening of `roots` with `o`; `render` renders the images (the preview only needs where
|
||||
/// they go).
|
||||
fn plan(src: &Document, roots: &[NodeId], o: &FlattenOptions, render: bool) -> Plan {
|
||||
let tmp = isolated_doc(src, roots.iter().filter_map(|id| src.node(*id).cloned()).collect());
|
||||
// Geometry effects first: what is left on the art are raster effects.
|
||||
let baked = effects::bake_document(&tmp).unwrap_or(tmp);
|
||||
|
|
@ -221,26 +786,28 @@ fn plan(src: &Document, roots: &[NodeId], o: &FlattenOptions) -> Plan {
|
|||
let mut sc = Scratch { brushes: vectorcraft_brush::library(&baked), doc: baked.clone() };
|
||||
let plain: Vec<Node> = art.iter().map(|n| sc.plain(n)).collect();
|
||||
let reaches: Vec<Option<Rect>> = plain.iter().map(reach).collect();
|
||||
let mut out = Plan { flat: vec![], kept: vec![] };
|
||||
let see_through: Vec<bool> = plain.iter().map(transparent).collect();
|
||||
let mut out = Plan { flat: vec![], kept: vec![], transparent: roots.iter().zip(&see_through).filter(|(_, t)| **t).map(|(id, _)| *id).collect() };
|
||||
for g in overlapping(&reaches) {
|
||||
let ids: Vec<NodeId> = g.iter().map(|&i| roots[i]).collect();
|
||||
let flat = g.iter().any(|&i| transparent(&plain[i])).then(|| {
|
||||
let flat = g.iter().any(|&i| see_through[i]).then(|| {
|
||||
let art: Vec<Node> = g.iter().map(|&i| art[i].clone()).collect();
|
||||
let plain: Vec<&Node> = g.iter().map(|&i| &plain[i]).collect();
|
||||
flatten_group(&baked, &art, &plain, o)
|
||||
let (regions, area) = flatten_group(&plain, o)?;
|
||||
let raster = area.as_ref().filter(|_| render).map(|a| render_raster(&baked, &art, a.rect, a.ppi, a.clip.clone(), o));
|
||||
Some(Flat { roots: ids.clone(), regions, area, raster })
|
||||
});
|
||||
match flat.flatten() {
|
||||
Some((regions, raster)) => out.flat.push(Flat { roots: ids, regions, raster }),
|
||||
Some(f) => out.flat.push(f),
|
||||
None => out.kept.extend(ids),
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// The regions and image replacing one group of overlapping objects (`art` as rendered, `plain`
|
||||
/// as composited); `None` when it paints nothing.
|
||||
#[allow(clippy::type_complexity)]
|
||||
fn flatten_group(doc: &Document, art: &[Node], plain: &[&Node], o: &FlattenOptions) -> Option<(Vec<(PathData, RegionFill)>, Option<Raster>)> {
|
||||
/// The regions and the image area replacing one group of overlapping objects (`plain`: the art as
|
||||
/// composited); `None` when it paints nothing.
|
||||
fn flatten_group(plain: &[&Node], o: &FlattenOptions) -> Option<(Regions, Option<Area>)> {
|
||||
let mut b = Builder::default();
|
||||
let elems: Vec<Elem> = plain.iter().filter_map(|n| b.elem(n)).collect();
|
||||
if b.shapes.is_empty() {
|
||||
|
|
@ -284,13 +851,13 @@ fn flatten_group(doc: &Document, art: &[Node], plain: &[&Node], o: &FlattenOptio
|
|||
}
|
||||
}
|
||||
let raster_rect = if all { plain.iter().filter_map(|n| reach(n)).reduce(|a, b| a.union(b)) } else { bounds(&complex) };
|
||||
let raster = raster_rect.map(|rect| {
|
||||
let area = raster_rect.map(|rect| {
|
||||
let clip = o.clip_complex_regions.then(|| PathData::new(complex.iter().flat_map(|&k| regions[k].path.subpaths.iter().cloned()).collect()));
|
||||
let ppi = if gradients_only { o.gradient_ppi } else { o.line_art_ppi };
|
||||
render_raster(doc, art, rect, ppi, clip.filter(|c| !c.is_empty()), o)
|
||||
Area { rect, ppi, clip: clip.filter(|c| !c.is_empty()), whole: all }
|
||||
});
|
||||
let vector = vector.into_iter().map(|(k, f)| (regions[k].path.clone(), f)).collect();
|
||||
Some((vector, raster))
|
||||
Some((vector, area))
|
||||
}
|
||||
|
||||
/// The flat colour of a region covered by `sources`; `None` where nothing paints it.
|
||||
|
|
@ -482,14 +1049,18 @@ fn kept_work(n: &Node, o: &FlattenOptions) -> bool {
|
|||
let mut any = false;
|
||||
n.walk(&mut |c| {
|
||||
any |= (o.text_to_outlines && matches!(c.kind, NodeKind::Text(_)))
|
||||
|| (o.strokes_to_outlines
|
||||
&& matches!(c.kind, NodeKind::Path { guide: false, .. } | NodeKind::Compound { .. })
|
||||
&& c.appearance.items.iter().any(|i| matches!(i, AppearanceItem::Stroke(s) if s.visible && !s.paint.is_none() && s.width > 0.0)))
|
||||
|| (o.strokes_to_outlines && stroked(c))
|
||||
|| (!o.preserve_overprints && overprints(c));
|
||||
});
|
||||
any
|
||||
}
|
||||
|
||||
/// Whether `n` is a path with a visible stroke (outlining turns it into a fill).
|
||||
fn stroked(n: &Node) -> bool {
|
||||
matches!(n.kind, NodeKind::Path { guide: false, .. } | NodeKind::Compound { .. })
|
||||
&& n.appearance.items.iter().any(|i| matches!(i, AppearanceItem::Stroke(s) if s.visible && !s.paint.is_none() && s.width > 0.0))
|
||||
}
|
||||
|
||||
fn overprints(n: &Node) -> bool {
|
||||
n.appearance.items.iter().any(AppearanceItem::overprint)
|
||||
|| matches!(&n.kind, NodeKind::Text(t) if t.runs.iter().any(|r| r.style.overprint_fill || r.style.overprint_stroke))
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -317,6 +317,11 @@ pub struct Prefs {
|
|||
/// Eyedropper Options (`eyedropper.setOptions`; a preference group, see
|
||||
/// [`cmd::prefscmds::PREF_GROUPS`]).
|
||||
pub eyedropper: EyedropperOptions,
|
||||
/// Edit → Transparency Flattener Presets: the user's presets (the built-in ones aren't stored).
|
||||
/// Not a `prefs.set` key or group (resetting the preferences keeps them): `flattener.presets.*`
|
||||
/// edit it.
|
||||
#[serde(skip_serializing_if = "Vec::is_empty")]
|
||||
pub flattener_presets: Vec<cmd::FlattenerPreset>,
|
||||
}
|
||||
|
||||
impl Default for Prefs {
|
||||
|
|
@ -435,6 +440,7 @@ impl Default for Prefs {
|
|||
Color::rgb8(r, g, b).to_hex()
|
||||
},
|
||||
eyedropper: Default::default(),
|
||||
flattener_presets: vec![],
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -793,6 +799,10 @@ mod tests_eyedropper;
|
|||
#[cfg(test)]
|
||||
mod tests_file;
|
||||
#[cfg(test)]
|
||||
mod tests_flatpresets;
|
||||
#[cfg(test)]
|
||||
mod tests_flatpreview;
|
||||
#[cfg(test)]
|
||||
mod tests_flatten;
|
||||
#[cfg(test)]
|
||||
mod tests_freeform;
|
||||
|
|
|
|||
|
|
@ -0,0 +1,136 @@
|
|||
//! Transparency Flattener Presets (M3.93): built-in presets stay as they are, saved presets are
|
||||
//! made, changed, renamed and deleted, travel through export/import and persist with the
|
||||
//! preferences.
|
||||
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use super::*;
|
||||
use crate::cmd::{FlattenOptions, FlattenerPreset};
|
||||
|
||||
fn run(s: &mut Session, id: &str, p: Value) -> Value {
|
||||
s.execute(id, &p).unwrap()
|
||||
}
|
||||
|
||||
fn names(s: &mut Session) -> Vec<(String, bool)> {
|
||||
let list = run(s, "flattener.presets.list", json!({}));
|
||||
list["presets"].as_array().unwrap().iter().map(|p| (p["name"].as_str().unwrap().to_string(), p["builtIn"].as_bool().unwrap())).collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn built_in_presets_are_listed_and_protected() {
|
||||
let mut s = Session::new();
|
||||
let builtin = [("High Resolution", true), ("Medium Resolution", true), ("Low Resolution", true)].map(|(n, b)| (n.to_string(), b));
|
||||
assert_eq!(names(&mut s), builtin);
|
||||
for name in ["High Resolution", "medium", "LOW"] {
|
||||
assert!(s.execute("flattener.presets.save", &json!({"name": name, "balance": 10})).is_err(), "{name} changed");
|
||||
assert!(s.execute("flattener.presets.delete", &json!({ "name": name })).is_err(), "{name} deleted");
|
||||
}
|
||||
assert!(s.execute("flattener.presets.delete", &json!({"name": "Nope"})).is_err());
|
||||
assert_eq!(FlattenOptions::preset("high"), FlattenOptions::find_preset("High Resolution", &[]));
|
||||
assert_eq!(names(&mut s), builtin, "nothing changed");
|
||||
// A saved preset can't take a built-in name either.
|
||||
run(&mut s, "flattener.presets.save", json!({"name": "Mine"}));
|
||||
assert!(s.execute("flattener.presets.save", &json!({"name": "Mine", "newName": "low resolution"})).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn saved_presets_are_made_changed_renamed_and_used() {
|
||||
let mut s = Session::new();
|
||||
let r = run(&mut s, "flattener.presets.save", json!({"name": "Press", "preset": "high", "balance": 40}));
|
||||
assert_eq!((r["name"].as_str(), r["created"].as_bool()), (Some("Press"), Some(true)));
|
||||
assert_eq!((r["options"]["balance"].as_f64(), r["options"]["lineArtPpi"].as_f64()), (Some(40.0), Some(1200.0)));
|
||||
// A change starts from the preset's own options.
|
||||
let r = run(&mut s, "flattener.presets.save", json!({"name": "press", "options": {"antiAlias": false}}));
|
||||
assert_eq!(
|
||||
(r["created"].as_bool(), r["options"]["balance"].as_f64(), r["options"]["antiAlias"].as_bool()),
|
||||
(Some(false), Some(40.0), Some(false))
|
||||
);
|
||||
assert_eq!(r["name"], "Press", "the saved name keeps its case");
|
||||
// Renaming, and names that are taken.
|
||||
run(&mut s, "flattener.presets.save", json!({"name": "Press", "newName": "Print"}));
|
||||
run(&mut s, "flattener.presets.save", json!({"name": "Other"}));
|
||||
assert!(s.execute("flattener.presets.save", &json!({"name": "Print", "newName": "other"})).is_err());
|
||||
assert!(s.execute("flattener.presets.save", &json!({"name": " "})).is_err());
|
||||
assert!(s.execute("flattener.presets.save", &json!({"name": "Print", "balance": 101})).is_err(), "options are validated");
|
||||
// Unnamed presets are numbered.
|
||||
assert_eq!(run(&mut s, "flattener.presets.save", json!({}))["name"], "Flattener Preset 1");
|
||||
assert_eq!(run(&mut s, "flattener.presets.save", json!({}))["name"], "Flattener Preset 2");
|
||||
let saved: Vec<String> = names(&mut s).into_iter().filter(|n| !n.1).map(|n| n.0).collect();
|
||||
assert_eq!(saved, ["Print", "Other", "Flattener Preset 1", "Flattener Preset 2"]);
|
||||
// Anywhere options are taken, a saved preset works by name.
|
||||
let o = s.flatten_options(&json!({"preset": "PRINT"})).unwrap();
|
||||
assert_eq!((o.balance, o.line_art_ppi, o.anti_alias), (40.0, 1200.0, false));
|
||||
assert!(s.flatten_options(&json!({"preset": "Gone"})).is_err());
|
||||
s.execute("file.new", &json!({"width": 100, "height": 100})).unwrap();
|
||||
let id = run(&mut s, "shape.rectangle", json!({"x": 10, "y": 10, "width": 50, "height": 50}))["id"].clone();
|
||||
run(&mut s, "transparency.set", json!({"ids": [id], "opacity": 50}));
|
||||
let r = run(&mut s, "object.flattenTransparency", json!({"ids": [id], "preset": "Print"}));
|
||||
assert_eq!(r["options"]["balance"], 40.0);
|
||||
assert_eq!(run(&mut s, "flattener.presets.delete", json!({"name": "print"}))["deleted"], "Print");
|
||||
assert!(s.execute("object.flattenTransparency", &json!({"preset": "Print"})).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn presets_round_trip_through_export_and_import() {
|
||||
let mut s = Session::new();
|
||||
assert!(s.execute("flattener.presets.export", &json!({})).is_err(), "nothing saved to export");
|
||||
run(&mut s, "flattener.presets.save", json!({"name": "A", "preset": "low", "clipComplexRegions": true}));
|
||||
run(&mut s, "flattener.presets.save", json!({"name": "B", "balance": 0}));
|
||||
let saved = s.prefs.flattener_presets.clone();
|
||||
let out = run(&mut s, "flattener.presets.export", json!({}));
|
||||
assert_eq!(out["count"], 2);
|
||||
let data = out["data"].as_str().unwrap().to_string();
|
||||
for n in ["A", "B"] {
|
||||
run(&mut s, "flattener.presets.delete", json!({ "name": n }));
|
||||
}
|
||||
assert_eq!(run(&mut s, "flattener.presets.import", json!({ "data": data }))["imported"], json!(["A", "B"]));
|
||||
assert_eq!(s.prefs.flattener_presets, saved, "the same presets come back");
|
||||
// Names in use get a number, unless the import replaces them.
|
||||
assert_eq!(run(&mut s, "flattener.presets.import", json!({ "data": data }))["imported"], json!(["A 2", "B 2"]));
|
||||
run(&mut s, "flattener.presets.save", json!({"name": "A", "balance": 55}));
|
||||
run(&mut s, "flattener.presets.import", json!({"data": data, "replace": true}));
|
||||
assert_eq!(s.prefs.flattener_presets[0], saved[0]);
|
||||
assert_eq!(s.prefs.flattener_presets.len(), 4);
|
||||
// Built-in presets export by id or name, and import as saved copies.
|
||||
let b64 = {
|
||||
let out = run(&mut s, "flattener.presets.export", json!({"names": ["high", "Medium Resolution"]}));
|
||||
assert_eq!(out["count"], 2);
|
||||
vectorcraft_format::base64_encode(out["data"].as_str().unwrap().as_bytes())
|
||||
};
|
||||
let r = run(&mut s, "flattener.presets.import", json!({ "dataBase64": b64 }));
|
||||
assert_eq!(r["imported"], json!(["High Resolution 2", "Medium Resolution 2"]));
|
||||
assert_eq!(s.flatten_options(&json!({"preset": "High Resolution 2"})).unwrap(), FlattenOptions::preset("high").unwrap());
|
||||
// Files on disk, and what isn't a presets file.
|
||||
let path = std::env::temp_dir().join(format!("vc-flattener-{}.{}", std::process::id(), crate::cmd::flatten::PRESET_FORMAT));
|
||||
let path = path.to_string_lossy().to_string();
|
||||
assert_eq!(run(&mut s, "flattener.presets.export", json!({"names": ["A"], "path": path}))["count"], 1);
|
||||
assert_eq!(run(&mut s, "flattener.presets.import", json!({ "path": path }))["imported"], json!(["A 3"]));
|
||||
let _ = std::fs::remove_file(&path);
|
||||
assert!(s.execute("flattener.presets.export", &json!({"names": ["Nope"]})).is_err());
|
||||
for bad in [json!({"data": "{}"}), json!({"data": "not json"}), json!({"data": "{\"format\": \"other\", \"presets\": []}"}), json!({})] {
|
||||
assert!(s.execute("flattener.presets.import", &bad).is_err(), "{bad}");
|
||||
}
|
||||
let wild = json!({"format": "vcflattener", "presets": [{"name": "Wild", "options": {"lineArtPpi": 99999}}]}).to_string();
|
||||
assert!(s.execute("flattener.presets.import", &json!({ "data": wild })).is_err(), "out-of-range values are rejected");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn saved_presets_persist_with_the_preferences() {
|
||||
let mut s = Session::new();
|
||||
// Preferences saved before presets existed load without any.
|
||||
let old = json!({"keyboardIncrement": 2.0});
|
||||
assert!(serde_json::from_value::<Prefs>(old).unwrap().flattener_presets.is_empty());
|
||||
assert!(Prefs::default().to_json().get("flattenerPresets").is_none(), "nothing saved while there are none");
|
||||
run(&mut s, "flattener.presets.save", json!({"name": "Keep", "gradientPpi": 72}));
|
||||
let saved = serde_json::to_string(&s.prefs).unwrap();
|
||||
let back: Prefs = serde_json::from_str(&saved).unwrap();
|
||||
assert_eq!(back.flattener_presets, [FlattenerPreset { name: "Keep".into(), options: s.flatten_options(&json!({"preset": "Keep"})).unwrap() }]);
|
||||
let mut t = Session::new();
|
||||
t.apply_prefs(back);
|
||||
assert_eq!(t.flatten_options(&json!({"preset": "keep"})).unwrap().gradient_ppi, 72.0);
|
||||
// They aren't a preference key, and resetting the preferences keeps them.
|
||||
assert!(t.execute("prefs.set", &json!({"key": "flattenerPresets", "value": []})).is_err());
|
||||
run(&mut t, "prefs.reset", json!({}));
|
||||
assert_eq!(t.prefs.flattener_presets.len(), 1);
|
||||
assert_eq!(run(&mut t, "prefs.get", json!({}))["prefs"]["flattenerPresets"][0]["name"], "Keep");
|
||||
}
|
||||
|
|
@ -0,0 +1,123 @@
|
|||
//! Flattener Preview (M3.94): `flattener.preview` reports what flattening the document would touch,
|
||||
//! without changing it.
|
||||
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use super::*;
|
||||
use crate::cmd::flatten::Highlight;
|
||||
|
||||
fn session() -> Session {
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", &json!({"width": 300, "height": 300})).unwrap();
|
||||
s
|
||||
}
|
||||
|
||||
fn run(s: &mut Session, id: &str, p: Value) -> Value {
|
||||
s.execute(id, &p).unwrap()
|
||||
}
|
||||
|
||||
/// An unstroked rectangle filled with `color`.
|
||||
fn rect(s: &mut Session, x: f64, y: f64, w: f64, h: f64, color: &str) -> u64 {
|
||||
let id = run(s, "shape.rectangle", json!({"x": x, "y": y, "width": w, "height": h}))["id"].as_u64().unwrap();
|
||||
run(s, "paint.setFill", json!({"color": color, "ids": [id]}));
|
||||
run(s, "paint.setStroke", json!({"none": true, "ids": [id]}));
|
||||
id
|
||||
}
|
||||
|
||||
fn preview(s: &mut Session, p: Value) -> Value {
|
||||
run(s, "flattener.preview", p)
|
||||
}
|
||||
|
||||
fn ids(v: &Value) -> Vec<u64> {
|
||||
v["regions"].as_array().unwrap().iter().filter_map(|r| r["id"].as_u64()).collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn type_under_a_transparent_rectangle_counts_as_affected() {
|
||||
let mut s = session();
|
||||
let text = run(&mut s, "text.create", json!({"x": 20, "y": 80, "text": "Under", "size": 48}))["id"].as_u64().unwrap();
|
||||
let top = rect(&mut s, 10.0, 30.0, 200.0, 80.0, "#3366cc");
|
||||
run(&mut s, "transparency.set", json!({"ids": [top], "opacity": 50}));
|
||||
// Apart from them: an opaque stroked square nothing overlaps.
|
||||
let alone = rect(&mut s, 220.0, 220.0, 40.0, 40.0, "#20a040");
|
||||
run(&mut s, "paint.setStroke", json!({"color": "#000000", "ids": [alone]}));
|
||||
let before = s.doc().unwrap().doc.clone();
|
||||
let undo = s.doc().unwrap().history.undo.len();
|
||||
let v = preview(&mut s, json!({"highlight": "allAffected"}));
|
||||
assert_eq!(ids(&v), [text, top], "the type and the rectangle over it");
|
||||
assert_eq!(v["counts"]["transparentObjects"], 1);
|
||||
assert_eq!(ids(&preview(&mut s, json!({"highlight": "transparentObjects"}))), [top]);
|
||||
assert_eq!(ids(&preview(&mut s, json!({"highlight": "outlinedText"}))), [text]);
|
||||
assert!(v["counts"]["vectorRegions"].as_u64().unwrap() > 1);
|
||||
assert_eq!(v["counts"]["allRasterized"], 0, "flat colours only");
|
||||
// The options decide what else changes: outlining every stroke takes in the lone square.
|
||||
let v = preview(&mut s, json!({"highlight": "outlinedStrokes", "strokesToOutlines": true}));
|
||||
assert_eq!(ids(&v), [alone]);
|
||||
assert_eq!(v["counts"]["allAffected"], 3);
|
||||
assert_eq!(v["options"]["strokesToOutlines"], true);
|
||||
// Nothing changed.
|
||||
assert_eq!(s.doc().unwrap().doc, before);
|
||||
assert_eq!(s.doc().unwrap().history.undo.len(), undo);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_opaque_document_reports_nothing() {
|
||||
let mut s = session();
|
||||
rect(&mut s, 10.0, 10.0, 100.0, 100.0, "#ff0000");
|
||||
rect(&mut s, 50.0, 50.0, 100.0, 100.0, "#0000ff");
|
||||
run(&mut s, "text.create", json!({"x": 20, "y": 200, "text": "Solid", "size": 30}));
|
||||
for h in Highlight::ALL {
|
||||
let v = preview(&mut s, json!({ "highlight": h.id(), "preset": "low" }));
|
||||
assert!(v["regions"].as_array().unwrap().is_empty(), "{}", h.id());
|
||||
assert!(v["counts"].as_object().unwrap().values().all(|c| c == 0), "{}: {}", h.id(), v["counts"]);
|
||||
assert_eq!(v["highlight"], h.id());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rasterized_areas_follow_the_balance_and_raster_effects() {
|
||||
let mut s = session();
|
||||
let a = rect(&mut s, 10.0, 10.0, 100.0, 100.0, "#ff0000");
|
||||
let b = rect(&mut s, 60.0, 60.0, 100.0, 100.0, "#0000ff");
|
||||
run(&mut s, "transparency.set", json!({"ids": [b], "opacity": 50}));
|
||||
// Balance 0 rasterizes the group whole: a complex region, also among all rasterized ones.
|
||||
let v = preview(&mut s, json!({"highlight": "rasterizedRegions", "balance": 0}));
|
||||
assert_eq!((v["counts"]["rasterizedRegions"].as_u64(), v["counts"]["allRasterized"].as_u64()), (Some(1), Some(1)));
|
||||
let r = &v["regions"][0]["bounds"];
|
||||
assert!(r["x"].as_f64().unwrap() <= 10.0 && r["width"].as_f64().unwrap() >= 150.0, "{r}");
|
||||
// A raster effect rasterizes where it reaches, whatever the balance.
|
||||
let e = rect(&mut s, 200.0, 200.0, 50.0, 50.0, "#20a040");
|
||||
run(&mut s, "effect.apply", json!({"effect": "stylize.dropShadow", "ids": [e]}));
|
||||
let v = preview(&mut s, json!({"highlight": "allRasterized", "preset": "high"}));
|
||||
assert_eq!((v["counts"]["rasterizedRegions"].as_u64(), v["counts"]["allRasterized"].as_u64()), (Some(0), Some(1)));
|
||||
assert_eq!(v["counts"]["transparentObjects"], 2);
|
||||
// Only some objects (`ids`), and the options in force.
|
||||
let v = preview(&mut s, json!({"ids": [a], "highlight": "allAffected"}));
|
||||
assert_eq!(v["counts"]["allAffected"], 0, "alone, the red square has no transparency");
|
||||
assert!(s.execute("flattener.preview", &json!({"highlight": "sparkles"})).is_err());
|
||||
assert!(s.execute("flattener.preview", &json!({"overprints": "maybe"})).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn overprints_and_patterns_are_reported() {
|
||||
let mut s = session();
|
||||
let r = rect(&mut s, 0.0, 0.0, 10.0, 10.0, "#ff0000");
|
||||
run(&mut s, "select.set", json!({"ids": [r]}));
|
||||
run(&mut s, "object.pattern.make", json!({"name": "Dots", "width": 20, "height": 20}));
|
||||
run(&mut s, "object.pattern.done", json!({}));
|
||||
let p = rect(&mut s, 100.0, 100.0, 100.0, 100.0, "#000000");
|
||||
run(&mut s, "paint.setFill", json!({"ids": [p], "swatch": "Dots"}));
|
||||
let top = rect(&mut s, 150.0, 150.0, 100.0, 100.0, "#ffcc00");
|
||||
run(&mut s, "transparency.set", json!({"ids": [top], "opacity": 60}));
|
||||
let v = preview(&mut s, json!({"highlight": "expandedPatterns"}));
|
||||
assert_eq!(ids(&v), [p]);
|
||||
// An overprinting object alone: affected once overprints are discarded.
|
||||
let op = rect(&mut s, 10.0, 220.0, 40.0, 40.0, "#00ffff");
|
||||
run(&mut s, "object.setOverprint", json!({"ids": [op], "fill": true}));
|
||||
assert_eq!(preview(&mut s, json!({}))["counts"]["allAffected"], 2);
|
||||
for o in ["discard", "simulate"] {
|
||||
let v = preview(&mut s, json!({ "overprints": o, "highlight": "allAffected" }));
|
||||
assert!(ids(&v).contains(&op), "{o}");
|
||||
assert_eq!(v["options"]["preserveOverprints"], false);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,328 @@
|
|||
//! Object → Flatten Transparency: a preset, the raster/vector balance, the resolutions and the
|
||||
//! outline and preserve options, previewed live on the canvas (off at first: flattening complex
|
||||
//! art takes a while); OK keeps the result as one undo step (`object.flattenTransparency`). Save
|
||||
//! Preset… keeps the options as a saved preset (`flattener.presets.save`).
|
||||
//!
|
||||
//! Fields: `preset` (a built-in or saved preset's name: setting it loads its options), the option
|
||||
//! keys of [`FlattenOptions`] (`balance`, `lineArtPpi`, `gradientPpi`, `textToOutlines`,
|
||||
//! `strokesToOutlines`, `clipComplexRegions`, `antiAlias`, `preserveAlpha`, `preserveOverprints`)
|
||||
//! and `preview`.
|
||||
|
||||
use serde_json::{Map, Value, json};
|
||||
use vectorcraft_engine::cmd::{FlattenOptions, FlattenerPreset};
|
||||
|
||||
use super::{DialogSpec, form};
|
||||
use crate::state::Dialog;
|
||||
use crate::theme::Tokens;
|
||||
use crate::{VectorcraftApp, widgets};
|
||||
|
||||
/// The dialog kind of Flatten Transparency.
|
||||
pub const KIND: &str = "flattenTransparency";
|
||||
|
||||
const CMD: &str = "object.flattenTransparency";
|
||||
|
||||
/// The preset whose options the fields hold (a `preset` set from outside loads its options).
|
||||
const APPLIED: &str = "__applied";
|
||||
/// Save Preset… is open, with the name typed in `__presetName`.
|
||||
const SAVING: &str = "__saving";
|
||||
const PRESET_NAME: &str = "__presetName";
|
||||
|
||||
/// What the preset dropdown shows once the options differ from the preset's.
|
||||
const CUSTOM: &str = "[Custom]";
|
||||
|
||||
pub(super) const SPEC: DialogSpec =
|
||||
DialogSpec { heading: |_| "Flatten Transparency".into(), body, confirm, preview: true, min_width: 420.0, ..DialogSpec::FORM };
|
||||
|
||||
/// Open Flatten Transparency for the selection with the default preset.
|
||||
pub fn open(app: &mut VectorcraftApp) {
|
||||
let name = FlattenOptions::preset_label(FlattenOptions::PRESETS[1]).unwrap_or_default();
|
||||
let mut d = Dialog::new(KIND, json!({ "preset": name, APPLIED: name, "preview": false }));
|
||||
put_options(&mut d.fields, &FlattenOptions::default());
|
||||
app.ui.dialog = Some(d);
|
||||
}
|
||||
|
||||
/// Write `o` into the option fields.
|
||||
pub(crate) fn put_options(fields: &mut Map<String, Value>, o: &FlattenOptions) {
|
||||
if let Value::Object(m) = serde_json::to_value(o).unwrap_or_default() {
|
||||
fields.extend(m);
|
||||
}
|
||||
}
|
||||
|
||||
/// The options the fields hold (a `preset` not loaded yet: its own).
|
||||
fn options(d: &Dialog, saved: &[FlattenerPreset]) -> Result<FlattenOptions, String> {
|
||||
if d.str("preset") != d.str(APPLIED) {
|
||||
return FlattenOptions::from_params_with(&json!({ "preset": d.str("preset") }), saved);
|
||||
}
|
||||
FlattenOptions::from_params_with(&Value::Object(d.fields.clone()), saved)
|
||||
}
|
||||
|
||||
/// `object.flattenTransparency` parameters: every option.
|
||||
fn params(d: &Dialog, saved: &[FlattenerPreset]) -> Result<Value, String> {
|
||||
let mut fields = Map::new();
|
||||
put_options(&mut fields, &options(d, saved)?);
|
||||
Ok(Value::Object(fields))
|
||||
}
|
||||
|
||||
/// Load the options of a preset set from outside (`ui.dialog.set`).
|
||||
fn sync(d: &mut Dialog, saved: &[FlattenerPreset]) {
|
||||
let name = d.str("preset");
|
||||
if name != d.str(APPLIED) {
|
||||
if let Ok(o) = options(d, saved) {
|
||||
put_options(&mut d.fields, &o);
|
||||
}
|
||||
d.fields.insert(APPLIED.into(), json!(name));
|
||||
}
|
||||
}
|
||||
|
||||
/// Make `name` the preset the fields hold.
|
||||
fn applied(d: &mut Dialog, name: &str) {
|
||||
d.fields.insert("preset".into(), json!(name));
|
||||
d.fields.insert(APPLIED.into(), json!(name));
|
||||
}
|
||||
|
||||
fn body(app: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) -> bool {
|
||||
let saved = &app.session.prefs.flattener_presets;
|
||||
sync(d, saved);
|
||||
let current = options(d, saved);
|
||||
let presets = app.session.flattener_presets();
|
||||
ui.horizontal(|ui| {
|
||||
widgets::dim_label(ui, "Preset:");
|
||||
if let Some(p) = preset_dropdown(ui, "flatten-preset", &presets, &d.str("preset"), current.as_ref().ok(), 220.0) {
|
||||
put_options(&mut d.fields, &p.options);
|
||||
applied(d, &p.name);
|
||||
}
|
||||
if widgets::flat_button(ui, "Save Preset…", 100.0).clicked() {
|
||||
let open = !d.bool(SAVING);
|
||||
d.fields.insert(SAVING.into(), json!(open));
|
||||
d.fields.insert(PRESET_NAME.into(), json!(app.session.new_preset_name()));
|
||||
}
|
||||
});
|
||||
if d.bool(SAVING) {
|
||||
save_row(app, ui, d);
|
||||
}
|
||||
ui.add_space(10.0);
|
||||
let mut o = current.clone().unwrap_or_default();
|
||||
if options_editor(ui, "flatten-dialog", &mut o, true) {
|
||||
put_options(&mut d.fields, &o);
|
||||
}
|
||||
if let Err(e) = ¤t {
|
||||
ui.label(egui::RichText::new(e).color(Tokens::get(ui.ctx()).text_dim).size(11.5));
|
||||
}
|
||||
// Invalid values keep the last preview.
|
||||
let p = params(d, &app.session.prefs.flattener_presets).unwrap_or_else(|_| d.fields.get(form::PREVIEWED).cloned().unwrap_or_default());
|
||||
form::preview(app, ui, d, "Flatten Transparency", CMD, p);
|
||||
false
|
||||
}
|
||||
|
||||
/// Save Preset…: the new preset's name, Save and Cancel (Enter in the name saves).
|
||||
fn save_row(app: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) {
|
||||
ui.add_space(6.0);
|
||||
ui.horizontal(|ui| {
|
||||
widgets::dim_label(ui, "Name:");
|
||||
let r = form::text_edit(ui, d, PRESET_NAME, 180.0);
|
||||
// Enter saves the preset instead of flattening.
|
||||
let enter = r.lost_focus() && ui.input_mut(|i| i.consume_key(egui::Modifiers::NONE, egui::Key::Enter));
|
||||
if (widgets::flat_button(ui, "Save", 60.0).clicked() || enter)
|
||||
&& let Err(e) = save_preset(app, d)
|
||||
{
|
||||
app.status(e);
|
||||
}
|
||||
if widgets::flat_button(ui, "Cancel", 60.0).clicked() {
|
||||
d.fields.insert(SAVING.into(), json!(false));
|
||||
}
|
||||
});
|
||||
let name = d.str(PRESET_NAME);
|
||||
if app.session.prefs.flattener_presets.iter().any(|p| p.name.eq_ignore_ascii_case(name.trim())) {
|
||||
widgets::dim_label(ui, "Replaces the saved preset of that name.");
|
||||
}
|
||||
}
|
||||
|
||||
/// Keep the dialog's options as the saved preset `__presetName` and select it.
|
||||
fn save_preset(app: &mut VectorcraftApp, d: &mut Dialog) -> Result<(), String> {
|
||||
let mut p = params(d, &app.session.prefs.flattener_presets)?;
|
||||
p["name"] = json!(d.str(PRESET_NAME));
|
||||
let r = app.run("flattener.presets.save", p)?;
|
||||
applied(d, r["name"].as_str().unwrap_or_default());
|
||||
d.fields.insert(SAVING.into(), json!(false));
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn confirm(app: &mut VectorcraftApp, d: &Dialog) -> Result<Value, String> {
|
||||
let p = params(d, &app.session.prefs.flattener_presets)?;
|
||||
form::commit_preview(app, CMD, p)
|
||||
}
|
||||
|
||||
/// The flattener preset dropdown: `presets` by name, showing `name` while `options` are that
|
||||
/// preset's, else [Custom]. Returns the preset chosen.
|
||||
pub(crate) fn preset_dropdown<'a>(
|
||||
ui: &mut egui::Ui,
|
||||
id: &str,
|
||||
presets: &'a [FlattenerPreset],
|
||||
name: &str,
|
||||
options: Option<&FlattenOptions>,
|
||||
width: f32,
|
||||
) -> Option<&'a FlattenerPreset> {
|
||||
let shown = presets.iter().find(|p| p.name == name && Some(&p.options) == options).map_or(CUSTOM, |p| p.name.as_str());
|
||||
let names: Vec<&str> = presets.iter().map(|p| p.name.as_str()).collect();
|
||||
widgets::dropdown(ui, id, shown, &names, width).map(|i| &presets[i])
|
||||
}
|
||||
|
||||
/// The width of the option value fields.
|
||||
const FIELD: f32 = 56.0;
|
||||
|
||||
/// The flattener options as Flatten Transparency, the preset manager and the Flattener Preview
|
||||
/// panel edit them: the raster/vector balance, both resolutions and the six switches. Returns true
|
||||
/// when one changed.
|
||||
pub(crate) fn options_editor(ui: &mut egui::Ui, id: &str, o: &mut FlattenOptions, enabled: bool) -> bool {
|
||||
let t = Tokens::get(ui.ctx());
|
||||
let before = o.clone();
|
||||
let dim = |ui: &mut egui::Ui, s: &str| {
|
||||
ui.label(egui::RichText::new(s).color(t.text_dim).size(11.5));
|
||||
};
|
||||
ui.add_enabled_ui(enabled, |ui| {
|
||||
widgets::dim_label(ui, "Raster/Vector Balance:");
|
||||
ui.horizontal(|ui| {
|
||||
ui.spacing_mut().item_spacing.x = 6.0;
|
||||
dim(ui, "Rasters");
|
||||
let rail = (ui.available_width() - FIELD - 56.0).clamp(80.0, 200.0);
|
||||
if let (Some(x), _) = widgets::color_slider(ui, (id, "balance-slider"), (o.balance / 100.0) as f32, rail, &|_| t.input_border) {
|
||||
o.balance = (x as f64 * 100.0).round();
|
||||
}
|
||||
dim(ui, "Vectors");
|
||||
if let Some(v) = widgets::plain_field(ui, (id, "balance"), o.balance, "", 0, FIELD - 12.0) {
|
||||
o.balance = v.round().clamp(0.0, 100.0);
|
||||
}
|
||||
});
|
||||
ui.add_space(4.0);
|
||||
egui::Grid::new((id, "resolutions")).num_columns(2).spacing([8.0, 4.0]).show(ui, |ui| {
|
||||
for (label, key) in [("Line Art and Text Resolution:", 0), ("Gradient and Mesh Resolution:", 1)] {
|
||||
widgets::dim_label(ui, label);
|
||||
ui.horizontal(|ui| {
|
||||
let v = if key == 0 { &mut o.line_art_ppi } else { &mut o.gradient_ppi };
|
||||
if let Some(n) = widgets::plain_field(ui, (id, key), *v, "", 0, FIELD) {
|
||||
*v = n.round().clamp(1.0, 2400.0);
|
||||
}
|
||||
dim(ui, "ppi");
|
||||
});
|
||||
ui.end_row();
|
||||
}
|
||||
});
|
||||
ui.add_space(4.0);
|
||||
let switches: [(&str, &mut bool); 6] = [
|
||||
("Convert All Text to Outlines", &mut o.text_to_outlines),
|
||||
("Convert All Strokes to Outlines", &mut o.strokes_to_outlines),
|
||||
("Clip Complex Regions", &mut o.clip_complex_regions),
|
||||
("Anti-alias Rasters", &mut o.anti_alias),
|
||||
("Preserve Alpha Transparency", &mut o.preserve_alpha),
|
||||
("Preserve Overprints and Spot Colors", &mut o.preserve_overprints),
|
||||
];
|
||||
for (label, v) in switches {
|
||||
if widgets::check(ui, label, *v, enabled) {
|
||||
*v = !*v;
|
||||
}
|
||||
}
|
||||
});
|
||||
*o != before
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use vectorcraft_engine::Session;
|
||||
|
||||
fn frame(app: &mut VectorcraftApp) {
|
||||
let ctx = egui::Context::default();
|
||||
crate::theme::install_fonts(&ctx);
|
||||
let mut out = ctx.run_ui(egui::RawInput::default(), |ui| super::super::show(app, ui.ctx()));
|
||||
out.textures_delta.clear();
|
||||
}
|
||||
|
||||
/// A document with a half-transparent red square over a blue one, both selected.
|
||||
fn scene() -> VectorcraftApp {
|
||||
let mut app = VectorcraftApp::new(Session::new(), Default::default());
|
||||
app.run("file.new", json!({"width": 200, "height": 200})).unwrap();
|
||||
for (x, color) in [(10, "#0000ff"), (60, "#ff0000")] {
|
||||
let id = app.run("shape.rectangle", json!({"x": x, "y": x, "width": 100, "height": 100})).unwrap()["id"].clone();
|
||||
app.run("paint.setFill", json!({"color": color, "ids": [id]})).unwrap();
|
||||
app.run("paint.setStroke", json!({"none": true, "ids": [id]})).unwrap();
|
||||
}
|
||||
app.run("transparency.set", json!({"opacity": 50})).unwrap();
|
||||
app.run("select.all", json!({})).unwrap();
|
||||
app
|
||||
}
|
||||
|
||||
fn transparent_left(app: &VectorcraftApp) -> bool {
|
||||
let mut any = false;
|
||||
for l in &app.session.doc().unwrap().doc.layers {
|
||||
l.walk(&mut |n| any |= n.opacity < 1.0 && !n.is_container());
|
||||
}
|
||||
any
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cancel_rolls_the_preview_back_and_ok_flattens_as_one_step() {
|
||||
let mut app = scene();
|
||||
let before = app.session.doc().unwrap().doc.clone();
|
||||
let undo = app.session.doc().unwrap().history.undo.len();
|
||||
app.run("ui.flattenTransparencyDialog", json!({})).unwrap();
|
||||
assert_eq!(app.ui.dialog.as_ref().map(|d| d.kind.as_str()), Some(KIND));
|
||||
frame(&mut app);
|
||||
assert!(!app.session.in_interaction(), "no preview until it is turned on");
|
||||
app.ui.dialog.as_mut().unwrap().fields.insert("preview".into(), json!(true));
|
||||
frame(&mut app);
|
||||
assert!(app.session.in_interaction() && !transparent_left(&app), "previewed on the canvas");
|
||||
super::super::cancel(&mut app);
|
||||
assert!(app.ui.dialog.is_none() && !app.session.in_interaction());
|
||||
assert_eq!(app.session.doc().unwrap().doc, before, "Cancel rolls back");
|
||||
// OK without a preview flattens with the dialog's options as one undo step.
|
||||
app.run("ui.flattenTransparencyDialog", json!({})).unwrap();
|
||||
let d = app.ui.dialog.as_mut().unwrap();
|
||||
d.fields.insert("preset".into(), json!("High Resolution"));
|
||||
frame(&mut app);
|
||||
assert_eq!(app.ui.dialog.as_ref().unwrap().f64("lineArtPpi", 0.0), 1200.0, "a preset set from outside loads");
|
||||
app.ui.dialog.as_mut().unwrap().fields.insert("balance".into(), json!(0));
|
||||
super::super::confirm(&mut app).unwrap();
|
||||
assert!(app.ui.dialog.is_none());
|
||||
let st = app.session.doc().unwrap();
|
||||
assert_eq!(st.history.undo.len(), undo + 1);
|
||||
let mut images = 0;
|
||||
st.doc.layers[0].walk(&mut |n| images += matches!(n.kind, vectorcraft_doc::NodeKind::Image(_)) as usize);
|
||||
assert_eq!(images, 1, "balance 0 rasterizes");
|
||||
assert!(!transparent_left(&app));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn save_preset_keeps_the_options_and_lists_them() {
|
||||
let mut app = scene();
|
||||
app.run("ui.flattenTransparencyDialog", json!({})).unwrap();
|
||||
let mut d = app.ui.dialog.clone().unwrap();
|
||||
d.fields.insert("balance".into(), json!(30));
|
||||
d.fields.insert(PRESET_NAME.into(), json!("Thirty"));
|
||||
save_preset(&mut app, &mut d).unwrap();
|
||||
assert_eq!(d.str("preset"), "Thirty");
|
||||
let saved = &app.session.prefs.flattener_presets;
|
||||
assert_eq!((saved.len(), saved[0].name.as_str(), saved[0].options.balance), (1, "Thirty", 30.0));
|
||||
// A saved preset set from outside loads like a built-in one.
|
||||
d.fields.insert("preset".into(), json!("Low Resolution"));
|
||||
app.ui.dialog = Some(d);
|
||||
frame(&mut app);
|
||||
assert_eq!(app.ui.dialog.as_ref().unwrap().f64("balance", 0.0), 75.0);
|
||||
app.ui.dialog.as_mut().unwrap().fields.insert("preset".into(), json!("Thirty"));
|
||||
frame(&mut app);
|
||||
assert_eq!(app.ui.dialog.as_ref().unwrap().f64("balance", 0.0), 30.0);
|
||||
// Built-in presets can't be replaced.
|
||||
let mut d = app.ui.dialog.clone().unwrap();
|
||||
d.fields.insert(PRESET_NAME.into(), json!("High Resolution"));
|
||||
assert!(save_preset(&mut app, &mut d).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bad_values_report_and_keep_the_dialog_open() {
|
||||
let mut app = scene();
|
||||
app.run("ui.flattenTransparencyDialog", json!({})).unwrap();
|
||||
app.ui.dialog.as_mut().unwrap().fields.insert("gradientPpi".into(), json!(9000));
|
||||
frame(&mut app);
|
||||
assert!(super::super::confirm(&mut app).unwrap_err().contains("resolutions"));
|
||||
assert!(app.ui.dialog.is_some());
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,294 @@
|
|||
//! Edit → Transparency Flattener Presets: every preset in a list (built-in ones in brackets, read
|
||||
//! only) with the selected one's name and options beside it, edited in place on saved presets
|
||||
//! (`flattener.presets.save`); New (a copy of the selected preset), Delete, Import… and Export…
|
||||
//! (the selected preset) under the list. Close keeps everything: each change is saved at once.
|
||||
//!
|
||||
//! Fields: `selected` (a preset's name), then the selected preset's `name` and option keys
|
||||
//! (setting them on a saved preset saves it; a new `name` renames it once the field isn't being
|
||||
//! edited; on a built-in preset they go back).
|
||||
|
||||
use serde_json::{Value, json};
|
||||
use vectorcraft_engine::cmd::FlattenOptions;
|
||||
use vectorcraft_engine::cmd::flatten::PRESET_FORMAT;
|
||||
|
||||
use super::flatten::{options_editor, put_options};
|
||||
use super::{DialogSpec, form};
|
||||
use crate::state::Dialog;
|
||||
use crate::theme::Tokens;
|
||||
use crate::{VectorcraftApp, widgets};
|
||||
|
||||
/// The dialog kind of Transparency Flattener Presets.
|
||||
pub const KIND: &str = "flattenerPresets";
|
||||
|
||||
/// The preset whose name and options the fields hold.
|
||||
const SHOWN: &str = "__shown";
|
||||
|
||||
pub(super) const SPEC: DialogSpec = DialogSpec {
|
||||
heading: |_| "Transparency Flattener Presets".into(),
|
||||
body,
|
||||
confirm: |app, _| {
|
||||
app.ui.dialog = None;
|
||||
Ok(Value::Null)
|
||||
},
|
||||
ok: None,
|
||||
min_width: 620.0,
|
||||
..DialogSpec::FORM
|
||||
};
|
||||
|
||||
/// Open the presets manager on `selected` (default: the first preset).
|
||||
pub fn open(app: &mut VectorcraftApp, selected: Option<&str>) {
|
||||
let first = FlattenOptions::preset_label(FlattenOptions::PRESETS[0]).unwrap_or_default();
|
||||
app.ui.dialog = Some(Dialog::new(KIND, json!({ "selected": selected.unwrap_or(first) })));
|
||||
}
|
||||
|
||||
/// What a button asked for, done once the list and fields are drawn.
|
||||
enum Action {
|
||||
Select(String),
|
||||
New,
|
||||
Delete,
|
||||
Import,
|
||||
Export,
|
||||
}
|
||||
|
||||
fn body(app: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) -> bool {
|
||||
let t = Tokens::get(ui.ctx());
|
||||
let presets = app.session.flattener_presets();
|
||||
let builtins = FlattenOptions::PRESETS.len();
|
||||
let i = presets.iter().position(|p| p.name.eq_ignore_ascii_case(d.str("selected").trim())).unwrap_or(0);
|
||||
let (preset, builtin) = (&presets[i], i < builtins);
|
||||
if d.str(SHOWN) != preset.name {
|
||||
put_options(&mut d.fields, &preset.options);
|
||||
for k in ["selected", "name", SHOWN] {
|
||||
d.fields.insert(k.into(), json!(preset.name));
|
||||
}
|
||||
}
|
||||
let mut act = None;
|
||||
let mut renaming = false;
|
||||
ui.horizontal_top(|ui| {
|
||||
ui.vertical(|ui| {
|
||||
ui.set_width(232.0);
|
||||
widgets::dim_label(ui, "Presets:");
|
||||
widgets::list_box(ui, |ui| {
|
||||
egui::ScrollArea::vertical().id_salt("flattener-presets").min_scrolled_height(250.0).max_height(250.0).show(ui, |ui| {
|
||||
ui.set_width(ui.available_width());
|
||||
ui.set_min_height(250.0);
|
||||
for (k, p) in presets.iter().enumerate() {
|
||||
let label = if k < builtins { format!("[{}]", p.name) } else { p.name.clone() };
|
||||
if ui.selectable_label(k == i, label).clicked() {
|
||||
act = Some(Action::Select(p.name.clone()));
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
ui.add_space(6.0);
|
||||
ui.horizontal(|ui| {
|
||||
ui.spacing_mut().item_spacing.x = 4.0;
|
||||
if widgets::flat_button(ui, "New", 44.0).on_hover_text("A saved copy of the selected preset").clicked() {
|
||||
act = Some(Action::New);
|
||||
}
|
||||
if ui.add_enabled_ui(!builtin, |ui| widgets::flat_button(ui, "Delete", 52.0)).inner.clicked() {
|
||||
act = Some(Action::Delete);
|
||||
}
|
||||
if widgets::flat_button(ui, "Import…", 62.0).clicked() {
|
||||
act = Some(Action::Import);
|
||||
}
|
||||
if widgets::flat_button(ui, "Export…", 62.0).on_hover_text("Save the selected preset to a file").clicked() {
|
||||
act = Some(Action::Export);
|
||||
}
|
||||
});
|
||||
});
|
||||
ui.add_space(18.0);
|
||||
ui.vertical(|ui| {
|
||||
ui.horizontal(|ui| {
|
||||
widgets::dim_label(ui, "Name:");
|
||||
if builtin {
|
||||
ui.label(egui::RichText::new(&preset.name).color(t.text_strong));
|
||||
} else {
|
||||
renaming = form::text_edit(ui, d, "name", 220.0).has_focus();
|
||||
}
|
||||
});
|
||||
ui.add_space(8.0);
|
||||
let mut o = FlattenOptions::from_params(&Value::Object(d.fields.clone())).unwrap_or_else(|_| preset.options.clone());
|
||||
if options_editor(ui, "flattener-presets", &mut o, !builtin) {
|
||||
put_options(&mut d.fields, &o);
|
||||
}
|
||||
if builtin {
|
||||
ui.add_space(6.0);
|
||||
ui.label(egui::RichText::new("Built-in presets don't change: New makes a copy you can edit.").color(t.text_dim).size(11.5));
|
||||
}
|
||||
});
|
||||
});
|
||||
// The fields (whether a widget or `ui.dialog.set` changed them) against the stored preset.
|
||||
let fields = FlattenOptions::from_params(&Value::Object(d.fields.clone()));
|
||||
if builtin {
|
||||
if fields.as_ref() != Ok(&preset.options) {
|
||||
put_options(&mut d.fields, &preset.options);
|
||||
}
|
||||
} else {
|
||||
match fields {
|
||||
Ok(o) if o != preset.options => {
|
||||
if let Err(e) = app.run("flattener.presets.save", json!({"name": preset.name, "options": o})) {
|
||||
app.status(e);
|
||||
}
|
||||
}
|
||||
Ok(_) => {}
|
||||
Err(e) => app.status(e),
|
||||
}
|
||||
let name = d.str("name");
|
||||
if !renaming && name.trim() != preset.name {
|
||||
match app.run("flattener.presets.save", json!({"name": preset.name, "newName": name})) {
|
||||
Ok(r) => select(d, r["name"].as_str().unwrap_or_default()),
|
||||
Err(e) => {
|
||||
app.status(e);
|
||||
d.fields.insert("name".into(), json!(preset.name));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if let Some(a) = act
|
||||
&& let Err(e) = run(app, d, a, &preset.name)
|
||||
{
|
||||
app.status(e);
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
/// Select the preset `name` (its values load on the next frame).
|
||||
fn select(d: &mut Dialog, name: &str) {
|
||||
d.fields.insert("selected".into(), json!(name));
|
||||
d.fields.remove(SHOWN);
|
||||
}
|
||||
|
||||
fn run(app: &mut VectorcraftApp, d: &mut Dialog, act: Action, current: &str) -> Result<(), String> {
|
||||
match act {
|
||||
Action::Select(name) => select(d, &name),
|
||||
Action::New => {
|
||||
let r = app.run("flattener.presets.save", json!({ "preset": current }))?;
|
||||
select(d, r["name"].as_str().unwrap_or_default());
|
||||
}
|
||||
Action::Delete => {
|
||||
let i = app.session.prefs.flattener_presets.iter().position(|p| p.name == current);
|
||||
app.run("flattener.presets.delete", json!({ "name": current }))?;
|
||||
// The one above it takes its place (the last built-in one above the first saved one).
|
||||
let next =
|
||||
app.session.flattener_presets().get((i.unwrap_or(0) + FlattenOptions::PRESETS.len()).saturating_sub(1)).map(|p| p.name.clone());
|
||||
select(d, &next.unwrap_or_default());
|
||||
}
|
||||
Action::Import => {
|
||||
// On the web the picked file arrives later and imports through `io::open_bytes`.
|
||||
if let Some(f) = app.services.open_async.as_mut() {
|
||||
f();
|
||||
return Ok(());
|
||||
}
|
||||
let path = app.services.pick_open.as_mut().and_then(|f| f()).ok_or("cancelled")?;
|
||||
let r = app.run("flattener.presets.import", json!({ "path": path }))?;
|
||||
if let Some(first) = r["imported"].get(0).and_then(Value::as_str) {
|
||||
select(d, first);
|
||||
}
|
||||
}
|
||||
Action::Export => {
|
||||
crate::io::save_command_output(app, "flattener.presets.export", PRESET_FORMAT, json!({ "names": [current] }))?;
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use vectorcraft_engine::Session;
|
||||
|
||||
fn frame(app: &mut VectorcraftApp) {
|
||||
let ctx = egui::Context::default();
|
||||
crate::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(app: &mut VectorcraftApp, k: &str, v: Value) {
|
||||
app.ui.dialog.as_mut().unwrap().fields.insert(k.into(), v);
|
||||
frame(app);
|
||||
}
|
||||
|
||||
fn field(app: &VectorcraftApp, k: &str) -> Value {
|
||||
app.ui.dialog.as_ref().unwrap().fields.get(k).cloned().unwrap_or_default()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn edits_save_saved_presets_and_leave_built_in_ones() {
|
||||
let mut app = VectorcraftApp::new(Session::new(), Default::default());
|
||||
app.run("ui.flattenerPresetsDialog", json!({})).unwrap();
|
||||
frame(&mut app);
|
||||
assert_eq!((field(&app, "selected"), field(&app, "lineArtPpi")), (json!("High Resolution"), json!(1200.0)));
|
||||
// Built-in presets go back to their values.
|
||||
set(&mut app, "balance", json!(10));
|
||||
assert_eq!(field(&app, "balance"), json!(100.0));
|
||||
frame(&mut app);
|
||||
assert!(app.session.prefs.flattener_presets.is_empty());
|
||||
// New makes an editable copy of the selected preset and selects it.
|
||||
let mut d = app.ui.dialog.clone().unwrap();
|
||||
run(&mut app, &mut d, Action::New, "High Resolution").unwrap();
|
||||
app.ui.dialog = Some(d);
|
||||
frame(&mut app);
|
||||
assert_eq!(field(&app, "selected"), json!("Flattener Preset 1"));
|
||||
assert_eq!(app.session.prefs.flattener_presets[0].options, FlattenOptions::preset("high").unwrap());
|
||||
// Its options and name save as they change.
|
||||
set(&mut app, "balance", json!(10));
|
||||
assert_eq!(app.session.prefs.flattener_presets[0].options.balance, 10.0);
|
||||
set(&mut app, "name", json!("Coarse"));
|
||||
assert_eq!(app.session.prefs.flattener_presets[0].name, "Coarse");
|
||||
frame(&mut app);
|
||||
assert_eq!((field(&app, "selected"), field(&app, "balance")), (json!("Coarse"), json!(10.0)));
|
||||
// A taken name is refused and the field goes back.
|
||||
set(&mut app, "name", json!("Low Resolution"));
|
||||
assert_eq!(field(&app, "name"), json!("Coarse"));
|
||||
// Delete selects the preset above it; Close keeps what was saved.
|
||||
let mut d = app.ui.dialog.clone().unwrap();
|
||||
run(&mut app, &mut d, Action::Delete, "Coarse").unwrap();
|
||||
assert_eq!(d.str("selected"), "Low Resolution");
|
||||
assert!(app.session.prefs.flattener_presets.is_empty());
|
||||
super::super::confirm(&mut app).unwrap();
|
||||
assert!(app.ui.dialog.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn saved_presets_persist_with_the_ui_state_and_preferences_still_apply() {
|
||||
let mut app = VectorcraftApp::new(Session::new(), Default::default());
|
||||
app.run("flattener.presets.save", json!({"name": "Kept", "lineArtPpi": 600})).unwrap();
|
||||
// Preferences OK with saved presets around (they aren't preference fields).
|
||||
app.run("edit.preferences", json!({})).unwrap();
|
||||
crate::prefs_dialog::confirm(&mut app).unwrap();
|
||||
crate::prefs_dialog::snapshot(&mut app);
|
||||
let saved = serde_json::to_vec(&app.ui).unwrap();
|
||||
let mut back = VectorcraftApp::new(Session::new(), Default::default());
|
||||
back.ui = serde_json::from_slice::<crate::UiState>(&saved).unwrap().sanitized();
|
||||
crate::prefs_dialog::restore(&mut back);
|
||||
assert_eq!(back.session.prefs.flattener_presets, app.session.prefs.flattener_presets);
|
||||
assert_eq!(back.session.flatten_options(&json!({"preset": "Kept"})).unwrap().line_art_ppi, 600.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn import_and_export_go_through_files() {
|
||||
let dir = std::env::temp_dir().join(format!("vc-flattener-ui-{}", std::process::id()));
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let file = dir.join("mine.vcflattener").to_string_lossy().to_string();
|
||||
let picked = file.clone();
|
||||
let services = crate::Services {
|
||||
pick_save: Some(Box::new(move |_: &str| Some(picked.clone()))),
|
||||
write: Some(Box::new(|p: &str, b: &[u8]| std::fs::write(p, b).map_err(|e| e.to_string()))),
|
||||
..Default::default()
|
||||
};
|
||||
let mut app = VectorcraftApp::new(Session::new(), services);
|
||||
app.run("flattener.presets.save", json!({"name": "Mine", "balance": 20})).unwrap();
|
||||
app.run("ui.flattenerPresetsDialog", json!({"selected": "Mine"})).unwrap();
|
||||
let mut d = app.ui.dialog.clone().unwrap();
|
||||
run(&mut app, &mut d, Action::Export, "Mine").unwrap();
|
||||
// Opening the file imports it (also how the web's picked file arrives).
|
||||
let bytes = std::fs::read(&file).unwrap();
|
||||
crate::io::open_bytes(&mut app, &file, &bytes, Some(file.clone())).unwrap();
|
||||
let names: Vec<&str> = app.session.prefs.flattener_presets.iter().map(|p| p.name.as_str()).collect();
|
||||
assert_eq!(names, ["Mine", "Mine 2"]);
|
||||
assert_eq!(app.session.prefs.flattener_presets[1].options.balance, 20.0);
|
||||
let _ = std::fs::remove_dir_all(dir);
|
||||
}
|
||||
}
|
||||
|
|
@ -16,6 +16,11 @@ pub(super) fn field(ui: &mut egui::Ui, d: &mut Dialog, key: &str, label: &str) {
|
|||
|
||||
/// A text field `width` wide bound to `d.fields[key]`. Returns true when it changed.
|
||||
pub(super) fn text(ui: &mut egui::Ui, d: &mut Dialog, key: &str, width: f32) -> bool {
|
||||
text_edit(ui, d, key, width).changed()
|
||||
}
|
||||
|
||||
/// [`text`] returning the field's response (focus, Enter).
|
||||
pub(super) fn text_edit(ui: &mut egui::Ui, d: &mut Dialog, key: &str, width: f32) -> egui::Response {
|
||||
let t = Tokens::get(ui.ctx());
|
||||
let mut s = d.str(key);
|
||||
let r = egui::Frame::NONE
|
||||
|
|
@ -24,11 +29,10 @@ pub(super) fn text(ui: &mut egui::Ui, d: &mut Dialog, key: &str, width: f32) ->
|
|||
.corner_radius(egui::CornerRadius::same(3))
|
||||
.inner_margin(egui::Margin::symmetric(6, 3))
|
||||
.show(ui, |ui| ui.add(egui::TextEdit::singleline(&mut s).frame(egui::Frame::NONE).desired_width(width)));
|
||||
let changed = r.inner.changed();
|
||||
if changed {
|
||||
if r.inner.changed() {
|
||||
d.fields.insert(key.into(), Value::String(s));
|
||||
}
|
||||
changed
|
||||
r.inner
|
||||
}
|
||||
|
||||
/// A checkbox bound to `d.fields[key]`.
|
||||
|
|
@ -171,11 +175,14 @@ pub(super) fn slider(
|
|||
}
|
||||
}
|
||||
|
||||
/// The field where [`preview`] keeps the parameters it last previewed.
|
||||
pub(super) const PREVIEWED: &str = "__previewed";
|
||||
|
||||
/// The Preview checkbox of a dialog that previews `cmd` on the canvas: while it is on, `params` run
|
||||
/// again on the interaction's snapshot whenever they differ from the last preview (whether a widget
|
||||
/// or `ui.dialog.set` changed them); turning it off rolls back.
|
||||
pub(super) fn preview(app: &mut crate::VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog, label: &str, cmd: &str, params: Value) {
|
||||
const LAST: &str = "__previewed";
|
||||
const LAST: &str = PREVIEWED;
|
||||
ui.add_space(8.0);
|
||||
let mut on = d.bool("preview");
|
||||
if crate::widgets::check(ui, "Preview", on, true) {
|
||||
|
|
|
|||
|
|
@ -18,6 +18,8 @@ mod document_setup;
|
|||
mod effect;
|
||||
mod export_for_screens;
|
||||
pub mod eyedropper;
|
||||
pub mod flatten;
|
||||
pub mod flattener_presets;
|
||||
mod form;
|
||||
mod gradient_stop;
|
||||
pub mod graphic_style_options;
|
||||
|
|
@ -156,6 +158,8 @@ registry! {
|
|||
SaveSwatchLibrary: [save_swatch_library::KIND] => save_swatch_library::SPEC,
|
||||
TileEdgeColor: [tile_edge_color::KIND] => tile_edge_color::SPEC,
|
||||
EyedropperOptions: [eyedropper::KIND] => eyedropper::SPEC,
|
||||
FlattenTransparency: [flatten::KIND] => flatten::SPEC,
|
||||
FlattenerPresets: [flattener_presets::KIND] => flattener_presets::SPEC,
|
||||
}
|
||||
|
||||
/// The spec for a `Dialog::kind` ([`DialogSpec::FORM`] when unregistered).
|
||||
|
|
|
|||
|
|
@ -9,8 +9,14 @@ use vectorcraft_geom::Affine;
|
|||
use crate::VectorcraftApp;
|
||||
|
||||
/// Open bytes of any readable format as a new document (templates open untitled); swatch library
|
||||
/// files open in the library panel.
|
||||
/// files open in the library panel and flattener presets files are imported.
|
||||
pub fn open_bytes(app: &mut VectorcraftApp, name: &str, bytes: &[u8], path: Option<String>) -> Result<(), String> {
|
||||
if vectorcraft_engine::cmd::flatten::PRESET_EXTS.contains(&fileio::extension(name).as_str()) {
|
||||
let r = app.run("flattener.presets.import", serde_json::json!({"data": String::from_utf8_lossy(bytes)}))?;
|
||||
let names: Vec<&str> = r["imported"].as_array().into_iter().flatten().filter_map(Value::as_str).collect();
|
||||
app.status(format!("Imported flattener presets: {}", names.join(", ")));
|
||||
return Ok(());
|
||||
}
|
||||
if vectorcraft_engine::cmd::swatchlib::LIBRARY_EXTS.contains(&fileio::extension(name).as_str()) {
|
||||
let p = match path {
|
||||
Some(path) => serde_json::json!({ "path": path }),
|
||||
|
|
|
|||
|
|
@ -259,6 +259,24 @@ pub const UI_COMMANDS: &[(&str, &str, &str, &str)] = &[
|
|||
"",
|
||||
"{} open Tile Edge Color (dialog `tileEdgeColor`, field `color`: #rrggbb or a preset name such as \"Light Blue\"); OK sets the preference patternTileEdgeColor (prefs.set), the colour of the tile edge and swatch bounds in pattern editing mode",
|
||||
),
|
||||
(
|
||||
"ui.flattenTransparencyDialog",
|
||||
"Flatten Transparency…",
|
||||
"",
|
||||
"{} open Flatten Transparency for the selection (dialog `flattenTransparency`, fields `preset` (a preset name: setting it loads its options), balance 0..100, lineArtPpi, gradientPpi 1..2400, textToOutlines, strokesToOutlines, clipComplexRegions, antiAlias, preserveAlpha, preserveOverprints, `preview` (off at first)): OK runs object.flattenTransparency with those options as one undo step",
|
||||
),
|
||||
(
|
||||
"ui.flattenerPresetsDialog",
|
||||
"Transparency Flattener Presets…",
|
||||
"",
|
||||
"{selected?: preset name} open the presets manager (dialog `flattenerPresets`, fields `selected`, then the selected preset's `name` and option keys: setting them on a saved preset saves it, a new `name` renames it; built-in presets don't change). New, Delete, Import… and Export… run flattener.presets.save / delete / import / export",
|
||||
),
|
||||
(
|
||||
"ui.flattenerPreview",
|
||||
"Flattener Preview",
|
||||
"",
|
||||
"{highlight?: none|rasterizedRegions|transparentObjects|allAffected|expandedPatterns|outlinedStrokes|outlinedText|allRasterized, overprints?: preserve|simulate|discard, preset?: name (loads its options), options?: {option keys, over the preset's}, showOptions?} set the Flattener Preview panel (ui.inspect: ui.flattener_preview), show it and refresh its snapshot → what flattener.preview answers for it",
|
||||
),
|
||||
];
|
||||
|
||||
/// Handle a UI command. `None` = not a UI command (the engine handles it).
|
||||
|
|
@ -607,6 +625,15 @@ pub fn run_ui_command(app: &mut VectorcraftApp, id: &str, p: &Value) -> Option<R
|
|||
crate::dialogs::graphic_style_options::open_merge(app, names.unwrap_or_default())
|
||||
}
|
||||
"ui.tileEdgeColor" => crate::dialogs::tile_edge_color::open(app),
|
||||
"ui.flattenTransparencyDialog" => {
|
||||
crate::dialogs::flatten::open(app);
|
||||
Ok(Value::Null)
|
||||
}
|
||||
"ui.flattenerPresetsDialog" => {
|
||||
crate::dialogs::flattener_presets::open(app, s("selected").as_deref());
|
||||
Ok(Value::Null)
|
||||
}
|
||||
"ui.flattenerPreview" => crate::panels::flattener_preview::command(app, p),
|
||||
_ => return None,
|
||||
};
|
||||
Some(r)
|
||||
|
|
@ -783,6 +810,7 @@ pub fn enabled(app: &VectorcraftApp, id: &str) -> bool {
|
|||
"ui.colorBalanceDialog" | "ui.saturateDialog" => app.session.active().is_some_and(|d| !d.selection.is_empty()),
|
||||
"ui.saveSwatchLibrary" => app.session.active().is_some(),
|
||||
id if id.starts_with(crate::panels::swatches::USER_SLOT) => crate::panels::swatches::user_library(app, id).is_some(),
|
||||
"ui.flattenTransparencyDialog" => app.session.active().is_some_and(|d| !d.selection.is_empty()),
|
||||
_ => true,
|
||||
}
|
||||
}
|
||||
|
|
@ -895,7 +923,7 @@ pub fn menu_tree() -> Vec<(&'static str, Vec<Item>)> {
|
|||
),
|
||||
todo("Edit Original"),
|
||||
Sep,
|
||||
todo("Transparency Flattener Presets…"),
|
||||
c("Transparency Flattener Presets…", "ui.flattenerPresetsDialog"),
|
||||
todo("Print Presets…"),
|
||||
todo("PDF Presets…"),
|
||||
cp("Perspective Grid Presets…", "perspective.grid.preset", json!({"kind": 2})),
|
||||
|
|
@ -977,7 +1005,7 @@ pub fn menu_tree() -> Vec<(&'static str, Vec<Item>)> {
|
|||
json!({"columns": 10, "rows": 10, "spacingX": 0, "spacingY": 0, "gray": false, "deleteRaster": false}),
|
||||
),
|
||||
c("Create Trim Marks", "object.createTrimMarks"),
|
||||
todo("Flatten Transparency…"),
|
||||
c("Flatten Transparency…", "ui.flattenTransparencyDialog"),
|
||||
Sep,
|
||||
c("Make Pixel Perfect", "object.makePixelPerfect"),
|
||||
Sep,
|
||||
|
|
@ -1338,6 +1366,7 @@ pub fn menu_tree() -> Vec<(&'static str, Vec<Item>)> {
|
|||
panel("Color", "color"),
|
||||
panel("Color Guide", "colorGuide"),
|
||||
panel("Document Info", "docInfo"),
|
||||
panel("Flattener Preview", crate::panels::flattener_preview::ID),
|
||||
panel("Gradient", "gradient"),
|
||||
panel("Graphic Styles", "graphicStyles"),
|
||||
panel("History", "history"),
|
||||
|
|
|
|||
|
|
@ -0,0 +1,489 @@
|
|||
//! Window → Flattener Preview: what flattening the document would touch (`flattener.preview`),
|
||||
//! highlighted in red over a grey preview of the art. Refresh takes a new snapshot of the
|
||||
//! document; Highlight picks what shows; Overprints, the preset and the options (Show Options in
|
||||
//! the panel menu) set how it flattens. Click the preview to zoom in, Alt-click to zoom out, drag
|
||||
//! to pan and double-click to fit.
|
||||
//!
|
||||
//! The settings live in `UiState::flattener_preview` (`ui.inspect`); `ui.flattenerPreview` sets
|
||||
//! them and refreshes.
|
||||
|
||||
use std::cell::RefCell;
|
||||
use std::sync::Arc;
|
||||
|
||||
use egui::{Rect, Sense, Stroke, StrokeKind, Ui, pos2, vec2};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::{Value, json};
|
||||
use vectorcraft_doc::Document;
|
||||
use vectorcraft_engine::cmd::FlattenOptions;
|
||||
use vectorcraft_engine::cmd::flatten::{FlattenReport, Highlight, Overprints};
|
||||
use vectorcraft_geom::{Affine, Point, Rect as DRect};
|
||||
use vectorcraft_render::RenderOptions;
|
||||
|
||||
use super::navigator::{doc_to_thumb, thumb_to_doc};
|
||||
use crate::dialogs::flatten::{options_editor, preset_dropdown};
|
||||
use crate::theme::Tokens;
|
||||
use crate::{VectorcraftApp, widgets};
|
||||
|
||||
/// The panel id (Window → Flattener Preview).
|
||||
pub const ID: &str = "flattenerPreview";
|
||||
|
||||
/// The panel's settings (kept with the UI state).
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(default, rename_all = "camelCase")]
|
||||
pub struct Settings {
|
||||
pub highlight: Highlight,
|
||||
pub overprints: Overprints,
|
||||
/// The preset the options came from.
|
||||
pub preset: String,
|
||||
pub options: FlattenOptions,
|
||||
/// The options show under the preset (panel menu → Show Options).
|
||||
pub show_options: bool,
|
||||
}
|
||||
|
||||
impl Default for Settings {
|
||||
fn default() -> Self {
|
||||
let preset = FlattenOptions::preset_label(FlattenOptions::PRESETS[1]).unwrap_or_default().to_string();
|
||||
Self { highlight: Highlight::None, overprints: Overprints::Preserve, preset, options: FlattenOptions::default(), show_options: false }
|
||||
}
|
||||
}
|
||||
|
||||
impl Settings {
|
||||
/// `flattener.preview` parameters for these settings.
|
||||
fn params(&self) -> Value {
|
||||
json!({ "options": self.options, "overprints": self.overprints.id() })
|
||||
}
|
||||
}
|
||||
|
||||
/// The document as the last Refresh saw it, and what flattening it would do.
|
||||
struct Shot {
|
||||
doc: Arc<Document>,
|
||||
doc_index: usize,
|
||||
revision: u64,
|
||||
/// The settings it was taken with.
|
||||
options: FlattenOptions,
|
||||
overprints: Overprints,
|
||||
report: FlattenReport,
|
||||
/// What the preview shows at its widest: the artboards and the art.
|
||||
fit: DRect,
|
||||
/// Tells snapshots apart for the texture cache.
|
||||
generation: u64,
|
||||
}
|
||||
|
||||
/// The preview's zoom (1 = fit) and centre.
|
||||
#[derive(Clone, Copy, Default)]
|
||||
struct View {
|
||||
zoom: f64,
|
||||
center: Option<Point>,
|
||||
}
|
||||
|
||||
impl View {
|
||||
/// The part of `fit` shown.
|
||||
fn visible(&self, fit: DRect) -> DRect {
|
||||
let z = self.zoom.max(1.0);
|
||||
let c = self.center.unwrap_or(fit.center());
|
||||
DRect::from_center_size(c, (fit.width() / z, fit.height() / z))
|
||||
}
|
||||
|
||||
/// Zoom by `factor` (1×–64×) keeping `at` under the pointer, panned within `fit`.
|
||||
fn zoom_at(&mut self, fit: DRect, at: Point, factor: f64) {
|
||||
let v = self.visible(fit);
|
||||
let z = (self.zoom.max(1.0) * factor).clamp(1.0, 64.0);
|
||||
let k = self.zoom.max(1.0) / z;
|
||||
let c = v.center();
|
||||
self.zoom = z;
|
||||
self.center = Some(Point::new(at.x + (c.x - at.x) * k, at.y + (c.y - at.y) * k));
|
||||
self.pan(fit, (0.0, 0.0));
|
||||
}
|
||||
|
||||
/// Move the centre by `d` (document units), keeping the view inside `fit`.
|
||||
fn pan(&mut self, fit: DRect, d: (f64, f64)) {
|
||||
let v = self.visible(fit);
|
||||
let (hw, hh) = (v.width() / 2.0, v.height() / 2.0);
|
||||
let c = v.center();
|
||||
let x = (c.x + d.0).clamp(fit.x0 + hw, (fit.x1 - hw).max(fit.x0 + hw));
|
||||
let y = (c.y + d.1).clamp(fit.y0 + hh, (fit.y1 - hh).max(fit.y0 + hh));
|
||||
self.center = Some(Point::new(x, y));
|
||||
}
|
||||
}
|
||||
|
||||
/// What the preview texture shows: (generation, highlight, simulated overprints, the visible rect's
|
||||
/// bits, its pixel size).
|
||||
type Key = (u64, Highlight, bool, [u64; 4], [u32; 2]);
|
||||
|
||||
#[derive(Default)]
|
||||
struct Cache {
|
||||
shot: Option<Shot>,
|
||||
view: View,
|
||||
generation: u64,
|
||||
renderer: Option<vectorcraft_render::Renderer>,
|
||||
key: Option<Key>,
|
||||
tex: Option<egui::TextureHandle>,
|
||||
}
|
||||
|
||||
thread_local! {
|
||||
static CACHE: RefCell<Cache> = RefCell::new(Cache::default());
|
||||
}
|
||||
|
||||
/// Take a new snapshot of the active document and work out what flattening it would do →
|
||||
/// `flattener.preview`'s answer for the panel's highlight.
|
||||
pub fn refresh(app: &mut VectorcraftApp) -> Result<Value, String> {
|
||||
let set = &app.ui.flattener_preview;
|
||||
let (o, report) = app.session.flattener_report(&set.params()).map_err(|e| e.to_string())?;
|
||||
let (options, overprints) = (set.options.clone(), set.overprints);
|
||||
let (st, doc_index) = (app.session.active().ok_or("no document open")?, app.session.active_index().unwrap_or(0));
|
||||
let mut out = report.to_json(&st.doc, set.highlight);
|
||||
out["options"] = serde_json::to_value(&o).unwrap_or_default();
|
||||
let mut fit = st.doc.artboards.iter().map(|a| a.rect).reduce(|a, b| a.union(b)).unwrap_or(DRect::new(0.0, 0.0, 612.0, 792.0));
|
||||
if let Some(b) = st.doc.art_bounds() {
|
||||
fit = fit.union(b);
|
||||
}
|
||||
let fit = fit.inflate(fit.width() * 0.03 + 4.0, fit.height() * 0.03 + 4.0);
|
||||
let (doc, revision) = (st.doc.clone(), st.revision);
|
||||
CACHE.with(|c| {
|
||||
let mut c = c.borrow_mut();
|
||||
c.generation += 1;
|
||||
c.shot = Some(Shot { doc, doc_index, revision, options, overprints, report, fit, generation: c.generation });
|
||||
c.view = View::default();
|
||||
});
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// `ui.flattenerPreview {highlight?, overprints?, preset?, options?, showOptions?}`: set the panel's
|
||||
/// settings (a preset loads its options, then `options` adjust them), show the panel and refresh.
|
||||
pub(crate) fn command(app: &mut VectorcraftApp, p: &Value) -> Result<Value, String> {
|
||||
let mut set = app.ui.flattener_preview.clone();
|
||||
if let Some(h) = p.get("highlight").and_then(Value::as_str) {
|
||||
set.highlight = Highlight::parse(h).ok_or_else(|| format!("unknown highlight `{h}`"))?;
|
||||
}
|
||||
if let Some(o) = p.get("overprints").and_then(Value::as_str) {
|
||||
set.overprints = Overprints::parse(o).ok_or_else(|| format!("unknown overprints `{o}` (preserve, simulate or discard)"))?;
|
||||
}
|
||||
if let Some(name) = p.get("preset").and_then(Value::as_str) {
|
||||
let preset = app.session.flattener_preset(name).ok_or_else(|| format!("unknown preset `{name}` (see flattener.presets.list)"))?;
|
||||
(set.preset, set.options) = (preset.name, preset.options);
|
||||
}
|
||||
if let Some(o) = p.get("options") {
|
||||
set.options = app.session.flatten_options(&json!({ "options": merged(&set.options, o) }))?;
|
||||
}
|
||||
if let Some(b) = p.get("showOptions").and_then(Value::as_bool) {
|
||||
set.show_options = b;
|
||||
}
|
||||
app.ui.flattener_preview = set;
|
||||
app.ui.open_panel = Some(ID.into());
|
||||
app.ui.dock = true;
|
||||
refresh(app)
|
||||
}
|
||||
|
||||
/// `base` with the keys of `over` set.
|
||||
fn merged(base: &FlattenOptions, over: &Value) -> Value {
|
||||
let mut v = serde_json::to_value(base).unwrap_or_default();
|
||||
if let (Some(m), Some(o)) = (v.as_object_mut(), over.as_object()) {
|
||||
m.extend(o.iter().map(|(k, x)| (k.clone(), x.clone())));
|
||||
}
|
||||
v
|
||||
}
|
||||
|
||||
pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) {
|
||||
let t = Tokens::get(ui.ctx());
|
||||
let Some(st) = app.session.active() else {
|
||||
super::empty_state(ui, "eye", "No document", "Open a document to preview its flattening.");
|
||||
return;
|
||||
};
|
||||
let (index, revision) = (app.session.active_index().unwrap_or(0), st.revision);
|
||||
// A document the panel hasn't seen yet gets a snapshot right away.
|
||||
let seen = CACHE.with(|c| c.borrow().shot.as_ref().map(|s| s.doc_index));
|
||||
if seen != Some(index)
|
||||
&& let Err(e) = refresh(app)
|
||||
{
|
||||
app.status(e);
|
||||
}
|
||||
let mut set = app.ui.flattener_preview.clone();
|
||||
let mut refresh_now = false;
|
||||
ui.horizontal(|ui| {
|
||||
if widgets::flat_button(ui, "Refresh", 64.0).on_hover_text("Preview the document as it is now").clicked() {
|
||||
refresh_now = true;
|
||||
}
|
||||
let labels = Highlight::ALL.map(Highlight::label);
|
||||
if let Some(i) = widgets::dropdown(ui, "fp-highlight", set.highlight.label(), &labels, ui.available_width().max(120.0)) {
|
||||
set.highlight = Highlight::ALL[i];
|
||||
}
|
||||
});
|
||||
ui.add_space(4.0);
|
||||
egui::Grid::new("fp-settings").num_columns(2).spacing([8.0, 6.0]).show(ui, |ui| {
|
||||
widgets::dim_label(ui, "Overprints:");
|
||||
let labels = Overprints::ALL.map(Overprints::label);
|
||||
if let Some(i) = widgets::dropdown(ui, "fp-overprints", set.overprints.label(), &labels, 150.0) {
|
||||
set.overprints = Overprints::ALL[i];
|
||||
}
|
||||
ui.end_row();
|
||||
widgets::dim_label(ui, "Preset:");
|
||||
let presets = app.session.flattener_presets();
|
||||
if let Some(p) = preset_dropdown(ui, "fp-preset", &presets, &set.preset, Some(&set.options), 150.0) {
|
||||
set.preset = p.name.clone();
|
||||
set.options = p.options.clone();
|
||||
}
|
||||
ui.end_row();
|
||||
});
|
||||
if set.show_options {
|
||||
widgets::divider(ui);
|
||||
options_editor(ui, "flattener-preview", &mut set.options, true);
|
||||
}
|
||||
ui.add_space(6.0);
|
||||
preview_area(ui, &t, set.highlight, set.overprints);
|
||||
// What is highlighted, and whether the snapshot is behind the document or the settings.
|
||||
let (count, stale) = CACHE.with(|c| {
|
||||
let c = c.borrow();
|
||||
let s = c.shot.as_ref();
|
||||
let count = s.map_or(0, |s| s.report.objects(set.highlight).len() + s.report.areas(set.highlight).len());
|
||||
(count, s.is_some_and(|s| s.revision != revision || s.options != set.options || s.overprints != set.overprints))
|
||||
});
|
||||
if set.highlight != Highlight::None {
|
||||
let what = if matches!(set.highlight, Highlight::RasterizedRegions | Highlight::AllRasterized) { "area" } else { "object" };
|
||||
widgets::dim_label(ui, &format!("{count} {what}{} highlighted", if count == 1 { "" } else { "s" }));
|
||||
}
|
||||
if stale {
|
||||
ui.label(egui::RichText::new("Changed since the last Refresh.").color(t.text_dim).size(11.5));
|
||||
}
|
||||
if set != app.ui.flattener_preview {
|
||||
app.ui.flattener_preview = set;
|
||||
}
|
||||
if refresh_now && let Err(e) = refresh(app) {
|
||||
app.status(e);
|
||||
}
|
||||
}
|
||||
|
||||
/// The preview: the snapshot rendered over the visible part, grey under a highlight with the
|
||||
/// highlighted art in `t.highlight`; click, Alt-click, drag and double-click zoom and pan.
|
||||
fn preview_area(ui: &mut Ui, t: &Tokens, h: Highlight, overprints: Overprints) {
|
||||
let w = ui.available_width();
|
||||
let (rect, resp) = ui.allocate_exact_size(vec2(w, (w * 0.8).clamp(140.0, 320.0)), Sense::click_and_drag());
|
||||
let resp = resp.on_hover_text("Click to zoom in, Alt-click to zoom out, drag to pan, double-click to fit");
|
||||
ui.painter().rect_filled(rect, 0.0, t.pasteboard);
|
||||
let ppp = ui.ctx().pixels_per_point();
|
||||
CACHE.with(|c| {
|
||||
let mut c = c.borrow_mut();
|
||||
let Cache { shot, view, renderer, key, tex, .. } = &mut *c;
|
||||
let Some(shot) = shot.as_ref() else { return };
|
||||
let visible = view.visible(shot.fit);
|
||||
if resp.double_clicked() {
|
||||
*view = View::default();
|
||||
} else if resp.clicked()
|
||||
&& let Some(p) = resp.interact_pointer_pos()
|
||||
{
|
||||
let alt = ui.input(|i| i.modifiers.alt);
|
||||
view.zoom_at(shot.fit, thumb_to_doc(visible, rect, p), if alt { 0.5 } else { 2.0 });
|
||||
} else if resp.dragged() {
|
||||
let per_px = (visible.width() / rect.width() as f64).max(visible.height() / rect.height() as f64);
|
||||
let d = resp.drag_delta();
|
||||
view.pan(shot.fit, (-d.x as f64 * per_px, -d.y as f64 * per_px));
|
||||
}
|
||||
let visible = view.visible(shot.fit);
|
||||
let px = [(rect.width() * ppp) as u32, (rect.height() * ppp) as u32];
|
||||
let k = (shot.generation, h, overprints == Overprints::Simulate, [visible.x0, visible.y0, visible.x1, visible.y1].map(f64::to_bits), px);
|
||||
if *key != Some(k) || tex.is_none() {
|
||||
let r = renderer.get_or_insert_with(vectorcraft_render::Renderer::new);
|
||||
let img = render(r, shot, visible, px, h, overprints, t.highlight);
|
||||
match tex {
|
||||
Some(tx) => tx.set(img, egui::TextureOptions::LINEAR),
|
||||
None => *tex = Some(ui.ctx().load_texture("flattener-preview", img, egui::TextureOptions::LINEAR)),
|
||||
}
|
||||
*key = Some(k);
|
||||
}
|
||||
if let Some(tx) = tex {
|
||||
let r =
|
||||
Rect::from_min_max(doc_to_thumb(visible, rect, visible.origin()), doc_to_thumb(visible, rect, Point::new(visible.x1, visible.y1)));
|
||||
ui.painter().with_clip_rect(rect).image(tx.id(), r, Rect::from_min_max(pos2(0.0, 0.0), pos2(1.0, 1.0)), egui::Color32::WHITE);
|
||||
}
|
||||
});
|
||||
ui.painter().rect_stroke(rect, 0.0, Stroke::new(1.0, t.border), StrokeKind::Outside);
|
||||
}
|
||||
|
||||
/// `visible` of the snapshot at most `px` pixels: in colour without a highlight, else in grey with
|
||||
/// the highlighted art in `color`.
|
||||
fn render(
|
||||
r: &mut vectorcraft_render::Renderer,
|
||||
shot: &Shot,
|
||||
visible: DRect,
|
||||
px: [u32; 2],
|
||||
h: Highlight,
|
||||
overprints: Overprints,
|
||||
color: egui::Color32,
|
||||
) -> egui::ColorImage {
|
||||
let s = (px[0] as f64 / visible.width()).min(px[1] as f64 / visible.height());
|
||||
let (w, hh) = ((visible.width() * s).round().max(1.0) as u32, (visible.height() * s).round().max(1.0) as u32);
|
||||
let view = Affine::scale(s) * Affine::translate((-visible.x0, -visible.y0));
|
||||
let opts = RenderOptions {
|
||||
background: Some([255; 4]),
|
||||
skip_templates: true,
|
||||
overprint_preview: overprints == Overprints::Simulate,
|
||||
..Default::default()
|
||||
};
|
||||
let mut img = r.render(&shot.doc, w, hh, view, &opts);
|
||||
if h != Highlight::None {
|
||||
let mask = shot
|
||||
.report
|
||||
.highlight_art(&shot.doc, h)
|
||||
.map(|art| r.render(&art, w, hh, view, &RenderOptions { skip_templates: true, ..Default::default() }));
|
||||
let red = [color.r(), color.g(), color.b()].map(f32::from);
|
||||
for (i, p) in img.pixels.chunks_exact_mut(4).enumerate() {
|
||||
// A light grey of the art, so the highlight stands out.
|
||||
let luma = 0.299 * p[0] as f32 + 0.587 * p[1] as f32 + 0.114 * p[2] as f32;
|
||||
let grey = 96.0 + luma * 0.62;
|
||||
let a = mask.as_ref().map_or(0.0, |m| m.pixels[i * 4 + 3] as f32 / 255.0);
|
||||
for c in 0..3 {
|
||||
p[c] = (grey + (red[c] - grey) * a).round() as u8;
|
||||
}
|
||||
}
|
||||
}
|
||||
egui::ColorImage::from_rgba_premultiplied([img.width as usize, img.height as usize], &img.pixels)
|
||||
}
|
||||
|
||||
pub fn menu(app: &mut VectorcraftApp, ui: &mut Ui) {
|
||||
if widgets::menu_item(ui, "Refresh", app.session.active().is_some(), false)
|
||||
&& let Err(e) = refresh(app)
|
||||
{
|
||||
app.status(e);
|
||||
}
|
||||
let shown = app.ui.flattener_preview.show_options;
|
||||
if widgets::menu_item(ui, "Show Options", true, shown) {
|
||||
app.ui.flattener_preview.show_options = !shown;
|
||||
}
|
||||
if widgets::menu_item(ui, "Fit in Window", true, false) {
|
||||
CACHE.with(|c| c.borrow_mut().view = View::default());
|
||||
}
|
||||
if widgets::menu_item(ui, "Save Transparency Flattener Preset…", true, false) {
|
||||
// Saved under a new name, then shown in the presets manager to name it.
|
||||
match app.run("flattener.presets.save", json!({ "options": app.ui.flattener_preview.options })) {
|
||||
Ok(r) => {
|
||||
let name = r["name"].as_str().unwrap_or_default().to_string();
|
||||
app.ui.flattener_preview.preset = name.clone();
|
||||
let _ = app.run("ui.flattenerPresetsDialog", json!({ "selected": name }));
|
||||
}
|
||||
Err(e) => app.status(e),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use vectorcraft_engine::Session;
|
||||
|
||||
/// A half-transparent blue rectangle over type, and a green square apart.
|
||||
fn app() -> VectorcraftApp {
|
||||
let mut app = VectorcraftApp::new(Session::new(), Default::default());
|
||||
app.run("file.new", json!({"width": 300, "height": 200})).unwrap();
|
||||
app.run("text.create", json!({"x": 20, "y": 80, "text": "Under", "size": 48})).unwrap();
|
||||
let top = app.run("shape.rectangle", json!({"x": 10, "y": 30, "width": 200, "height": 80})).unwrap()["id"].clone();
|
||||
app.run("paint.setFill", json!({"color": "#3366cc", "ids": [top]})).unwrap();
|
||||
app.run("transparency.set", json!({"ids": [top], "opacity": 50})).unwrap();
|
||||
app.run("shape.rectangle", json!({"x": 240, "y": 140, "width": 40, "height": 40})).unwrap();
|
||||
app
|
||||
}
|
||||
|
||||
/// Two headless frames of the panel (the texture uploads in the first).
|
||||
fn frame(app: &mut VectorcraftApp) -> egui::Context {
|
||||
let ctx = egui::Context::default();
|
||||
crate::theme::install_fonts(&ctx);
|
||||
for _ in 0..2 {
|
||||
let mut out = ctx.run_ui(egui::RawInput::default(), |ui| show(app, ui));
|
||||
out.textures_delta.clear();
|
||||
}
|
||||
ctx
|
||||
}
|
||||
|
||||
fn pixels() -> egui::ColorImage {
|
||||
CACHE.with(|c| {
|
||||
let mut c = c.borrow_mut();
|
||||
let Cache { shot, view, renderer, .. } = &mut *c;
|
||||
let shot = shot.as_ref().unwrap();
|
||||
render(
|
||||
renderer.get_or_insert_with(vectorcraft_render::Renderer::new),
|
||||
shot,
|
||||
view.visible(shot.fit),
|
||||
[300, 200],
|
||||
Highlight::None,
|
||||
Overprints::Preserve,
|
||||
egui::Color32::RED,
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_panel_takes_a_snapshot_and_highlights_in_red() {
|
||||
let mut app = app();
|
||||
frame(&mut app);
|
||||
assert!(CACHE.with(|c| c.borrow().shot.is_some()), "a first look refreshes");
|
||||
assert!(CACHE.with(|c| c.borrow().tex.is_some()), "the preview is drawn");
|
||||
let v = command(&mut app, &json!({"highlight": "allAffected"})).unwrap();
|
||||
assert_eq!(v["counts"]["allAffected"], 2);
|
||||
assert_eq!(app.ui.open_panel.as_deref(), Some(ID));
|
||||
let text = crate::tests_labels::painted_text(&mut app, show);
|
||||
assert!(text.contains("2 objects highlighted") && text.contains("All Affected Objects"), "{text}");
|
||||
// The highlight is red where the art is affected and grey elsewhere.
|
||||
let (shot_fit, img) = CACHE.with(|c| {
|
||||
let mut c = c.borrow_mut();
|
||||
let Cache { shot, renderer, .. } = &mut *c;
|
||||
let shot = shot.as_ref().unwrap();
|
||||
let fit = DRect::new(0.0, 0.0, 300.0, 200.0);
|
||||
(
|
||||
shot.fit,
|
||||
render(
|
||||
renderer.get_or_insert_with(vectorcraft_render::Renderer::new),
|
||||
shot,
|
||||
fit,
|
||||
[300, 200],
|
||||
Highlight::AllAffected,
|
||||
Overprints::Preserve,
|
||||
egui::Color32::RED,
|
||||
),
|
||||
)
|
||||
});
|
||||
assert!(shot_fit.contains(Point::new(300.0, 200.0)));
|
||||
let at = |x: usize, y: usize| img.pixels[y * img.width() + x];
|
||||
assert_eq!(at(100, 60), egui::Color32::RED, "inside the transparent rectangle");
|
||||
let apart = at(260, 160);
|
||||
assert!(apart.r() == apart.g() && apart.g() == apart.b(), "the lone square is grey: {apart:?}");
|
||||
// Without a highlight it is in colour.
|
||||
let plain = pixels();
|
||||
assert!(plain.pixels.iter().any(|p| i32::from(p.b()) > i32::from(p.r()) + 40), "the blue shows");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn edits_wait_for_refresh_and_settings_drive_the_report() {
|
||||
let mut app = app();
|
||||
frame(&mut app);
|
||||
let text = crate::tests_labels::painted_text(&mut app, show);
|
||||
assert!(!text.contains("Changed since"), "{text}");
|
||||
app.run("select.all", json!({})).unwrap();
|
||||
app.run("transparency.set", json!({"opacity": 100})).unwrap();
|
||||
let text = crate::tests_labels::painted_text(&mut app, show);
|
||||
assert!(text.contains("Changed since the last Refresh."), "{text}");
|
||||
let v = command(&mut app, &json!({"highlight": "transparentObjects"})).unwrap();
|
||||
assert_eq!(v["counts"]["transparentObjects"], 0, "an opaque document");
|
||||
// A preset and options: balance 0 rasterizes; the dropdown then reads [Custom].
|
||||
let v = command(&mut app, &json!({"preset": "low", "options": {"balance": 0}, "showOptions": true})).unwrap();
|
||||
assert_eq!((v["options"]["balance"].as_f64(), v["options"]["gradientPpi"].as_f64()), (Some(0.0), Some(150.0)));
|
||||
assert!(app.ui.flattener_preview.show_options);
|
||||
let text = crate::tests_labels::painted_text(&mut app, show);
|
||||
assert!(text.contains("[Custom]") && text.contains("Raster/Vector Balance:"), "{text}");
|
||||
assert!(command(&mut app, &json!({"highlight": "nope"})).is_err());
|
||||
// The settings persist with the UI state.
|
||||
let back: crate::UiState = serde_json::from_value(serde_json::to_value(&app.ui).unwrap()).unwrap();
|
||||
assert_eq!(back.flattener_preview, app.ui.flattener_preview);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn zoom_and_pan_stay_inside_the_document() {
|
||||
let fit = DRect::new(0.0, 0.0, 200.0, 100.0);
|
||||
let mut v = View::default();
|
||||
assert_eq!(v.visible(fit), fit);
|
||||
v.zoom_at(fit, Point::new(50.0, 25.0), 2.0);
|
||||
let r = v.visible(fit);
|
||||
assert!((r.width() - 100.0).abs() < 1e-9 && r.x0 >= 0.0 && r.y0 >= 0.0, "{r:?}");
|
||||
v.pan(fit, (-1000.0, 0.0));
|
||||
assert_eq!(v.visible(fit).x0, 0.0);
|
||||
v.zoom_at(fit, Point::new(0.0, 0.0), 0.25);
|
||||
assert_eq!(v.visible(fit), fit, "never smaller than fit");
|
||||
}
|
||||
}
|
||||
|
|
@ -12,6 +12,7 @@ pub mod character;
|
|||
pub mod color;
|
||||
pub mod color_guide;
|
||||
pub mod doc_info;
|
||||
pub mod flattener_preview;
|
||||
pub mod glyphs;
|
||||
pub mod gradient;
|
||||
pub mod graphic_styles;
|
||||
|
|
@ -88,6 +89,7 @@ pub fn show_icon_panel(app: &mut VectorcraftApp, ui: &mut Ui, id: &str) {
|
|||
"paraStyles" => text_styles::show(app, ui, text_styles::Kind::Para),
|
||||
"magicWand" => magic_wand::show(app, ui),
|
||||
"tabs" => tabs::show(app, ui),
|
||||
flattener_preview::ID => flattener_preview::show(app, ui),
|
||||
_ => {
|
||||
dim_label(ui, "This panel is on the roadmap (see the parity plan).");
|
||||
}
|
||||
|
|
@ -126,6 +128,7 @@ pub fn panel_menu_items(app: &mut VectorcraftApp, ui: &mut Ui, id: &str) {
|
|||
"paraStyles" => text_styles::menu(app, ui, text_styles::Kind::Para),
|
||||
"magicWand" => magic_wand::menu(app, ui),
|
||||
"tabs" => tabs::menu(app, ui),
|
||||
flattener_preview::ID => flattener_preview::menu(app, ui),
|
||||
_ => {
|
||||
ui.add_enabled(false, egui::Button::new("No options").frame(false));
|
||||
}
|
||||
|
|
|
|||
|
|
@ -6,7 +6,7 @@
|
|||
|
||||
use serde_json::{Map, Value, json};
|
||||
use vectorcraft_engine::Prefs;
|
||||
use vectorcraft_engine::cmd::prefscmds::{PREF_CATEGORIES, PREF_SPECS, PrefKind};
|
||||
use vectorcraft_engine::cmd::prefscmds::{PREF_CATEGORIES, PREF_GROUPS, PREF_SPECS, PrefKind};
|
||||
|
||||
use crate::state::Dialog;
|
||||
use crate::theme::{self, Brightness, Tokens};
|
||||
|
|
@ -20,7 +20,9 @@ const UI_FIELDS: &[(&str, &str, &str)] = &[
|
|||
];
|
||||
|
||||
pub fn open(app: &mut VectorcraftApp, category: Option<&str>) {
|
||||
// One field per preference key or group (the flattener presets have commands of their own).
|
||||
let mut fields: Map<String, Value> = app.session.prefs.to_json().as_object().cloned().unwrap_or_default();
|
||||
fields.retain(|k, _| vectorcraft_engine::cmd::prefscmds::spec(k).is_some() || PREF_GROUPS.contains(&k.as_str()));
|
||||
let cat = category.and_then(|c| PREF_CATEGORIES.iter().find(|x| x.eq_ignore_ascii_case(c))).copied().unwrap_or(PREF_CATEGORIES[0]);
|
||||
fields.insert("__category".into(), json!(cat));
|
||||
fields.insert("__smartGuides".into(), json!(app.ui.view.smart_guides));
|
||||
|
|
|
|||
|
|
@ -88,6 +88,7 @@ pub const ICON_PANELS: &[(&str, &str, &str)] = &[
|
|||
("separations", "Separations Preview", "printer"),
|
||||
("imageTrace", "Image Trace", "image"),
|
||||
("magicWand", "Magic Wand", "wand-sparkles"),
|
||||
(crate::panels::flattener_preview::ID, "Flattener Preview", "eye"),
|
||||
];
|
||||
|
||||
/// Groups of icon panels separated by dividers in the collapsed column.
|
||||
|
|
@ -252,6 +253,9 @@ pub struct UiState {
|
|||
/// The library open in the library panel (Window → Swatch Libraries).
|
||||
#[serde(default)]
|
||||
pub library_panel: Option<crate::panels::library_panel::OpenLibrary>,
|
||||
/// Flattener Preview panel: highlight, overprints, preset and options (`ui.flattenerPreview`).
|
||||
#[serde(default)]
|
||||
pub flattener_preview: crate::panels::flattener_preview::Settings,
|
||||
}
|
||||
|
||||
impl UiState {
|
||||
|
|
@ -305,6 +309,7 @@ impl Default for UiState {
|
|||
engine_prefs: Value::Null,
|
||||
color_guide: Default::default(),
|
||||
library_panel: None,
|
||||
flattener_preview: Default::default(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -81,6 +81,8 @@ pub struct Tokens {
|
|||
/// Window Close button (custom title bar) when hovered, and its glyph there.
|
||||
pub caption_close: Color32,
|
||||
pub caption_close_text: Color32,
|
||||
/// What preview images mark (Flattener Preview highlights).
|
||||
pub highlight: Color32,
|
||||
}
|
||||
|
||||
impl Tokens {
|
||||
|
|
@ -121,6 +123,7 @@ impl Tokens {
|
|||
font_size: 13.0,
|
||||
caption_close: hex(0xc42b1c),
|
||||
caption_close_text: Color32::WHITE,
|
||||
highlight: hex(0xff3030),
|
||||
};
|
||||
match b {
|
||||
Brightness::Dark => base,
|
||||
|
|
|
|||
|
|
@ -107,3 +107,24 @@ forward (`paint.toggleActive {fill}`) and open a popover with the Swatches panel
|
|||
a swatch clicked there runs `paint.setFill` / `paint.setStroke`. Panel keys (Color F6, Color Guide Shift+F3,
|
||||
Appearance Shift+F6, Graphic Styles Shift+F5, Stroke Cmd+F10, Gradient Cmd+F9, Transparency Cmd+Shift+F10) run
|
||||
`window.panel {panel}` and can be pressed with `ui.key`; `ui.menu.list` shows them on the Window menu's items.
|
||||
|
||||
Flatten Transparency: `ui.flattenTransparencyDialog` opens the `flattenTransparency` dialog for the selection
|
||||
(fields `preset`: a preset name, setting it loads that preset's options; the option keys of
|
||||
`object.flattenTransparency`: `balance` 0–100, `lineArtPpi` and `gradientPpi` 1–2400, `textToOutlines`,
|
||||
`strokesToOutlines`, `clipComplexRegions`, `antiAlias`, `preserveAlpha`, `preserveOverprints`; `preview`, off at first).
|
||||
With `preview` on it flattens on the canvas while open; `ui.dialog.confirm` keeps the result as one undo step and
|
||||
`ui.dialog.cancel` rolls it back. Out-of-range values answer with an error and the dialog stays open.
|
||||
|
||||
Flattener presets: `ui.flattenerPresetsDialog {selected?}` opens Edit → Transparency Flattener Presets (dialog
|
||||
`flattenerPresets`). Fields: `selected` (a preset name), then the selected preset's `name` and option keys. On a saved
|
||||
preset, `ui.dialog.set` of an option saves it and a new `name` renames it (both through `flattener.presets.save`);
|
||||
built-in presets don't change. New, Delete, Import… and Export… are `flattener.presets.save {preset}`,
|
||||
`flattener.presets.delete`, `flattener.presets.import` and `flattener.presets.export`; `ui.dialog.confirm` closes.
|
||||
Opening a `.vcflattener` file with `app.open` imports its presets. In the Flatten Transparency dialog, Save Preset…
|
||||
keeps the dialog's options as a saved preset.
|
||||
|
||||
Flattener Preview: `window.panel {panel: "flattenerPreview"}` shows the panel; `ui.flattenerPreview {highlight?,
|
||||
overprints?, preset?, options?, showOptions?}` sets it (a preset loads its options, `options` adjust them), shows it
|
||||
and refreshes its snapshot of the document, answering as `flattener.preview` does. `ui.inspect` reports the settings
|
||||
as `ui.flattener_preview`. The panel keeps its snapshot until Refresh (it notes when the document or the settings
|
||||
changed since); its preview greys the art and paints the highlighted objects or areas red.
|
||||
|
|
|
|||
27
docs/mcp.md
27
docs/mcp.md
|
|
@ -458,3 +458,30 @@ ink with other colours is written in process colours, with a warning).
|
|||
```json
|
||||
{"name":"run_command","arguments":{"command":"paint.editGradient","params":{"stops":[{"offset":0,"swatch":"Ink"},{"offset":1,"swatch":"Ink","tint":20}]}}}
|
||||
```
|
||||
|
||||
## Transparency flattener presets
|
||||
|
||||
`flattener.presets.list` lists the built-in presets (High, Medium and Low Resolution, `builtIn: true`) and the saved
|
||||
ones, each with its `options`. `flattener.presets.save {name?, newName?, preset?, …options}` creates or changes a saved
|
||||
preset (a change starts from the preset's own options; `newName` renames it; built-in presets can't change);
|
||||
`flattener.presets.delete {name}` deletes one. `flattener.presets.export {names?, path?}` writes them as a
|
||||
`.vcflattener` JSON file (without `path` it returns `data`), and `flattener.presets.import {path? | data? |
|
||||
dataBase64?, replace?}` adds a file's presets (names in use get a number unless `replace`). Saved presets live with
|
||||
the preferences and work as `preset` in `object.flattenTransparency` (and anywhere else flattener options are taken).
|
||||
|
||||
```json
|
||||
{"name":"run_command","arguments":{"command":"flattener.presets.save","params":{"name":"Press","preset":"high","balance":60}}}
|
||||
{"name":"run_command","arguments":{"command":"object.flattenTransparency","params":{"preset":"Press"}}}
|
||||
```
|
||||
|
||||
## Flattener Preview
|
||||
|
||||
`flattener.preview {highlight?, overprints?, preset?, …options, ids?}` reports what flattening the document (or
|
||||
`ids`) would do without changing it: `counts` of transparent objects, all affected objects, patterns, outlined
|
||||
strokes and type, rasterized complex regions (areas the raster/vector balance rasterizes whole), all rasterized areas
|
||||
and flat-colour regions, plus `regions` (bounds, and `id` for objects) for the `highlight` asked for. `overprints:
|
||||
"discard"` or `"simulate"` flatten without preserving overprints.
|
||||
|
||||
```json
|
||||
{"name":"run_command","arguments":{"command":"flattener.preview","params":{"highlight":"allRasterized","preset":"low"}}}
|
||||
```
|
||||
|
|
|
|||
Loading…
Reference in New Issue