M3.94: Window > Flattener Preview shows what flattening would rasterize, outline and change

flattener.preview {highlight, overprints?, preset?|options?, ids?} reports, without changing the
document, the transparent and affected objects, patterns, outlined strokes and type, rasterized
complex regions, all rasterized areas and flat-colour regions (FlattenReport, worked out by the
flattener's own plan without rendering images). The panel previews a snapshot taken on Refresh,
greyed with the chosen highlight in red, zoomable (click, Alt-click, drag, double-click); its
Overprints, preset and options (Show Options) set how it flattens, and its menu saves them as a
preset. ui.flattenerPreview drives it; the settings persist with the UI state.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
bflatasticandClaude Opus 5.5 committed 2026-10-05 02:36:43 +07:00
1 parent 1dc158cafe
commit 953024cc35
11 files changed
+1017 -30

No files matched your search

+348 -22
View File
@@ -15,6 +15,9 @@
//! 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;
@@ -89,6 +92,15 @@ pub fn specs() -> Vec<CommandSpec> {
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
),
]
}
@@ -218,7 +230,7 @@ fn flatten(s: &mut Session, p: &Value) -> Result<Value> {
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 }));
@@ -265,6 +277,13 @@ impl Session {
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> {
@@ -341,12 +360,9 @@ fn presets_export(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "flattener.presets.export";
let presets = match p.get("names").and_then(Value::as_array) {
Some(names) => {
let all = s.flattener_presets();
let find = |n: &Value| {
let n = n.as_str().unwrap_or_default().trim();
// Built-in ones also by id (`high`).
let key = FlattenOptions::preset_label(&n.to_ascii_lowercase()).unwrap_or(n);
all.iter().find(|q| q.name.eq_ignore_ascii_case(key)).cloned().ok_or_else(|| bad(C, format!("no preset named `{n}`")))
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<_>>>()?
}
@@ -425,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.
@@ -432,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,
@@ -458,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);
@@ -466,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() {
@@ -529,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.
@@ -727,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))
+2
View File
@@ -801,6 +801,8 @@ mod tests_file;
#[cfg(test)]
mod tests_flatpresets;
#[cfg(test)]
mod tests_flatpreview;
#[cfg(test)]
mod tests_flatten;
#[cfg(test)]
mod tests_freeform;
+123
View File
@@ -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);
}
}
+18 -8
View File
@@ -85,16 +85,11 @@ fn body(app: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) -> bool {
sync(d, saved);
let current = options(d, saved);
let presets = app.session.flattener_presets();
let shown = match &current {
Ok(o) => presets.iter().find(|p| p.name == d.str("preset") && p.options == *o).map_or(CUSTOM, |p| p.name.as_str()),
Err(_) => CUSTOM,
};
ui.horizontal(|ui| {
widgets::dim_label(ui, "Preset:");
let names: Vec<&str> = presets.iter().map(|p| p.name.as_str()).collect();
if let Some(i) = widgets::dropdown(ui, "flatten-preset", shown, &names, 220.0) {
put_options(&mut d.fields, &presets[i].options);
applied(d, &presets[i].name);
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);
@@ -157,6 +152,21 @@ fn confirm(app: &mut VectorcraftApp, d: &Dialog) -> Result<Value, String> {
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;
+8
View File
@@ -271,6 +271,12 @@ pub const UI_COMMANDS: &[(&str, &str, &str, &str)] = &[
"",
"{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).
@@ -627,6 +633,7 @@ pub fn run_ui_command(app: &mut VectorcraftApp, id: &str, p: &Value) -> Option<R
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)
@@ -1359,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");
}
}
+3
View File
@@ -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));
}
+5
View File
@@ -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(),
}
}
}
+3
View File
@@ -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,
+6
View File
@@ -122,3 +122,9 @@ built-in presets don't change. New, Delete, Import… and Export… are `flatten
`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.
+12
View File
@@ -473,3 +473,15 @@ the preferences and work as `preset` in `object.flattenTransparency` (and anywhe
{"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"}}}
```