mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-10-25 10:17:02 -06:00
Type is drawn as its glyph outlines under a geometry effect, in EMF and WMF export, in DXF export when it is written as outlines, through Flatten Transparency with Convert All Text to Outlines, and inside an envelope. Those outlines differed from the type the canvas draws in four ways. They had no underline or strikethrough bars. A character stroke on scaled type kept its unscaled weight. An unplaced gradient was fitted to the glyphs it painted instead of the layout box. A run's pattern tiles stayed in document space instead of scaling and turning with the type. A group's or layer's own fills and strokes also painted the glyphs of the type in it without the bars. The outlines now include each run's underline and strikethrough bars, as the canvas has drawn them since #850. Each run's path is made in text space with its unplaced gradients fixed to the layout box, then placed by the type's transform, which also scales its stroke weight and dashes and transforms its pattern tiles. The type's own fills and strokes have their unplaced gradients fixed to the bounding box of the layout box in the document, where the canvas fits them. Geometry effects that move or scale the outlines move and scale the type's gradients with them: the type's own effects, also inside an envelope and after Flatten Transparency has converted the type to outlines, and the effects of a group, symbol instance or envelope that holds the type. outline_text marks its group with a new node field, type_outlines, which is saved with the document. reshape_leaves maps the placed gradients in each marked group through the map that takes the group's bounds before the effects onto its bounds after them. A scale out of proportion stretches a radial gradient into an ellipse and turns the isolines of a diagonal linear gradient, as it does on type scaled by its own transform. Placed gradients outside those groups stay where they are.
523 lines
26 KiB
Rust
523 lines
26 KiB
Rust
//! Object → Flatten Transparency (M3.91): atomic regions with the composited colours, rasterized
|
|
//! complex regions, the options and presets, undo and the look of the result.
|
|
|
|
use serde_json::{Value, json};
|
|
use vectorcraft_color::{BlendMode, Color, Paint};
|
|
use vectorcraft_doc::{AppearanceItem, Node, NodeKind};
|
|
use vectorcraft_geom::{BezPath, Point, Rect, Shape};
|
|
use vectorcraft_render::{Rendered, Renderer};
|
|
|
|
use super::*;
|
|
|
|
/// Colours here go through the process-wide colour settings, which the colour management tests
|
|
/// change: one at a time with them.
|
|
fn settings_lock() -> std::sync::MutexGuard<'static, ()> {
|
|
crate::tests_colormgmt::GLOBAL.lock().unwrap_or_else(|e| e.into_inner())
|
|
}
|
|
|
|
fn session() -> Session {
|
|
let mut s = Session::new();
|
|
s.execute("file.new", &json!({"width": 200, "height": 200})).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 node(s: &Session, id: u64) -> Node {
|
|
s.doc().unwrap().doc.node(NodeId(id)).unwrap().clone()
|
|
}
|
|
|
|
fn exists(s: &Session, id: u64) -> bool {
|
|
s.doc().unwrap().doc.node(NodeId(id)).is_some()
|
|
}
|
|
|
|
/// The document over white at 1 px per point.
|
|
fn picture(s: &Session) -> Rendered {
|
|
Renderer::new().render_region(&s.doc().unwrap().doc, Rect::new(0.0, 0.0, 200.0, 200.0), 1.0, true)
|
|
}
|
|
|
|
fn flatten(s: &mut Session, p: Value) -> (Vec<u64>, u64, u64) {
|
|
let r = run(s, "object.flattenTransparency", p);
|
|
let ids = r["ids"].as_array().unwrap().iter().map(|v| v.as_u64().unwrap()).collect();
|
|
(ids, r["vector"].as_u64().unwrap(), r["rasterized"].as_u64().unwrap())
|
|
}
|
|
|
|
fn outline(n: &Node) -> BezPath {
|
|
match &n.kind {
|
|
NodeKind::Path { path, .. } => path.to_bezpath(),
|
|
NodeKind::Compound { children, .. } => children.iter().fold(BezPath::new(), |mut bp, c| {
|
|
bp.extend(outline(c));
|
|
bp
|
|
}),
|
|
_ => panic!("a region is a path: {}", n.kind_label()),
|
|
}
|
|
}
|
|
|
|
/// The flat-colour regions in a flattened group.
|
|
fn regions(s: &Session, group: u64) -> Vec<Node> {
|
|
let g = node(s, group);
|
|
g.children().unwrap().iter().filter(|c| matches!(c.kind, NodeKind::Path { .. } | NodeKind::Compound { .. })).map(|c| (**c).clone()).collect()
|
|
}
|
|
|
|
/// A pixel centre well inside `n` (2 pt from its edges).
|
|
fn interior(n: &Node) -> Option<Point> {
|
|
let bp = outline(n);
|
|
let b = bp.bounding_box();
|
|
let inside = |p: Point| [(0.0, 0.0), (2.0, 0.0), (-2.0, 0.0), (0.0, 2.0), (0.0, -2.0)].iter().all(|(dx, dy)| bp.winding(p + (*dx, *dy)) != 0);
|
|
(b.y0.floor() as i32..b.y1.ceil() as i32)
|
|
.flat_map(|y| (b.x0.floor() as i32..b.x1.ceil() as i32).map(move |x| Point::new(x as f64 + 0.5, y as f64 + 0.5)))
|
|
.find(|p| inside(*p))
|
|
}
|
|
|
|
/// A region's colour over white, as 8-bit RGB.
|
|
fn shown(n: &Node) -> [u8; 3] {
|
|
let AppearanceItem::Fill(f) = &n.appearance.items[0] else { panic!("a region has one fill") };
|
|
let [r, g, b] = f.paint.color().unwrap().to_rgb();
|
|
let a = n.opacity;
|
|
let q = |v: f32| ((v * a + 1.0 - a) * 255.0).round() as u8;
|
|
[q(r), q(g), q(b)]
|
|
}
|
|
|
|
fn pixel(img: &Rendered, p: Point) -> [u8; 3] {
|
|
let [r, g, b, _] = img.pixel(p.x as u32, p.y as u32);
|
|
[r, g, b]
|
|
}
|
|
|
|
/// Every region of `group` shows the colour `before` had inside it, within `tol`/255: 1 where the
|
|
/// renderer composites once, 2 where it rounds a group's 8-bit pixels again (the regions' own
|
|
/// colours are exact).
|
|
fn assert_regions_match(s: &Session, before: &Rendered, group: u64, tol: u8, what: &str) {
|
|
let regions = regions(s, group);
|
|
assert!(!regions.is_empty(), "{what}: no regions");
|
|
for r in ®ions {
|
|
let Some(p) = interior(r) else { continue };
|
|
let (want, got) = (pixel(before, p), shown(r));
|
|
assert!(want.iter().zip(got).all(|(a, b)| a.abs_diff(b) <= tol), "{what}: at {p:?} the art showed {want:?}, the region has {got:?}");
|
|
}
|
|
}
|
|
|
|
/// Share of pixels whose colour moved by more than 8/255.
|
|
fn changed(a: &Rendered, b: &Rendered) -> f64 {
|
|
let n = a
|
|
.pixels
|
|
.as_chunks::<4>()
|
|
.0
|
|
.iter()
|
|
.zip(b.pixels.as_chunks::<4>().0)
|
|
.filter(|(p, q)| p.iter().zip(q.iter()).any(|(x, y)| x.abs_diff(*y) > 8))
|
|
.count();
|
|
n as f64 / (a.pixels.len() / 4) as f64
|
|
}
|
|
|
|
#[test]
|
|
fn two_half_transparent_rects_give_three_regions_with_their_pixels() {
|
|
let _settings = settings_lock();
|
|
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": [a, b], "opacity": 50}));
|
|
let before = picture(&s);
|
|
let (ids, vector, rasterized) = flatten(&mut s, json!({"ids": [a, b]}));
|
|
assert_eq!((ids.len(), vector, rasterized), (1, 3, 0));
|
|
assert!(!exists(&s, a) && !exists(&s, b), "the objects are replaced");
|
|
let rs = regions(&s, ids[0]);
|
|
assert_eq!(rs.len(), 3);
|
|
assert!(rs.iter().all(|r| r.opacity == 1.0 && r.has_default_transparency()), "regions are opaque");
|
|
assert_regions_match(&s, &before, ids[0], 1, "50% rects");
|
|
// Red alone over white is pink; blue over red over white is purple.
|
|
let at = |p: Point| rs.iter().find(|r| outline(r).winding(p) != 0).map(shown).unwrap();
|
|
assert_eq!(at(Point::new(30.0, 30.0)), [255, 128, 128]);
|
|
assert!(at(Point::new(85.0, 85.0)).iter().zip([128, 64, 191]).all(|(x, y)| x.abs_diff(y) <= 1), "{:?}", at(Point::new(85.0, 85.0)));
|
|
assert_eq!(s.doc().unwrap().selection.objects, vec![NodeId(ids[0])]);
|
|
}
|
|
|
|
#[test]
|
|
fn every_blend_mode_flattens_to_its_pixels() {
|
|
let _settings = settings_lock();
|
|
for mode in BlendMode::ALL {
|
|
// Normal at full opacity is no transparency: nothing to flatten.
|
|
for opacity in if mode == BlendMode::Normal { &[70][..] } else { &[100, 70][..] } {
|
|
let mut s = session();
|
|
let a = rect(&mut s, 10.0, 10.0, 100.0, 100.0, "#cc6633");
|
|
let b = rect(&mut s, 60.0, 60.0, 100.0, 100.0, "#4d99e6");
|
|
run(&mut s, "transparency.set", json!({"ids": [b], "blend": mode.label(), "opacity": opacity}));
|
|
let before = picture(&s);
|
|
let (ids, vector, _) = flatten(&mut s, json!({"ids": [a, b]}));
|
|
assert_eq!(vector, 3, "{mode:?}");
|
|
// The renderer rounds the blended source again before mixing it in by its opacity.
|
|
assert_regions_match(&s, &before, ids[0], 2, &format!("{mode:?} at {opacity}%"));
|
|
// The overlap is the reference formula's colour.
|
|
let [r, g, b] = [0xcc, 0x66, 0x33].map(|v| v as f32 / 255.0);
|
|
let src = [0x4d, 0x99, 0xe6].map(|v| v as f32 / 255.0);
|
|
let want = vectorcraft_color::blend::composite(mode, [r, g, b, 1.0], [src[0], src[1], src[2], *opacity as f32 / 100.0]);
|
|
let overlap = regions(&s, ids[0]).into_iter().find(|r| outline(r).winding(Point::new(85.0, 85.0)) != 0).unwrap();
|
|
let got = shown(&overlap);
|
|
assert!((0..3).all(|i| (want[i] * 255.0 - got[i] as f32).abs() <= 0.5 + 1e-3), "{mode:?} at {opacity}%: {got:?} vs {want:?}");
|
|
}
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn groups_flatten_as_they_composite() {
|
|
let _settings = settings_lock();
|
|
type Setup = fn(&mut Session, u64, u64, u64);
|
|
let cases: [(&str, Setup); 8] = [
|
|
("group with its own fill", |s, _, b, c| {
|
|
run(s, "select.set", json!({"ids": [b, c]}));
|
|
let g = run(s, "object.group", json!({}))["id"].clone();
|
|
run(s, "appearance.addFill", json!({"ids": [g]}));
|
|
run(s, "appearance.setItem", json!({"ids": [g], "index": 0, "color": "#ffff00", "opacity": 50, "blend": "Multiply"}));
|
|
}),
|
|
("knockout shape", |s, _, b, c| {
|
|
run(s, "transparency.set", json!({"ids": [b], "opacity": 50}));
|
|
run(s, "transparency.set", json!({"ids": [c], "opacity": 40, "knockoutShape": true}));
|
|
run(s, "select.set", json!({"ids": [b, c]}));
|
|
run(s, "object.group", json!({}));
|
|
run(s, "transparency.set", json!({"knockout": "on"}));
|
|
}),
|
|
("knockout group", |s, _, b, c| {
|
|
run(s, "transparency.set", json!({"ids": [b, c], "opacity": 50}));
|
|
run(s, "select.set", json!({"ids": [b, c]}));
|
|
run(s, "object.group", json!({}));
|
|
run(s, "transparency.set", json!({"knockout": "on"}));
|
|
}),
|
|
("non-isolated group with opacity", |s, _, b, c| {
|
|
run(s, "transparency.set", json!({"ids": [c], "blend": "Multiply"}));
|
|
run(s, "select.set", json!({"ids": [b, c]}));
|
|
run(s, "object.group", json!({}));
|
|
run(s, "transparency.set", json!({"opacity": 60}));
|
|
}),
|
|
("isolated group", |s, _, b, c| {
|
|
run(s, "transparency.set", json!({"ids": [c], "blend": "Difference"}));
|
|
run(s, "select.set", json!({"ids": [b, c]}));
|
|
run(s, "object.group", json!({}));
|
|
run(s, "transparency.set", json!({"opacity": 80, "isolate": true}));
|
|
}),
|
|
("blending group with blending content", |s, _, b, c| {
|
|
run(s, "transparency.set", json!({"ids": [c], "blend": "Multiply"}));
|
|
run(s, "select.set", json!({"ids": [b, c]}));
|
|
run(s, "object.group", json!({}));
|
|
run(s, "transparency.set", json!({"blend": "Screen", "opacity": 90}));
|
|
}),
|
|
("non-isolated knockout group", |s, _, b, c| {
|
|
run(s, "transparency.set", json!({"ids": [b], "opacity": 50}));
|
|
run(s, "transparency.set", json!({"ids": [c], "blend": "Multiply", "opacity": 80}));
|
|
run(s, "select.set", json!({"ids": [b, c]}));
|
|
run(s, "object.group", json!({}));
|
|
run(s, "transparency.set", json!({"knockout": "on"}));
|
|
}),
|
|
("clip group", |s, _, b, c| {
|
|
run(s, "transparency.set", json!({"ids": [b], "opacity": 50}));
|
|
let e = run(s, "shape.ellipse", json!({"x": 40, "y": 40, "width": 120, "height": 120}))["id"].clone();
|
|
run(s, "select.set", json!({"ids": [b, c, e]}));
|
|
run(s, "object.clippingMask.make", json!({}));
|
|
}),
|
|
];
|
|
for (what, setup) in cases {
|
|
let mut s = session();
|
|
let a = rect(&mut s, 0.0, 80.0, 200.0, 40.0, "#33aa55");
|
|
let b = rect(&mut s, 10.0, 10.0, 100.0, 100.0, "#e04020");
|
|
let c = rect(&mut s, 60.0, 60.0, 100.0, 100.0, "#2060e0");
|
|
setup(&mut s, a, b, c);
|
|
let before = picture(&s);
|
|
run(&mut s, "select.all", json!({}));
|
|
let (ids, vector, rasterized) = flatten(&mut s, json!({}));
|
|
assert!(ids.len() == 1 && vector >= 3 && rasterized == 0, "{what}: {ids:?} {vector} {rasterized}");
|
|
assert_regions_match(&s, &before, ids[0], 2, what);
|
|
assert!(changed(&before, &picture(&s)) < 0.02, "{what}: the look changed");
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn balance_trades_vector_regions_for_an_image() {
|
|
let _settings = settings_lock();
|
|
let setup = || {
|
|
let mut s = session();
|
|
let a = rect(&mut s, 10.0, 10.0, 100.0, 100.0, "#ff8000");
|
|
let b = rect(&mut s, 60.0, 60.0, 100.0, 100.0, "#0080ff");
|
|
run(&mut s, "transparency.set", json!({"ids": [b], "opacity": 50, "blend": "Multiply"}));
|
|
(s, a, b)
|
|
};
|
|
let (mut s, a, b) = setup();
|
|
assert_eq!(flatten(&mut s, json!({"ids": [a, b], "balance": 100})).1, 3);
|
|
let (mut s, a, b) = setup();
|
|
let before = picture(&s);
|
|
let (ids, vector, rasterized) = flatten(&mut s, json!({"ids": [a, b], "balance": 0, "clipComplexRegions": true, "lineArtPpi": 144}));
|
|
assert_eq!((vector, rasterized), (0, 1));
|
|
let g = node(&s, ids[0]);
|
|
let clip = &g.children().unwrap()[0];
|
|
assert!(clip.clips(), "clipped to the regions");
|
|
let NodeKind::Image(im) = &clip.children().unwrap()[1].kind else { panic!("an image") };
|
|
assert!(im.width >= 300, "at the line art resolution: {}", im.width);
|
|
assert!(changed(&before, &picture(&s)) < 0.02);
|
|
// Unclipped, the image is a plain rectangle.
|
|
let (mut s, a, b) = setup();
|
|
let (ids, ..) = flatten(&mut s, json!({"ids": [a, b], "balance": 0, "clipComplexRegions": false}));
|
|
assert!(matches!(node(&s, ids[0]).children().unwrap()[0].kind, NodeKind::Image(_)));
|
|
assert!(changed(&before, &picture(&s)) < 0.02);
|
|
// In between, a group splitting into more regions than the balance allows is rasterized.
|
|
let (mut s, a, b) = setup();
|
|
assert_eq!(flatten(&mut s, json!({"ids": [a, b], "balance": 5})).2, 1);
|
|
}
|
|
|
|
#[test]
|
|
fn gradients_rasterize_and_flat_areas_stay_vector() {
|
|
let _settings = settings_lock();
|
|
for clip in [true, false] {
|
|
let mut s = session();
|
|
let g = rect(&mut s, 10.0, 10.0, 100.0, 100.0, "#000000");
|
|
run(
|
|
&mut s,
|
|
"paint.setFill",
|
|
json!({"ids": [g], "gradient": {"kind": "linear", "stops": [{"offset": 0, "color": "#ff0000"}, {"offset": 1, "color": "#0000ff"}]}}),
|
|
);
|
|
let t = rect(&mut s, 80.0, 80.0, 100.0, 100.0, "#20c040");
|
|
run(&mut s, "transparency.set", json!({"ids": [t], "opacity": 50}));
|
|
let before = picture(&s);
|
|
let (ids, vector, rasterized) = flatten(&mut s, json!({"ids": [g, t], "preset": "high", "clipComplexRegions": clip}));
|
|
assert_eq!(rasterized, 1, "clip {clip}");
|
|
assert!(vector >= 1, "the green alone stays vector (clip {clip})");
|
|
assert_regions_match(&s, &before, ids[0], 1, "gradient");
|
|
assert!(changed(&before, &picture(&s)) < 0.02, "clip {clip}");
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn flatten_is_one_undo_step() {
|
|
let _settings = settings_lock();
|
|
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}));
|
|
let steps = s.doc().unwrap().history.undo.len();
|
|
let doc = s.doc().unwrap().doc.clone();
|
|
flatten(&mut s, json!({"ids": [a, b]}));
|
|
assert_eq!(s.doc().unwrap().history.undo.len(), steps + 1);
|
|
assert_eq!(s.doc().unwrap().history.undo.last().unwrap().label, "Flatten Transparency");
|
|
run(&mut s, "edit.undo", json!({}));
|
|
assert_eq!(s.doc().unwrap().doc.layers, doc.layers);
|
|
run(&mut s, "edit.redo", json!({}));
|
|
assert!(!exists(&s, a));
|
|
}
|
|
|
|
/// Flattening a document for export in which two objects have the same id (a damaged file)
|
|
/// removes both objects and puts one group in their place.
|
|
#[test]
|
|
fn flattening_objects_that_share_an_id_removes_both() {
|
|
let _settings = settings_lock();
|
|
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": [a, b], "opacity": 50}));
|
|
let mut doc = (*s.doc().unwrap().doc).clone();
|
|
doc.node_mut(NodeId(b)).unwrap().id = NodeId(a);
|
|
let flat = crate::cmd::flatten::flatten_document(&doc, &crate::cmd::FlattenOptions::default()).unwrap().unwrap();
|
|
let art = flat.layers[0].children().unwrap();
|
|
assert_eq!(art.len(), 1, "one group where the two objects were");
|
|
assert!(matches!(art[0].kind, NodeKind::Group { .. }) && art[0].id != NodeId(a));
|
|
}
|
|
|
|
#[test]
|
|
fn objects_without_transparency_stay_unless_outlined() {
|
|
let _settings = settings_lock();
|
|
let mut s = session();
|
|
let a = rect(&mut s, 10.0, 10.0, 50.0, 50.0, "#ff0000");
|
|
run(&mut s, "transparency.set", json!({"ids": [a], "opacity": 50}));
|
|
let far = rect(&mut s, 120.0, 120.0, 50.0, 50.0, "#00ff00");
|
|
run(&mut s, "paint.setStroke", json!({"color": "#000000", "ids": [far]}));
|
|
let text = run(&mut s, "text.create", json!({"x": 10, "y": 150, "text": "Hi", "size": 24}))["id"].as_u64().unwrap();
|
|
// Nothing transparent: nothing happens.
|
|
let steps = s.doc().unwrap().history.undo.len();
|
|
let (ids, vector, rasterized) = flatten(&mut s, json!({"ids": [far, text]}));
|
|
assert_eq!((ids, vector, rasterized, s.doc().unwrap().history.undo.len()), (vec![far, text], 0, 0, steps));
|
|
// The transparent rectangle is flattened; the others keep their ids.
|
|
let (ids, vector, _) = flatten(&mut s, json!({"ids": [a, far, text]}));
|
|
assert_eq!(vector, 1);
|
|
assert!(exists(&s, far) && exists(&s, text) && !exists(&s, a) && ids.contains(&far) && ids.contains(&text));
|
|
// On request, type and strokes are outlined too.
|
|
let (ids, ..) = flatten(&mut s, json!({"ids": [far, text], "textToOutlines": true, "strokesToOutlines": true}));
|
|
assert!(!exists(&s, far) && !exists(&s, text) && ids.len() == 2);
|
|
for id in ids {
|
|
node(&s, id).walk(&mut |c| {
|
|
assert!(!matches!(c.kind, NodeKind::Text(_)), "type is outlined");
|
|
assert!(c.appearance.stroke().is_none_or(|st| st.paint.is_none()), "strokes are outlined");
|
|
});
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn outlined_type_keeps_its_underline() {
|
|
let _settings = settings_lock();
|
|
let mut s = session();
|
|
let text = run(&mut s, "text.create", json!({"x": 10, "y": 150, "text": "Hi", "size": 24}))["id"].as_u64().unwrap();
|
|
run(&mut s, "text.setFormat", json!({"ids": [text], "underline": true}));
|
|
let (ids, ..) = flatten(&mut s, json!({"ids": [text], "textToOutlines": true}));
|
|
// "Hi" has no ink below its baseline (y 150): the outlines reach the bar under it.
|
|
let b = node(&s, ids[0]).geometric_bounds().unwrap();
|
|
assert!(b.y1 > 151.0, "{b:?}");
|
|
}
|
|
|
|
#[test]
|
|
fn outlined_type_keeps_its_gradient_on_the_outlines_its_transform_effect_moves() {
|
|
let _settings = settings_lock();
|
|
// "IIII" at 80 pt with its baseline at (x, y), its characters filled with a red-to-blue gradient
|
|
// that runs left to right, with a Transform effect of `params` if any.
|
|
let typed = |x: f64, y: f64, params: Option<Value>| {
|
|
let mut s = session();
|
|
let t = run(&mut s, "text.create", json!({"x": x, "y": y, "text": "IIII", "size": 80}))["id"].as_u64().unwrap();
|
|
let stops = json!([{"offset": 0, "color": "#ff0000"}, {"offset": 1, "color": "#0000ff"}]);
|
|
run(&mut s, "paint.setFill", json!({"ids": [t], "gradient": {"kind": "linear", "stops": stops}}));
|
|
if let Some(params) = params {
|
|
run(&mut s, "effect.apply", json!({"effect": "distort.transform", "params": params, "ids": [t], "item": null}));
|
|
}
|
|
(s, t)
|
|
};
|
|
let mut diffs = vec![];
|
|
// The effect moves the type 30 pt right (along the gradient) or 20 pt down (across it). Type
|
|
// laid out at the moved baseline is the reference.
|
|
for (dx, dy) in [(30.0, 0.0), (0.0, 20.0)] {
|
|
let reference = picture(&typed(10.0 + dx, 150.0 + dy, None).0);
|
|
let (mut s, t) = typed(10.0, 150.0, Some(json!({"moveH": dx, "moveV": dy})));
|
|
flatten(&mut s, json!({"ids": [t], "textToOutlines": true}));
|
|
diffs.push(changed(&picture(&s), &reference));
|
|
// The outlines draw the same after the document is saved and opened again.
|
|
let back = vectorcraft_format::load(&vectorcraft_format::save(&s.doc().unwrap().doc, false)).unwrap();
|
|
diffs.push(changed(&Renderer::new().render_region(&back, Rect::new(0.0, 0.0, 200.0, 200.0), 1.0, true), &reference));
|
|
}
|
|
assert!(diffs.iter().all(|&d| d < 0.0002), "shares of the pixels that changed: {diffs:?}");
|
|
}
|
|
|
|
#[test]
|
|
fn preserve_alpha_composites_over_nothing() {
|
|
let _settings = settings_lock();
|
|
let mut s = session();
|
|
let a = rect(&mut s, 10.0, 10.0, 100.0, 100.0, "#ff0000");
|
|
run(&mut s, "transparency.set", json!({"ids": [a], "opacity": 50}));
|
|
let before = picture(&s);
|
|
let (ids, ..) = flatten(&mut s, json!({"ids": [a], "preserveAlpha": true}));
|
|
let r = ®ions(&s, ids[0])[0];
|
|
assert_eq!((r.opacity, r.appearance.fill_paint()), (0.5, Paint::solid(Color::rgb(1.0, 0.0, 0.0))));
|
|
assert_regions_match(&s, &before, ids[0], 1, "preserve alpha");
|
|
// Over white by default: opaque pink.
|
|
run(&mut s, "edit.undo", json!({}));
|
|
let (ids, ..) = flatten(&mut s, json!({"ids": [a]}));
|
|
let r = ®ions(&s, ids[0])[0];
|
|
assert_eq!((r.opacity, shown(r)), (1.0, [255, 128, 128]));
|
|
}
|
|
|
|
#[test]
|
|
fn single_paints_keep_their_colour_and_overprint() {
|
|
let _settings = settings_lock();
|
|
let mut s = session();
|
|
let a = rect(&mut s, 10.0, 10.0, 100.0, 100.0, "#000000");
|
|
run(&mut s, "paint.setFill", json!({"ids": [a], "color": {"c": 0, "m": 100, "y": 50, "k": 0}}));
|
|
run(&mut s, "object.setOverprint", json!({"ids": [a], "fill": true}));
|
|
let b = rect(&mut s, 60.0, 60.0, 100.0, 100.0, "#0000ff");
|
|
run(&mut s, "transparency.set", json!({"ids": [b], "opacity": 50}));
|
|
let cmyk_region = |s: &Session, ids: &[u64]| regions(s, ids[0]).into_iter().find(|r| outline(r).winding(Point::new(30.0, 30.0)) != 0).unwrap();
|
|
let (ids, ..) = flatten(&mut s, json!({"ids": [a, b]}));
|
|
let r = cmyk_region(&s, &ids);
|
|
let AppearanceItem::Fill(f) = &r.appearance.items[0] else { panic!() };
|
|
assert!(matches!(f.paint.color(), Some(Color::Cmyk { .. })) && f.overprint, "{:?}", f);
|
|
run(&mut s, "edit.undo", json!({}));
|
|
let (ids, ..) = flatten(&mut s, json!({"ids": [a, b], "preserveOverprints": false}));
|
|
let r = cmyk_region(&s, &ids);
|
|
let AppearanceItem::Fill(f) = &r.appearance.items[0] else { panic!() };
|
|
assert!(matches!(f.paint.color(), Some(Color::Rgb { .. })) && !f.overprint);
|
|
}
|
|
|
|
#[test]
|
|
fn options_come_from_presets_and_params() {
|
|
let _settings = settings_lock();
|
|
use crate::cmd::FlattenOptions;
|
|
let high = FlattenOptions::preset("High Resolution").unwrap();
|
|
assert_eq!((high.balance, high.line_art_ppi, high.clip_complex_regions), (100.0, 1200.0, true));
|
|
assert_eq!(FlattenOptions::default(), FlattenOptions::preset("medium").unwrap());
|
|
assert_ne!(FlattenOptions::preset("low"), FlattenOptions::preset("medium"));
|
|
let o = FlattenOptions::from_params(&json!({"preset": "low", "balance": 40, "options": {"antiAlias": false}})).unwrap();
|
|
assert_eq!((o.balance, o.gradient_ppi, o.anti_alias), (40.0, 150.0, false));
|
|
for bad in [json!({"preset": "ultra"}), json!({"balance": 120}), json!({"lineArtPpi": 0}), json!({"balance": "lots"})] {
|
|
assert!(FlattenOptions::from_params(&bad).is_err(), "{bad}");
|
|
}
|
|
let mut s = session();
|
|
let a = rect(&mut s, 10.0, 10.0, 50.0, 50.0, "#ff0000");
|
|
run(&mut s, "transparency.set", json!({"ids": [a], "opacity": 50}));
|
|
assert!(s.execute("object.flattenTransparency", &json!({"ids": [a], "balance": -1})).is_err());
|
|
run(&mut s, "select.none", json!({}));
|
|
assert!(s.execute("object.flattenTransparency", &json!({})).is_err(), "nothing to flatten");
|
|
let r = run(&mut s, "object.flattenTransparency", json!({"ids": [a], "preset": "high"}));
|
|
assert_eq!(r["options"]["lineArtPpi"], 1200.0);
|
|
}
|
|
|
|
#[test]
|
|
fn a_mixed_scene_looks_the_same_flattened() {
|
|
let _settings = settings_lock();
|
|
for preset in FlattenPresets::ALL {
|
|
let mut s = session();
|
|
rect(&mut s, 0.0, 0.0, 200.0, 200.0, "#ffcc00");
|
|
let r = rect(&mut s, 30.0, 30.0, 80.0, 80.0, "#3355cc");
|
|
run(&mut s, "paint.setStroke", json!({"color": "#000000", "ids": [r]}));
|
|
run(&mut s, "stroke.set", json!({"weight": 8, "ids": [r]}));
|
|
run(&mut s, "transparency.set", json!({"ids": [r], "opacity": 60}));
|
|
let t = run(&mut s, "text.create", json!({"x": 40, "y": 170, "text": "Hi", "size": 48, "color": "#e01010"}))["id"].clone();
|
|
run(&mut s, "transparency.set", json!({"ids": [t], "opacity": 70, "blend": "Multiply"}));
|
|
let e = run(&mut s, "shape.ellipse", json!({"x": 120, "y": 40, "width": 60, "height": 60}))["id"].clone();
|
|
run(&mut s, "paint.setFill", json!({"color": "#20a040", "ids": [e]}));
|
|
run(&mut s, "effect.apply", json!({"effect": "stylize.dropShadow", "ids": [e]}));
|
|
let m = rect(&mut s, 130.0, 130.0, 40.0, 40.0, "#8040c0");
|
|
run(&mut s, "transparency.set", json!({"ids": [m], "opacity": 50}));
|
|
run(&mut s, "select.set", json!({"ids": [m]}));
|
|
let sym = run(&mut s, "symbol.new", json!({"name": "Half"}))["id"].as_u64().unwrap();
|
|
assert!(matches!(node(&s, sym).kind, NodeKind::SymbolInstance { .. }));
|
|
let before = picture(&s);
|
|
run(&mut s, "select.all", json!({}));
|
|
let (ids, vector, rasterized) = flatten(&mut s, json!({"preset": preset, "lineArtPpi": 216, "gradientPpi": 144}));
|
|
assert!(ids.len() == 1 && rasterized == 1, "{preset}: the shadow is rasterized ({ids:?}, {rasterized})");
|
|
assert!(preset == "low" || vector > 4, "{preset}: {vector} regions");
|
|
let mut kinds = vec![];
|
|
node(&s, ids[0]).walk(&mut |c| kinds.push(c.kind_label()));
|
|
assert!(!kinds.iter().any(|k| ["Type", "Symbol"].contains(k)), "{preset}: {kinds:?}");
|
|
let diff = changed(&before, &picture(&s));
|
|
assert!(diff < 0.03, "{preset}: {:.1}% of the pixels changed", diff * 100.0);
|
|
}
|
|
}
|
|
|
|
struct FlattenPresets;
|
|
impl FlattenPresets {
|
|
const ALL: [&'static str; 3] = crate::cmd::FlattenOptions::PRESETS;
|
|
}
|
|
|
|
#[test]
|
|
fn opacity_masks_are_rasterized_with_their_look() {
|
|
let _settings = settings_lock();
|
|
let mut s = session();
|
|
rect(&mut s, 0.0, 0.0, 200.0, 200.0, "#ffffff");
|
|
let a = rect(&mut s, 20.0, 20.0, 160.0, 160.0, "#d02060");
|
|
let m = rect(&mut s, 40.0, 40.0, 120.0, 120.0, "#000000");
|
|
run(
|
|
&mut s,
|
|
"paint.setFill",
|
|
json!({"ids": [m], "gradient": {"kind": "linear", "stops": [{"offset": 0, "color": "#ffffff"}, {"offset": 1, "color": "#000000"}]}}),
|
|
);
|
|
run(&mut s, "transparency.makeOpacityMask", json!({"ids": [a, m]}));
|
|
assert!(node(&s, a).extras().mask.is_some());
|
|
let before = picture(&s);
|
|
run(&mut s, "select.all", json!({}));
|
|
let (ids, _, rasterized) = flatten(&mut s, json!({"lineArtPpi": 144}));
|
|
assert_eq!((ids.len(), rasterized), (1, 1));
|
|
let mut masks = 0;
|
|
node(&s, ids[0]).walk(&mut |c| masks += c.extras().mask.is_some() as usize);
|
|
assert_eq!(masks, 0, "no transparency is left");
|
|
assert!(changed(&before, &picture(&s)) < 0.02);
|
|
}
|