mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-11-14 15:57:49 +00:00
Merge main into feat/smooth-cursor-without-path
This commit is contained in:
@@ -123,6 +123,7 @@ Ranked; detail in [docs/roadmap.md](docs/roadmap.md) and [docs/gaps.md](docs/gap
|
||||
|
||||
- 2026-11-05: Window › Toolbars › Keep Tools Beside the Canvas, off by default, docks the Tools panel between left-side panels and the canvas instead of at the window's left edge, and the canvas's left edge then also docks a floating Tools panel (`window.toolsBesideCanvas`, #5395). Workspaces keep the setting; workspaces saved without it keep Tools at the window's edge. An optional VectorCraft layout choice; headline numbers unchanged.
|
||||
- 2026-11-12: Stencil Bridges in the Cutting Tools add-on (Object › Plug-ins › Cutting Tools): strips of a set width are cut out of the selected art so that every island of a stencil cut from it (the inside of an O, a hole in a shape) stays attached to the sheet, by the shortest crossings first, vertical, horizontal or the shortest among 12 directions 15° apart, with up to 16 bridges per island; type is measured by its glyph outlines and stays type, and the original goes to a hidden layer or is replaced. One undo step (`addon.cutting.bridges`). A VectorCraft addition: headline numbers unchanged.
|
||||
- 2026-11-13: Banner Hems and Grommets in the Cutting Tools add-on (Object › Plug-ins › Cutting Tools): on the chosen sides of the selected art's bounds, or of each artboard, a hem line at a set depth inside the edge and grommet marks (a circle with a cross) at a set inset, one at each end of a side and the rest spread evenly at most a set spacing apart, stroked in a chosen swatch; a dialog with Preview, one undo step (`addon.cutting.banner`). A VectorCraft addition: headline numbers unchanged.
|
||||
- 2026-11-05: Release Type on a Path joins the Type Tools add-on (Object › Plug-ins › Type Tools): each selected type on a path splits into point type with the same text and attributes, on one straight line starting where its first character sat, and the plain path it was set on, with its exact shape and place in the stacking order and no paint; or keeps only the path or only the text, optionally leaving the original. A dialog with Preview, one undo step (`addon.type.releasePath`, #5532). A VectorCraft addition: headline numbers unchanged.
|
||||
- 2026-11-04: New Document: the units chosen in Preset Details now belong to the chosen document, as the pane's options are listed for the selected preset; the chosen card shows them, every other Recent and Saved card keeps the units its document or preset was made in, and the built-in tabs still follow the chosen units, so switching between inches and millimetres no longer restyles every recent template. Scores and estimates unchanged.
|
||||
- 2026-11-02: Mouse wheel over a closed font-family field in Character, Properties or Control changes selected text through the family list using saved font filters (excluding search text), one undo step per notch, stopping at the ends (#5253). Scores and estimates unchanged.
|
||||
|
||||
@@ -166,6 +166,16 @@ pub struct LabelStyles {
|
||||
/// The anti-aliasing method the legend labels keep once set. `None` draws them Sharp.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub legend_anti_alias: Option<crate::text::TypeAntiAlias>,
|
||||
/// The object fill and stroke rows the value axis numbers keep once set with the Appearance controls ([`LabelRows`]).
|
||||
/// `None`: no rows, opaque and Normal.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub values_rows: Option<LabelRows>,
|
||||
/// The object fill and stroke rows the category labels keep, as [`Self::values_rows`].
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub categories_rows: Option<LabelRows>,
|
||||
/// The object fill and stroke rows the legend labels keep, as [`Self::values_rows`].
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub legend_rows: Option<LabelRows>,
|
||||
}
|
||||
|
||||
impl LabelStyles {
|
||||
@@ -355,6 +365,22 @@ pub struct SeriesPaint {
|
||||
pub blend: Option<vectorcraft_color::BlendMode>,
|
||||
}
|
||||
|
||||
/// The object rows one kind of graph label keeps ([`LabelStyles::values_rows`]): the paint, weight, opacity and blend mode
|
||||
/// of a label's topmost visible fill and stroke row (an absent paint: no row of that kind), the label's object opacity and
|
||||
/// blend mode, and where the two rows paint relative to each other and to the characters.
|
||||
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(default, rename_all = "camelCase")]
|
||||
pub struct LabelRows {
|
||||
#[serde(flatten)]
|
||||
pub paint: SeriesPaint,
|
||||
/// How many of the kept rows paint below the characters, counted from the bottom. Absent: none.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub below: Option<u8>,
|
||||
/// The stroke row paints below the fill row.
|
||||
#[serde(skip_serializing_if = "std::ops::Not::not")]
|
||||
pub stroke_under: bool,
|
||||
}
|
||||
|
||||
/// Graph data and options. `rows` are categories (one per row of the Graph Data window), each
|
||||
/// holding one value per series; `series` are the legend labels (the first row of the window).
|
||||
/// Scatter graphs read each series as (y, x) column pairs, like Illustrator.
|
||||
|
||||
@@ -92,8 +92,8 @@ pub use compound_shape::ShapeMode;
|
||||
pub use corners::LiveCorners;
|
||||
pub use depth::{MAX_DEPTH, MAX_SYMBOL_NODES};
|
||||
pub use graph::{
|
||||
ColumnDesign, ColumnType, Fractions, GraphDesign, GraphKind, GraphSpec, LabelStyles, MAX_GRAPH_CATEGORIES, MAX_GRAPH_SERIES, PiePosition,
|
||||
PieSort, SeriesPaint, TickLength, ValueAxisSide,
|
||||
ColumnDesign, ColumnType, Fractions, GraphDesign, GraphKind, GraphSpec, LabelRows, LabelStyles, MAX_GRAPH_CATEGORIES, MAX_GRAPH_SERIES,
|
||||
PiePosition, PieSort, SeriesPaint, TickLength, ValueAxisSide,
|
||||
};
|
||||
pub use hit::{Hit, HitKind};
|
||||
pub use links::{LinkInfo, PlacementOptions};
|
||||
|
||||
@@ -388,7 +388,9 @@ pub fn graph_paints(n: &Node) -> impl Iterator<Item = &Paint> {
|
||||
let g = n.extras().graph.as_deref();
|
||||
let series = g.into_iter().flat_map(|g| &g.series_paints).flat_map(|s| [s.fill.as_ref(), s.stroke.as_ref()]).flatten();
|
||||
let labels = g.into_iter().flat_map(|g| [&g.label_styles.values, &g.label_styles.categories, &g.label_styles.legend]).flatten();
|
||||
series.chain(labels.flat_map(|st| [&st.fill, &st.stroke]))
|
||||
let l = g.map(|g| &g.label_styles);
|
||||
let rows = l.into_iter().flat_map(|l| [&l.values_rows, &l.categories_rows, &l.legend_rows]).flatten();
|
||||
series.chain(labels.flat_map(|st| [&st.fill, &st.stroke])).chain(rows.flat_map(|r| [r.paint.fill.as_ref(), r.paint.stroke.as_ref()]).flatten())
|
||||
}
|
||||
|
||||
/// Mutable [`graph_paints`] of graph `g`, in the same order.
|
||||
@@ -396,7 +398,10 @@ fn graph_paints_mut(g: &mut crate::graph::GraphSpec) -> impl Iterator<Item = &mu
|
||||
let series = g.series_paints.iter_mut().flat_map(|s| [s.fill.as_mut(), s.stroke.as_mut()]).flatten();
|
||||
let l = &mut g.label_styles;
|
||||
let labels = [&mut l.values, &mut l.categories, &mut l.legend].into_iter().flatten();
|
||||
series.chain(labels.flat_map(|st| [&mut st.fill, &mut st.stroke]))
|
||||
let rows = [&mut l.values_rows, &mut l.categories_rows, &mut l.legend_rows].into_iter().flatten();
|
||||
series
|
||||
.chain(labels.flat_map(|st| [&mut st.fill, &mut st.stroke]))
|
||||
.chain(rows.flat_map(|r| [r.paint.fill.as_mut(), r.paint.stroke.as_mut()]).flatten())
|
||||
}
|
||||
|
||||
/// Mutable [`node_paints`], in the same order.
|
||||
@@ -689,6 +694,10 @@ mod tests {
|
||||
series_paints: vec![crate::SeriesPaint { fill: Some(red.clone()), stroke: Some(red.clone()), ..Default::default() }],
|
||||
label_styles: crate::graph::LabelStyles {
|
||||
legend: Some(crate::CharStyle { fill: red.clone(), stroke: Paint::None, ..Default::default() }),
|
||||
values_rows: Some(crate::LabelRows {
|
||||
paint: crate::SeriesPaint { fill: Some(red.clone()), ..Default::default() },
|
||||
..Default::default()
|
||||
}),
|
||||
..Default::default()
|
||||
},
|
||||
..Default::default()
|
||||
@@ -696,17 +705,18 @@ mod tests {
|
||||
d.node_mut(a).unwrap().extras_mut().graph = Some(Box::new(spec));
|
||||
let mut seen = 0;
|
||||
d.visit_paints(&mut |p| seen += usize::from(*p == red));
|
||||
assert_eq!(seen, 4, "the fill and the graph's series fill, series stroke and legend fill");
|
||||
assert_eq!(seen, 5, "the fill and the graph's series fill, series stroke, legend fill and value label fill row");
|
||||
let blue = Color::rgb(0.0, 0.0, 1.0);
|
||||
let mut to_blue = |c: &mut Color, s: &mut Option<String>, _: &mut f32| s.as_deref() == Some("Brand") && std::mem::replace(c, blue) != blue;
|
||||
assert_eq!(d.map_solid_paints(&mut to_blue), 4);
|
||||
assert_eq!(d.map_solid_paints(&mut to_blue), 5);
|
||||
let g = d.node(a).unwrap().extras().graph.clone().unwrap();
|
||||
assert_eq!(g.series_paints[0].fill, Some(linked(blue, "Brand")));
|
||||
assert_eq!(g.series_paints[0].stroke, Some(linked(blue, "Brand")));
|
||||
let legend = g.label_styles.legend.unwrap();
|
||||
assert_eq!((legend.fill, legend.stroke), (linked(blue, "Brand"), Paint::None));
|
||||
assert_eq!(g.label_styles.values_rows.unwrap().paint.fill, Some(linked(blue, "Brand")));
|
||||
let mut cleared = |p: &Paint| (*p == linked(blue, "Brand")).then_some(Paint::None);
|
||||
assert_eq!(d.map_paints(&mut cleared), 4, "the fill and the graph's three");
|
||||
assert_eq!(d.map_paints(&mut cleared), 5, "the fill and the graph's four");
|
||||
assert!(graph_paints(d.node(a).unwrap()).all(Paint::is_none));
|
||||
}
|
||||
|
||||
|
||||
@@ -209,6 +209,19 @@ pub(crate) fn solid_paints(n: &Node) -> Vec<AppearanceItem> {
|
||||
runs
|
||||
}
|
||||
|
||||
/// [`solid_paints`] with, for each paint, the item of `n` it copies: the item a live-effect plug-in
|
||||
/// before the paint's own 3D effect is told it sits on. The paints of a type's first run copy no
|
||||
/// item of `n` and have none.
|
||||
pub(crate) fn solid_paint_sources(n: &Node) -> (Vec<Option<&AppearanceItem>>, Vec<AppearanceItem>) {
|
||||
let own: Vec<&AppearanceItem> = n.appearance.items.iter().filter(|i| crate::paints(i)).collect();
|
||||
if own.is_empty() {
|
||||
let paints = solid_paints(n);
|
||||
return (vec![None; paints.len()], paints);
|
||||
}
|
||||
let paints = own.iter().map(|i| (*i).clone()).collect();
|
||||
(own.into_iter().map(Some).collect(), paints)
|
||||
}
|
||||
|
||||
fn facets(id: &str, path: &PathData, rule: FillRule, bounds: Rect, p: &Value) -> Result<Vec<Facet>, String> {
|
||||
let view = view_options(p);
|
||||
match id {
|
||||
@@ -1771,7 +1784,7 @@ pub fn solid_art_in(doc: Option<&Document>, n: &Node) -> Option<Node> {
|
||||
}
|
||||
let (path, rule) = profile(n)?;
|
||||
let ctx = GeomContext::of(n);
|
||||
let paints = solid_paints(n);
|
||||
let (sources, paints) = solid_paint_sources(n);
|
||||
let mut children = vec![];
|
||||
let remaining: Vec<Effect>;
|
||||
if let Some((index, fx)) = solid(&n.appearance.effects) {
|
||||
@@ -1789,9 +1802,9 @@ pub fn solid_art_in(doc: Option<&Document>, n: &Node) -> Option<Node> {
|
||||
crate::raster::follow_paints(&mut paints, &n.appearance.effects);
|
||||
// Every item's shadow goes behind every item's art.
|
||||
let mut shadows = vec![];
|
||||
for item in &paints {
|
||||
for (source, item) in sources.into_iter().zip(&paints) {
|
||||
if let Some((index, fx)) = solid(item.effects()) {
|
||||
let p = geometry(&path, item.effects().get(..index)?, &ctx.item(item));
|
||||
let p = geometry(&path, item.effects().get(..index)?, &ctx.item(source.unwrap_or(item)));
|
||||
let (shadow, surfaces) = art_for(doc, n, fx, &[Profile { path: p, rule, items: vec![item.clone()], opacity: 1.0, scopes: vec![] }])?;
|
||||
let later: Vec<Effect> = item.effects().iter().skip(index + 1).filter(|e| !is_three_d(&e.id)).cloned().collect();
|
||||
let with_later = |mut art: Arc<Node>| {
|
||||
@@ -2086,16 +2099,16 @@ fn meshes_within(doc: Option<&Document>, n: &Node, total: &mut usize, limit: usi
|
||||
} else {
|
||||
let (path, rule) = object_profile(n, label)?;
|
||||
let ctx = GeomContext::of(n);
|
||||
let paints = solid_paints(n);
|
||||
let (sources, paints) = solid_paint_sources(n);
|
||||
if let Some((index, fx)) = solid(&n.appearance.effects) {
|
||||
let path = geometry(&path, n.appearance.effects.get(..index).unwrap_or_default(), &ctx);
|
||||
meshes_for(fx, &[Profile { path, rule, items: paints, opacity: 1.0, scopes: vec![] }], total, limit)?
|
||||
} else {
|
||||
let path = geometry(&path, &n.appearance.effects, &ctx);
|
||||
let mut out = vec![];
|
||||
for item in &paints {
|
||||
for (source, item) in sources.into_iter().zip(&paints) {
|
||||
if let Some((index, fx)) = solid(item.effects()) {
|
||||
let p = geometry(&path, item.effects().get(..index).unwrap_or_default(), &ctx.item(item));
|
||||
let p = geometry(&path, item.effects().get(..index).unwrap_or_default(), &ctx.item(source.unwrap_or(item)));
|
||||
out.extend(meshes_for(fx, &[Profile { path: p, rule, items: vec![item.clone()], opacity: 1.0, scopes: vec![] }], total, limit)?);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,7 +10,7 @@ use vectorcraft_three_d::Facet;
|
||||
|
||||
use super::{
|
||||
Profile, ROTATE, built_solids, facets, geometry, has_solid, is_container, is_plane, is_three_d, label_of, members, object_profile, region,
|
||||
reshape_members, resolve, resolve_for, solid, solid_paints, solid_parts, union_bounds,
|
||||
reshape_members, resolve, resolve_for, solid, solid_paint_sources, solid_parts, union_bounds,
|
||||
};
|
||||
use crate::{GeomContext, is_geometry};
|
||||
|
||||
@@ -105,16 +105,16 @@ fn solid_face_paths(doc: Option<&Document>, n: &Node) -> Result<Vec<PathData>, S
|
||||
let label = n.appearance.effects.iter().find(|e| e.visible && is_three_d(&e.id)).map_or("This effect", |e| label_of(&e.id));
|
||||
let (path, rule) = object_profile(n, label)?;
|
||||
let ctx = GeomContext::of(n);
|
||||
let paints = solid_paints(n);
|
||||
let (sources, paints) = solid_paint_sources(n);
|
||||
if let Some((index, fx)) = solid(&n.appearance.effects) {
|
||||
let path = geometry(&path, n.appearance.effects.get(..index).unwrap_or_default(), &ctx);
|
||||
return facet_paths(fx, &[Profile { path, rule, items: paints, opacity: 1.0, scopes: vec![] }]);
|
||||
}
|
||||
let path = geometry(&path, &n.appearance.effects, &ctx);
|
||||
let mut out = vec![];
|
||||
for item in &paints {
|
||||
for (source, item) in sources.into_iter().zip(&paints) {
|
||||
if let Some((index, fx)) = solid(item.effects()) {
|
||||
let p = geometry(&path, item.effects().get(..index).unwrap_or_default(), &ctx.item(item));
|
||||
let p = geometry(&path, item.effects().get(..index).unwrap_or_default(), &ctx.item(source.unwrap_or(item)));
|
||||
let faces = facet_paths(fx, &[Profile { path: p, rule, items: vec![item.clone()], opacity: 1.0, scopes: vec![] }])?;
|
||||
out.extend(reshaped(faces, item.effects().get(index + 1..).unwrap_or_default(), &GeomContext { doc, ..ctx }));
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@ mod imageoutline;
|
||||
mod painted;
|
||||
mod rasterfollow;
|
||||
mod scale;
|
||||
mod solidcontext;
|
||||
mod transformfx;
|
||||
|
||||
fn fx(id: &str, params: Value) -> Effect {
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
//! A live-effect plug-in before a fill's or stroke's own 3D effect is told which fill or stroke it
|
||||
//! is on, in the drawn art, the exported meshes and the wireframe.
|
||||
|
||||
use serde_json::json;
|
||||
use vectorcraft_color::{Color, Paint};
|
||||
use vectorcraft_doc::{Appearance, AppearanceItem, Effect, FillLayer, Node, NodeId};
|
||||
use vectorcraft_geom::{PathData, Rect, shapes};
|
||||
use vectorcraft_plugins::wat as tpl;
|
||||
|
||||
use crate::{EXTRUDE, solid_art, three_d_meshes_in, three_d_wireframe_in};
|
||||
|
||||
/// Widths the plug-in's squares are drawn at: `LARGE` when it is told it is on the item it looks
|
||||
/// for, `SMALL` otherwise.
|
||||
const LARGE: f64 = 600.0;
|
||||
const SMALL: f64 = 6.0;
|
||||
|
||||
/// A rectangle with a fill, a 1 pt stroke and a second fill on top. The item at `at` (an index in
|
||||
/// its appearance items) has, in its own effects, live-effect plug-in `name` and then Extrude &
|
||||
/// Bevel. The plug-in returns a `LARGE` square when it is given `item`, and a `SMALL` one otherwise.
|
||||
fn plugin_then_extrude(name: &str, at: usize, item: &str) -> Node {
|
||||
let square = |w: f64| shapes::rectangle(Rect::new(0.0, 0.0, w, w));
|
||||
let id = format!("test.effects.solidcontext.{name}");
|
||||
let manifest = format!(r#"{{"id":"{id}","name":"Test {id}","version":"1","kind":"effect","params":{{}}}}"#);
|
||||
let (large, small) = (json!({"objects": [{"path": square(LARGE)}]}).to_string(), json!({"objects": [{"path": square(SMALL)}]}).to_string());
|
||||
let module = wat::parse_str(tpl::when_given_else(&manifest, tpl::Given::Params, item, &large, &small)).unwrap();
|
||||
vectorcraft_plugins::registry::install_bytes(&module).unwrap();
|
||||
let mut appearance = Appearance::basic(Paint::solid(Color::rgb(1.0, 0.0, 0.0)), Paint::solid(Color::BLACK), 1.0);
|
||||
appearance.items.push(AppearanceItem::Fill(FillLayer::new(Paint::solid(Color::rgb(0.0, 0.0, 1.0)))));
|
||||
let effects = appearance.items[at].effects_mut();
|
||||
effects.push(Effect { id: format!("plugin.{id}"), params: json!({}), visible: true });
|
||||
effects.push(crate::new_effect(EXTRUDE, &json!({})).unwrap());
|
||||
Node::path(NodeId(7), shapes::rectangle(Rect::new(0.0, 0.0, 20.0, 20.0)), appearance)
|
||||
}
|
||||
|
||||
/// The width of the union of `paths`' bounds.
|
||||
fn width(paths: impl IntoIterator<Item = PathData>) -> f64 {
|
||||
paths.into_iter().filter_map(|p| p.bounds()).reduce(|a, b| a.union(b)).map_or(0.0, |b| b.width())
|
||||
}
|
||||
|
||||
/// Whether each of the art, the meshes and the wireframe `n`'s 3D effect draws is wider than half
|
||||
/// of `LARGE`, with the widths.
|
||||
fn drawn_large(n: &Node) -> ([bool; 3], [f64; 3]) {
|
||||
let art = solid_art(n).unwrap();
|
||||
let mut paths = vec![];
|
||||
art.walk(&mut |c| paths.extend(c.path_data().cloned()));
|
||||
let meshes = three_d_meshes_in(None, n).unwrap();
|
||||
let xs = meshes.iter().flat_map(|m| m.mesh.positions.iter().map(|p| p[0]));
|
||||
let mesh_width = xs.clone().fold(f64::NEG_INFINITY, f64::max) - xs.fold(f64::INFINITY, f64::min);
|
||||
let widths = [width(paths), mesh_width, width(three_d_wireframe_in(None, n).unwrap())];
|
||||
(widths.map(|w| w > LARGE / 2.0), widths)
|
||||
}
|
||||
|
||||
/// A plug-in before the second fill's Extrude & Bevel is given `{"fill": 1}`, not `"object"`, so
|
||||
/// the art, meshes and wireframe are all built from the square it returns for that fill.
|
||||
#[test]
|
||||
fn a_plugin_before_a_fills_extrude_is_told_its_fill() {
|
||||
let (large, widths) = drawn_large(&plugin_then_extrude("fill", 2, r#""item":{"fill":1}"#));
|
||||
assert_eq!(large, [true; 3], "the art, meshes and wireframe are {widths:?} wide");
|
||||
}
|
||||
|
||||
/// A plug-in before the stroke's Extrude & Bevel is given `{"stroke": 0}`.
|
||||
#[test]
|
||||
fn a_plugin_before_a_strokes_extrude_is_told_its_stroke() {
|
||||
let (large, widths) = drawn_large(&plugin_then_extrude("stroke", 1, r#""item":{"stroke":0}"#));
|
||||
assert_eq!(large, [true; 3], "the art, meshes and wireframe are {widths:?} wide");
|
||||
}
|
||||
|
||||
/// A plug-in on a fill is not told it is on the object: the square it returns otherwise is the one
|
||||
/// extruded.
|
||||
#[test]
|
||||
fn a_plugin_on_a_fill_is_not_told_it_is_on_the_object() {
|
||||
let (large, widths) = drawn_large(&plugin_then_extrude("object", 2, r#""item":"object""#));
|
||||
assert_eq!(large, [false; 3], "the art, meshes and wireframe are {widths:?} wide");
|
||||
}
|
||||
@@ -46,7 +46,7 @@ pub mod importmatches;
|
||||
pub(crate) mod inline;
|
||||
mod interactivity;
|
||||
mod intertwine;
|
||||
mod layer;
|
||||
pub(crate) mod layer;
|
||||
mod layerpanel;
|
||||
pub mod library;
|
||||
pub mod linkfiles;
|
||||
|
||||
@@ -10,7 +10,7 @@ use vectorcraft_geom::{PathData, Point, Rect};
|
||||
use vectorcraft_pathops::addons as po;
|
||||
|
||||
use super::super::super::edit::outermost;
|
||||
use super::super::super::layer::empty_artboards;
|
||||
use super::super::super::layer::{empty_artboards, remove_artboards};
|
||||
use crate::Result;
|
||||
|
||||
/// Sine of the turn below which an anchor on a straight run is collinear.
|
||||
@@ -508,35 +508,6 @@ fn artboards(d: &mut Document, sel: &mut Selection, scope: &Scope) -> Result<Out
|
||||
if empty.is_empty() {
|
||||
return Ok(Outcome::default());
|
||||
}
|
||||
// `empty` is in artboard order: one walk drops them and the others keep their order.
|
||||
let mut gone: HashSet<u32> = HashSet::with_capacity(empty.len());
|
||||
let mut i = 0;
|
||||
d.artboards.retain(|a| {
|
||||
let keep = empty.binary_search(&i).is_err();
|
||||
i += 1;
|
||||
if !keep {
|
||||
gone.insert(a.id);
|
||||
}
|
||||
keep
|
||||
});
|
||||
count_artboard_pass();
|
||||
for &id in &gone {
|
||||
d.retire_artboard_id(id);
|
||||
}
|
||||
d.retain_guides(sel, |g| !g.artboard.is_some_and(|a| gone.contains(&a)));
|
||||
count_artboard_pass();
|
||||
remove_artboards(d, sel, &empty)?;
|
||||
Ok(Outcome { count: empty.len(), ids: vec![], artboards: empty })
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
thread_local! {
|
||||
/// Tests: how many walks [`artboards`] made over the artboards and the ruler guides on this
|
||||
/// thread.
|
||||
pub(super) static ARTBOARD_PASSES: std::cell::Cell<usize> = const { std::cell::Cell::new(0) };
|
||||
}
|
||||
|
||||
/// Tests: count one walk over the artboards or the ruler guides.
|
||||
fn count_artboard_pass() {
|
||||
#[cfg(test)]
|
||||
ARTBOARD_PASSES.set(ARTBOARD_PASSES.get() + 1);
|
||||
}
|
||||
|
||||
@@ -502,7 +502,7 @@ fn removing_many_selected_objects_looks_at_each_root_once_a_pass() {
|
||||
|
||||
#[test]
|
||||
fn deleting_many_empty_artboards_walks_the_artboards_and_guides_once() {
|
||||
use super::checks::ARTBOARD_PASSES;
|
||||
use crate::cmd::layer::ARTBOARD_PASSES;
|
||||
let mut s = session();
|
||||
rect(&mut s, 200.0, 20.0, 80.0, 60.0);
|
||||
let (keep, gone) = s
|
||||
|
||||
@@ -206,11 +206,31 @@ struct Source {
|
||||
text: String,
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
thread_local! {
|
||||
/// Tests: how many times [`thread_index`] went through the document's threads on this thread.
|
||||
static THREAD_SCANS: std::cell::Cell<usize> = const { std::cell::Cell::new(0) };
|
||||
}
|
||||
|
||||
/// Each object the document's text threads list, with the index of the first thread that lists it.
|
||||
fn thread_index(d: &Document) -> std::collections::HashMap<NodeId, usize> {
|
||||
#[cfg(test)]
|
||||
THREAD_SCANS.with(|c| c.set(c.get() + 1));
|
||||
let mut out = std::collections::HashMap::new();
|
||||
for (i, thread) in d.text_threads.iter().enumerate() {
|
||||
for id in thread {
|
||||
out.entry(*id).or_insert(i);
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
impl Source {
|
||||
/// Horizontal point or area type that isn't threaded and whose text all fits; None otherwise.
|
||||
fn of(d: &Document, id: NodeId) -> Option<Self> {
|
||||
/// Horizontal point or area type that isn't threaded (`threaded` false) and whose text all
|
||||
/// fits; None otherwise.
|
||||
fn of(d: &Document, id: NodeId, threaded: bool) -> Option<Self> {
|
||||
let t = text_of(d, id)?;
|
||||
if t.vertical || matches!(t.kind, TextKind::OnPath { .. }) || d.text_threads.iter().any(|th| th.contains(&id)) {
|
||||
if t.vertical || matches!(t.kind, TextKind::OnPath { .. }) || threaded {
|
||||
return None;
|
||||
}
|
||||
let lay = layout(t);
|
||||
@@ -376,8 +396,10 @@ fn break_text(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
};
|
||||
let d = &s.doc()?.doc;
|
||||
let (mut items, mut skipped, mut count) = (vec![], vec![], 0);
|
||||
for id in texts_under(d, &checked_ids_or_selection(s, p, C)?)? {
|
||||
let (Some(src), Some(node)) = (Source::of(d, id), d.node(id)) else {
|
||||
let ids = texts_under(d, &checked_ids_or_selection(s, p, C)?)?;
|
||||
let threads = thread_index(d);
|
||||
for id in ids {
|
||||
let (Some(src), Some(node)) = (Source::of(d, id, threads.contains_key(&id)), d.node(id)) else {
|
||||
skipped.push(id);
|
||||
continue;
|
||||
};
|
||||
@@ -405,7 +427,8 @@ fn split_text(s: &mut Session, _: &Value) -> Result<Value> {
|
||||
return Err(EngineError::Other("Click in type with a Type tool to split it".into()));
|
||||
};
|
||||
let d = &s.doc()?.doc;
|
||||
let (Some(src), Some(node)) = (Source::of(d, id), d.node(id).filter(|_| d.is_editable(id))) else {
|
||||
let threaded = d.text_threads.iter().any(|th| th.contains(&id));
|
||||
let (Some(src), Some(node)) = (Source::of(d, id, threaded), d.node(id).filter(|_| d.is_editable(id))) else {
|
||||
return Err(EngineError::Other("Select horizontal point or area type whose text fits".into()));
|
||||
};
|
||||
let text = &src.text;
|
||||
@@ -821,13 +844,17 @@ fn remove_breaks(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let ids = texts_under(d, &checked_ids_or_selection(s, p, C)?)?;
|
||||
// A threaded story whose frames are all among them is read as one text, once.
|
||||
let named: std::collections::HashSet<NodeId> = ids.iter().copied().collect();
|
||||
let threads = thread_index(d);
|
||||
let mut done = std::collections::HashSet::new();
|
||||
let mut out: Vec<(NodeId, Vec<(usize, bool)>)> = vec![];
|
||||
for &id in &ids {
|
||||
if !done.insert(id) {
|
||||
continue;
|
||||
}
|
||||
let story = crate::cmd::threads::story_of(d, id);
|
||||
let story = match threads.get(&id).and_then(|&i| d.text_threads.get(i)) {
|
||||
Some(thread) => crate::cmd::threads::story_in(d, thread, id),
|
||||
None => vec![],
|
||||
};
|
||||
if !story.is_empty() && story.iter().all(|f| named.contains(f)) {
|
||||
done.extend(story.iter().copied());
|
||||
out.extend(story_cuts(d, &story, brk));
|
||||
|
||||
@@ -158,6 +158,62 @@ fn break_text_refuses_what_it_cannot_break() {
|
||||
assert!(matches!(r, Err(EngineError::BadParams { .. })), "{r:?}");
|
||||
}
|
||||
|
||||
/// Two area frames threaded together, with `story` flowing through them → (first, second).
|
||||
fn threaded(s: &mut Session, story: &str) -> (NodeId, NodeId) {
|
||||
let a = text(s, json!({"x": 10, "y": 300, "text": story, "size": 12, "area": {"width": 150, "height": 60}}));
|
||||
let b = NodeId(s.execute("shape.rectangle", &json!({"x": 200, "y": 300, "width": 150, "height": 60})).unwrap()["id"].as_u64().unwrap());
|
||||
select(s, &[a, b]);
|
||||
s.execute("text.thread.create", &json!({})).unwrap();
|
||||
(a, b)
|
||||
}
|
||||
|
||||
fn thread_scans() -> usize {
|
||||
super::THREAD_SCANS.with(|c| c.get())
|
||||
}
|
||||
|
||||
/// Threaded frames in the selection stay as they are and are reported as skipped; the other type
|
||||
/// is broken. The document's threads are gone through once for the whole selection, not once
|
||||
/// per object.
|
||||
#[test]
|
||||
fn break_text_skips_threaded_frames_and_reads_the_threads_once() {
|
||||
let mut s = session();
|
||||
let (a, b) = threaded(&mut s, "Threaded story text");
|
||||
let mut plain = vec![];
|
||||
for i in 0..6 {
|
||||
plain.push(text(&mut s, json!({"x": 20, "y": 30 + 30 * i, "text": "Two words", "size": 14})));
|
||||
}
|
||||
let was = glyphs(&s, &plain);
|
||||
let frames = (obj(&s, a), obj(&s, b));
|
||||
let before = doc_json(&s);
|
||||
let steps = undo_steps(&s);
|
||||
let mut all = vec![a, b];
|
||||
all.extend(&plain);
|
||||
select(&mut s, &all);
|
||||
let scans = thread_scans();
|
||||
let r = s.execute("addon.cleanup.breakText", &json!({"by": "words"})).unwrap();
|
||||
assert_eq!(thread_scans() - scans, 1, "the threads are gone through once");
|
||||
assert_eq!(ids(&json!({"ids": r["skipped"]})), [a, b]);
|
||||
assert_eq!((obj(&s, a), obj(&s, b)), frames, "the threaded frames stay as they are");
|
||||
let pieces = ids(&r);
|
||||
assert_eq!(pieces.len(), 12);
|
||||
assert_eq!(s.doc().unwrap().selection.objects.len(), 12, "the pieces are selected");
|
||||
assert_same_places(&was, &glyphs(&s, &pieces));
|
||||
assert_eq!(undo_steps(&s), steps + 1);
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
assert_eq!(doc_json(&s), before);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn split_text_refuses_a_threaded_frame() {
|
||||
let mut s = session();
|
||||
let (a, _) = threaded(&mut s, "Threaded story text");
|
||||
select(&mut s, &[a]);
|
||||
edit_text(&mut s, a, 9, 9);
|
||||
let before = doc_json(&s);
|
||||
assert!(other(s.execute("addon.cleanup.splitText", &json!({}))).contains("point or area type"));
|
||||
assert_eq!(doc_json(&s), before);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn combine_puts_glyph_pieces_back_together_where_they_were() {
|
||||
let mut s = session();
|
||||
@@ -300,6 +356,29 @@ fn remove_breaks_with_ids_null_in_the_characters_a_type_tool_selected() {
|
||||
assert_eq!(obj(&s, id).plain_text(), "a b\nc");
|
||||
}
|
||||
|
||||
/// Remove Breaks reads a threaded story whose frames are all selected as one text and each other
|
||||
/// object on its own, going through the document's threads once for the whole selection.
|
||||
#[test]
|
||||
fn remove_breaks_reads_the_threads_once() {
|
||||
let mut s = session();
|
||||
let (a, b) = threaded(&mut s, "one\ntwo");
|
||||
let mut plain = vec![];
|
||||
for i in 0..6 {
|
||||
plain.push(text(&mut s, json!({"x": 20, "y": 30 + 30 * i, "text": "a\nb", "size": 12})));
|
||||
}
|
||||
let mut all = vec![a, b];
|
||||
all.extend(&plain);
|
||||
select(&mut s, &all);
|
||||
let scans = thread_scans();
|
||||
let r = s.execute("addon.cleanup.removeBreaks", &json!({"kind": "paragraph"})).unwrap();
|
||||
assert_eq!(thread_scans() - scans, 1, "the threads are gone through once");
|
||||
assert_eq!(r["removed"], 7);
|
||||
assert_eq!(obj(&s, a).plain_text(), "one two");
|
||||
for id in plain {
|
||||
assert_eq!(obj(&s, id).plain_text(), "a b");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn split_at_the_cursor_and_around_a_selection() {
|
||||
for (anchor, caret, want) in [(6, 6, &["Split", "here please"][..]), (6, 10, &["Split", "here", "please"][..])] {
|
||||
|
||||
@@ -79,7 +79,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Apply Fill to Stroke",
|
||||
["Object", "Plug-ins", "Color Effects"],
|
||||
None,
|
||||
"{ids?|id?, skipNone?: bool} each target's top stroke takes its own top fill, as it is (a colour in its model, a global or spot colour linked at its tint, a gradient or pattern with its placement, None); a stroke given paint at width 0 gets 1 pt, as Swap Fill and Stroke does. Type: each run's stroke takes its own fill, and while the Type tool has characters selected (no ids), only those. A graph's series marks and labels keep the paint when the graph is drawn again. skipNone: leave alone a target whose fill is None. A target whose stroke already has that paint is left as it is, and when nothing changes no undo step is recorded. The default stroke takes the default fill the same way → {objects}",
|
||||
"{ids?|id?, skipNone?: bool} each target's top stroke takes its own top fill, as it is (a colour in its model, a global or spot colour linked at its tint, a gradient or pattern with its placement, None); a stroke given paint at width 0 gets 1 pt, as Swap Fill and Stroke does. Type: each run's stroke takes its own fill, and while the Type tool has characters selected (no ids), only those. A graph's labels keep the paint when the graph is drawn again. On a path directly in a graph series, the graph also stores the paint and weight of the topmost visible stroke when the command wrote that row (exceptions: docs/mcp.md). skipNone: leave alone a target whose fill is None. A target whose stroke already has that paint is left as it is, and when nothing changes no undo step is recorded. The default stroke takes the default fill the same way → {objects}",
|
||||
has_doc,
|
||||
|s, p| copy_paint(s, p, true)
|
||||
),
|
||||
@@ -226,6 +226,8 @@ fn copy_paint(s: &mut Session, p: &Value, to_stroke: bool) -> Result<Value> {
|
||||
if !ids.is_empty() {
|
||||
let before = s.doc()?.doc.clone();
|
||||
super::super::overprint::edit_counted(s, label, |d| {
|
||||
// Each object with the row the paint went to, for the paint a graph stores for its series.
|
||||
let mut written = Vec::new();
|
||||
for id in &ids {
|
||||
let Some(node) = d.node_mut(*id) else { continue };
|
||||
if let NodeKind::Text(t) = &mut node.kind {
|
||||
@@ -239,19 +241,23 @@ fn copy_paint(s: &mut Session, p: &Value, to_stroke: bool) -> Result<Value> {
|
||||
let (from, to) = if to_stroke { (ap.fill_paint(), ap.stroke_paint()) } else { (ap.stroke_paint(), ap.fill_paint()) };
|
||||
// A missing item reads as None: copying None onto it would add an item that
|
||||
// changes nothing.
|
||||
if (skip_none && from.is_none()) || from == to {
|
||||
if skip_none && from.is_none() {
|
||||
continue;
|
||||
}
|
||||
if to_stroke {
|
||||
restore_unpainted_stroke(ap, None, &from);
|
||||
ap.set_stroke(from);
|
||||
} else {
|
||||
ap.set_fill(from);
|
||||
if from != to {
|
||||
if to_stroke {
|
||||
restore_unpainted_stroke(ap, None, &from);
|
||||
ap.set_stroke(from);
|
||||
} else {
|
||||
ap.set_fill(from);
|
||||
}
|
||||
}
|
||||
// A row that already had the paint counts as written with it, as for `paint.setFill`.
|
||||
written.extend(ap.items.iter().rposition(|it| it.is_fill() != to_stroke).map(|at| (*id, at)));
|
||||
}
|
||||
objects = super::super::appearance::count_changed(d, &before, &ids);
|
||||
// The graph keeps the paint its series marks and labels now have.
|
||||
let series = super::super::graph::capture_series_paints(d, &ids, !to_stroke);
|
||||
let series = super::super::appearance::capture_series_paint_writes(&before, d, &ids, &written);
|
||||
let labels = super::super::graph::capture_label_styles(d, &ids, ©_run);
|
||||
Ok(objects + usize::from(series || labels))
|
||||
})?;
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
//! Cutting Tools (`addon.cutting.*`), a built-in add-on that prepares art for cutting. Stencil
|
||||
//! Bridges cuts bridges out of the selected art so that a stencil cut from it holds together
|
||||
//! ([`stencil`]), and Weed Box and Lines adds a weed box and weeding lines around the selected art
|
||||
//! for vinyl cutting ([`weed`]). Its submenu (Object › Plug-ins › Cutting Tools) and dialogs are
|
||||
//! the UI's.
|
||||
//! for vinyl cutting ([`weed`]). Banner Hems and Grommets marks the hems and grommets of a banner
|
||||
//! ([`banner`]). Its submenu (Object › Plug-ins › Cutting Tools) and dialogs are the UI's.
|
||||
|
||||
mod banner;
|
||||
mod stencil;
|
||||
mod weed;
|
||||
|
||||
@@ -12,5 +13,6 @@ use super::super::CommandSpec;
|
||||
pub fn specs() -> Vec<CommandSpec> {
|
||||
let mut v = stencil::specs();
|
||||
v.extend(weed::specs());
|
||||
v.extend(banner::specs());
|
||||
v
|
||||
}
|
||||
|
||||
@@ -0,0 +1,228 @@
|
||||
//! Banner Hems and Grommets (`addon.cutting.banner`), a command of the built-in Cutting Tools
|
||||
//! add-on for finishing a printed banner: on each chosen side of the banner it adds a hem line,
|
||||
//! where the edge is folded back and sewn (<https://en.wikipedia.org/wiki/Hem>), and grommet
|
||||
//! marks, where rings are set into the material to hang it
|
||||
//! (<https://en.wikipedia.org/wiki/Grommet>), spread evenly at most a set spacing apart. The
|
||||
//! banner is the selected art's bounds, or each artboard.
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use serde_json::{Value, json};
|
||||
use vectorcraft_color::Paint;
|
||||
use vectorcraft_color::swatch::REGISTRATION;
|
||||
use vectorcraft_doc::{Appearance, Node};
|
||||
use vectorcraft_geom::{Point, Rect, shapes};
|
||||
|
||||
use super::super::super::edit::selected_roots;
|
||||
use super::super::super::paint::swatch_solid;
|
||||
use super::super::super::pathops::length_given;
|
||||
use super::super::super::{CommandSpec, bad, cmd, has_doc, switch_or, text_param};
|
||||
use super::weed::outside_clips;
|
||||
use crate::{Result, Session};
|
||||
|
||||
/// Banner Hems and Grommets' command.
|
||||
pub const BANNER: &str = "addon.cutting.banner";
|
||||
/// The sides of a banner, in the order the grommets go round it.
|
||||
const SIDES: [&str; 4] = ["top", "right", "bottom", "left"];
|
||||
/// The largest hem, inset or spacing, in points.
|
||||
const MAX_DISTANCE: f64 = 100_000.0;
|
||||
/// The most grommets one run adds.
|
||||
const MAX_GROMMETS: usize = 100_000;
|
||||
/// Two grommet centers closer than this are one grommet (the corner that two sides share).
|
||||
const SAME: f64 = 1e-6;
|
||||
|
||||
pub fn specs() -> Vec<CommandSpec> {
|
||||
vec![cmd!(
|
||||
"addon.cutting.banner",
|
||||
"Banner Hems and Grommets…",
|
||||
["Object", "Plug-ins", "Cutting Tools"],
|
||||
None,
|
||||
"{hem?: pt 0..100000 (72: from each edge to its hem line; 0: no hem lines), grommets?: bool (true), spacing?: pt more than 0 and at most 100000 (1728: the most the grommets on a side are apart), grommetInset?: pt 0..100000 (36: from each edge to the grommets' centers), diameter?: pt 0.01..1000 (27: each grommet mark's circle), top?, right?, bottom?, left?: bool (true: the sides that get a hem and grommets), weight?: pt 0.01..1000 (0.3), swatch?: a color swatch the marks are stroked in (\"[Registration]\"), allArtboards?: bool (default: true when nothing is selected)} mark the hems and grommets of a banner, the selected art's bounds or each artboard: a hem line `hem` inside each chosen edge, running the edge's whole length, and grommet marks (a circle with a cross at its center) `grommetInset` inside each chosen edge, one at each end of the side and the rest spread evenly between them, as few as keep them at most `spacing` apart. One group named Banner Marks per banner, stroked with no fill, on top of the current layer, selected, as one undo step → {id: the first group, ids, hems (hem lines), grommets (grommet marks)}",
|
||||
has_doc,
|
||||
banner
|
||||
)]
|
||||
}
|
||||
|
||||
/// The distance `p` gives for `key` (0..[`MAX_DISTANCE`]), or `default`.
|
||||
fn distance(p: &Value, key: &str, default: f64) -> Result<f64> {
|
||||
match length_given(p, key, BANNER)? {
|
||||
None => Ok(default),
|
||||
Some(v) if (0.0..=MAX_DISTANCE).contains(&v) => Ok(v),
|
||||
Some(_) => Err(bad(BANNER, format!("`{key}` must be from 0 to {MAX_DISTANCE} pt"))),
|
||||
}
|
||||
}
|
||||
|
||||
/// The length `p` gives for `key` (0.01..1000), or `default`.
|
||||
fn size(p: &Value, key: &str, default: f64) -> Result<f64> {
|
||||
match length_given(p, key, BANNER)? {
|
||||
None => Ok(default),
|
||||
Some(v) if (0.01..=1000.0).contains(&v) => Ok(v),
|
||||
Some(_) => Err(bad(BANNER, format!("`{key}` must be from 0.01 to 1000 pt"))),
|
||||
}
|
||||
}
|
||||
|
||||
/// Which sides are on, in [`SIDES`] order.
|
||||
type Sides = [bool; 4];
|
||||
|
||||
/// Whether marks `depth` inside a banner's near and far edges, `len` apart, fit: they stay inside
|
||||
/// the banner, and when both edges are chosen the two sets stay apart.
|
||||
fn fits(depth: f64, len: f64, near: bool, far: bool) -> bool {
|
||||
match (near, far) {
|
||||
(true, true) => 2.0 * depth < len,
|
||||
(true, false) | (false, true) => depth < len,
|
||||
(false, false) => true,
|
||||
}
|
||||
}
|
||||
|
||||
/// The hem lines of banner `r`: one `hem` inside each chosen edge, from one end of the edge to the
|
||||
/// other.
|
||||
fn hem_lines(r: Rect, hem: f64, sides: Sides) -> Vec<(Point, Point)> {
|
||||
if hem <= 0.0 {
|
||||
return vec![];
|
||||
}
|
||||
let lines = [
|
||||
(Point::new(r.x0, r.y0 + hem), Point::new(r.x1, r.y0 + hem)),
|
||||
(Point::new(r.x1 - hem, r.y0), Point::new(r.x1 - hem, r.y1)),
|
||||
(Point::new(r.x0, r.y1 - hem), Point::new(r.x1, r.y1 - hem)),
|
||||
(Point::new(r.x0 + hem, r.y0), Point::new(r.x0 + hem, r.y1)),
|
||||
];
|
||||
lines.into_iter().zip(sides).filter(|(_, on)| *on).map(|(l, _)| l).collect()
|
||||
}
|
||||
|
||||
/// The grommet centers of banner `r`: on each chosen side, `inset` inside the edge, one at each end
|
||||
/// (`inset` from the edges across it) and the rest spread evenly between them, as few as keep them
|
||||
/// at most `spacing` apart. They go round the banner clockwise from the top left; a corner that two
|
||||
/// chosen sides share has one grommet. `None` when there would be more than `limit`.
|
||||
fn grommet_centers(r: Rect, inset: f64, spacing: f64, sides: Sides, limit: usize) -> Option<Vec<Point>> {
|
||||
let (l, t, rt, b) = (r.x0 + inset, r.y0 + inset, r.x1 - inset, r.y1 - inset);
|
||||
// Each side from its start to its end, clockwise.
|
||||
let runs = [
|
||||
(Point::new(l, t), Point::new(rt, t)),
|
||||
(Point::new(rt, t), Point::new(rt, b)),
|
||||
(Point::new(rt, b), Point::new(l, b)),
|
||||
(Point::new(l, b), Point::new(l, t)),
|
||||
];
|
||||
let mut out: Vec<Point> = vec![];
|
||||
for ((a, z), on) in runs.into_iter().zip(sides) {
|
||||
if !on {
|
||||
continue;
|
||||
}
|
||||
let gaps = ((z - a).hypot() / spacing - 1e-9).ceil().max(1.0);
|
||||
if !gaps.is_finite() || gaps > limit as f64 {
|
||||
return None;
|
||||
}
|
||||
let gaps = gaps as usize;
|
||||
for i in 0..=gaps {
|
||||
let p = a.lerp(z, i as f64 / gaps as f64);
|
||||
// A side starts where the one before it ends, and the last one ends where the first
|
||||
// starts.
|
||||
let shared = |q: Option<&Point>| q.is_some_and(|q| (*q - p).hypot() < SAME);
|
||||
if !shared(out.last()) && !shared(out.first()) {
|
||||
out.push(p);
|
||||
}
|
||||
if out.len() > limit {
|
||||
return None;
|
||||
}
|
||||
}
|
||||
}
|
||||
Some(out)
|
||||
}
|
||||
|
||||
fn banner(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let hem = distance(p, "hem", 72.0)?;
|
||||
let inset = distance(p, "grommetInset", 36.0)?;
|
||||
let spacing = match length_given(p, "spacing", BANNER)? {
|
||||
None => 1728.0,
|
||||
Some(v) if v > 0.0 && v <= MAX_DISTANCE => v,
|
||||
Some(_) => return Err(bad(BANNER, format!("`spacing` must be more than 0 and at most {MAX_DISTANCE} pt"))),
|
||||
};
|
||||
let diameter = size(p, "diameter", 27.0)?;
|
||||
let weight = size(p, "weight", 0.3)?;
|
||||
let with_grommets = switch_or(p, "grommets", true, BANNER)?;
|
||||
let mut sides = [true; 4];
|
||||
for (on, key) in sides.iter_mut().zip(SIDES) {
|
||||
*on = switch_or(p, key, true, BANNER)?;
|
||||
}
|
||||
if !sides.contains(&true) {
|
||||
return Err(bad(BANNER, "turn on at least one side"));
|
||||
}
|
||||
if hem == 0.0 && !with_grommets {
|
||||
return Err(bad(BANNER, "with no hem and no grommets there is nothing to mark"));
|
||||
}
|
||||
let swatch = text_param(p, "swatch", BANNER)?.unwrap_or(REGISTRATION);
|
||||
let stroke = swatch_solid(&s.doc()?.doc, swatch, None).map_err(|e| bad(BANNER, e))?;
|
||||
let all = switch_or(p, "allArtboards", s.doc()?.selection.is_empty(), BANNER)?;
|
||||
let banners: Vec<Rect> = if all {
|
||||
s.doc()?.doc.artboards.iter().map(|a| a.rect).collect()
|
||||
} else {
|
||||
let roots = selected_roots(s)?;
|
||||
if roots.is_empty() {
|
||||
return Err(bad(BANNER, "select the banner, or mark every artboard"));
|
||||
}
|
||||
s.doc()?.doc.bounds_of(&roots, false).into_iter().collect()
|
||||
};
|
||||
if banners.is_empty() {
|
||||
return Err(bad(BANNER, "there is no banner to mark"));
|
||||
}
|
||||
let [top, right, bottom, left] = sides;
|
||||
let mut marks = vec![];
|
||||
let mut total = 0;
|
||||
for r in &banners {
|
||||
let (w, h) = (r.width(), r.height());
|
||||
if !(w.is_finite() && h.is_finite()) || w <= 0.0 || h <= 0.0 {
|
||||
return Err(bad(BANNER, "the banner has no width or height"));
|
||||
}
|
||||
// A hem line must stay inside the banner and apart from the hem line on the far side.
|
||||
if hem > 0.0 && !(fits(hem, h, top, bottom) && fits(hem, w, left, right)) {
|
||||
return Err(bad(BANNER, "the hem is too deep for the banner"));
|
||||
}
|
||||
let centers = if with_grommets {
|
||||
// The grommets on a side run from `inset` past one end to `inset` before the other,
|
||||
// stay inside the banner and stay apart from the grommets on the far side.
|
||||
let runs_fit = (!(top || bottom) || 2.0 * inset <= w) && (!(left || right) || 2.0 * inset <= h);
|
||||
if !(runs_fit && fits(inset, h, top, bottom) && fits(inset, w, left, right)) {
|
||||
return Err(bad(BANNER, "the grommet inset is too large for the banner"));
|
||||
}
|
||||
grommet_centers(*r, inset, spacing, sides, MAX_GROMMETS - total)
|
||||
.ok_or_else(|| bad(BANNER, format!("the spacing makes more than {MAX_GROMMETS} grommets")))?
|
||||
} else {
|
||||
vec![]
|
||||
};
|
||||
total += centers.len();
|
||||
marks.push((hem_lines(*r, hem, sides), centers));
|
||||
}
|
||||
let parent = outside_clips(&s.doc()?.doc, s.doc()?.target_parent()?);
|
||||
let ap = Appearance::basic(Paint::None, stroke, weight);
|
||||
let half = diameter / 2.0;
|
||||
let (ids, hems) = s.edit("Banner Hems and Grommets", |d, sel| {
|
||||
let (mut ids, mut hems) = (vec![], 0);
|
||||
for (lines, centers) in &marks {
|
||||
let mut children = vec![];
|
||||
for (a, b) in lines {
|
||||
let mut n = Node::path(d.alloc_id(), shapes::line(*a, *b), ap.clone());
|
||||
n.name = Some("Hem".into());
|
||||
children.push(Arc::new(n));
|
||||
}
|
||||
hems += lines.len();
|
||||
for c in centers {
|
||||
let ring = Node::path(d.alloc_id(), shapes::ellipse(Rect::new(c.x - half, c.y - half, c.x + half, c.y + half)), ap.clone());
|
||||
let across = Node::path(d.alloc_id(), shapes::line(Point::new(c.x - half, c.y), Point::new(c.x + half, c.y)), ap.clone());
|
||||
let down = Node::path(d.alloc_id(), shapes::line(Point::new(c.x, c.y - half), Point::new(c.x, c.y + half)), ap.clone());
|
||||
let mut g = Node::group(d.alloc_id(), vec![Arc::new(ring), Arc::new(across), Arc::new(down)]);
|
||||
g.name = Some("Grommet".into());
|
||||
children.push(Arc::new(g));
|
||||
}
|
||||
let mut g = Node::group(d.alloc_id(), children);
|
||||
g.name = Some("Banner Marks".into());
|
||||
ids.push(g.id);
|
||||
d.insert(parent, usize::MAX, g)?;
|
||||
}
|
||||
sel.set(ids.iter().copied());
|
||||
Ok((ids, hems))
|
||||
})?;
|
||||
let first = ids.first().map(|i| i.0);
|
||||
Ok(json!({ "id": first, "ids": ids.iter().map(|i| i.0).collect::<Vec<_>>(), "hems": hems, "grommets": total }))
|
||||
}
|
||||
@@ -0,0 +1,263 @@
|
||||
//! Tests for `addon.cutting.banner`: hem lines and grommet marks on the sides of a banner.
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use serde_json::{Value, json};
|
||||
use vectorcraft_color::Paint;
|
||||
use vectorcraft_doc::{Appearance, Node, NodeKind};
|
||||
use vectorcraft_geom::{Point, Rect, shapes};
|
||||
|
||||
use super::{grommet_centers, hem_lines};
|
||||
use crate::*;
|
||||
|
||||
fn session() -> Session {
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", &json!({"width": 800, "height": 600})).unwrap();
|
||||
s
|
||||
}
|
||||
|
||||
fn id_of(v: &Value) -> NodeId {
|
||||
NodeId(v["id"].as_u64().unwrap())
|
||||
}
|
||||
|
||||
fn rect(s: &mut Session, x: f64, y: f64, w: f64, h: f64) -> NodeId {
|
||||
id_of(&s.execute("shape.rectangle", &json!({"x": x, "y": y, "width": w, "height": h})).unwrap())
|
||||
}
|
||||
|
||||
fn node(s: &Session, id: NodeId) -> Node {
|
||||
s.doc().unwrap().doc.node(id).cloned().unwrap()
|
||||
}
|
||||
|
||||
/// Runs Banner Hems and Grommets with `p` and gives the first group it made.
|
||||
fn banner(s: &mut Session, p: Value) -> (Value, Node) {
|
||||
let r = s.execute("addon.cutting.banner", &p).unwrap();
|
||||
let g = node(s, id_of(&r));
|
||||
(r, g)
|
||||
}
|
||||
|
||||
fn round(p: Point) -> (f64, f64) {
|
||||
let r = |v: f64| (v * 1000.0).round() / 1000.0;
|
||||
(r(p.x), r(p.y))
|
||||
}
|
||||
|
||||
/// The hem lines in group `g` as their two ends.
|
||||
fn hems(g: &Node) -> Vec<((f64, f64), (f64, f64))> {
|
||||
g.children()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.filter(|c| c.name.as_deref() == Some("Hem"))
|
||||
.map(|c| match &c.kind {
|
||||
NodeKind::Path { path, .. } => {
|
||||
let a: Vec<Point> = path.anchors().map(|(_, _, a)| a.p).collect();
|
||||
assert_eq!(a.len(), 2, "a hem line is one straight segment");
|
||||
(round(a[0]), round(a[1]))
|
||||
}
|
||||
other => panic!("not a path: {other:?}"),
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// The grommet marks in group `g` as their centers.
|
||||
fn grommets(g: &Node) -> Vec<(f64, f64)> {
|
||||
g.children().unwrap().iter().filter(|c| c.name.as_deref() == Some("Grommet")).map(|c| round(c.geometric_bounds().unwrap().center())).collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hems_and_grommets_go_round_a_selected_banner() {
|
||||
let mut s = session();
|
||||
rect(&mut s, 0.0, 0.0, 400.0, 200.0);
|
||||
let undo = s.doc().unwrap().history.undo.len();
|
||||
let (r, g) = banner(&mut s, json!({"hem": 20, "grommetInset": 10, "spacing": 150, "diameter": 8}));
|
||||
assert_eq!(g.name.as_deref(), Some("Banner Marks"));
|
||||
assert_eq!(
|
||||
hems(&g),
|
||||
vec![((0.0, 20.0), (400.0, 20.0)), ((380.0, 0.0), (380.0, 200.0)), ((0.0, 180.0), (400.0, 180.0)), ((20.0, 0.0), (20.0, 200.0))]
|
||||
);
|
||||
// 380 pt along the top and bottom need 3 gaps at most 150 apart, 180 down the sides 2; the
|
||||
// corners are shared.
|
||||
assert_eq!(
|
||||
grommets(&g),
|
||||
vec![
|
||||
(10.0, 10.0),
|
||||
(136.667, 10.0),
|
||||
(263.333, 10.0),
|
||||
(390.0, 10.0),
|
||||
(390.0, 100.0),
|
||||
(390.0, 190.0),
|
||||
(263.333, 190.0),
|
||||
(136.667, 190.0),
|
||||
(10.0, 190.0),
|
||||
(10.0, 100.0)
|
||||
]
|
||||
);
|
||||
assert_eq!(r["hems"], 4);
|
||||
assert_eq!(r["grommets"], 10);
|
||||
assert_eq!(s.doc().unwrap().history.undo.len(), undo + 1, "one undo step");
|
||||
assert_eq!(s.doc().unwrap().selection.objects, [g.id]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_grommet_is_a_circle_with_a_cross_stroked_in_registration() {
|
||||
let mut s = session();
|
||||
rect(&mut s, 0.0, 0.0, 100.0, 100.0);
|
||||
let (_, g) = banner(&mut s, json!({"hem": 0, "grommetInset": 10, "diameter": 6, "weight": 0.5}));
|
||||
let first = g.children().unwrap().iter().find(|c| c.name.as_deref() == Some("Grommet")).cloned().unwrap();
|
||||
assert_eq!(first.geometric_bounds(), Some(Rect::new(7.0, 7.0, 13.0, 13.0)));
|
||||
let parts = first.children().unwrap();
|
||||
assert_eq!(parts.len(), 3, "a circle and two lines");
|
||||
for c in parts.iter() {
|
||||
assert!(matches!(c.kind, NodeKind::Path { .. }), "a path");
|
||||
assert_eq!(c.appearance.fill_paint(), Paint::None);
|
||||
assert!(c.appearance.stroke_paint().is_registration());
|
||||
assert_eq!(c.appearance.stroke_width(), 0.5);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn every_artboard_is_marked_when_nothing_is_selected() {
|
||||
let mut s = session();
|
||||
s.execute("artboard.new", &json!({"x": 900, "y": 0, "width": 200, "height": 150})).unwrap();
|
||||
s.execute("select.none", &json!({})).unwrap();
|
||||
let r = s.execute("addon.cutting.banner", &json!({"grommets": false})).unwrap();
|
||||
let ids: Vec<NodeId> = r["ids"].as_array().unwrap().iter().map(|v| NodeId(v.as_u64().unwrap())).collect();
|
||||
assert_eq!(ids.len(), 2);
|
||||
let second = node(&s, ids[1]);
|
||||
assert_eq!(hems(&second)[0], ((900.0, 72.0), (1100.0, 72.0)), "the default hem is 72 pt");
|
||||
assert!(grommets(&second).is_empty());
|
||||
assert_eq!(r["grommets"], 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn only_the_chosen_sides_are_marked() {
|
||||
let mut s = session();
|
||||
rect(&mut s, 0.0, 0.0, 300.0, 100.0);
|
||||
let (_, g) = banner(&mut s, json!({"hem": 10, "grommetInset": 5, "spacing": 1000, "right": false, "bottom": false, "left": false}));
|
||||
assert_eq!(hems(&g), vec![((0.0, 10.0), (300.0, 10.0))]);
|
||||
assert_eq!(grommets(&g), vec![(5.0, 5.0), (295.0, 5.0)]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn units_are_read_and_bad_params_are_refused() {
|
||||
let mut s = session();
|
||||
rect(&mut s, 0.0, 0.0, 400.0, 400.0);
|
||||
let (_, g) = banner(&mut s, json!({"hem": "1 in", "grommetInset": "0.5 in", "spacing": "10 in"}));
|
||||
assert_eq!(hems(&g)[0], ((0.0, 72.0), (400.0, 72.0)));
|
||||
assert_eq!(grommets(&g)[0], (36.0, 36.0));
|
||||
let before = s.doc().unwrap().history.undo.len();
|
||||
for p in [
|
||||
json!({"hem": -1}),
|
||||
json!({"hem": "x"}),
|
||||
json!({"spacing": 0}),
|
||||
json!({"diameter": 0}),
|
||||
json!({"weight": 1e9}),
|
||||
json!({"top": "yes"}),
|
||||
json!({"top": false, "right": false, "bottom": false, "left": false}),
|
||||
json!({"hem": 0, "grommets": false}),
|
||||
json!({"hem": 250}),
|
||||
json!({"grommetInset": 250}),
|
||||
json!({"spacing": 0.001}),
|
||||
json!({"swatch": "no such swatch"}),
|
||||
] {
|
||||
assert!(s.execute("addon.cutting.banner", &p).is_err(), "{p} is refused");
|
||||
}
|
||||
assert_eq!(s.doc().unwrap().history.undo.len(), before, "a refused run changes nothing");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hems_and_grommets_on_one_side_may_go_past_the_middle() {
|
||||
let mut s = session();
|
||||
rect(&mut s, 0.0, 0.0, 300.0, 100.0);
|
||||
let one = json!({"right": false, "bottom": false, "left": false, "spacing": 1000});
|
||||
let with = |extra: Value| {
|
||||
let mut p = one.clone();
|
||||
p.as_object_mut().unwrap().extend(extra.as_object().unwrap().clone());
|
||||
p
|
||||
};
|
||||
let (_, g) = banner(&mut s, with(json!({"hem": 60, "grommetInset": 70})));
|
||||
assert_eq!(hems(&g), vec![((0.0, 60.0), (300.0, 60.0))]);
|
||||
assert_eq!(grommets(&g), vec![(70.0, 70.0), (230.0, 70.0)]);
|
||||
for p in [with(json!({"hem": 100})), with(json!({"hem": 0, "grommetInset": 100})), with(json!({"grommetInset": 151}))] {
|
||||
assert!(s.execute("addon.cutting.banner", &p).is_err(), "{p} reaches the far edge or past the ends");
|
||||
}
|
||||
// A grommet inset of half the width puts the grommets of a side on one point: one grommet.
|
||||
let mut s = session();
|
||||
rect(&mut s, 0.0, 0.0, 100.0, 300.0);
|
||||
let (_, g) = banner(&mut s, with(json!({"hem": 0, "grommetInset": 50, "spacing": 10})));
|
||||
assert_eq!(grommets(&g), vec![(50.0, 50.0)]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hems_and_grommets_on_opposite_sides_stay_apart() {
|
||||
let mut s = session();
|
||||
rect(&mut s, 0.0, 0.0, 300.0, 100.0);
|
||||
let pair = |p: Value| {
|
||||
let mut q = json!({"right": false, "left": false, "spacing": 1000});
|
||||
q.as_object_mut().unwrap().extend(p.as_object().unwrap().clone());
|
||||
q
|
||||
};
|
||||
// Half the height would put the top and bottom marks on the same line.
|
||||
let refused = [
|
||||
pair(json!({"hem": 50})),
|
||||
pair(json!({"hem": 0, "grommetInset": 50})),
|
||||
json!({"hem": 0, "grommetInset": 150, "top": false, "bottom": false}),
|
||||
];
|
||||
for p in refused {
|
||||
assert!(s.execute("addon.cutting.banner", &p).is_err(), "{p} is refused");
|
||||
}
|
||||
let (_, g) = banner(&mut s, pair(json!({"hem": 49.5, "grommetInset": 49.5})));
|
||||
assert_eq!(hems(&g), vec![((0.0, 49.5), (300.0, 49.5)), ((0.0, 50.5), (300.0, 50.5))]);
|
||||
assert_eq!(grommets(&g), vec![(49.5, 49.5), (250.5, 49.5), (250.5, 50.5), (49.5, 50.5)]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_marks_go_outside_a_clip_group_edited_in_isolation() {
|
||||
let mut s = session();
|
||||
let parent = s.doc().unwrap().target_parent().unwrap();
|
||||
let (g, art) = s
|
||||
.edit("clip group", |d, _| {
|
||||
let clip = Arc::new(Node::path(d.alloc_id(), shapes::rectangle(Rect::new(100.0, 100.0, 150.0, 140.0)), Appearance::default()));
|
||||
let art = Node::path(d.alloc_id(), shapes::rectangle(Rect::new(0.0, 0.0, 400.0, 200.0)), Appearance::default());
|
||||
let art_id = art.id;
|
||||
let mut g = Node::group(d.alloc_id(), vec![clip, Arc::new(art)]);
|
||||
if let NodeKind::Group { clip, .. } = &mut g.kind {
|
||||
*clip = true;
|
||||
}
|
||||
let id = g.id;
|
||||
d.insert(parent, usize::MAX, g)?;
|
||||
Ok((id, art_id))
|
||||
})
|
||||
.unwrap();
|
||||
s.execute("object.isolate", &json!({"id": g.0})).unwrap();
|
||||
s.execute("select.set", &json!({"ids": [art.0]})).unwrap();
|
||||
let (_, marks) = banner(&mut s, json!({"hem": 20, "grommets": false}));
|
||||
// The clipping path would hide marks inside the clip group.
|
||||
assert_eq!(s.doc().unwrap().doc.parent_of(marks.id), parent);
|
||||
assert_eq!(hems(&marks)[0], ((0.0, 20.0), (400.0, 20.0)), "the selected art is the banner");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn all_artboards_marks_the_artboards_even_with_a_selection() {
|
||||
let mut s = session();
|
||||
rect(&mut s, 10.0, 10.0, 100.0, 100.0);
|
||||
let (_, g) = banner(&mut s, json!({"allArtboards": true, "grommets": false}));
|
||||
assert_eq!(hems(&g)[0], ((0.0, 72.0), (800.0, 72.0)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_empty_document_has_no_banner_to_mark_when_artboards_are_off() {
|
||||
let mut s = session();
|
||||
assert!(s.execute("addon.cutting.banner", &json!({"allArtboards": false})).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_side_shorter_than_the_spacing_has_a_grommet_at_each_end() {
|
||||
let r = Rect::new(0.0, 0.0, 100.0, 50.0);
|
||||
let c = grommet_centers(r, 0.0, 1000.0, [true, false, false, false], 10).unwrap();
|
||||
assert_eq!(c, vec![Point::new(0.0, 0.0), Point::new(100.0, 0.0)]);
|
||||
// Spacing that divides the side exactly does not add a grommet, even when the division
|
||||
// rounds up (2.1 / 0.3 is 7.000000000000001).
|
||||
let c = grommet_centers(Rect::new(0.0, 0.0, 2.1, 1.0), 0.0, 0.3, [true, false, false, false], 100).unwrap();
|
||||
assert_eq!(c.len(), 8);
|
||||
assert!(grommet_centers(r, 0.0, 1.0, [true; 4], 10).is_none(), "over the limit");
|
||||
assert!(hem_lines(r, 0.0, [true; 4]).is_empty());
|
||||
}
|
||||
@@ -72,7 +72,7 @@ fn lines_across(length: f64, count: Option<usize>, spacing: Option<f64>) -> Resu
|
||||
/// Where the Weeding group goes: `parent`, or, when `parent` is inside a clip group or clipping
|
||||
/// sublayer (a group edited in isolation), the container around the outermost of them. A clipping
|
||||
/// path would hide the lines and the box, which lie outside the art.
|
||||
fn outside_clips(d: &Document, parent: Option<NodeId>) -> Option<NodeId> {
|
||||
pub(super) fn outside_clips(d: &Document, parent: Option<NodeId>) -> Option<NodeId> {
|
||||
let clips = |id: NodeId| d.node(id).is_some_and(|n| matches!(n.kind, NodeKind::Group { clip: true, .. } | NodeKind::Layer { clip: true, .. }));
|
||||
let (mut at, mut out) = (parent, parent);
|
||||
while let Some(p) = at {
|
||||
|
||||
@@ -611,7 +611,7 @@ fn remove_keep_shape(s: &mut Session, _: &Value) -> Result<Value> {
|
||||
fn picked_segments(segments: Option<&BTreeSet<(usize, usize)>>, si: usize, sp: &SubPath, anchors: &BTreeSet<usize>) -> BTreeSet<usize> {
|
||||
let n = sp.anchors.len().max(1);
|
||||
match segments.filter(|set| !set.is_empty()) {
|
||||
Some(set) => set.iter().filter(|(s, _)| *s == si).map(|(_, k)| *k).collect(),
|
||||
Some(set) => super::super::path::subpath_refs(set, si).collect(),
|
||||
None => (0..sp.segment_count()).filter(|k| anchors.contains(k) && anchors.contains(&((k + 1) % n))).collect(),
|
||||
}
|
||||
}
|
||||
@@ -683,6 +683,18 @@ pub(super) fn close(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
|
||||
// ---------- Split / Add Points at Crossings ----------
|
||||
|
||||
/// The crossing places `on` of one path, as `(segment, t)` for each of its `subpaths` subpaths in
|
||||
/// the order they come in `on`. A place on a subpath past the last is left out.
|
||||
fn places_by_subpath(on: &[(usize, usize, f64)], subpaths: usize) -> Vec<Vec<(usize, f64)>> {
|
||||
let mut by_sub = vec![vec![]; subpaths];
|
||||
for &(si, seg, t) in on {
|
||||
if let Some(here) = by_sub.get_mut(si) {
|
||||
here.push((seg, t));
|
||||
}
|
||||
}
|
||||
by_sub
|
||||
}
|
||||
|
||||
fn crossings(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const C: &str = "addon.pathtools.crossings";
|
||||
let (cut, self_crossings) = (switch_or(p, "cut", false, C)?, switch_or(p, "selfCrossings", false, C)?);
|
||||
@@ -704,8 +716,7 @@ fn crossings(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
}
|
||||
let mut subs = vec![];
|
||||
let mut marks = vec![];
|
||||
for (si, sp) in path.subpaths.iter().enumerate() {
|
||||
let here: Vec<(usize, f64)> = on.iter().filter(|(s, ..)| *s == si).map(|&(_, seg, t)| (seg, t)).collect();
|
||||
for (sp, here) in path.subpaths.iter().zip(places_by_subpath(&on, path.subpaths.len())) {
|
||||
let (out, m) = pt::insert_at(sp, &here);
|
||||
count += m.len();
|
||||
subs.push(out);
|
||||
|
||||
@@ -6,7 +6,7 @@ use std::collections::BTreeSet;
|
||||
use serde_json::{Value, json};
|
||||
use vectorcraft_doc::{LiveCorners, LiveShape, NodeId};
|
||||
use vectorcraft_geom::shapes::CornerKind;
|
||||
use vectorcraft_geom::{AnchorKind, PathData, Point};
|
||||
use vectorcraft_geom::{AnchorKind, PathData, Point, SubPath};
|
||||
use vectorcraft_tools::{PointerEvent, PointerKind};
|
||||
|
||||
use crate::{Session, ViewInfo};
|
||||
@@ -275,6 +275,152 @@ fn crossings_add_anchors_or_cut_in_one_step() {
|
||||
refuses(&mut s, "addon.pathtools.crossings", &[json!({"cut": "yes"}), json!({"ids": [123456]})]);
|
||||
}
|
||||
|
||||
/// A selected path with an open polyline subpath through each of `subs`; an empty list makes an
|
||||
/// empty subpath.
|
||||
fn compound(s: &mut Session, subs: &[&[(f64, f64)]]) -> NodeId {
|
||||
let id = line(s, &[(0.0, 0.0), (1.0, 0.0)], false);
|
||||
let pd =
|
||||
PathData::new(subs.iter().map(|pts| SubPath::polyline(&pts.iter().map(|&(x, y)| Point::new(x, y)).collect::<Vec<_>>(), false)).collect());
|
||||
s.edit("Test", |d, _| {
|
||||
super::set_path(d, id, pd);
|
||||
Ok(())
|
||||
})
|
||||
.unwrap();
|
||||
select(s, &[id]);
|
||||
id
|
||||
}
|
||||
|
||||
/// The anchor points of each subpath of `pd`, rounded to a millionth.
|
||||
fn points(pd: &PathData) -> Vec<Vec<(f64, f64)>> {
|
||||
let r = |v: f64| (v * 1e6).round() / 1e6;
|
||||
pd.subpaths.iter().map(|sp| sp.anchors.iter().map(|a| (r(a.p.x), r(a.p.y))).collect()).collect()
|
||||
}
|
||||
|
||||
/// A path's subpaths: A (0), an empty one (1), C (2) and D (3, the last).
|
||||
const SUBS: [&[(f64, f64)]; 4] =
|
||||
[&[(0.0, 0.0), (100.0, 0.0), (200.0, 0.0)], &[], &[(0.0, 50.0), (100.0, 50.0)], &[(0.0, 100.0), (100.0, 100.0), (200.0, 100.0)]];
|
||||
|
||||
/// Direct-select `segments` of `id` and the anchors `anchors`.
|
||||
fn select_segments(s: &mut Session, id: NodeId, anchors: &[(usize, usize)], segments: &[(usize, usize)]) {
|
||||
s.select(|_, sel| {
|
||||
sel.set([id]);
|
||||
sel.anchors.insert(id, anchors.iter().copied().collect());
|
||||
sel.segments.insert(id, segments.iter().copied().collect());
|
||||
})
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn picked_segments_reads_only_its_own_subpath() {
|
||||
let set: BTreeSet<(usize, usize)> = [(0, 0), (0, 2), (1, 1), (3, 0), (3, 1)].into();
|
||||
let sp = SubPath::polyline(&[Point::new(0.0, 0.0), Point::new(1.0, 0.0), Point::new(2.0, 0.0), Point::new(3.0, 0.0)], false);
|
||||
let all: BTreeSet<usize> = (0..4).collect();
|
||||
let picked = |si| super::picked_segments(Some(&set), si, &sp, &all).into_iter().collect::<Vec<_>>();
|
||||
assert_eq!([picked(0), picked(1), picked(2), picked(3), picked(4)], [vec![0, 2], vec![1], vec![], vec![0, 1], vec![]]);
|
||||
// Without direct-selected segments, the segments between two selected anchors.
|
||||
let anchors: BTreeSet<usize> = [0, 1, 3].into();
|
||||
for none in [None, Some(&BTreeSet::new())] {
|
||||
assert_eq!(super::picked_segments(none, 0, &sp, &anchors).into_iter().collect::<Vec<_>>(), [0]);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn places_by_subpath_keeps_the_order_and_leaves_out_missing_subpaths() {
|
||||
let on = [(3, 1, 0.75), (0, 0, 0.5), (3, 1, 0.25), (7, 0, 0.5), (0, 1, 0.1), (3, 0, 0.5), (0, 0, 0.2)];
|
||||
let by_sub = super::places_by_subpath(&on, 4);
|
||||
assert_eq!(by_sub, [vec![(0, 0.5), (1, 0.1), (0, 0.2)], vec![], vec![], vec![(1, 0.75), (1, 0.25), (0, 0.5)]]);
|
||||
// The same places as filtering the whole list for each subpath.
|
||||
for (si, here) in by_sub.iter().enumerate() {
|
||||
let filtered: Vec<(usize, f64)> = on.iter().filter(|(s, ..)| *s == si).map(|&(_, seg, t)| (seg, t)).collect();
|
||||
assert_eq!(*here, filtered);
|
||||
}
|
||||
assert!(super::places_by_subpath(&on, 0).is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn add_points_adds_to_the_selected_segments_of_each_subpath() {
|
||||
let mut s = session();
|
||||
let c = compound(&mut s, &SUBS);
|
||||
let before = path(&s, c);
|
||||
// The first segment of A and the second of D; a segment of C without its anchors selected.
|
||||
select_segments(&mut s, c, &[(0, 0), (0, 1), (0, 2), (3, 0), (3, 1), (3, 2)], &[(0, 0), (2, 0), (3, 1)]);
|
||||
let n = steps(&s);
|
||||
assert_eq!(s.execute("addon.pathtools.addPoints", &json!({})).unwrap()["added"], json!(2));
|
||||
let want = vec![
|
||||
vec![(0.0, 0.0), (50.0, 0.0), (100.0, 0.0), (200.0, 0.0)],
|
||||
vec![],
|
||||
vec![(0.0, 50.0), (100.0, 50.0)],
|
||||
vec![(0.0, 100.0), (100.0, 100.0), (150.0, 100.0), (200.0, 100.0)],
|
||||
];
|
||||
let after = path(&s, c);
|
||||
assert_eq!(points(&after), want);
|
||||
assert_eq!(steps(&s), n + 1);
|
||||
let st = s.doc().unwrap();
|
||||
assert_eq!((st.selection.objects.clone(), st.selection.anchors.get(&c), st.selection.segments.get(&c)), (vec![c], None, None));
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
assert_eq!(path(&s, c), before);
|
||||
s.execute("edit.redo", &json!({})).unwrap();
|
||||
assert_eq!(path(&s, c), after);
|
||||
// Without selected segments: those between two selected anchors, here on the last subpath only.
|
||||
for segments in [None, Some(BTreeSet::new())] {
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
s.select(|_, sel| {
|
||||
sel.set([c]);
|
||||
sel.anchors.insert(c, [(0, 0), (3, 1), (3, 2)].into());
|
||||
sel.segments.remove(&c);
|
||||
if let Some(set) = segments.clone() {
|
||||
sel.segments.insert(c, set);
|
||||
}
|
||||
})
|
||||
.unwrap();
|
||||
assert_eq!(s.execute("addon.pathtools.addPoints", &json!({})).unwrap()["added"], json!(1));
|
||||
assert_eq!(
|
||||
points(&path(&s, c)),
|
||||
[points(&before)[..3].to_vec(), vec![vec![(0.0, 100.0), (100.0, 100.0), (150.0, 100.0), (200.0, 100.0)]]].concat()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn crossings_add_anchors_on_each_subpath_of_a_compound_path() {
|
||||
let mut s = session();
|
||||
// A and the last subpath cross both lines; the empty subpath and C, which ends short of them, do not.
|
||||
let c = compound(&mut s, &[&[(0.0, 50.0), (200.0, 50.0)], &[], &[(0.0, 150.0), (40.0, 150.0)], &[(0.0, 250.0), (400.0, 250.0)]]);
|
||||
let q = line(&mut s, &[(100.0, 0.0), (100.0, 300.0)], false);
|
||||
let r = line(&mut s, &[(150.0, 0.0), (150.0, 300.0)], false);
|
||||
select(&mut s, &[c, q, r]);
|
||||
let n = steps(&s);
|
||||
assert_eq!(s.execute("addon.pathtools.crossings", &json!({})).unwrap()["crossings"], json!(8));
|
||||
let want = vec![
|
||||
vec![(0.0, 50.0), (100.0, 50.0), (150.0, 50.0), (200.0, 50.0)],
|
||||
vec![],
|
||||
vec![(0.0, 150.0), (40.0, 150.0)],
|
||||
vec![(0.0, 250.0), (100.0, 250.0), (150.0, 250.0), (400.0, 250.0)],
|
||||
];
|
||||
assert_eq!(points(&path(&s, c)), want);
|
||||
assert_eq!(points(&path(&s, q)), [vec![(100.0, 0.0), (100.0, 50.0), (100.0, 250.0), (100.0, 300.0)]]);
|
||||
assert_eq!(steps(&s), n + 1);
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
// Cut: the compound path keeps its pieces as subpaths, in order; each line becomes three paths.
|
||||
let out = ids(&s.execute("addon.pathtools.crossings", &json!({"cut": true})).unwrap());
|
||||
assert_eq!(out.len(), 7);
|
||||
assert_eq!(out[0], c);
|
||||
let pieces = vec![
|
||||
vec![(0.0, 50.0), (100.0, 50.0)],
|
||||
vec![(100.0, 50.0), (150.0, 50.0)],
|
||||
vec![(150.0, 50.0), (200.0, 50.0)],
|
||||
vec![],
|
||||
vec![(0.0, 150.0), (40.0, 150.0)],
|
||||
vec![(0.0, 250.0), (100.0, 250.0)],
|
||||
vec![(100.0, 250.0), (150.0, 250.0)],
|
||||
vec![(150.0, 250.0), (400.0, 250.0)],
|
||||
];
|
||||
assert_eq!(points(&path(&s, c)), pieces);
|
||||
let qs: Vec<Vec<Vec<(f64, f64)>>> = out[1..4].iter().map(|id| points(&path(&s, *id))).collect();
|
||||
assert_eq!(qs, [[vec![(100.0, 0.0), (100.0, 50.0)]], [vec![(100.0, 50.0), (100.0, 250.0)]], [vec![(100.0, 250.0), (100.0, 300.0)]]]);
|
||||
assert_eq!(steps(&s), n + 1);
|
||||
}
|
||||
|
||||
// ---------- Parametric Shape Tool ----------
|
||||
|
||||
/// The parametric shape object `id` is now, as the tool reads it.
|
||||
@@ -887,6 +1033,35 @@ fn divide_into_dashes_divides_only_the_selected_segments() {
|
||||
assert_eq!(layer, pieces, "in the path's place, in order");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn divide_into_dashes_divides_the_selected_segments_of_each_subpath() {
|
||||
let mut s = session();
|
||||
let c = compound(&mut s, &SUBS);
|
||||
let before = path(&s, c);
|
||||
// The first segment of A and the second of D; a segment of C without its anchors selected.
|
||||
select_segments(&mut s, c, &[(0, 0), (0, 1), (0, 2), (3, 0), (3, 1), (3, 2)], &[(0, 0), (2, 0), (3, 1)]);
|
||||
let n = steps(&s);
|
||||
let r = s.execute("addon.pathtools.divideDashes", &json!({"dash": 25})).unwrap();
|
||||
assert_eq!(steps(&s), n + 1);
|
||||
assert_eq!(r["dashes"], json!(8));
|
||||
let made: Vec<Vec<Vec<(f64, f64)>>> = ids(&r).iter().map(|id| points(&path(&s, *id))).collect();
|
||||
let piece = |x0: f64, x1: f64, y: f64| vec![vec![(x0, y), (x1, y)]];
|
||||
let mut want = vec![vec![vec![], vec![(0.0, 50.0), (100.0, 50.0)]]];
|
||||
want.extend([(0.0, 25.0), (25.0, 50.0), (50.0, 75.0), (75.0, 100.0)].map(|(a, b)| piece(a, b, 0.0)));
|
||||
want.push(vec![vec![(100.0, 0.0), (200.0, 0.0)]]);
|
||||
want.push(vec![vec![(0.0, 100.0), (100.0, 100.0)]]);
|
||||
want.extend([(100.0, 125.0), (125.0, 150.0), (150.0, 175.0), (175.0, 200.0)].map(|(a, b)| piece(a, b, 100.0)));
|
||||
assert_eq!(made, want, "what stays of the path, then each subpath's pieces in order");
|
||||
assert_eq!(s.doc().unwrap().selection.objects, ids(&r));
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
assert_eq!(path(&s, c), before);
|
||||
// With no segments selected: those between two selected anchors, here on the last subpath only.
|
||||
select_segments(&mut s, c, &[(0, 0), (3, 1), (3, 2)], &[]);
|
||||
let r = s.execute("addon.pathtools.divideDashes", &json!({"dash": 25})).unwrap();
|
||||
assert_eq!((ids(&r).len(), r["dashes"].clone()), (6, json!(4)));
|
||||
assert_eq!(points(&path(&s, ids(&r)[0])), points(&before)[..3]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn divide_into_dashes_refuses_bad_params_and_too_many_pieces() {
|
||||
let mut s = session();
|
||||
|
||||
@@ -48,7 +48,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Add New Fill",
|
||||
["Window", "Appearance"],
|
||||
Some("Cmd+/"),
|
||||
"{ids?|id?, target?: \"object\"|\"contents\"} add a fill on top of each selected object's own stack, on an image right below its Contents row, with its top fill's paint (type without one: its characters' fill; else the default fill). With a fill row active (appearance.setActiveItem), no `ids`, no `id` and no `target: \"contents\"`, the new fill takes that row's paint, goes right above it, and becomes the active row; appearance.duplicateItem copies a whole row. On a path directly in a graph series (a series group's id stands for those paths), the graph also stores the paint, weight, opacity and blend mode of the topmost visible fill and stroke (exceptions: docs/mcp.md)",
|
||||
"{ids?|id?, target?: \"object\"|\"contents\"} add a fill on top of each selected object's own stack, on an image right below its Contents row, with its top fill's paint (type without one: its characters' fill; else the default fill). With a fill row active (appearance.setActiveItem), no `ids`, no `id` and no `target: \"contents\"`, the new fill takes that row's paint, goes right above it, and becomes the active row; appearance.duplicateItem copies a whole row. On a path directly in a graph series (a series group's id stands for those paths), the graph also stores the paint, weight, opacity and blend mode of the topmost visible fill and stroke (exceptions: docs/mcp.md); on a graph label, the graph stores that label's topmost visible fill and stroke rows and object opacity and blend mode for every label of its kind",
|
||||
has_doc,
|
||||
|s, p| add_item(s, p, true)
|
||||
),
|
||||
@@ -57,7 +57,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Add New Stroke",
|
||||
["Window", "Appearance"],
|
||||
Some("Cmd+Alt+/"),
|
||||
"{ids?|id?, target?} add a stroke on top of each selected object's own stack, on an image right below its Contents row, with its top stroke's paint (else the default stroke) and that stroke's weight when it has paint, at least 1 pt. On a path directly in a graph series (a series group's id stands for those paths), the graph also stores the paint, weight, opacity and blend mode of the topmost visible fill and stroke (exceptions: docs/mcp.md)",
|
||||
"{ids?|id?, target?} add a stroke on top of each selected object's own stack, on an image right below its Contents row, with its top stroke's paint (else the default stroke) and that stroke's weight when it has paint, at least 1 pt. On a path directly in a graph series (a series group's id stands for those paths), the graph also stores the paint, weight, opacity and blend mode of the topmost visible fill and stroke (exceptions: docs/mcp.md); on a graph label, the graph stores that label's topmost visible fill and stroke rows and object opacity and blend mode for every label of its kind",
|
||||
has_doc,
|
||||
|s, p| add_item(s, p, false)
|
||||
),
|
||||
@@ -66,7 +66,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Clear Appearance",
|
||||
["Window", "Appearance"],
|
||||
None,
|
||||
"{ids?|id?, target?} leave each object one empty fill and stroke (None; a group none of its own) and reset its opacity and blend mode. On a path directly in a graph series (a series group's id stands for those paths and the group), the graph also stores the paint, weight, opacity and blend mode of the topmost visible fill and stroke and the object's opacity and blend mode (exceptions: docs/mcp.md)",
|
||||
"{ids?|id?, target?} leave each object one empty fill and stroke (None; a group none of its own) and reset its opacity and blend mode. On a path directly in a graph series (a series group's id stands for those paths and the group), the graph also stores the paint, weight, opacity and blend mode of the topmost visible fill and stroke and the object's opacity and blend mode (exceptions: docs/mcp.md); on a graph label, the graph stores that label's topmost visible fill and stroke rows and object opacity and blend mode for every label of its kind",
|
||||
has_doc,
|
||||
clear_appearance
|
||||
),
|
||||
@@ -75,7 +75,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Reduce to Basic Appearance",
|
||||
["Window", "Appearance"],
|
||||
None,
|
||||
"{ids?|id?, target?} keep only one visible fill and stroke (without ids: the Appearance panel's active row in place of the topmost one of its kind; else the topmost), without their own opacity, blend mode or effects (a group keeps no rows it lacked), and drop the object's effects. On a path directly in a graph series (a series group's id stands for those paths and the group), the graph also stores the paint, weight, opacity and blend mode of the topmost visible fill and stroke (exceptions: docs/mcp.md)",
|
||||
"{ids?|id?, target?} keep only one visible fill and stroke (without ids: the Appearance panel's active row in place of the topmost one of its kind; else the topmost), without their own opacity, blend mode or effects (a group keeps no rows it lacked), and drop the object's effects. On a path directly in a graph series (a series group's id stands for those paths and the group), the graph also stores the paint, weight, opacity and blend mode of the topmost visible fill and stroke (exceptions: docs/mcp.md); on a graph label, the graph stores that label's topmost visible fill and stroke rows and object opacity and blend mode for every label of its kind",
|
||||
has_doc,
|
||||
reduce_basic
|
||||
),
|
||||
@@ -84,7 +84,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Appearance Item",
|
||||
[],
|
||||
None,
|
||||
"{index: paint-order item index, ids?|id? (default: the selection), target?: \"object\"|\"contents\", opacity?: 0..100, blend?: name, visible?: bool, weight?: pt (strokes), color?|none?|swatch?|gradient? (as paint.setFill), tint?: 0..100, keepModel?: bool (both as paint.setFill), aligned?: bool (index and item are rows of the first target's stack: each other target edits its equal one, as the Appearance panel does for a mixed selection)} edit one fill/stroke of each target object's own appearance stack. On a path directly in a graph series (a series group's id stands for those paths), the graph also stores the paint, weight, opacity and blend mode of the topmost visible fill and stroke (exceptions: docs/mcp.md)",
|
||||
"{index: paint-order item index, ids?|id? (default: the selection), target?: \"object\"|\"contents\", opacity?: 0..100, blend?: name, visible?: bool, weight?: pt (strokes), color?|none?|swatch?|gradient? (as paint.setFill), tint?: 0..100, keepModel?: bool (both as paint.setFill), aligned?: bool (index and item are rows of the first target's stack: each other target edits its equal one, as the Appearance panel does for a mixed selection)} edit one fill/stroke of each target object's own appearance stack. On a path directly in a graph series (a series group's id stands for those paths), the graph also stores the paint, weight, opacity and blend mode of the topmost visible fill and stroke (exceptions: docs/mcp.md); on a graph label, the graph stores that label's topmost visible fill and stroke rows and object opacity and blend mode for every label of its kind",
|
||||
has_doc,
|
||||
set_item
|
||||
),
|
||||
@@ -93,7 +93,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Remove Item",
|
||||
[],
|
||||
None,
|
||||
"{index | indices: [..] (paint-order item indices), ids?|id?, target?, aligned?: bool (index and item are rows of the first target's stack: each other target edits its equal one, as the Appearance panel does for a mixed selection)} remove those fills/strokes from each target object's own stack. On a path directly in a graph series (a series group's id stands for those paths), the graph also stores the paint, weight, opacity and blend mode of the topmost visible fill and stroke (exceptions: docs/mcp.md)",
|
||||
"{index | indices: [..] (paint-order item indices), ids?|id?, target?, aligned?: bool (index and item are rows of the first target's stack: each other target edits its equal one, as the Appearance panel does for a mixed selection)} remove those fills/strokes from each target object's own stack. On a path directly in a graph series (a series group's id stands for those paths), the graph also stores the paint, weight, opacity and blend mode of the topmost visible fill and stroke (exceptions: docs/mcp.md); on a graph label, the graph stores that label's topmost visible fill and stroke rows and object opacity and blend mode for every label of its kind",
|
||||
has_doc,
|
||||
remove_item
|
||||
),
|
||||
@@ -111,7 +111,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Duplicate Item",
|
||||
["Window", "Appearance"],
|
||||
None,
|
||||
"{index | indices: [..] (paint-order item indices), to?: paint-order index of the copy (with several, of the lowest copy: they land together in their order; default: each right above its item; not with aligned rows of a mixed selection), contents?: count (with `to`: how many items paint below the Contents (Characters) row afterwards; default: it stays between the same items), ids?|id?, target?, aligned?: bool (index and item are rows of the first target's stack: each other target edits its equal one, as the Appearance panel does for a mixed selection)} copy fills/strokes with their effects, as Alt-dragging rows places them. On a path directly in a graph series (a series group's id stands for those paths), the graph also stores the paint, weight, opacity and blend mode of the topmost visible fill and stroke (exceptions: docs/mcp.md)",
|
||||
"{index | indices: [..] (paint-order item indices), to?: paint-order index of the copy (with several, of the lowest copy: they land together in their order; default: each right above its item; not with aligned rows of a mixed selection), contents?: count (with `to`: how many items paint below the Contents (Characters) row afterwards; default: it stays between the same items), ids?|id?, target?, aligned?: bool (index and item are rows of the first target's stack: each other target edits its equal one, as the Appearance panel does for a mixed selection)} copy fills/strokes with their effects, as Alt-dragging rows places them. On a path directly in a graph series (a series group's id stands for those paths), the graph also stores the paint, weight, opacity and blend mode of the topmost visible fill and stroke (exceptions: docs/mcp.md); on a graph label, the graph stores that label's topmost visible fill and stroke rows and object opacity and blend mode for every label of its kind",
|
||||
has_doc,
|
||||
duplicate_item
|
||||
),
|
||||
@@ -120,7 +120,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Reorder Appearance Item",
|
||||
[],
|
||||
None,
|
||||
"{from: paint-order item index | \"contents\", indices?: [..] (several items instead of from: they move as one block in their order, the lowest landing at `to`; a block landing next to the Contents row keeps its lowest item's side), to, contents?: count, ids?|id?, target?, aligned?: bool (refused when the targets' appearances differ)} move fill/stroke `from` to paint-order index `to`; on a group, layer or type the Contents (Characters) row stays between the same items (a moved item landing next to it keeps its side) unless `contents` says how many items paint below it afterwards. from \"contents\": move that row so `to` items paint below the members (characters) and the rest above. On a path directly in a graph series (a series group's id stands for those paths, except with from \"contents\", which moves the group's own row), the graph also stores the paint, weight, opacity and blend mode of the topmost visible fill and stroke (exceptions: docs/mcp.md) → {index: where the item landed | contents: the row's slot}",
|
||||
"{from: paint-order item index | \"contents\", indices?: [..] (several items instead of from: they move as one block in their order, the lowest landing at `to`; a block landing next to the Contents row keeps its lowest item's side), to, contents?: count, ids?|id?, target?, aligned?: bool (refused when the targets' appearances differ)} move fill/stroke `from` to paint-order index `to`; on a group, layer or type the Contents (Characters) row stays between the same items (a moved item landing next to it keeps its side) unless `contents` says how many items paint below it afterwards. from \"contents\": move that row so `to` items paint below the members (characters) and the rest above. On a path directly in a graph series (a series group's id stands for those paths, except with from \"contents\", which moves the group's own row), the graph also stores the paint, weight, opacity and blend mode of the topmost visible fill and stroke (exceptions: docs/mcp.md); on a graph label, the graph stores that label's topmost visible fill and stroke rows and object opacity and blend mode for every label of its kind → {index: where the item landed | contents: the row's slot}",
|
||||
has_doc,
|
||||
move_item
|
||||
),
|
||||
@@ -129,7 +129,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Eyedropper",
|
||||
[],
|
||||
None,
|
||||
"{source: id, ids?, pickUp?, apply? (trees as eyedropper.setOptions, merged over the Eyedropper Options for this call), reverse?: bool, append?: bool, defaultsOnly?: bool} copy the attributes both picked up and applied (by default the whole appearance stack, opacity and blend mode, and from type to type the character and paragraph attributes using effective character dimensions while preserving target transforms) from `source` to ids (default: highlighted characters while editing type, after Select All in a frame of threaded text the text of the story's frames, leaving out `source`, otherwise the object selection; highlighted characters reject enabled non-neutral object transparency), the fill, stroke and weight also to the paint defaults; `defaultsOnly` updates only paint defaults and cannot be combined with ids, reverse or append; `reverse` (Alt-click) copies from the first selected object (or ids) onto `source` instead, or with nothing selected what the Eyedropper picked up last (a click without Alt); `append` (Shift+Alt-click) adds the source's fills, strokes and effects on top of each target's stack; placed gradients land at the same place relative to each target's bounds (defaults: fitted to new art); with an object of a dynamic symbol instance selected by Direct Selection and no ids, reverse, append or defaultsOnly, that object takes the source's fill and stroke (solid, none, gradient or pattern) for this instance only, as the Eyedropper Options allow, and a source that is not a path, compound path or type, or options that pick up neither color, are refused. On a path directly in a graph series, the graph also stores the paint, weight, opacity and blend mode of the topmost visible fill and stroke and the object's opacity and blend mode (exceptions: docs/mcp.md) → {ids}",
|
||||
"{source: id, ids?, pickUp?, apply? (trees as eyedropper.setOptions, merged over the Eyedropper Options for this call), reverse?: bool, append?: bool, defaultsOnly?: bool} copy the attributes both picked up and applied (by default the whole appearance stack, opacity and blend mode, and from type to type the character and paragraph attributes using effective character dimensions while preserving target transforms) from `source` to ids (default: highlighted characters while editing type, after Select All in a frame of threaded text the text of the story's frames, leaving out `source`, otherwise the object selection; highlighted characters reject enabled non-neutral object transparency), the fill, stroke and weight also to the paint defaults; `defaultsOnly` updates only paint defaults and cannot be combined with ids, reverse or append; `reverse` (Alt-click) copies from the first selected object (or ids) onto `source` instead, or with nothing selected what the Eyedropper picked up last (a click without Alt); `append` (Shift+Alt-click) adds the source's fills, strokes and effects on top of each target's stack; placed gradients land at the same place relative to each target's bounds (defaults: fitted to new art); with an object of a dynamic symbol instance selected by Direct Selection and no ids, reverse, append or defaultsOnly, that object takes the source's fill and stroke (solid, none, gradient or pattern) for this instance only, as the Eyedropper Options allow, and a source that is not a path, compound path or type, or options that pick up neither color, are refused. On a path directly in a graph series, the graph also stores the paint, weight, opacity and blend mode of the topmost visible fill and stroke and the object's opacity and blend mode (exceptions: docs/mcp.md); on a graph label, the graph stores, for every label of its kind, the character attributes the pick gives all of its text, its object opacity and blend mode, and with `append` its topmost visible fill and stroke rows → {ids}",
|
||||
has_doc,
|
||||
copy_from
|
||||
),
|
||||
@@ -147,7 +147,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Show All Hidden Attributes",
|
||||
["Window", "Appearance"],
|
||||
None,
|
||||
"{ids?|id?, target?} make every hidden fill, stroke and effect (the object's and each item's) of each selected object visible again; errors when nothing is hidden. On a path directly in a graph series (a series group's id stands for those paths and the group), the graph also stores the paint, weight, opacity and blend mode of the topmost visible fill and stroke (exceptions: docs/mcp.md) → {ids}",
|
||||
"{ids?|id?, target?} make every hidden fill, stroke and effect (the object's and each item's) of each selected object visible again; errors when nothing is hidden. On a path directly in a graph series (a series group's id stands for those paths and the group), the graph also stores the paint, weight, opacity and blend mode of the topmost visible fill and stroke (exceptions: docs/mcp.md); on a graph label, the graph stores that label's topmost visible fill and stroke rows and object opacity and blend mode for every label of its kind → {ids}",
|
||||
has_doc,
|
||||
show_all_hidden
|
||||
),
|
||||
@@ -165,7 +165,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Move Appearance",
|
||||
[],
|
||||
None,
|
||||
"{source: id, target: id, copy?: bool} give `target` (a layer, group or object) the appearance (fills, strokes, effects) and transparency (opacity, blend mode, isolation, knockout) of `source`, as dragging a target circle onto another in the Layers panel does; the source is left with a cleared appearance (as appearance.clear) unless `copy` (Alt-drag). Opacity masks stay; placed gradients keep their place relative to each object's bounds. On a path directly in a graph series (a series group's id stands for those paths; as `source`, when the group has no effect, opacity, blend mode, isolation, knockout or style link of its own, the topmost one is read and a move clears them all; as `target` each takes the appearance and the group is cleared, a `source` among them keeps its look, and a `source` that is the group's only path is an error), the graph also stores the paint, weight, opacity and blend mode of the topmost visible fill and stroke and the object's opacity and blend mode (exceptions: docs/mcp.md) → {source, target}",
|
||||
"{source: id, target: id, copy?: bool} give `target` (a layer, group or object) the appearance (fills, strokes, effects) and transparency (opacity, blend mode, isolation, knockout) of `source`, as dragging a target circle onto another in the Layers panel does; the source is left with a cleared appearance (as appearance.clear) unless `copy` (Alt-drag). Opacity masks stay; placed gradients keep their place relative to each object's bounds. On a path directly in a graph series (a series group's id stands for those paths; as `source`, when the group has no effect, opacity, blend mode, isolation, knockout or style link of its own, the topmost one is read and a move clears them all; as `target` each takes the appearance and the group is cleared, a `source` among them keeps its look, and a `source` that is the group's only path is an error), the graph also stores the paint, weight, opacity and blend mode of the topmost visible fill and stroke and the object's opacity and blend mode (exceptions: docs/mcp.md); on a graph label, the graph stores that label's topmost visible fill and stroke rows and object opacity and blend mode for every label of its kind → {source, target}",
|
||||
has_doc,
|
||||
transfer
|
||||
),
|
||||
@@ -545,15 +545,21 @@ pub(crate) fn edit_items_then(
|
||||
let per = item.per_target(s, ids);
|
||||
super::overprint::edit_counted(s, label, |d| {
|
||||
let mut edited = Vec::new();
|
||||
// The objects whose rows `f` edited: on type, no row means its characters.
|
||||
let mut rows = Vec::new();
|
||||
for (id, item) in ids.iter().zip(&per) {
|
||||
let Some(item) = item else { continue };
|
||||
let Some(n) = d.node_mut(*id) else { continue };
|
||||
let index = item.resolve(&n.appearance, fill, cmd)?;
|
||||
f(n, index)?;
|
||||
edited.push(*id);
|
||||
if index.is_some() {
|
||||
rows.push(*id);
|
||||
}
|
||||
}
|
||||
let more = after(&before, d, &edited)?;
|
||||
Ok(count_changed(d, &before, &edited) + usize::from(more))
|
||||
let labels = super::graph::capture_label_rows(d, &rows);
|
||||
Ok(count_changed(d, &before, &edited) + usize::from(more || labels))
|
||||
})?;
|
||||
Ok(())
|
||||
}
|
||||
@@ -690,6 +696,7 @@ fn add_item(s: &mut Session, p: &Value, fill: bool) -> Result<Value> {
|
||||
let mut rows = top_paint_changed(&before, d, &ids);
|
||||
rows.extend(painted_rows(d, added));
|
||||
super::graph::capture_series_top_paints(d, &rows);
|
||||
super::graph::capture_label_rows(d, &ids);
|
||||
Ok(())
|
||||
})?;
|
||||
// A fill added above the active row becomes the active row; otherwise the rows above the new
|
||||
@@ -712,7 +719,8 @@ fn clear_appearance(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let kept = super::graph::capture_series_opacity(d, &both_kinds(&ids));
|
||||
let painted = super::graph::capture_series_top_paints(d, &top_paint_changed(&before, d, &ids));
|
||||
let object = super::graph::capture_series_object_opacity(d, &ids);
|
||||
Ok(count_changed(d, &before, &ids) + usize::from(kept || painted || object))
|
||||
let labels = super::graph::capture_label_rows(d, &ids);
|
||||
Ok(count_changed(d, &before, &ids) + usize::from(kept || painted || object || labels))
|
||||
})?;
|
||||
s.active_appearance_item = None;
|
||||
ok()
|
||||
@@ -806,6 +814,33 @@ pub(crate) fn capture_series_changes(before: &vectorcraft_doc::Document, d: &mut
|
||||
!marks.is_empty() && capture_series_writes(before, d, &marks, (false, false), (false, false), false, false)
|
||||
}
|
||||
|
||||
/// Store on the graph the paint that an edit of `ids` (from `before` to `d`) leaves on the topmost visible fill and stroke
|
||||
/// of the series marks among them: a paint or stroke weight that changed, and the paint of each row the edit wrote
|
||||
/// (`written`: an object with the index of the row) when that row is the topmost visible one of its kind and has paint.
|
||||
/// A row written under that one, and a hidden one, keep what the graph stores. When the edit reaches several marks of one
|
||||
/// series, the frontmost one sets what the graph stores, whatever order `ids` are in. Returns whether the stored paint
|
||||
/// changed.
|
||||
pub(crate) fn capture_series_paint_writes(
|
||||
before: &vectorcraft_doc::Document,
|
||||
d: &mut vectorcraft_doc::Document,
|
||||
ids: &[NodeId],
|
||||
written: &[(NodeId, usize)],
|
||||
) -> bool {
|
||||
// Only objects directly in a series group can be marks: other edits skip the comparison of paints and the search of
|
||||
// the document for graphs.
|
||||
let in_series = |id: &NodeId| d.parent_of(*id).and_then(|p| d.node(p)).is_some_and(|g| g.extras().series_index.is_some());
|
||||
let mut marks: Vec<NodeId> = ids.iter().copied().filter(in_series).collect();
|
||||
// A later mark's capture replaces an earlier one's: take them in paint order, each with all of its rows.
|
||||
marks.sort_by_cached_key(|id| d.index_path(*id));
|
||||
let mut rows = Vec::new();
|
||||
for id in &marks {
|
||||
rows.extend(top_paint_changed(before, d, &[*id]));
|
||||
let shown = written.iter().filter(|(w, _)| w == id).filter_map(|(_, at)| Some((*id, top_row_kind(&d.node(*id)?.appearance, *at)?)));
|
||||
rows.extend(painted_rows(d, shown.collect::<Vec<_>>()));
|
||||
}
|
||||
super::graph::capture_series_top_paints(d, &rows)
|
||||
}
|
||||
|
||||
/// The kind of the row at `at` (fill `true`, stroke `false`) when it is the topmost visible row of that kind in `ap`.
|
||||
fn top_row_kind(ap: &Appearance, at: usize) -> Option<bool> {
|
||||
let fill = ap.items.get(at)?.is_fill();
|
||||
@@ -884,8 +919,10 @@ fn transfer(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
rows.extend(painted_rows(d, both_kinds(&targets)));
|
||||
let painted = super::graph::capture_series_top_paints(d, &rows);
|
||||
let object = super::graph::capture_series_object_opacity(d, &changed);
|
||||
// The targets last: on two labels of one kind, the target's rows are the ones the kind keeps.
|
||||
let labels = super::graph::capture_label_rows(d, &changed);
|
||||
let all: Vec<NodeId> = from.iter().chain(&targets).chain(&group).copied().collect();
|
||||
Ok(count_changed(d, &before, &all) + usize::from(kept || painted || object))
|
||||
Ok(count_changed(d, &before, &all) + usize::from(kept || painted || object || labels))
|
||||
})?;
|
||||
s.active_appearance_item = None;
|
||||
Ok(json!({ "source": source.0, "target": target.0 }))
|
||||
@@ -941,7 +978,8 @@ fn reduce_basic(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
}
|
||||
let kept = super::graph::capture_series_opacity(d, &both_kinds(&ids));
|
||||
let painted = super::graph::capture_series_top_paints(d, &top_paint_changed(&before, d, &ids));
|
||||
Ok(count_changed(d, &before, &ids) + usize::from(kept || painted))
|
||||
let labels = super::graph::capture_label_rows(d, &ids);
|
||||
Ok(count_changed(d, &before, &ids) + usize::from(kept || painted || labels))
|
||||
})?;
|
||||
s.active_appearance_item = None;
|
||||
ok()
|
||||
@@ -1016,7 +1054,8 @@ fn set_item(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let mut rows = top_paint_changed(&before, d, &ids);
|
||||
rows.extend(painted_rows(d, written));
|
||||
let painted = super::graph::capture_series_top_paints(d, &rows);
|
||||
Ok(count_changed(d, &before, &ids) + usize::from(kept || painted))
|
||||
let labels = super::graph::capture_label_rows(d, &ids);
|
||||
Ok(count_changed(d, &before, &ids) + usize::from(kept || painted || labels))
|
||||
})?;
|
||||
if let Some(pa) = &paint {
|
||||
s.remember_paint(pa);
|
||||
@@ -1062,7 +1101,8 @@ fn remove_item(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
}
|
||||
let kept = super::graph::capture_series_opacity(d, &top_opacity_changed(&before, d, &ids));
|
||||
let painted = super::graph::capture_series_top_paints(d, &top_paint_changed(&before, d, &ids));
|
||||
Ok(count_changed(d, &before, &ids) + usize::from(kept || painted))
|
||||
let labels = super::graph::capture_label_rows(d, &ids);
|
||||
Ok(count_changed(d, &before, &ids) + usize::from(kept || painted || labels))
|
||||
})?;
|
||||
// The rows removed from the active row's object, which need not come first in `ids`.
|
||||
let active = s.active_appearance_item.as_ref().and_then(|a| a.objects.first().copied());
|
||||
@@ -1146,6 +1186,7 @@ fn duplicate_item(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let mut rows = top_paint_changed(&before, d, &ids);
|
||||
rows.extend(painted_rows(d, added));
|
||||
super::graph::capture_series_top_paints(d, &rows);
|
||||
super::graph::capture_label_rows(d, &ids);
|
||||
Ok(())
|
||||
})?;
|
||||
// Rows at or above each copy move up by one (by the block, for copies placed at `to`).
|
||||
@@ -1185,6 +1226,7 @@ fn show_all_hidden(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
}
|
||||
super::graph::capture_series_opacity(d, &top_opacity_changed(&before, d, &shown));
|
||||
super::graph::capture_series_top_paints(d, &top_paint_changed(&before, d, &shown));
|
||||
super::graph::capture_label_rows(d, &shown);
|
||||
Ok(shown)
|
||||
})?;
|
||||
Ok(json!({ "ids": ids.iter().map(|i| i.0).collect::<Vec<_>>() }))
|
||||
@@ -1230,7 +1272,8 @@ fn move_item(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
}
|
||||
let kept = super::graph::capture_series_opacity(d, &top_opacity_changed(&before, d, &ids));
|
||||
let painted = super::graph::capture_series_top_paints(d, &top_paint_changed(&before, d, &ids));
|
||||
Ok(count_changed(d, &before, &ids) + usize::from(kept || painted))
|
||||
let labels = super::graph::capture_label_rows(d, &ids);
|
||||
Ok(count_changed(d, &before, &ids) + usize::from(kept || painted || labels))
|
||||
})?;
|
||||
// The moved row stays the active one; the rows it passed shift by one.
|
||||
s.remap_appearance_item(&ids, |a| {
|
||||
@@ -1275,7 +1318,8 @@ fn move_items(s: &mut Session, p: &Value, ids: &[NodeId], to: usize) -> Result<V
|
||||
}
|
||||
let kept = super::graph::capture_series_opacity(d, &top_opacity_changed(&before, d, ids));
|
||||
let painted = super::graph::capture_series_top_paints(d, &top_paint_changed(&before, d, ids));
|
||||
Ok(count_changed(d, &before, ids) + usize::from(kept || painted))
|
||||
let labels = super::graph::capture_label_rows(d, ids);
|
||||
Ok(count_changed(d, &before, ids) + usize::from(kept || painted || labels))
|
||||
})?;
|
||||
let m = idx.len();
|
||||
let landed = first_landed.unwrap_or(to);
|
||||
@@ -1307,7 +1351,9 @@ fn move_contents(s: &mut Session, ids: &[NodeId], to: usize) -> Result<Value> {
|
||||
slot.get_or_insert(n.appearance.contents_at());
|
||||
}
|
||||
slot.ok_or_else(|| bad("appearance.moveItem", "only groups, layers, type and images have a Contents (Characters) row"))?;
|
||||
Ok(count_changed(d, &before, ids))
|
||||
// A graph label's kind keeps where its rows paint relative to the characters.
|
||||
let labels = super::graph::capture_label_rows(d, ids);
|
||||
Ok(count_changed(d, &before, ids) + usize::from(labels))
|
||||
})?;
|
||||
Ok(json!({ "contents": slot }))
|
||||
}
|
||||
@@ -1462,9 +1508,46 @@ fn copy_from(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
if (targets.is_empty() && ranges.is_empty()) || attrs.is_empty() {
|
||||
return Ok(finish(s, &[]));
|
||||
}
|
||||
// What a graph keeps for the kind of a label whose whole text took the pick: the attributes the pick gave its
|
||||
// characters, with a picked gradient fitted to each label, and the picked paragraph attributes.
|
||||
let source_rows = picked.placed(None);
|
||||
let kept_text = |id: NodeId, st: &mut CharStyle| {
|
||||
Picked::run(st, &source_rows, &attrs, characters.get(&id));
|
||||
if attrs.appearance.fill.color {
|
||||
st.fill = super::gradient::unplaced(&st.fill);
|
||||
}
|
||||
if attrs.appearance.stroke.color {
|
||||
st.stroke = super::gradient::unplaced(&st.stroke);
|
||||
}
|
||||
};
|
||||
let kept_para = picked.text.as_ref().filter(|_| attrs.paragraph).map(|(_, para)| para);
|
||||
// Only a pick that applies character attributes or the paints, overprint and stroke options characters take changes
|
||||
// what a kind keeps for its characters.
|
||||
let (f, k) = (attrs.appearance.fill, attrs.appearance.stroke);
|
||||
let to_characters = (attrs.character && picked.text.is_some())
|
||||
|| f.color
|
||||
|| f.overprint
|
||||
|| k.color
|
||||
|| k.overprint
|
||||
|| k.weight
|
||||
|| k.cap
|
||||
|| k.join
|
||||
|| k.miter
|
||||
|| k.dash;
|
||||
let kept_text: Option<super::graph::LabelTextChange> = to_characters.then_some(&kept_text);
|
||||
if !ranges.is_empty() {
|
||||
let ids: Vec<NodeId> = ranges.iter().map(|(f, _)| *f).collect();
|
||||
let before = s.doc()?.doc.clone();
|
||||
// The labels whose whole text is highlighted.
|
||||
let whole: Vec<NodeId> = ranges
|
||||
.iter()
|
||||
.filter(|(id, range)| match (range, before.node(*id).map(|n| &n.kind)) {
|
||||
(None, _) => true,
|
||||
(Some((start, end)), Some(NodeKind::Text(t))) => *start == 0 && *end >= vectorcraft_text::edit::runs_len(&t.runs),
|
||||
_ => false,
|
||||
})
|
||||
.map(|(id, _)| *id)
|
||||
.collect();
|
||||
super::overprint::edit_counted(s, "Eyedropper", |d| {
|
||||
for &(id, range) in &ranges {
|
||||
let Some(NodeKind::Text(t)) = d.node_mut(id).map(|n| &mut n.kind) else { return Err(EngineError::NoNode(id)) };
|
||||
@@ -1477,7 +1560,8 @@ fn copy_from(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
super::typecmd::refresh_bounds(t);
|
||||
}
|
||||
check_text_bounds(d)?;
|
||||
Ok(count_changed(d, &before, &ids))
|
||||
let labels = super::graph::capture_label_text(d, &whole, kept_text, kept_para)?;
|
||||
Ok(count_changed(d, &before, &ids) + usize::from(labels))
|
||||
})?;
|
||||
return Ok(finish(s, &ids));
|
||||
}
|
||||
@@ -1532,7 +1616,17 @@ fn copy_from(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
// A pick of the object's transparency writes it on every target.
|
||||
let objects = if attrs.appearance.transparency { targets.clone() } else { object_opacity_changed(&before, d, &targets) };
|
||||
let object = super::graph::capture_series_object_opacity(d, &objects);
|
||||
Ok(count_changed(d, &before, &targets) + usize::from(kept || painted || object))
|
||||
// Graph labels keep their new rows, characters and paragraphs per kind of label.
|
||||
// On type, only `append` adds rows and a transparency pick changes the object's opacity and blend mode; the other
|
||||
// attributes go to the characters.
|
||||
let rows = if append {
|
||||
super::graph::capture_label_rows(d, &targets)
|
||||
} else {
|
||||
attrs.appearance.transparency && super::graph::capture_label_object_opacity(d, &targets)
|
||||
};
|
||||
// `append` adds rows and leaves the characters as they were.
|
||||
let text = !append && super::graph::capture_label_text(d, &targets, kept_text, kept_para)?;
|
||||
Ok(count_changed(d, &before, &targets) + usize::from(kept || painted || object || rows || text))
|
||||
})?;
|
||||
Ok(finish(s, &targets))
|
||||
}
|
||||
|
||||
@@ -666,6 +666,11 @@ fn map_graph_colors(g: &mut vectorcraft_doc::GraphSpec, f: &mut dyn FnMut(&Color
|
||||
map_paint_colors(&mut st.fill, f);
|
||||
map_paint_colors(&mut st.stroke, f);
|
||||
}
|
||||
for r in [&mut styles.values_rows, &mut styles.categories_rows, &mut styles.legend_rows].into_iter().flatten() {
|
||||
for paint in [&mut r.paint.fill, &mut r.paint.stroke].into_iter().flatten() {
|
||||
map_paint_colors(paint, f);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// `f` applied to solid, gradient-stop and freeform-control colors, with any swatch link.
|
||||
|
||||
@@ -110,7 +110,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Smooth",
|
||||
[],
|
||||
None,
|
||||
"{points: [[x,y]…], radius?: pt (12), fidelity?: pt (2.5)} smooth selected paths near the drag → {ids, before, after}",
|
||||
"{points: [[x,y]…], radius?: pt (12), fidelity?: pt (2.5; kept within 0.000078125 and about 3195, the Smooth tool's 0.05 to 100 px at the highest and the lowest zoom: the tool sends its Fidelity in screen pixels divided by the zoom)} smooth selected paths near the drag → {ids, before, after}",
|
||||
has_selection,
|
||||
smooth_region
|
||||
),
|
||||
|
||||
@@ -329,7 +329,7 @@ pub(super) fn smooth_region(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const C: &str = "path.smoothRegion";
|
||||
let pts = points_param(p, "points", C)?;
|
||||
let radius = number_or(p, "radius", 12.0, C)?.max(0.0);
|
||||
let tol = number_or(p, "fidelity", 2.5, C)?.clamp(vectorcraft_tools::draw2::SMOOTH_MIN_FIDELITY, 100.0);
|
||||
let tol = number_or(p, "fidelity", 2.5, C)?.clamp(vectorcraft_tools::draw2::SMOOTH_MIN_TOLERANCE, vectorcraft_tools::draw2::SMOOTH_MAX_TOLERANCE);
|
||||
let sigma = radius * SMOOTH_SPREAD;
|
||||
let st = s.doc()?;
|
||||
let mut plan = vec![];
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -103,6 +103,70 @@ fn use_artboards_false_writes_one_page_of_the_arts_bounds() {
|
||||
}
|
||||
}
|
||||
|
||||
/// In a PDF without a standard, the warning for a page longer than 14,400 pt written with a
|
||||
/// `UserUnit` above 1 names "Write pages longer than 200 in at full size in points": at PDF 1.5
|
||||
/// (written as 1.6) and later, through `document.exportPdf`, `document.export`, a page of the
|
||||
/// art's bounds and the Summary's export, with one such warning per page. PDF/A files and `.ai`
|
||||
/// saves keep the warning without the option's name. Pages written in points (the option on, PDF
|
||||
/// 1.4, PDF/X, a page too narrow for a `UserUnit` above 1) and a page too long and narrow for
|
||||
/// 14,400 units have other warnings, which don't name it.
|
||||
#[test]
|
||||
fn a_page_with_a_user_unit_names_the_option_that_writes_it_in_points() {
|
||||
use super::pdf::LONG_PAGES_IN_POINTS;
|
||||
const SMALLER: &str = "so apps that ignore UserUnit show it that many times smaller";
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", &json!({"width": 20_000, "height": 1_000})).unwrap();
|
||||
s.execute("shape.rectangle", &json!({"x": 100, "y": 100, "width": 19_000, "height": 500})).unwrap();
|
||||
let warnings = |v: &Value| -> Vec<String> {
|
||||
v["warnings"].as_array().unwrap().iter().filter_map(|w| w.as_str()).filter(|w| w.contains("UserUnit")).map(str::to_string).collect()
|
||||
};
|
||||
let named = |v: &Value| warnings(v).iter().any(|w| w.contains(LONG_PAGES_IN_POINTS));
|
||||
for p in [
|
||||
json!({"longPagesInPoints": false}),
|
||||
json!({"longPagesInPoints": false, "compatibility": "1.5"}),
|
||||
json!({"longPagesInPoints": false, "compatibility": "2.0"}),
|
||||
json!({"longPagesInPoints": false, "useArtboards": false}),
|
||||
] {
|
||||
let mut q = p.clone();
|
||||
q["includeDocument"] = json!(true);
|
||||
for v in [export(&mut s, p.clone()), s.execute("document.export", &as_export(&p)).unwrap(), s.execute("document.pdfSettings", &q).unwrap()] {
|
||||
let page: Vec<String> = warnings(&v).into_iter().filter(|w| w.starts_with("the page of artboard")).collect();
|
||||
assert_eq!(page.len(), 1, "{p}: {v}");
|
||||
assert!(page[0].ends_with(&format!("{SMALLER}{LONG_PAGES_IN_POINTS}")), "{p}: {page:?}");
|
||||
assert_eq!(warnings(&v).iter().filter(|w| w.contains("apps that ignore UserUnit")).count(), 1, "{p}: {v}");
|
||||
}
|
||||
}
|
||||
for p in [
|
||||
json!({"longPagesInPoints": true}),
|
||||
json!({"longPagesInPoints": false, "compatibility": "1.4"}),
|
||||
json!({"longPagesInPoints": false, "preset": "PDF/X-4:2010"}),
|
||||
] {
|
||||
assert!(!named(&export(&mut s, p.clone())), "{p}");
|
||||
}
|
||||
for p in
|
||||
[json!({"longPagesInPoints": false, "standard": "pdfA2b"}), json!({"longPagesInPoints": false, "standard": "pdfA2b", "compatibility": "1.4"})]
|
||||
{
|
||||
let v = export(&mut s, p.clone());
|
||||
assert!(warnings(&v).iter().any(|w| w.ends_with(SMALLER)) && !named(&v), "{p}: {v}");
|
||||
}
|
||||
let ai = s.execute("document.export", &json!({"format": "ai", "longPagesInPoints": false})).unwrap();
|
||||
assert!(warnings(&ai).iter().any(|w| w.ends_with(SMALLER)) && !named(&ai), "{ai}");
|
||||
// The art's bounds are within 14,400 pt.
|
||||
s.execute("select.all", &json!({})).unwrap();
|
||||
s.execute("edit.clear", &json!({})).unwrap();
|
||||
s.execute("shape.rectangle", &json!({"x": 100, "y": 100, "width": 500, "height": 500})).unwrap();
|
||||
assert!(!named(&export(&mut s, json!({"longPagesInPoints": false, "useArtboards": false}))));
|
||||
// A page 3 pt high keeps a UserUnit of 1, so it is written in points either way; a page
|
||||
// 85,040 × 17 pt gets a UserUnit above 1 that leaves its long side past 14,400 units.
|
||||
for (w, h) in [(20_000, 3), (85_040, 17)] {
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", &json!({"width": w, "height": h})).unwrap();
|
||||
s.execute("shape.rectangle", &json!({"x": 100, "y": 0, "width": w - 1_000, "height": h})).unwrap();
|
||||
let v = export(&mut s, json!({"longPagesInPoints": false}));
|
||||
assert!(!named(&v), "{w} × {h}: {v}");
|
||||
}
|
||||
}
|
||||
|
||||
/// A PDF of the art's bounds carries no Preserve Editing data, through `document.exportPdf`,
|
||||
/// `document.export` and the Summary's export in memory, and says so in a warning: reopened, its
|
||||
/// editing data would replace the document's artboards with the one around the art. A PDF of the
|
||||
|
||||
@@ -19,7 +19,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Add Freeform Point",
|
||||
[],
|
||||
None,
|
||||
"{at: [x,y] (document coordinates), color? (default: the gradient's colour there; #rrggbbaa and #rgba are refused), keepModel?: bool (as paint.setFill), opacity?: 0..1 (or 0..100), spread?: 0..1 (or 0..100 %; the radius of pure colour around the point, a fraction of half the object's larger side; default 0), line?: true (join it to the selected point: extends the line ending there, else starts one), ids?|id?, stroke?: bool (default: the targeted item's kind, else the active proxy), item?: fill/stroke item index|null (omitted: the Appearance panel's active item when it is of the edited kind), target?: \"object\"|\"contents\"} add a point to the freeform gradient (an unplaced one keeps its automatic points first) and select it. On a path directly in a graph series, the graph also stores the edited gradient when it is on the topmost visible fill or stroke, as for the other paint.freeform edits (docs/mcp.md) → {index}",
|
||||
"{at: [x,y] (document coordinates), color? (default: the gradient's colour there; #rrggbbaa and #rgba are refused), keepModel?: bool (as paint.setFill), opacity?: 0..1 (or 0..100), spread?: 0..1 (or 0..100 %; the radius of pure colour around the point, a fraction of half the object's larger side; default 0), line?: true (join it to the selected point: extends the line ending there, else starts one), ids?|id?, stroke?: bool (default: the targeted item's kind, else the active proxy), item?: fill/stroke item index|null (omitted: the Appearance panel's active item when it is of the edited kind), target?: \"object\"|\"contents\"} add a point to the freeform gradient (an unplaced one keeps its automatic points first) and select it. On a path directly in a graph series, the graph also stores the edited gradient when it is on the topmost visible fill or stroke, as for the other paint.freeform edits (docs/mcp.md); on a graph label's row, the graph stores the label's topmost visible fill and stroke rows for every label of its kind → {index}",
|
||||
has_doc,
|
||||
add_point
|
||||
),
|
||||
|
||||
@@ -432,16 +432,24 @@ fn can_toggle(s: &Session) -> std::result::Result<(), String> {
|
||||
let [id] = st.selection.objects.as_slice() else {
|
||||
return Err(if st.selection.is_empty() { "nothing selected" } else { "select one object" }.into());
|
||||
};
|
||||
// A group's contents are checked when Global Edit starts: measuring them here would walk
|
||||
// every object in it each time the menu or the Properties panel asks. Other objects are
|
||||
// measured ([`supported`]), not reduced to their form: the Control bar asks every frame.
|
||||
match st.doc.node(*id) {
|
||||
Some(n) if n.extras().mask.is_none() && plain_group(n) => Ok(()),
|
||||
Some(n) if supported(n) => Ok(()),
|
||||
_ => Err(UNSUPPORTED.into()),
|
||||
}
|
||||
// The object, a group's contents included, is measured ([`supported`]), not reduced to its
|
||||
// form, and the answer is kept until the document's revision moves: the Control bar asks
|
||||
// every frame, and a group's walk takes time with the anchors in it.
|
||||
let ok = match st.global_edit_support.get() {
|
||||
Some((rev, key, ok)) if rev == st.revision && key == *id => ok,
|
||||
_ => {
|
||||
let ok = st.doc.node(*id).is_some_and(supported);
|
||||
st.global_edit_support.set(Some((st.revision, *id, ok)));
|
||||
ok
|
||||
}
|
||||
};
|
||||
if ok { Ok(()) } else { Err(UNSUPPORTED.into()) }
|
||||
}
|
||||
|
||||
/// [`can_toggle`]'s answer for the one selected object: the revision and the object it was found
|
||||
/// for, and whether Global Edit supports it.
|
||||
pub type SupportCache = std::cell::Cell<Option<(u64, NodeId, bool)>>;
|
||||
|
||||
fn toggle(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
if s.doc()?.global_edit.is_some() {
|
||||
let st = s.doc_mut()?;
|
||||
@@ -989,8 +997,7 @@ mod tests {
|
||||
continue;
|
||||
}
|
||||
asked += 1;
|
||||
let today = n.extras().mask.is_none() && plain_group(&n) || by_form(&n);
|
||||
assert_eq!(can_toggle(&s).is_ok(), today, "seed {seed}");
|
||||
assert_eq!(can_toggle(&s).is_ok(), by_form(&n), "seed {seed}");
|
||||
match s.execute(C, &json!({})) {
|
||||
Ok(_) => {
|
||||
assert!(by_form(&n), "seed {seed}");
|
||||
|
||||
@@ -24,7 +24,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Gradient",
|
||||
["Window", "Gradient"],
|
||||
None,
|
||||
"{stroke?: bool (default: the targeted item's kind, else the active proxy), kind?: linear|radial|conic|diamond|freeform (conic, also accepted as angle or conical, sweeps the stops once around the center, clockwise from the vector, with a seam where the last stop meets the first; diamond runs the stops from the center out to a square with a corner at the end of the vector; freeform places points on each object, coloured along the stops), mode?: points|lines (freeform: how the Gradient tool adds points), method?: classic|perceptual|hue-clockwise|hue-counterclockwise (how the colours between the stops mix: in display RGB, perceptually (OKLab) so the steps look even, or around the hue wheel: hue, saturation and brightness each change evenly from one stop to the next, the hue angle falling with hue-clockwise (red, magenta, blue, cyan, green, yellow) and rising with hue-counterclockwise; two stops whose hues are less than half a color level apart keep that hue; a stop whose RGB numbers are less than half a color level apart is gray and takes the hue of the stop it mixes with, and black that stop's saturation too; these two methods are VectorCraft's own, and SVG and PDF exports draw their gradients as RGB images, CMYK and spot color stops included; SVG exports draw a Perceptual gradient as an image, and PDF exports do too unless it is linear, radial, conic or diamond, has spot color stops, every other stop is paper white or written in CMYK or gray (an export that converts colors to a CMYK destination profile writes RGB and Lab stops in CMYK, and so does a CMYK document unless the export converts colors to an RGB destination profile), it is not a dithered fill and its curve does not have too many stops: then PDF writes tints of one spot ink as a Separation shading in that ink, and spot inks mixed with other spot, CMYK or gray inks as a DeviceN shading; freeform gradients mix their own way), spread?: pad|repeat|reflect (linear, radial, conic and diamond: what the gradient paints past the ends of its stops: pad carries the end colors on; repeat and reflect run the stops `repeats` times along the vector (around the circle, for a conic) and on past both ends, reflect mirroring every other run; an error on a freeform gradient), repeats?: 1..100 (a whole number: how many times repeat and reflect run the stops along the vector; VectorCraft's own limit; pad keeps it unused), stops?: [{offset 0..1, color? (needed without swatch; #rrggbbaa and #rgba are refused), opacity? 0..1 (or 0..100), midpoint? 0.13..0.87, swatch?: colour swatch name (a global or spot colour or tint swatch links the stop, so swatch edits recolour it and a spot stop prints on its plate; a process colour just gives its colour), tint?: 0..100 (% of the linked swatch; default 100, or a tint swatch's own)}] (at least 2; a freeform gradient's points are recoloured along them), keepModel?: bool (as paint.setFill; a stop color the gradient already has keeps its model), angle?: deg counter-clockwise (0: the gradient runs left to right; radial, conic and diamond: the vector turns about the center), aspect?: % (radial, conic and diamond; a negative one mirrors a conic's sweep and is an error for the other kinds), reverse?: bool (the same ramp the other way round: the stops in reverse order, and a hue method turned the other way unless `method` is given too), dither?: bool (fills only: a linear, radial, conic or diamond gradient fill is drawn with fine noise that hides banding, and as pixels in SVG and PDF exports), item?: fill/stroke item index|null (omitted: the Appearance panel's active item when it is of the edited kind), ids?|id?, strokeMode?: within|along|across (strokes only: the gradient lies on the page and shows through the stroke, runs from the start of each subpath to its end, or runs from the stroke's left edge to its right all along it; with nothing selected, for the next object drawn; type characters' own strokes always paint within), target?: \"object\"|\"contents\"} edit the gradient in place (keeps its placement); solid/none paints become the default gradient. On a path directly in a graph series, the graph also stores the gradient when it is on the topmost visible fill or stroke (docs/mcp.md)",
|
||||
"{stroke?: bool (default: the targeted item's kind, else the active proxy), kind?: linear|radial|conic|diamond|freeform (conic, also accepted as angle or conical, sweeps the stops once around the center, clockwise from the vector, with a seam where the last stop meets the first; diamond runs the stops from the center out to a square with a corner at the end of the vector; freeform places points on each object, coloured along the stops), mode?: points|lines (freeform: how the Gradient tool adds points), method?: classic|perceptual|hue-clockwise|hue-counterclockwise (how the colours between the stops mix: in display RGB, perceptually (OKLab) so the steps look even, or around the hue wheel: hue, saturation and brightness each change evenly from one stop to the next, the hue angle falling with hue-clockwise (red, magenta, blue, cyan, green, yellow) and rising with hue-counterclockwise; two stops whose hues are less than half a color level apart keep that hue; a stop whose RGB numbers are less than half a color level apart is gray and takes the hue of the stop it mixes with, and black that stop's saturation too; these two methods are VectorCraft's own, and SVG and PDF exports draw their gradients as RGB images, CMYK and spot color stops included; SVG exports draw a Perceptual gradient as an image, and PDF exports do too unless it is linear, radial, conic or diamond, has spot color stops, every other stop is paper white or written in CMYK or gray (an export that converts colors to a CMYK destination profile writes RGB and Lab stops in CMYK, and so does a CMYK document unless the export converts colors to an RGB destination profile), it is not a dithered fill and its curve does not have too many stops: then PDF writes tints of one spot ink as a Separation shading in that ink, and spot inks mixed with other spot, CMYK or gray inks as a DeviceN shading; freeform gradients mix their own way), spread?: pad|repeat|reflect (linear, radial, conic and diamond: what the gradient paints past the ends of its stops: pad carries the end colors on; repeat and reflect run the stops `repeats` times along the vector (around the circle, for a conic) and on past both ends, reflect mirroring every other run; an error on a freeform gradient), repeats?: 1..100 (a whole number: how many times repeat and reflect run the stops along the vector; VectorCraft's own limit; pad keeps it unused), stops?: [{offset 0..1, color? (needed without swatch; #rrggbbaa and #rgba are refused), opacity? 0..1 (or 0..100), midpoint? 0.13..0.87, swatch?: colour swatch name (a global or spot colour or tint swatch links the stop, so swatch edits recolour it and a spot stop prints on its plate; a process colour just gives its colour), tint?: 0..100 (% of the linked swatch; default 100, or a tint swatch's own)}] (at least 2; a freeform gradient's points are recoloured along them), keepModel?: bool (as paint.setFill; a stop color the gradient already has keeps its model), angle?: deg counter-clockwise (0: the gradient runs left to right; radial, conic and diamond: the vector turns about the center), aspect?: % (radial, conic and diamond; a negative one mirrors a conic's sweep and is an error for the other kinds), reverse?: bool (the same ramp the other way round: the stops in reverse order, and a hue method turned the other way unless `method` is given too), dither?: bool (fills only: a linear, radial, conic or diamond gradient fill is drawn with fine noise that hides banding, and as pixels in SVG and PDF exports), item?: fill/stroke item index|null (omitted: the Appearance panel's active item when it is of the edited kind), ids?|id?, strokeMode?: within|along|across (strokes only: the gradient lies on the page and shows through the stroke, runs from the start of each subpath to its end, or runs from the stroke's left edge to its right all along it; with nothing selected, for the next object drawn; type characters' own strokes always paint within), target?: \"object\"|\"contents\"} edit the gradient in place (keeps its placement); solid/none paints become the default gradient. On a path directly in a graph series, the graph also stores the gradient when it is on the topmost visible fill or stroke (docs/mcp.md); on a graph label's row, the graph stores the label's topmost visible fill and stroke rows for every label of its kind",
|
||||
has_doc,
|
||||
edit_gradient
|
||||
),
|
||||
@@ -33,7 +33,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Gradient Vector",
|
||||
[],
|
||||
None,
|
||||
"{start?: [x,y], end?: [x,y] (document coordinates; both or neither: omitted, the vector stays), aspect?: % (radial, conic and diamond: the extent's height / width across the vector, a negative one mirroring a conic's sweep and an error for the other kinds; default: kept), focal?: [x,y] (document coordinates) | null (radial: the focal point, where the first stop sits, pulled inside the extent ellipse; null centres it; default: it keeps its place in the ellipse; an error on a conic or diamond gradient), ids?|id?, stroke?: bool (default: the targeted item's kind, else the active proxy), item?: fill/stroke item index|null (alias: index; omitted: the Appearance panel's active item when it is of the edited kind), target?: \"object\"|\"contents\", index?: appearance item index (as item)} set the gradient vector, aspect ratio and focal point (solid paints become the default gradient; type objects set it on their runs, in text space). On a path directly in a graph series, the graph also stores the gradient with its placement when it is on the topmost visible fill or stroke (docs/mcp.md)",
|
||||
"{start?: [x,y], end?: [x,y] (document coordinates; both or neither: omitted, the vector stays), aspect?: % (radial, conic and diamond: the extent's height / width across the vector, a negative one mirroring a conic's sweep and an error for the other kinds; default: kept), focal?: [x,y] (document coordinates) | null (radial: the focal point, where the first stop sits, pulled inside the extent ellipse; null centres it; default: it keeps its place in the ellipse; an error on a conic or diamond gradient), ids?|id?, stroke?: bool (default: the targeted item's kind, else the active proxy), item?: fill/stroke item index|null (alias: index; omitted: the Appearance panel's active item when it is of the edited kind), target?: \"object\"|\"contents\", index?: appearance item index (as item)} set the gradient vector, aspect ratio and focal point (solid paints become the default gradient; type objects set it on their runs, in text space). On a path directly in a graph series, the graph also stores the gradient with its placement when it is on the topmost visible fill or stroke (docs/mcp.md); on a graph label's row, the graph stores the label's topmost visible fill and stroke rows for every label of its kind, with the gradient fitted to each label",
|
||||
has_doc,
|
||||
set_gradient_geom
|
||||
),
|
||||
|
||||
@@ -45,9 +45,9 @@ use generate::generate;
|
||||
#[cfg(test)]
|
||||
use series::default_series_color;
|
||||
pub(crate) use series::{
|
||||
capture_label_anti_alias, capture_label_paragraphs, capture_label_styles, capture_series_object_opacity, capture_series_opacity,
|
||||
capture_series_paints, capture_series_top_paints, paint_targets, series_group_marks, series_group_marks_where, series_marks_in,
|
||||
series_object_opacity_differs, shown_paint,
|
||||
LabelTextChange, capture_label_anti_alias, capture_label_object_opacity, capture_label_paragraphs, capture_label_rows, capture_label_styles,
|
||||
capture_label_text, capture_series_object_opacity, capture_series_opacity, capture_series_top_paints, label_object_opacity_differs,
|
||||
paint_targets, series_group_marks, series_group_marks_where, series_marks_in, series_object_opacity_differs, shown_paint,
|
||||
};
|
||||
|
||||
#[cfg(test)]
|
||||
|
||||
@@ -73,6 +73,44 @@ pub(super) fn label_anti_alias(styles: &vectorcraft_doc::LabelStyles, role: Role
|
||||
kept.unwrap_or_default()
|
||||
}
|
||||
|
||||
/// The object rows labels of `role` keep, if any.
|
||||
fn kept_rows(styles: &vectorcraft_doc::LabelStyles, role: Role) -> Option<&vectorcraft_doc::LabelRows> {
|
||||
match role {
|
||||
Role::Value => styles.values_rows.as_ref(),
|
||||
Role::Category => styles.categories_rows.as_ref(),
|
||||
Role::Legend => styles.legend_rows.as_ref(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Give label `n` the object rows `kept` keeps: a fill row and a stroke row for the kept paints, in their kept order and
|
||||
/// place relative to the characters, and the object opacity and blend mode. An opacity read from a file is held to 0..1
|
||||
/// and a weight to 0..1000 pt; one that is not a number is left out (a stroke then takes 1 pt).
|
||||
fn apply_label_rows(n: &mut Node, kept: &vectorcraft_doc::LabelRows) {
|
||||
use vectorcraft_doc::{AppearanceItem, FillLayer, StrokeLayer};
|
||||
let rows = &kept.paint;
|
||||
let unit = |o: Option<f32>| o.filter(|o| o.is_finite()).map(|o| o.clamp(0.0, 1.0));
|
||||
let fill = rows.fill.as_ref().map(|paint| {
|
||||
let mut f = FillLayer::new(paint.clone());
|
||||
f.opacity = unit(rows.fill_opacity).unwrap_or(1.0);
|
||||
f.blend = rows.fill_blend.unwrap_or_default();
|
||||
AppearanceItem::Fill(f)
|
||||
});
|
||||
let stroke = rows.stroke.as_ref().map(|paint| {
|
||||
let width = rows.stroke_width.filter(|w| w.is_finite()).map_or(1.0, |w| w.clamp(0.0, 1000.0));
|
||||
let mut st = StrokeLayer::new(paint.clone(), width);
|
||||
st.opacity = unit(rows.stroke_opacity).unwrap_or(1.0);
|
||||
st.blend = rows.stroke_blend.unwrap_or_default();
|
||||
AppearanceItem::Stroke(st)
|
||||
});
|
||||
let items: Vec<AppearanceItem> = if kept.stroke_under { [stroke, fill] } else { [fill, stroke] }.into_iter().flatten().collect();
|
||||
let mut ap = Appearance::clone(&n.appearance);
|
||||
ap.items = items;
|
||||
ap.set_contents_at(usize::from(kept.below.unwrap_or(0)));
|
||||
n.appearance = ap.into();
|
||||
n.opacity = unit(rows.opacity).unwrap_or(1.0);
|
||||
n.blend = rows.blend.unwrap_or_default();
|
||||
}
|
||||
|
||||
/// How big labels of `role` are set: a kept style's size, else 9 pt. The labels' spacing follows it.
|
||||
pub(super) fn label_size(styles: &vectorcraft_doc::LabelStyles, role: Role) -> f64 {
|
||||
label_style(styles, role).size
|
||||
@@ -90,7 +128,15 @@ impl Gen<'_> {
|
||||
}
|
||||
pub(super) fn text(&mut self, role: Role, at: Point, s: &str, justify: Justify) -> Arc<Node> {
|
||||
let t = self.text_object(role, at, s, justify);
|
||||
Arc::new(Node::new(self.d.alloc_id(), NodeKind::Text(Box::new(t))))
|
||||
self.label_node(role, t)
|
||||
}
|
||||
/// Label text `t` of `role` as a node, with the object rows its kind keeps.
|
||||
fn label_node(&mut self, role: Role, t: TextObject) -> Arc<Node> {
|
||||
let mut n = Node::new(self.d.alloc_id(), NodeKind::Text(Box::new(t)));
|
||||
if let Some(rows) = kept_rows(self.styles, role) {
|
||||
apply_label_rows(&mut n, rows);
|
||||
}
|
||||
Arc::new(n)
|
||||
}
|
||||
/// Label `s` of `role` set at `at` and laid out, not yet in the document.
|
||||
fn text_object(&self, role: Role, at: Point, s: &str, justify: Justify) -> TextObject {
|
||||
@@ -121,7 +167,7 @@ impl Gen<'_> {
|
||||
t.xf = Affine::translate((0.0, half - (first + last) / 2.0)) * t.xf;
|
||||
}
|
||||
}
|
||||
Arc::new(Node::new(self.d.alloc_id(), NodeKind::Text(Box::new(t))))
|
||||
self.label_node(role, t)
|
||||
}
|
||||
pub(super) fn group(&mut self, name: &str, children: Vec<Arc<Node>>) -> Arc<Node> {
|
||||
let mut g = Node::group(self.d.alloc_id(), children);
|
||||
|
||||
@@ -180,29 +180,6 @@ pub(crate) fn series_group_marks_where(doc: &Document, ids: &[NodeId], stands: i
|
||||
out
|
||||
}
|
||||
|
||||
/// Copy the paint just applied to generated series art into that series' fill or stroke. Called
|
||||
/// inside the same document edit, so the visible paint and future regeneration undo together.
|
||||
/// Gradients, patterns and swatch links are kept as [`Paint`], not reduced to a solid colour.
|
||||
/// Returns whether a series' captured fill or stroke changed.
|
||||
pub(crate) fn capture_series_paints(doc: &mut Document, ids: &[NodeId], fill: bool) -> bool {
|
||||
let members = series_members(doc);
|
||||
if members.is_empty() {
|
||||
return false;
|
||||
}
|
||||
let captures: Vec<_> = ids
|
||||
.iter()
|
||||
.filter_map(|id| {
|
||||
// Only a series' own marks set its paint, not the art of a marker design.
|
||||
let r = members.get(id).filter(|r| doc.parent_of(*id) == Some(r.group))?;
|
||||
let node = doc.node(*id)?;
|
||||
let paint = node.appearance.paint_at(None, fill)?.clone();
|
||||
let width = (!fill).then(|| node.appearance.stroke_width());
|
||||
Some((r.graph, r.index, fill, paint, width))
|
||||
})
|
||||
.collect();
|
||||
store_series_paints(doc, captures)
|
||||
}
|
||||
|
||||
/// The paint of the topmost visible fill (`true`) or stroke (`false`) of `node`, and a stroke's weight (0 without paint).
|
||||
/// With no such row: no paint.
|
||||
pub(crate) fn shown_paint(node: &Node, fill: bool) -> (Paint, f64) {
|
||||
@@ -215,7 +192,8 @@ pub(crate) fn shown_paint(node: &Node, fill: bool) -> (Paint, f64) {
|
||||
|
||||
/// Copy the paint of the topmost visible fill (`true`) or stroke (`false`), and a stroke's weight, of generated series
|
||||
/// art just edited (`edited`: each object with the kind of row, later entries replacing earlier ones) into that series'
|
||||
/// fill or stroke; art left without such a row keeps no paint. A series that draws Filled Lines bands stores no stroke: its
|
||||
/// fill or stroke; art left without such a row keeps no paint. Gradients, patterns and swatch links are kept as
|
||||
/// [`Paint`], not reduced to a solid color. A series that draws Filled Lines bands stores no stroke: its
|
||||
/// bands keep their own outline. Called inside the same document edit, so both undo together. Returns whether a series'
|
||||
/// captured fill or stroke changed.
|
||||
pub(crate) fn capture_series_top_paints(doc: &mut Document, edited: &[(NodeId, bool)]) -> bool {
|
||||
@@ -268,13 +246,35 @@ fn series_draws_bands(g: &GraphSpec, i: usize) -> bool {
|
||||
}
|
||||
}
|
||||
|
||||
/// Store each capture (graph, series index, fill or stroke, paint, stroke weight) in its series. Returns whether a
|
||||
/// series' captured fill or stroke changed.
|
||||
fn store_series_paints(doc: &mut Document, captures: Vec<(NodeId, usize, bool, Paint, Option<f64>)>) -> bool {
|
||||
/// Each graph's stored series paints before an edit's first capture into it. A later capture can put back what an earlier
|
||||
/// one changed, so an edit compares what it leaves with these ([`settle_series_paints`]).
|
||||
type SeriesPaintsBefore = HashMap<NodeId, Vec<vectorcraft_doc::SeriesPaint>>;
|
||||
|
||||
/// What an edit gave the characters of graph label `id`, applied to a character style ([`capture_label_text`]).
|
||||
pub(crate) type LabelTextChange<'a> = &'a dyn Fn(NodeId, &mut CharStyle);
|
||||
|
||||
/// Does any graph in `before` now store other series paints than it did before the edit's first capture? Slots the
|
||||
/// captures added past the old end and left empty are removed again, so they do not count as a change.
|
||||
fn settle_series_paints(doc: &mut Document, before: SeriesPaintsBefore) -> bool {
|
||||
let mut changed = false;
|
||||
for (graph, was) in before {
|
||||
let Some(spec) = doc.node_mut(graph).and_then(|n| n.extras_if_any().and_then(|e| e.graph.as_deref_mut())) else { continue };
|
||||
let empty = vectorcraft_doc::SeriesPaint::default();
|
||||
if spec.series_paints.get(was.len()..).is_some_and(|added| added.iter().all(|slot| *slot == empty)) {
|
||||
spec.series_paints.truncate(was.len());
|
||||
}
|
||||
changed |= spec.series_paints != was;
|
||||
}
|
||||
changed
|
||||
}
|
||||
|
||||
/// Store each capture (graph, series index, fill or stroke, paint, stroke weight) in its series. Returns whether a
|
||||
/// series' captured fill or stroke differs from what the graph stored before the first capture.
|
||||
fn store_series_paints(doc: &mut Document, captures: Vec<(NodeId, usize, bool, Paint, Option<f64>)>) -> bool {
|
||||
let mut before = SeriesPaintsBefore::new();
|
||||
for (graph, index, fill, paint, width) in captures {
|
||||
let Some(spec) = doc.node_mut(graph).and_then(|n| n.extras_if_any().and_then(|e| e.graph.as_deref_mut())) else { continue };
|
||||
let was = spec.series_paints.clone();
|
||||
before.entry(graph).or_insert_with(|| spec.series_paints.clone());
|
||||
// `index` is below the graph's series count (`series_members`).
|
||||
if spec.series_paints.len() <= index {
|
||||
spec.series_paints.resize(index + 1, vectorcraft_doc::SeriesPaint::default());
|
||||
@@ -286,9 +286,8 @@ fn store_series_paints(doc: &mut Document, captures: Vec<(NodeId, usize, bool, P
|
||||
slot.stroke = Some(paint);
|
||||
slot.stroke_width = width;
|
||||
}
|
||||
changed |= spec.series_paints != was;
|
||||
}
|
||||
changed
|
||||
settle_series_paints(doc, before)
|
||||
}
|
||||
|
||||
/// Copy the opacity and blend mode of the topmost visible fill (`true`) or stroke (`false`) of generated series art just
|
||||
@@ -315,14 +314,14 @@ pub(crate) fn capture_series_opacity(doc: &mut Document, edited: &[(NodeId, bool
|
||||
Some((r.graph, r.index, *fill, (opacity < 1.0).then_some(opacity), (blend != BlendMode::Normal).then_some(blend)))
|
||||
})
|
||||
.collect();
|
||||
let mut changed = false;
|
||||
let mut before = SeriesPaintsBefore::new();
|
||||
for (graph, index, fill, kept, blend) in captures {
|
||||
let Some(spec) = doc.node_mut(graph).and_then(|n| n.extras_if_any().and_then(|e| e.graph.as_deref_mut())) else { continue };
|
||||
// A fully opaque Normal row on a series the graph keeps nothing for leaves the graph as it is.
|
||||
if kept.is_none() && blend.is_none() && spec.series_paints.len() <= index {
|
||||
continue;
|
||||
}
|
||||
let was = spec.series_paints.clone();
|
||||
before.entry(graph).or_insert_with(|| spec.series_paints.clone());
|
||||
// `index` is below the graph's series count (`series_members`).
|
||||
if spec.series_paints.len() <= index {
|
||||
spec.series_paints.resize(index + 1, vectorcraft_doc::SeriesPaint::default());
|
||||
@@ -333,32 +332,30 @@ pub(crate) fn capture_series_opacity(doc: &mut Document, edited: &[(NodeId, bool
|
||||
} else {
|
||||
(slot.stroke_opacity, slot.stroke_blend) = (kept, blend);
|
||||
}
|
||||
changed |= spec.series_paints != was;
|
||||
}
|
||||
changed
|
||||
settle_series_paints(doc, before)
|
||||
}
|
||||
|
||||
/// Copy the object opacity and blend mode of generated series art just edited (`edited`) into that series' object opacity
|
||||
/// and blend mode. Called inside the same document edit, so both undo together. Returns whether a series' kept object
|
||||
/// opacity or blend mode changed.
|
||||
pub(crate) fn capture_series_object_opacity(doc: &mut Document, edited: &[NodeId]) -> bool {
|
||||
let mut changed = false;
|
||||
let mut before = SeriesPaintsBefore::new();
|
||||
for (graph, index, opacity, blend) in object_opacity_captures(doc, edited) {
|
||||
let Some(spec) = doc.node_mut(graph).and_then(|n| n.extras_if_any().and_then(|e| e.graph.as_deref_mut())) else { continue };
|
||||
// A fully opaque Normal object on a series the graph keeps nothing for leaves the graph as it is.
|
||||
if opacity.is_none() && blend.is_none() && spec.series_paints.len() <= index {
|
||||
continue;
|
||||
}
|
||||
let was = spec.series_paints.clone();
|
||||
before.entry(graph).or_insert_with(|| spec.series_paints.clone());
|
||||
// `index` is below the graph's series count (`series_members`).
|
||||
if spec.series_paints.len() <= index {
|
||||
spec.series_paints.resize(index + 1, vectorcraft_doc::SeriesPaint::default());
|
||||
}
|
||||
let Some(slot) = spec.series_paints.get_mut(index) else { continue };
|
||||
(slot.opacity, slot.blend) = (opacity, blend);
|
||||
changed |= spec.series_paints != was;
|
||||
}
|
||||
changed
|
||||
settle_series_paints(doc, before)
|
||||
}
|
||||
|
||||
/// Would [`capture_series_object_opacity`] change what the graph stores for `edited`? Reads the document without copying
|
||||
@@ -528,6 +525,149 @@ pub(crate) fn capture_label_paragraphs(
|
||||
Ok(changed)
|
||||
}
|
||||
|
||||
/// The object rows graph label `n` shows ([`vectorcraft_doc::LabelRows`]): the paint, weight, opacity and blend mode of
|
||||
/// its topmost visible fill and stroke row, its object opacity and blend mode, and where those rows paint. A placed
|
||||
/// gradient is kept without its placement, so it fits each label it lands on. `None`: no visible row, opaque and Normal.
|
||||
pub(crate) fn label_rows(n: &Node) -> Option<vectorcraft_doc::LabelRows> {
|
||||
use vectorcraft_doc::AppearanceItem;
|
||||
let items = &n.appearance.items;
|
||||
let top = |fill: bool| items.iter().rposition(|it| it.is_fill() == fill && it.visible());
|
||||
let opacity = |o: f32| (o != 1.0).then_some(o);
|
||||
let blend = |b: BlendMode| (b != BlendMode::Normal).then_some(b);
|
||||
let unplaced = crate::cmd::gradient::unplaced;
|
||||
let mut rows = vectorcraft_doc::SeriesPaint { opacity: opacity(n.opacity), blend: blend(n.blend), ..Default::default() };
|
||||
let (fill, stroke) = (top(true), top(false));
|
||||
if let Some(f) = fill.and_then(|i| items.get(i)) {
|
||||
(rows.fill, rows.fill_opacity, rows.fill_blend) = (Some(unplaced(f.paint())), opacity(f.opacity()), blend(f.blend()));
|
||||
}
|
||||
if let Some(AppearanceItem::Stroke(st)) = stroke.and_then(|i| items.get(i)) {
|
||||
(rows.stroke, rows.stroke_width, rows.stroke_opacity, rows.stroke_blend) =
|
||||
(Some(unplaced(&st.paint)), Some(st.width), opacity(st.opacity), blend(st.blend));
|
||||
}
|
||||
let contents = n.appearance.contents_at();
|
||||
let below = [fill, stroke].into_iter().flatten().filter(|i| *i < contents).fold(0u8, |k, _| k + 1);
|
||||
let rows = vectorcraft_doc::LabelRows {
|
||||
paint: rows,
|
||||
below: (below > 0).then_some(below),
|
||||
stroke_under: matches!((fill, stroke), (Some(f), Some(s)) if s < f),
|
||||
};
|
||||
(rows != vectorcraft_doc::LabelRows::default()).then_some(rows)
|
||||
}
|
||||
|
||||
/// Keep the object rows ([`label_rows`]) of graph labels `ids` for every label of their kind, as
|
||||
/// [`capture_label_styles`] keeps a character format, even when a label already showed them: the last label of a kind
|
||||
/// in the order of `ids` sets what that kind keeps. Objects other than type are left out before the graph's labels are
|
||||
/// looked up. Called inside the same edit, so they undo together. Returns whether kept rows changed.
|
||||
pub(crate) fn capture_label_rows(doc: &mut Document, ids: &[NodeId]) -> bool {
|
||||
store_label_rows(doc, ids, false)
|
||||
}
|
||||
|
||||
/// Keep the object opacity and blend mode of graph labels `ids` for every label of their kind, as [`capture_label_rows`]
|
||||
/// keeps them, and leave the kept fill and stroke rows as they are. Returns whether what the graph keeps changed.
|
||||
pub(crate) fn capture_label_object_opacity(doc: &mut Document, ids: &[NodeId]) -> bool {
|
||||
store_label_rows(doc, ids, true)
|
||||
}
|
||||
|
||||
/// Would [`capture_label_object_opacity`] change what a graph keeps for labels `ids`? Reads the document without copying
|
||||
/// it.
|
||||
pub(crate) fn label_object_opacity_differs(doc: &Document, ids: &[NodeId]) -> bool {
|
||||
label_rows_of(doc, ids).into_iter().any(|(graph, role, rows)| {
|
||||
let Some(spec) = doc.node(graph).and_then(|n| n.extras().graph.as_deref()) else { return false };
|
||||
let slot = match role {
|
||||
Role::Value => &spec.label_styles.values_rows,
|
||||
Role::Category => &spec.label_styles.categories_rows,
|
||||
Role::Legend => &spec.label_styles.legend_rows,
|
||||
};
|
||||
*slot != kept_label_rows(slot, rows, true)
|
||||
})
|
||||
}
|
||||
|
||||
/// [`capture_label_rows`], or with `object_only` [`capture_label_object_opacity`].
|
||||
fn store_label_rows(doc: &mut Document, ids: &[NodeId], object_only: bool) -> bool {
|
||||
let mut changed = false;
|
||||
for (graph, role, rows) in label_rows_of(doc, ids) {
|
||||
let Some(spec) = doc.node_mut(graph).and_then(|n| n.extras_if_any().and_then(|e| e.graph.as_deref_mut())) else { continue };
|
||||
let slot = match role {
|
||||
Role::Value => &mut spec.label_styles.values_rows,
|
||||
Role::Category => &mut spec.label_styles.categories_rows,
|
||||
Role::Legend => &mut spec.label_styles.legend_rows,
|
||||
};
|
||||
let kept = kept_label_rows(slot, rows, object_only);
|
||||
changed |= *slot != kept;
|
||||
*slot = kept;
|
||||
}
|
||||
changed
|
||||
}
|
||||
|
||||
/// What a kind keeps after a label of it shows `rows`: those rows, or with `object_only` what `slot` keeps with the
|
||||
/// label's object opacity and blend mode.
|
||||
fn kept_label_rows(
|
||||
slot: &Option<vectorcraft_doc::LabelRows>,
|
||||
rows: Option<vectorcraft_doc::LabelRows>,
|
||||
object_only: bool,
|
||||
) -> Option<vectorcraft_doc::LabelRows> {
|
||||
if !object_only {
|
||||
return rows;
|
||||
}
|
||||
let mut kept = slot.clone().unwrap_or_default();
|
||||
let shown = rows.unwrap_or_default();
|
||||
(kept.paint.opacity, kept.paint.blend) = (shown.paint.opacity, shown.paint.blend);
|
||||
(kept != vectorcraft_doc::LabelRows::default()).then_some(kept)
|
||||
}
|
||||
|
||||
/// The graph, kind and rows ([`label_rows`]) of the last label of each kind among `ids`.
|
||||
fn label_rows_of(doc: &Document, ids: &[NodeId]) -> Vec<(NodeId, Role, Option<vectorcraft_doc::LabelRows>)> {
|
||||
// Graph labels are type: without type among `ids`, the graph's labels are not looked up.
|
||||
if !ids.iter().any(|id| doc.node(*id).is_some_and(|n| matches!(n.kind, NodeKind::Text(_)))) {
|
||||
return vec![];
|
||||
}
|
||||
let labels = graph_labels(doc);
|
||||
let mut picked: Vec<(NodeId, Role, Option<vectorcraft_doc::LabelRows>)> = vec![];
|
||||
for (&(g, role), n) in ids.iter().rev().filter_map(|id| Some((labels.get(id)?, doc.node(*id)?))) {
|
||||
if !picked.iter().any(|(pg, pr, _)| *pg == g && *pr == role) {
|
||||
picked.push((g, role, label_rows(n)));
|
||||
}
|
||||
}
|
||||
picked
|
||||
}
|
||||
|
||||
/// Keep what an edit gave all of the text of graph labels `ids` for every label of their kind: `change`, when given (the
|
||||
/// character attributes the edit applied to label `id`), on the kind's kept style, as [`capture_label_styles`] keeps a
|
||||
/// character format, and `para`, when given, as the kind's paragraph attributes ([`capture_label_paragraphs`]).
|
||||
/// Attributes the edit did not apply stay as the kind keeps them. Returns whether a kept style or set of attributes
|
||||
/// changed.
|
||||
pub(crate) fn capture_label_text(
|
||||
doc: &mut Document,
|
||||
ids: &[NodeId],
|
||||
change: Option<LabelTextChange>,
|
||||
para: Option<&vectorcraft_doc::ParaStyle>,
|
||||
) -> Result<bool> {
|
||||
if (change.is_none() && para.is_none()) || !ids.iter().any(|id| doc.node(*id).is_some_and(|n| matches!(n.kind, NodeKind::Text(_)))) {
|
||||
return Ok(false);
|
||||
}
|
||||
// The last label of each kind sets what the kind keeps.
|
||||
let labels = graph_labels(doc);
|
||||
let mut last: Vec<((NodeId, Role), NodeId)> = vec![];
|
||||
for id in ids.iter().rev() {
|
||||
if let Some(kind) = labels.get(id).filter(|k| !last.iter().any(|(l, _)| l == *k)) {
|
||||
last.push((*kind, *id));
|
||||
}
|
||||
}
|
||||
let mut changed = false;
|
||||
for id in last.iter().rev().map(|(_, id)| id) {
|
||||
if let Some(change) = change {
|
||||
changed |= capture_label_styles(doc, &[*id], &|st| change(*id, st));
|
||||
}
|
||||
if let Some(para) = para {
|
||||
changed |= capture_label_paragraphs(doc, &[*id], &|p| {
|
||||
*p = para.clone();
|
||||
Ok(())
|
||||
})?;
|
||||
}
|
||||
}
|
||||
Ok(changed)
|
||||
}
|
||||
|
||||
/// The graphs and kinds of label among `ids`, each once.
|
||||
fn label_kinds(doc: &Document, ids: &[NodeId]) -> Vec<(NodeId, Role)> {
|
||||
if ids.is_empty() {
|
||||
|
||||
@@ -1381,7 +1381,7 @@ fn removing_a_filled_line_bands_stroke_leaves_its_color() {
|
||||
let (mut s, id, band) = band_graph();
|
||||
let was = series_parts(&s, id, 0)[0].appearance.fill_paint();
|
||||
s.execute("paint.setStroke", &json!({"ids": [band.0], "none": true})).unwrap();
|
||||
assert_eq!(spec(&s, id).series_paints[0].stroke, Some(Paint::None));
|
||||
assert!(spec(&s, id).series_paints.first().is_none_or(|p| p.stroke.is_none()));
|
||||
assert_eq!(redrawn_band(&mut s, id).0, was);
|
||||
}
|
||||
|
||||
@@ -2209,6 +2209,257 @@ fn swapping_onto_a_hidden_top_fill_leaves_the_stored_fill() {
|
||||
assert_eq!(spec(&s, id).series_paints[0].fill, red);
|
||||
}
|
||||
|
||||
/// A column graph whose series 0 stores a red fill and a red 2 pt stroke set on its second mark, while the topmost fill
|
||||
/// (over a visible one) and the green stroke of its first mark are hidden: the graph and the first mark.
|
||||
fn hidden_top_rows() -> (Session, NodeId, NodeId) {
|
||||
let (mut s, id, _, marks) = column_marks();
|
||||
s.execute("appearance.addFill", &json!({"ids": [marks[0].0]})).unwrap();
|
||||
let (_, upper) = fill_rows(&s, marks[0]);
|
||||
s.execute("appearance.setItem", &json!({"ids": [marks[0].0], "index": upper, "visible": false})).unwrap();
|
||||
let stroke = top_row(&s, marks[0], false);
|
||||
s.execute("appearance.setItem", &json!({"ids": [marks[0].0], "index": stroke, "color": "#00ff00", "weight": 3, "visible": false})).unwrap();
|
||||
s.execute("appearance.setItem", &json!({"ids": [marks[1].0], "index": top_row(&s, marks[1], true), "color": "#ff0000"})).unwrap();
|
||||
s.execute("appearance.setItem", &json!({"ids": [marks[1].0], "index": top_row(&s, marks[1], false), "color": "#ff0000", "weight": 2})).unwrap();
|
||||
(s, id, marks[0])
|
||||
}
|
||||
|
||||
/// `paint.setFill`, `paint.setStroke`, `paint.none`, Apply Fill to Stroke and Apply Stroke to Fill write the topmost row
|
||||
/// of the kind even when it is hidden. The mark shows what it showed before, and the series keeps the paint it stored.
|
||||
#[test]
|
||||
fn a_paint_set_on_a_hidden_top_row_leaves_the_stored_paint() {
|
||||
let cases = [
|
||||
("paint.setFill", json!({"color": "#0000ff"})),
|
||||
("paint.setFill", json!({"none": true})),
|
||||
("paint.setStroke", json!({"color": "#0000ff"})),
|
||||
("paint.setStroke", json!({"none": true})),
|
||||
("paint.none", json!({})),
|
||||
("addon.color.fillToStroke", json!({})),
|
||||
("addon.color.strokeToFill", json!({})),
|
||||
];
|
||||
every_case(cases, |(cmd, params)| {
|
||||
let (mut s, id, mark) = hidden_top_rows();
|
||||
let was = spec(&s, id).series_paints;
|
||||
let red = was[0].fill.clone().unwrap();
|
||||
assert_eq!((was[0].stroke.clone(), was[0].stroke_width), (Some(red.clone()), Some(2.0)));
|
||||
let mut p = params.clone();
|
||||
if *cmd == "paint.none" {
|
||||
s.execute("select.set", &json!({"ids": [mark.0]})).unwrap();
|
||||
} else {
|
||||
p["ids"] = json!([mark.0]);
|
||||
}
|
||||
let before = s.doc().unwrap().doc.node(mark).unwrap().clone();
|
||||
s.execute(cmd, &p).unwrap();
|
||||
assert_ne!(*s.doc().unwrap().doc.node(mark).unwrap(), before, "the command wrote the hidden row");
|
||||
assert_eq!(spec(&s, id).series_paints, was);
|
||||
s.execute("graph.setData", &json!({"rows": [[1, 2], [3, 4]]})).unwrap();
|
||||
assert!(marks_of(&s, id, 0).iter().all(|m| *m == (red.clone(), red.clone(), 2.0)), "{:?}", marks_of(&s, id, 0));
|
||||
});
|
||||
}
|
||||
|
||||
/// `paint.setFill` with `item` on the visible fill under a hidden topmost one changes what the mark shows, and the series
|
||||
/// stores it. On a fill under a visible topmost one, the series keeps the paint of the topmost one.
|
||||
#[test]
|
||||
fn a_paint_set_on_a_row_under_the_top_one_is_stored_only_when_it_shows() {
|
||||
let (mut s, id, mark) = hidden_top_rows();
|
||||
let (lower, _) = fill_rows(&s, mark);
|
||||
s.execute("paint.setFill", &json!({"ids": [mark.0], "item": lower, "color": "#0000ff"})).unwrap();
|
||||
let blue = s.doc().unwrap().doc.node(mark).unwrap().appearance.paint_at(Some(lower), true).cloned();
|
||||
assert!(blue.as_ref().is_some_and(|p| !p.is_none()));
|
||||
assert_eq!(spec(&s, id).series_paints[0].fill, blue);
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
assert_ne!(spec(&s, id).series_paints[0].fill, blue);
|
||||
|
||||
// The first mark has a green fill over another one, and the series stores the red fill of the second mark.
|
||||
let (mut s, id, _, marks) = column_marks();
|
||||
s.execute("appearance.addFill", &json!({"ids": [marks[0].0]})).unwrap();
|
||||
let (lower, upper) = fill_rows(&s, marks[0]);
|
||||
s.execute("appearance.setItem", &json!({"ids": [marks[0].0], "index": upper, "color": "#00ff00"})).unwrap();
|
||||
s.execute("appearance.setItem", &json!({"ids": [marks[1].0], "index": top_row(&s, marks[1], true), "color": "#ff0000"})).unwrap();
|
||||
let red = Some(fill_of(&s, marks[1]));
|
||||
assert_ne!(red, Some(fill_of(&s, marks[0])));
|
||||
assert_eq!(spec(&s, id).series_paints[0].fill, red);
|
||||
s.execute("paint.setFill", &json!({"ids": [marks[0].0], "item": lower, "color": "#0000ff"})).unwrap();
|
||||
assert_ne!(s.doc().unwrap().doc.node(marks[0]).unwrap().appearance.paint_at(Some(lower), true), red.as_ref());
|
||||
assert_eq!(spec(&s, id).series_paints[0].fill, red);
|
||||
s.execute("graph.setData", &json!({"id": id.0, "rows": [[1, 2], [3, 4]]})).unwrap();
|
||||
assert!(marks_of(&s, id, 0).iter().all(|m| Some(&m.0) == red.as_ref()), "{:?}", marks_of(&s, id, 0));
|
||||
}
|
||||
|
||||
/// A library gradient applied to a selected mark goes to the topmost fill as `paint.setFill` puts it there: the series
|
||||
/// stores it from a visible row, and keeps its stored paint when that row is hidden.
|
||||
#[test]
|
||||
fn a_library_gradient_on_a_series_mark_is_stored_only_from_a_row_it_shows() {
|
||||
for hidden in [false, true] {
|
||||
let (mut s, id, mark) = hidden_top_rows();
|
||||
if !hidden {
|
||||
s.execute("appearance.showAllHidden", &json!({"ids": [mark.0]})).unwrap();
|
||||
}
|
||||
let lib = s.execute("library.create", &json!({})).unwrap()["id"].as_str().unwrap().to_string();
|
||||
let rect = s.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 40, "height": 20})).unwrap()["id"].clone();
|
||||
let stops = json!([{"offset": 0, "color": "#ff0000"}, {"offset": 1, "color": "#0000ff"}]);
|
||||
s.execute("paint.setFill", &json!({"gradient": {"kind": "linear", "stops": stops}, "ids": [rect]})).unwrap();
|
||||
s.execute("select.set", &json!({"ids": [rect]})).unwrap();
|
||||
s.execute("library.add", &json!({"library": lib, "kind": "fillGradient"})).unwrap();
|
||||
s.execute("select.set", &json!({"ids": [mark.0]})).unwrap();
|
||||
let was = spec(&s, id).series_paints;
|
||||
s.execute("library.use", &json!({"library": lib, "kind": "fillGradient", "item": "Gradient"})).unwrap();
|
||||
let fill = fill_of(&s, mark);
|
||||
assert!(matches!(fill, Paint::Gradient(_)), "{hidden}");
|
||||
let now = spec(&s, id).series_paints;
|
||||
if hidden {
|
||||
assert_eq!(now, was);
|
||||
} else {
|
||||
assert_eq!(now[0].fill, Some(fill));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A mark's fill and stroke rows already have the paint a command writes, while the series stores the paint of another
|
||||
/// mark. `paint.setFill`, `paint.setStroke`, Apply Fill to Stroke and Apply Stroke to Fill leave the mark as it was, and
|
||||
/// the series stores the mark's paint for the kind of row the command wrote, and only for that kind.
|
||||
#[test]
|
||||
fn writing_the_paint_a_mark_already_has_stores_it_for_the_series() {
|
||||
let cases = [
|
||||
("paint.setFill", json!({"color": "#ff0000"}), true),
|
||||
("paint.setStroke", json!({"color": "#ff0000"}), false),
|
||||
("addon.color.fillToStroke", json!({}), false),
|
||||
("addon.color.strokeToFill", json!({}), true),
|
||||
("addon.color.fillToStroke", json!({"skipNone": true}), false),
|
||||
];
|
||||
every_case(cases, |(cmd, params, fill)| {
|
||||
let (mut s, id, _, marks) = column_marks();
|
||||
for (mark, hex, weight) in [(marks[0], "#ff0000", 3), (marks[1], "#0000ff", 2)] {
|
||||
s.execute("appearance.setItem", &json!({"ids": [mark.0], "index": top_row(&s, mark, true), "color": hex})).unwrap();
|
||||
s.execute("appearance.setItem", &json!({"ids": [mark.0], "index": top_row(&s, mark, false), "color": hex, "weight": weight})).unwrap();
|
||||
}
|
||||
let was = spec(&s, id).series_paints;
|
||||
let (red, blue) = (fill_of(&s, marks[0]), fill_of(&s, marks[1]));
|
||||
assert_eq!((was[0].fill.clone(), was[0].stroke.clone(), was[0].stroke_width), (Some(blue.clone()), Some(blue.clone()), Some(2.0)));
|
||||
let before = s.doc().unwrap().doc.node(marks[0]).unwrap().clone();
|
||||
let mut p = params.clone();
|
||||
p["ids"] = json!([marks[0].0]);
|
||||
s.execute(cmd, &p).unwrap();
|
||||
assert_eq!(*s.doc().unwrap().doc.node(marks[0]).unwrap(), before, "the mark is as it was");
|
||||
let now = spec(&s, id).series_paints;
|
||||
let expected = if *fill { (Some(red), Some(blue), Some(2.0)) } else { (Some(blue), Some(red), Some(3.0)) };
|
||||
assert_eq!((now[0].fill.clone(), now[0].stroke.clone(), now[0].stroke_width), expected);
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
assert_eq!(spec(&s, id).series_paints, was);
|
||||
});
|
||||
}
|
||||
|
||||
/// Apply Fill to Stroke and Apply Stroke to Fill on a mark store the paint they give its topmost visible row, and every
|
||||
/// mark of the series has it after the graph is drawn again. With `skipNone`, a mark without the source paint keeps what
|
||||
/// the series stores.
|
||||
#[test]
|
||||
fn applying_one_paint_kind_to_the_other_on_a_series_mark_is_kept() {
|
||||
let (mut s, id, marks) = stored_red_stroke();
|
||||
s.execute("appearance.setItem", &json!({"ids": [marks[0].0], "index": top_row(&s, marks[0], true), "color": "#0000ff"})).unwrap();
|
||||
let blue = fill_of(&s, marks[0]);
|
||||
s.execute("addon.color.fillToStroke", &json!({"ids": [marks[0].0]})).unwrap();
|
||||
let kept = spec(&s, id).series_paints;
|
||||
assert_eq!((kept[0].fill.clone(), kept[0].stroke.clone()), (Some(blue.clone()), Some(blue.clone())));
|
||||
s.execute("graph.setData", &json!({"id": id.0, "rows": [[1, 2], [3, 4]]})).unwrap();
|
||||
assert!(marks_of(&s, id, 0).iter().all(|m| (&m.0, &m.1) == (&blue, &blue)), "{:?}", marks_of(&s, id, 0));
|
||||
|
||||
let (mut s, id, marks) = stored_red_stroke();
|
||||
let red = spec(&s, id).series_paints[0].stroke.clone();
|
||||
s.execute("addon.color.strokeToFill", &json!({"ids": [marks[1].0]})).unwrap();
|
||||
assert_eq!(spec(&s, id).series_paints[0].fill, red);
|
||||
s.execute("paint.setFill", &json!({"ids": [marks[0].0], "none": true})).unwrap();
|
||||
s.execute("appearance.setItem", &json!({"ids": [marks[1].0], "index": top_row(&s, marks[1], true), "color": "#0000ff"})).unwrap();
|
||||
let was = spec(&s, id).series_paints;
|
||||
s.execute("addon.color.fillToStroke", &json!({"ids": [marks[0].0], "skipNone": true})).unwrap();
|
||||
assert_eq!(spec(&s, id).series_paints, was);
|
||||
}
|
||||
|
||||
/// When one paint command writes several marks of a series with different paints, the frontmost mark sets what the
|
||||
/// graph stores, whatever order the ids are given in and whatever the marks' ids are.
|
||||
#[test]
|
||||
fn a_paint_command_on_several_marks_of_a_series_stores_the_frontmost_ones_paint() {
|
||||
every_case([[0, 1, 2], [2, 1, 0], [1, 2, 0]], |order| {
|
||||
let (mut s, id, _, marks) = column_marks();
|
||||
// The mark made last goes to the back, so the marks' ids are not in paint order.
|
||||
s.execute("select.set", &json!({"ids": [marks[2].0]})).unwrap();
|
||||
s.execute("object.arrange.sendToBack", &json!({})).unwrap();
|
||||
let drawn: Vec<NodeId> = series_parts(&s, id, 0).iter().map(|m| m.id).filter(|m| marks[..3].contains(m)).collect();
|
||||
assert_eq!(drawn, [marks[2], marks[0], marks[1]]);
|
||||
for (mark, hex) in [(marks[0], "#ff0000"), (marks[1], "#00ff00"), (marks[2], "#0000ff")] {
|
||||
s.execute("appearance.setItem", &json!({"ids": [mark.0], "index": top_row(&s, mark, true), "color": hex})).unwrap();
|
||||
}
|
||||
s.execute("addon.color.fillToStroke", &json!({"ids": order.map(|i| marks[i].0)})).unwrap();
|
||||
assert_eq!(spec(&s, id).series_paints[0].stroke, Some(fill_of(&s, marks[1])), "{order:?}");
|
||||
});
|
||||
|
||||
// The frontmost mark loses its stroke and a mark under it takes one: the series stores the frontmost mark's.
|
||||
let (mut s, id, marks) = stored_red_stroke();
|
||||
s.execute("appearance.setItem", &json!({"ids": [marks[2].0], "index": top_row(&s, marks[2], false), "color": "#00ff00", "weight": 2})).unwrap();
|
||||
s.execute("paint.setFill", &json!({"ids": [marks[2].0], "none": true})).unwrap();
|
||||
s.execute("appearance.setItem", &json!({"ids": [marks[0].0], "index": top_row(&s, marks[0], true), "color": "#0000ff"})).unwrap();
|
||||
s.execute("addon.color.fillToStroke", &json!({"ids": [marks[0].0, marks[2].0]})).unwrap();
|
||||
assert!(!s.doc().unwrap().doc.node(marks[0]).unwrap().appearance.stroke_paint().is_none());
|
||||
assert_eq!(spec(&s, id).series_paints[0].stroke, Some(Paint::None));
|
||||
}
|
||||
|
||||
/// Apply Fill to Stroke on two marks whose strokes already have their fills, while the series stores the front mark's
|
||||
/// stroke: the back mark's stroke is taken first and the front mark's after it, so the graph ends up storing what it
|
||||
/// stored before. The document and its undo history stay as they are.
|
||||
#[test]
|
||||
fn a_paint_command_that_leaves_a_series_stored_paint_as_it_was_records_no_undo_step() {
|
||||
let (mut s, id, _, marks) = column_marks();
|
||||
for (mark, hex, weight) in [(marks[0], "#ff0000", 3), (marks[1], "#0000ff", 2)] {
|
||||
s.execute("appearance.setItem", &json!({"ids": [mark.0], "index": top_row(&s, mark, true), "color": hex})).unwrap();
|
||||
s.execute("appearance.setItem", &json!({"ids": [mark.0], "index": top_row(&s, mark, false), "color": hex, "weight": weight})).unwrap();
|
||||
}
|
||||
let drawn: Vec<NodeId> = series_parts(&s, id, 0).iter().map(|m| m.id).filter(|m| marks[..2].contains(m)).collect();
|
||||
assert_eq!(drawn, [marks[0], marks[1]], "the second mark is in front");
|
||||
let was = s.doc().unwrap().doc.clone();
|
||||
let blue = fill_of(&s, marks[1]);
|
||||
assert_eq!(spec(&s, id).series_paints[0].stroke, Some(blue));
|
||||
let undo = s.doc().unwrap().history.undo.len();
|
||||
s.execute("addon.color.fillToStroke", &json!({"ids": [marks[1].0, marks[0].0]})).unwrap();
|
||||
assert_eq!(s.doc().unwrap().doc, was);
|
||||
assert_eq!(s.doc().unwrap().history.undo.len(), undo);
|
||||
}
|
||||
|
||||
/// A stroke set on a Filled Lines band with `paint.setStroke` or Apply Fill to Stroke is not stored: the band keeps its
|
||||
/// color and its thin black outline when the graph is drawn again. A fill set with `paint.setFill` is stored.
|
||||
#[test]
|
||||
fn a_paint_command_stroke_on_a_filled_line_band_does_not_repaint_it() {
|
||||
every_case([("paint.setStroke", json!({"color": "#ff0000"})), ("addon.color.fillToStroke", json!({}))], |(cmd, params)| {
|
||||
let (mut s, id, band) = band_graph();
|
||||
let was = series_parts(&s, id, 0)[0].appearance.fill_paint();
|
||||
let mut p = params.clone();
|
||||
p["ids"] = json!([band.0]);
|
||||
s.execute(cmd, &p).unwrap();
|
||||
assert!(!s.doc().unwrap().doc.node(band).unwrap().appearance.stroke_paint().is_none());
|
||||
assert!(spec(&s, id).series_paints.first().is_none_or(|p| p.stroke.is_none()));
|
||||
assert_eq!(redrawn_band(&mut s, id), (was, Paint::solid(vectorcraft_color::Color::BLACK), 0.25));
|
||||
});
|
||||
let (mut s, id, band) = band_graph();
|
||||
s.execute("paint.setFill", &json!({"ids": [band.0], "color": "#0000ff"})).unwrap();
|
||||
let blue = s.doc().unwrap().doc.node(band).unwrap().appearance.fill_paint();
|
||||
assert_eq!(redrawn_band(&mut s, id), (blue, Paint::solid(vectorcraft_color::Color::BLACK), 0.25));
|
||||
}
|
||||
|
||||
/// `paint.setStroke` with None on a marker that shows no stroke changes nothing, and the line keeps the stroke the series
|
||||
/// stores. On the line itself it stores the empty stroke.
|
||||
#[test]
|
||||
fn removing_the_stroke_of_a_mark_without_one_leaves_the_stored_stroke() {
|
||||
let (mut s, id, line, marker) = line_and_marker();
|
||||
s.execute("paint.setStroke", &json!({"ids": [line.0], "color": "#ff0000"})).unwrap();
|
||||
let red = s.doc().unwrap().doc.node(line).unwrap().appearance.stroke_paint();
|
||||
assert_eq!(spec(&s, id).series_paints[0].stroke, Some(red.clone()));
|
||||
s.execute("paint.setStroke", &json!({"ids": [marker.0], "none": true})).unwrap();
|
||||
assert_eq!(spec(&s, id).series_paints[0].stroke, Some(red.clone()));
|
||||
s.execute("graph.setData", &json!({"rows": [[1, 2], [3, 4]]})).unwrap();
|
||||
assert_eq!(line_parts(&s, id).0.appearance.stroke_paint(), red);
|
||||
|
||||
let line = line_parts(&s, id).0.id;
|
||||
s.execute("paint.setStroke", &json!({"ids": [line.0], "none": true})).unwrap();
|
||||
assert_eq!(spec(&s, id).series_paints[0].stroke, Some(Paint::None));
|
||||
}
|
||||
|
||||
/// Run `case` on each of `cases`, and fail naming every case that failed, not only the first one.
|
||||
fn every_case<T: std::fmt::Debug>(cases: impl IntoIterator<Item = T>, case: impl Fn(&T)) {
|
||||
let failed: Vec<T> = cases.into_iter().filter(|c| std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| case(c))).is_err()).collect();
|
||||
@@ -4828,6 +5079,407 @@ fn formatting_one_label_keeps_that_kind_of_label() {
|
||||
assert!(serde_json::to_value(GraphSpec::default()).unwrap().get("labelStyles").is_none());
|
||||
}
|
||||
|
||||
/// Each Axes and Legend text after the graph is drawn again, with its object rows (fill or stroke, color, opacity, and
|
||||
/// for a stroke its weight) and its object opacity.
|
||||
type RowLooks = Vec<(bool, String, f32, f64)>;
|
||||
fn redrawn_label_rows(s: &mut Session, id: NodeId) -> Vec<(String, RowLooks, f32)> {
|
||||
s.execute("graph.setData", &json!({"id": id.0, "csv": ",2024,2025\nQ1,3,2\nQ2,5,4\nQ3,-1,6"})).unwrap();
|
||||
let n = s.doc().unwrap().doc.node(id).unwrap().clone();
|
||||
let mut out = vec![];
|
||||
for g in ["Axes", "Legend"] {
|
||||
for c in group(&n, g).children().unwrap() {
|
||||
if let NodeKind::Text(t) = &c.kind {
|
||||
let rows = c
|
||||
.appearance
|
||||
.items
|
||||
.iter()
|
||||
.map(|it| {
|
||||
let w = match it {
|
||||
vectorcraft_doc::AppearanceItem::Stroke(st) => st.width,
|
||||
vectorcraft_doc::AppearanceItem::Fill(_) => 0.0,
|
||||
};
|
||||
(it.is_fill(), it.paint().color().map(|c| c.to_hex()).unwrap_or_default(), it.opacity(), w)
|
||||
})
|
||||
.collect();
|
||||
out.push((t.plain_text(), rows, c.opacity));
|
||||
}
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
fn rows_of(looks: &[(String, RowLooks, f32)], text: &str) -> (RowLooks, f32) {
|
||||
looks.iter().find(|(t, _, _)| t == text).map(|(_, r, o)| (r.clone(), *o)).unwrap_or_else(|| panic!("no label {text}"))
|
||||
}
|
||||
|
||||
/// Fill and stroke rows given to one label with the Appearance commands are kept for every label of its kind when the
|
||||
/// graph is drawn again, through save and open, and undo with the change.
|
||||
#[test]
|
||||
fn appearance_rows_on_one_label_are_kept_for_its_kind() {
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", &json!({"width": 800, "height": 800})).unwrap();
|
||||
let id = graph(&mut s, "column");
|
||||
// A category label: a red fill row at 50%.
|
||||
let c = text_id(&s, id, "Axes", "Q2");
|
||||
s.execute("appearance.addFill", &json!({"ids": [c.0]})).unwrap();
|
||||
s.execute("appearance.setItem", &json!({"ids": [c.0], "index": 0, "color": "#ff0000", "opacity": 50})).unwrap();
|
||||
// A value axis number: a 2 pt blue stroke row.
|
||||
let v = text_id(&s, id, "Axes", "4");
|
||||
s.execute("appearance.addStroke", &json!({"ids": [v.0]})).unwrap();
|
||||
s.execute("appearance.setItem", &json!({"ids": [v.0], "index": 0, "color": "#0000ff", "weight": 2})).unwrap();
|
||||
let looks = redrawn_label_rows(&mut s, id);
|
||||
assert_eq!(rows_of(&looks, "Q1"), (vec![(true, "#ff0000".into(), 0.5, 0.0)], 1.0), "every category label: {looks:?}");
|
||||
assert_eq!(rows_of(&looks, "0"), (vec![(false, "#0000ff".into(), 1.0, 2.0)], 1.0), "every value axis number: {looks:?}");
|
||||
assert_eq!(rows_of(&looks, "2024"), (vec![], 1.0), "the legend keeps none: {looks:?}");
|
||||
// One undo takes the data change back, the next the stroke's color and weight together with what the graph keeps.
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
let kept = spec(&s, id).label_styles.values_rows.unwrap();
|
||||
assert_ne!(kept.paint.stroke.and_then(|p| p.color()).map(|c| c.to_hex()), Some("#0000ff".into()));
|
||||
assert_eq!(kept.paint.stroke_width, Some(1.0));
|
||||
s.execute("edit.redo", &json!({})).unwrap();
|
||||
let path = vectorcraft_testkit::temp_dir("graph-label-rows").join("labels.vectorcraft");
|
||||
s.execute("document.save", &json!({"path": path})).unwrap();
|
||||
s.execute("document.open", &json!({"path": path})).unwrap();
|
||||
let g = spec(&s, id);
|
||||
assert_eq!(g.label_styles.categories_rows.unwrap().paint.fill_opacity, Some(0.5));
|
||||
assert_eq!(g.label_styles.values_rows.unwrap().paint.stroke_width, Some(2.0));
|
||||
assert!(serde_json::to_value(vectorcraft_doc::LabelStyles::default()).unwrap().as_object().unwrap().is_empty());
|
||||
}
|
||||
|
||||
/// Removing a label's rows, clearing its appearance or hiding its row leaves its kind with what the label then shows.
|
||||
#[test]
|
||||
fn removing_a_labels_rows_changes_what_its_kind_keeps() {
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", &json!({"width": 800, "height": 800})).unwrap();
|
||||
let id = graph(&mut s, "column");
|
||||
let c = text_id(&s, id, "Axes", "Q2");
|
||||
s.execute("appearance.addFill", &json!({"ids": [c.0]})).unwrap();
|
||||
s.execute("appearance.setItem", &json!({"ids": [c.0], "index": 0, "color": "#ff0000"})).unwrap();
|
||||
s.execute("appearance.removeItem", &json!({"ids": [c.0], "index": 0})).unwrap();
|
||||
assert_eq!(spec(&s, id).label_styles.categories_rows, None, "no row left");
|
||||
s.execute("appearance.addFill", &json!({"ids": [c.0]})).unwrap();
|
||||
s.execute("appearance.setItem", &json!({"ids": [c.0], "index": 0, "color": "#ff0000", "visible": false})).unwrap();
|
||||
assert_eq!(spec(&s, id).label_styles.categories_rows, None, "a hidden row shows nothing");
|
||||
s.execute("appearance.showAllHidden", &json!({"ids": [c.0]})).unwrap();
|
||||
let looks = redrawn_label_rows(&mut s, id);
|
||||
assert_eq!(rows_of(&looks, "Q3").0, vec![(true, "#ff0000".into(), 1.0, 0.0)], "{looks:?}");
|
||||
let c = text_id(&s, id, "Axes", "Q1");
|
||||
s.execute("appearance.clear", &json!({"ids": [c.0]})).unwrap();
|
||||
let looks = redrawn_label_rows(&mut s, id);
|
||||
assert_eq!(rows_of(&looks, "Q3").0, vec![(true, String::new(), 1.0, 0.0), (false, String::new(), 1.0, 1.0)], "{looks:?}");
|
||||
}
|
||||
|
||||
/// A fill row painted with `paint.setFill {item}` is kept with the label's rows, and its characters keep their own fill.
|
||||
#[test]
|
||||
fn painting_a_labels_row_keeps_the_row_and_not_its_characters() {
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", &json!({"width": 800, "height": 800})).unwrap();
|
||||
let id = graph(&mut s, "column");
|
||||
let l = text_id(&s, id, "Legend", "2025");
|
||||
s.execute("appearance.addFill", &json!({"ids": [l.0]})).unwrap();
|
||||
s.execute("paint.setFill", &json!({"ids": [l.0], "item": 0, "color": "#00ff00"})).unwrap();
|
||||
let g = spec(&s, id);
|
||||
assert_eq!(g.label_styles.legend_rows.and_then(|r| r.paint.fill).and_then(|p| p.color()).map(|c| c.to_hex()), Some("#00ff00".into()));
|
||||
assert_eq!(g.label_styles.legend, None, "the characters keep the default fill");
|
||||
let looks = label_looks(&mut s, id);
|
||||
assert!(looks.iter().find(|(t, _, _)| t == "2024").is_some_and(|(_, _, fill)| fill == "#000000"), "{looks:?}");
|
||||
}
|
||||
|
||||
/// Moving or copying an appearance onto a label (Layers panel drag) keeps the source's rows and object opacity for the
|
||||
/// label's kind.
|
||||
#[test]
|
||||
fn an_appearance_moved_onto_a_label_is_kept_for_its_kind() {
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", &json!({"width": 800, "height": 800})).unwrap();
|
||||
let id = graph(&mut s, "column");
|
||||
let r = NodeId(s.execute("shape.rectangle", &json!({"x": 600, "y": 600, "width": 30, "height": 40})).unwrap()["id"].as_u64().unwrap());
|
||||
s.execute("paint.setFill", &json!({"ids": [r.0], "color": "#12ab34"})).unwrap();
|
||||
s.execute("transparency.set", &json!({"ids": [r.0], "opacity": 40})).unwrap();
|
||||
let v = text_id(&s, id, "Axes", "2");
|
||||
s.execute("appearance.transfer", &json!({"source": r.0, "target": v.0, "copy": true})).unwrap();
|
||||
let looks = redrawn_label_rows(&mut s, id);
|
||||
let (rows, opacity) = rows_of(&looks, "6");
|
||||
assert_eq!((rows.first().map(|r| r.1.clone()), opacity), (Some("#12ab34".into()), 0.4), "{looks:?}");
|
||||
}
|
||||
|
||||
/// The Eyedropper onto a label keeps the characters' style, the paragraph attributes and the appended rows it gave the
|
||||
/// label for every label of its kind.
|
||||
#[test]
|
||||
fn the_eyedropper_on_a_label_is_kept_for_its_kind() {
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", &json!({"width": 800, "height": 800})).unwrap();
|
||||
let id = graph(&mut s, "column");
|
||||
let src = NodeId(s.execute("text.create", &json!({"x": 10, "y": 10, "text": "x", "size": 14})).unwrap()["id"].as_u64().unwrap());
|
||||
s.execute("paint.setFill", &json!({"ids": [src.0], "color": "#00ff00"})).unwrap();
|
||||
let v = text_id(&s, id, "Axes", "4");
|
||||
let steps = s.doc().unwrap().history.undo.len();
|
||||
s.execute("appearance.copyFrom", &json!({"source": src.0, "ids": [v.0]})).unwrap();
|
||||
assert_eq!(s.doc().unwrap().history.undo.len(), steps + 1, "one step");
|
||||
let looks = label_looks(&mut s, id);
|
||||
assert!(looks.iter().any(|(t, size, fill)| t == "0" && *size == 14.0 && fill == "#00ff00"), "every value axis number: {looks:?}");
|
||||
assert!(looks.iter().any(|(t, size, fill)| t == "Q1" && *size == 9.0 && fill == "#000000"), "other labels: {looks:?}");
|
||||
// Appended rows (Shift+Alt-click) go with the label's rows.
|
||||
let r = NodeId(s.execute("shape.rectangle", &json!({"x": 600, "y": 600, "width": 30, "height": 40})).unwrap()["id"].as_u64().unwrap());
|
||||
s.execute("paint.setFill", &json!({"ids": [r.0], "color": "#abcdef"})).unwrap();
|
||||
let c = text_id(&s, id, "Axes", "Q1");
|
||||
s.execute("appearance.copyFrom", &json!({"source": r.0, "ids": [c.0], "append": true})).unwrap();
|
||||
let looks = redrawn_label_rows(&mut s, id);
|
||||
assert!(rows_of(&looks, "Q3").0.iter().any(|(fill, hex, _, _)| *fill && hex == "#abcdef"), "{looks:?}");
|
||||
}
|
||||
|
||||
/// Kept rows read from a saved graph's label styles are held to the ranges the controls keep to, and a place for the
|
||||
/// Characters row past the kept rows puts every row below the characters.
|
||||
#[test]
|
||||
fn kept_label_rows_from_a_file_stay_in_range() {
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", &json!({"width": 800, "height": 800})).unwrap();
|
||||
let id = graph(&mut s, "column");
|
||||
let paint = |hex: &str| serde_json::to_value(Paint::solid(vectorcraft_color::Color::from_hex(hex).unwrap())).unwrap();
|
||||
let saved = json!({"legendRows": {
|
||||
"fill": paint("#ff0000"), "fillOpacity": 7.0,
|
||||
"stroke": paint("#0000ff"), "strokeWidth": 1e9, "strokeOpacity": -2.0,
|
||||
"opacity": -3.0, "below": 9,
|
||||
}});
|
||||
let styles: vectorcraft_doc::LabelStyles = serde_json::from_value(saved).unwrap();
|
||||
s.edit("rows", |d, _| {
|
||||
d.node_mut(id).unwrap().extras_mut().graph.as_deref_mut().unwrap().label_styles = styles;
|
||||
Ok(())
|
||||
})
|
||||
.unwrap();
|
||||
let looks = redrawn_label_rows(&mut s, id);
|
||||
assert_eq!(rows_of(&looks, "2024"), (vec![(true, "#ff0000".into(), 1.0, 0.0), (false, "#0000ff".into(), 0.0, 1000.0)], 0.0), "{looks:?}");
|
||||
assert_eq!(label_node(&s, id, "Legend", "2024").appearance.contents_at(), 2);
|
||||
}
|
||||
|
||||
/// Label `text` of group `grp` of graph `id`.
|
||||
fn label_node(s: &Session, id: NodeId, grp: &str, text: &str) -> vectorcraft_doc::Node {
|
||||
s.doc().unwrap().doc.node(text_id(s, id, grp, text)).unwrap().clone()
|
||||
}
|
||||
|
||||
/// An edit that leaves a label showing what it showed still sets what its kind keeps.
|
||||
#[test]
|
||||
fn a_label_already_showing_a_row_still_sets_what_its_kind_keeps() {
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", &json!({"width": 800, "height": 800})).unwrap();
|
||||
let id = graph(&mut s, "column");
|
||||
let c = text_id(&s, id, "Axes", "Q2");
|
||||
s.execute("appearance.addFill", &json!({"ids": [c.0]})).unwrap();
|
||||
s.execute("appearance.setItem", &json!({"ids": [c.0], "index": 0, "color": "#ff0000"})).unwrap();
|
||||
redrawn_label_rows(&mut s, id);
|
||||
let a = text_id(&s, id, "Axes", "Q1");
|
||||
s.execute("appearance.setItem", &json!({"ids": [a.0], "index": 0, "color": "#0000ff"})).unwrap();
|
||||
// Q3 shows the red the graph drew it with.
|
||||
let b = text_id(&s, id, "Axes", "Q3");
|
||||
s.execute("appearance.setItem", &json!({"ids": [b.0], "index": 0, "color": "#ff0000"})).unwrap();
|
||||
let looks = redrawn_label_rows(&mut s, id);
|
||||
assert_eq!(rows_of(&looks, "Q1").0, vec![(true, "#ff0000".into(), 1.0, 0.0)], "{looks:?}");
|
||||
}
|
||||
|
||||
/// Paint given to a label's characters leaves the rows its kind keeps as they are.
|
||||
#[test]
|
||||
fn paint_on_a_labels_characters_leaves_the_rows_its_kind_keeps() {
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", &json!({"width": 800, "height": 800})).unwrap();
|
||||
let id = graph(&mut s, "column");
|
||||
let c = text_id(&s, id, "Axes", "Q2");
|
||||
s.execute("appearance.addFill", &json!({"ids": [c.0]})).unwrap();
|
||||
s.execute("appearance.setItem", &json!({"ids": [c.0], "index": 0, "color": "#ff0000"})).unwrap();
|
||||
redrawn_label_rows(&mut s, id);
|
||||
let a = text_id(&s, id, "Axes", "Q1");
|
||||
s.execute("appearance.setItem", &json!({"ids": [a.0], "index": 0, "color": "#0000ff"})).unwrap();
|
||||
// Q3 still shows red; its characters take green.
|
||||
let b = text_id(&s, id, "Axes", "Q3");
|
||||
s.execute("paint.setFill", &json!({"ids": [b.0], "color": "#00ff00"})).unwrap();
|
||||
s.execute("paint.setStroke", &json!({"ids": [b.0], "color": "#00ff00"})).unwrap();
|
||||
// The Eyedropper from type, transparency included, onto its characters.
|
||||
let src = NodeId(s.execute("text.create", &json!({"x": 10, "y": 10, "text": "x", "size": 14})).unwrap()["id"].as_u64().unwrap());
|
||||
s.execute("appearance.copyFrom", &json!({"source": src.0, "ids": [b.0]})).unwrap();
|
||||
let looks = redrawn_label_rows(&mut s, id);
|
||||
assert_eq!(rows_of(&looks, "Q1").0, vec![(true, "#0000ff".into(), 1.0, 0.0)], "{looks:?}");
|
||||
}
|
||||
|
||||
/// When labels of one kind are edited together, the last one in the order of `ids` sets what the kind keeps.
|
||||
#[test]
|
||||
fn the_last_label_of_a_kind_edited_together_sets_what_it_keeps() {
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", &json!({"width": 800, "height": 800})).unwrap();
|
||||
let id = graph(&mut s, "column");
|
||||
let (a, b) = (text_id(&s, id, "Axes", "Q1"), text_id(&s, id, "Axes", "Q2"));
|
||||
for (l, color) in [(a, "#ff0000"), (b, "#0000ff")] {
|
||||
s.execute("appearance.addFill", &json!({"ids": [l.0]})).unwrap();
|
||||
s.execute("appearance.setItem", &json!({"ids": [l.0], "index": 0, "color": color})).unwrap();
|
||||
}
|
||||
let kept = |s: &Session| spec(s, id).label_styles.categories_rows.unwrap().paint.fill.unwrap().color().unwrap().to_hex();
|
||||
s.execute("appearance.addFill", &json!({"ids": [a.0, b.0]})).unwrap();
|
||||
assert_eq!(kept(&s), "#0000ff");
|
||||
s.execute("appearance.addFill", &json!({"ids": [b.0, a.0]})).unwrap();
|
||||
assert_eq!(kept(&s), "#ff0000");
|
||||
}
|
||||
|
||||
/// A label's object opacity and blend mode set without a row (`transparency.set` without `item`) are kept for its kind.
|
||||
#[test]
|
||||
fn a_labels_object_opacity_and_blend_mode_are_kept_for_its_kind() {
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", &json!({"width": 800, "height": 800})).unwrap();
|
||||
let id = graph(&mut s, "column");
|
||||
// Another value axis number sets the kind's fill row; the label below has none, and the kind keeps the row.
|
||||
let u = text_id(&s, id, "Axes", "2");
|
||||
s.execute("appearance.addFill", &json!({"ids": [u.0]})).unwrap();
|
||||
s.execute("appearance.setItem", &json!({"ids": [u.0], "index": 0, "color": "#0000ff"})).unwrap();
|
||||
let v = text_id(&s, id, "Axes", "4");
|
||||
s.execute("transparency.set", &json!({"ids": [v.0], "opacity": 50, "blend": "multiply"})).unwrap();
|
||||
let looks = redrawn_label_rows(&mut s, id);
|
||||
assert_eq!(rows_of(&looks, "0"), (vec![(true, "#0000ff".into(), 1.0, 0.0)], 0.5), "{looks:?}");
|
||||
assert_eq!(label_node(&s, id, "Axes", "0").blend, BlendMode::Multiply);
|
||||
assert_eq!(label_node(&s, id, "Axes", "Q1").blend, BlendMode::Normal);
|
||||
// Another label of the kind sets them again.
|
||||
let w = text_id(&s, id, "Axes", "2");
|
||||
s.execute("transparency.set", &json!({"ids": [w.0], "opacity": 50, "blend": "screen"})).unwrap();
|
||||
assert_eq!(label_node(&s, id, "Axes", "0").blend, BlendMode::Multiply);
|
||||
let looks = redrawn_label_rows(&mut s, id);
|
||||
assert_eq!(rows_of(&looks, "0").1, 0.5, "{looks:?}");
|
||||
assert_eq!(label_node(&s, id, "Axes", "0").blend, BlendMode::Screen);
|
||||
}
|
||||
|
||||
/// Stroke options given to a label's stroke row, a fill row painted as the Appearance panel's active row, and a row's
|
||||
/// blend mode are kept for the label's kind.
|
||||
#[test]
|
||||
fn stroke_options_and_the_active_row_on_a_label_are_kept_for_its_kind() {
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", &json!({"width": 800, "height": 800})).unwrap();
|
||||
let id = graph(&mut s, "column");
|
||||
let v = text_id(&s, id, "Axes", "4");
|
||||
s.execute("appearance.addStroke", &json!({"ids": [v.0]})).unwrap();
|
||||
s.execute("stroke.set", &json!({"ids": [v.0], "item": 0, "weight": 3})).unwrap();
|
||||
let c = text_id(&s, id, "Axes", "Q2");
|
||||
s.execute("appearance.addFill", &json!({"ids": [c.0]})).unwrap();
|
||||
s.execute("select.set", &json!({"ids": [c.0]})).unwrap();
|
||||
s.execute("appearance.setActiveItem", &json!({"index": 0})).unwrap();
|
||||
s.execute("paint.setFill", &json!({"color": "#00ffff"})).unwrap();
|
||||
s.execute("transparency.set", &json!({"item": 0, "blend": "multiply"})).unwrap();
|
||||
let looks = redrawn_label_rows(&mut s, id);
|
||||
assert_eq!(rows_of(&looks, "0").0.first().map(|r| r.3), Some(3.0), "{looks:?}");
|
||||
assert_eq!(rows_of(&looks, "Q1").0.first().map(|r| r.1.clone()), Some("#00ffff".into()), "{looks:?}");
|
||||
let q1 = label_node(&s, id, "Axes", "Q1");
|
||||
assert_eq!(q1.appearance.items.first().map(|it| it.blend()), Some(BlendMode::Multiply));
|
||||
let NodeKind::Text(t) = &q1.kind else { panic!("not type") };
|
||||
assert_eq!(t.runs[0].style.fill.color().map(|c| c.to_hex()), Some("#000000".into()), "the characters keep their fill");
|
||||
}
|
||||
|
||||
/// A row below the Characters row stays below the characters when the graph is drawn again.
|
||||
#[test]
|
||||
fn a_kept_row_keeps_its_place_below_the_characters() {
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", &json!({"width": 800, "height": 800})).unwrap();
|
||||
let id = graph(&mut s, "column");
|
||||
let c = text_id(&s, id, "Axes", "Q2");
|
||||
s.execute("appearance.addStroke", &json!({"ids": [c.0]})).unwrap();
|
||||
s.execute("appearance.addFill", &json!({"ids": [c.0]})).unwrap();
|
||||
// The stroke below the characters, the fill above them.
|
||||
s.execute("appearance.moveItem", &json!({"ids": [c.0], "from": "contents", "to": 1})).unwrap();
|
||||
let look = |ap: &vectorcraft_doc::Appearance| (ap.contents_at(), ap.items.iter().map(|it| it.is_fill()).collect::<Vec<_>>());
|
||||
assert_eq!(look(&label_node(&s, id, "Axes", "Q2").appearance), (1, vec![false, true]));
|
||||
redrawn_label_rows(&mut s, id);
|
||||
assert_eq!(look(&label_node(&s, id, "Axes", "Q1").appearance), (1, vec![false, true]));
|
||||
}
|
||||
|
||||
/// The Eyedropper keeps for a label's kind only the attributes it applied: a fill the label had of its own stays out.
|
||||
#[test]
|
||||
fn the_eyedropper_keeps_only_what_it_applied_for_a_labels_kind() {
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", &json!({"width": 800, "height": 800})).unwrap();
|
||||
let id = graph(&mut s, "column");
|
||||
let src = NodeId(s.execute("text.create", &json!({"x": 10, "y": 10, "text": "x", "size": 14})).unwrap()["id"].as_u64().unwrap());
|
||||
s.execute("paint.setFill", &json!({"ids": [src.0], "color": "#00ff00"})).unwrap();
|
||||
let v = text_id(&s, id, "Axes", "4");
|
||||
// A fill of the label's own, as the generator gives labels on dark pie wedges.
|
||||
s.edit("own fill", |d, _| {
|
||||
if let NodeKind::Text(t) = &mut d.node_mut(v).unwrap().kind {
|
||||
t.runs.iter_mut().for_each(|r| r.style.fill = Paint::solid(vectorcraft_color::Color::rgb(0.0, 0.0, 1.0)));
|
||||
}
|
||||
Ok(())
|
||||
})
|
||||
.unwrap();
|
||||
// A pick of transparency alone leaves the kind's character style unset.
|
||||
let transparency = json!({"character": false, "paragraph": false, "appearance": {"fill": false, "stroke": false}});
|
||||
s.execute("appearance.copyFrom", &json!({"source": src.0, "ids": [v.0], "apply": transparency})).unwrap();
|
||||
assert_eq!(spec(&s, id).label_styles.values, None);
|
||||
s.execute("appearance.copyFrom", &json!({"source": src.0, "ids": [v.0], "apply": {"appearance": false}})).unwrap();
|
||||
let looks = label_looks(&mut s, id);
|
||||
assert!(looks.iter().any(|(t, size, fill)| t == "0" && *size == 14.0 && fill == "#000000"), "{looks:?}");
|
||||
}
|
||||
|
||||
/// Paint given to all of a label's text highlighted with the Type tool is kept for its kind, as for the whole label.
|
||||
#[test]
|
||||
fn paint_on_a_labels_highlighted_text_is_kept_for_its_kind() {
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", &json!({"width": 800, "height": 800})).unwrap();
|
||||
let id = graph(&mut s, "column");
|
||||
let v = text_id(&s, id, "Axes", "4");
|
||||
s.select_tool("type", crate::ViewInfo::default()).unwrap();
|
||||
s.set_tool_option("textSelection", &json!({"id": v.0, "start": 0, "end": 1}));
|
||||
s.execute("paint.setFill", &json!({"color": "#ff8800"})).unwrap();
|
||||
s.select_tool("selection", crate::ViewInfo::default()).unwrap();
|
||||
let looks = label_looks(&mut s, id);
|
||||
assert!(looks.iter().any(|(t, _, fill)| t == "0" && fill == "#ff8800"), "{looks:?}");
|
||||
}
|
||||
|
||||
/// Paint given to the whole graph without a row is kept for the characters of every kind of label.
|
||||
#[test]
|
||||
fn paint_on_the_whole_graph_is_kept_for_every_kind_of_label() {
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", &json!({"width": 800, "height": 800})).unwrap();
|
||||
let id = graph(&mut s, "column");
|
||||
s.execute("select.set", &json!({"ids": [id.0]})).unwrap();
|
||||
s.execute("paint.setFill", &json!({"color": "#ff00ff"})).unwrap();
|
||||
let looks = label_looks(&mut s, id);
|
||||
for t in ["0", "Q1", "2024"] {
|
||||
assert!(looks.iter().any(|(x, _, fill)| x == t && fill == "#ff00ff"), "{t}: {looks:?}");
|
||||
}
|
||||
}
|
||||
|
||||
/// A gradient placed on one label's characters or on its row is kept without that placement, so it fits each label of
|
||||
/// the kind when the graph is drawn again.
|
||||
#[test]
|
||||
fn a_placed_gradient_on_a_label_is_kept_to_fit_each_label() {
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", &json!({"width": 800, "height": 800})).unwrap();
|
||||
let id = graph(&mut s, "column");
|
||||
let gradient = json!({"start": [30, 60], "end": [90, 60]});
|
||||
let v = text_id(&s, id, "Axes", "4");
|
||||
s.execute("paint.setFill", &json!({"ids": [v.0], "gradient": gradient})).unwrap();
|
||||
let c = text_id(&s, id, "Axes", "Q2");
|
||||
s.execute("appearance.addFill", &json!({"ids": [c.0]})).unwrap();
|
||||
s.execute("paint.setFill", &json!({"ids": [c.0], "item": 0, "gradient": gradient})).unwrap();
|
||||
let placed = |p: &Paint| matches!(p, Paint::Gradient(g) if g.geom.is_some());
|
||||
let g = spec(&s, id);
|
||||
assert!(g.label_styles.values.is_some_and(|st| matches!(st.fill, Paint::Gradient(_)) && !placed(&st.fill)));
|
||||
assert!(g.label_styles.categories_rows.and_then(|r| r.paint.fill).is_some_and(|f| matches!(f, Paint::Gradient(_)) && !placed(&f)));
|
||||
}
|
||||
|
||||
/// A graphic style applied to a label is kept with the label's rows for its kind.
|
||||
#[test]
|
||||
fn a_graphic_style_on_a_label_is_kept_for_its_kind() {
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", &json!({"width": 800, "height": 800})).unwrap();
|
||||
let id = graph(&mut s, "column");
|
||||
let r = NodeId(s.execute("shape.rectangle", &json!({"x": 600, "y": 600, "width": 30, "height": 40})).unwrap()["id"].as_u64().unwrap());
|
||||
s.execute("paint.setFill", &json!({"ids": [r.0], "color": "#ff8000"})).unwrap();
|
||||
s.execute("graphicStyle.new", &json!({"id": r.0, "name": "Orange"})).unwrap();
|
||||
let v = text_id(&s, id, "Axes", "4");
|
||||
s.execute("graphicStyle.apply", &json!({"name": "Orange", "ids": [v.0]})).unwrap();
|
||||
let looks = redrawn_label_rows(&mut s, id);
|
||||
assert!(rows_of(&looks, "0").0.iter().any(|(fill, hex, _, _)| *fill && hex == "#ff8000"), "{looks:?}");
|
||||
// A label still linked to the style follows a redefinition, and its kind keeps the new look.
|
||||
let v = text_id(&s, id, "Axes", "4");
|
||||
s.execute("graphicStyle.apply", &json!({"name": "Orange", "ids": [v.0]})).unwrap();
|
||||
s.execute("paint.setFill", &json!({"ids": [r.0], "color": "#0080ff"})).unwrap();
|
||||
s.execute("graphicStyle.redefine", &json!({"name": "Orange", "id": r.0})).unwrap();
|
||||
let looks = redrawn_label_rows(&mut s, id);
|
||||
assert!(rows_of(&looks, "0").0.iter().any(|(fill, hex, _, _)| *fill && hex == "#0080ff"), "{looks:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pie_names_and_wedge_labels_keep_their_style() {
|
||||
let mut s = Session::new();
|
||||
|
||||
@@ -994,20 +994,53 @@ fn artboard_delete(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
ok()
|
||||
}
|
||||
|
||||
/// Delete artboards `indices` (sorted) of `d` with their guides; at least one must be left.
|
||||
fn remove_artboards(d: &mut Document, sel: &mut vectorcraft_doc::Selection, indices: &[usize]) -> Result<()> {
|
||||
/// Delete artboards `indices` (sorted, each once) of `d` with their guides; at least one must be
|
||||
/// left. The other artboards keep their order, no later artboard takes a deleted one's id, and
|
||||
/// `sel` forgets the deleted guides. One walk over the artboards and one over the ruler guides,
|
||||
/// however many go. Delete Artboard, Delete Empty Artboards, Cut Artboard and Artwork Cleanup's
|
||||
/// Empty Artboards.
|
||||
pub(crate) fn remove_artboards(d: &mut Document, sel: &mut vectorcraft_doc::Selection, indices: &[usize]) -> Result<()> {
|
||||
if indices.iter().any(|i| *i >= d.artboards.len()) {
|
||||
return Err(EngineError::Other("no such artboard".into()));
|
||||
}
|
||||
if indices.len() >= d.artboards.len() {
|
||||
return Err(EngineError::Other("a document needs at least one artboard".into()));
|
||||
}
|
||||
// The last first, so the others keep their places; their guides go with them.
|
||||
for &i in indices.iter().rev() {
|
||||
let id = d.artboards.remove(i).id;
|
||||
d.retire_artboard_id(id);
|
||||
d.retain_guides(sel, |g| g.artboard != Some(id));
|
||||
if indices.is_empty() {
|
||||
return Ok(());
|
||||
}
|
||||
let mut gone: HashSet<u32> = HashSet::with_capacity(indices.len());
|
||||
let mut i = 0;
|
||||
d.artboards.retain(|a| {
|
||||
let keep = indices.binary_search(&i).is_err();
|
||||
i += 1;
|
||||
if !keep {
|
||||
gone.insert(a.id);
|
||||
}
|
||||
keep
|
||||
});
|
||||
count_artboard_pass();
|
||||
for &id in &gone {
|
||||
d.retire_artboard_id(id);
|
||||
}
|
||||
d.retain_guides(sel, |g| !g.artboard.is_some_and(|a| gone.contains(&a)));
|
||||
count_artboard_pass();
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
thread_local! {
|
||||
/// Tests: how many walks [`remove_artboards`] made over the artboards and the ruler guides on
|
||||
/// this thread.
|
||||
pub(crate) static ARTBOARD_PASSES: std::cell::Cell<usize> = const { std::cell::Cell::new(0) };
|
||||
}
|
||||
|
||||
/// Tests: count one walk over the artboards or the ruler guides.
|
||||
fn count_artboard_pass() {
|
||||
#[cfg(test)]
|
||||
ARTBOARD_PASSES.set(ARTBOARD_PASSES.get() + 1);
|
||||
}
|
||||
|
||||
/// The artboards Delete Empty Artboards deletes: those with no artwork
|
||||
/// ([`Document::artboard_has_art`]) that aren't locked, but artboard 1 when every artboard is one
|
||||
/// of them (a document keeps one, as Delete Artboards keeps artboard 1).
|
||||
|
||||
@@ -251,7 +251,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Object Properties",
|
||||
[],
|
||||
None,
|
||||
"{ids?|id?, name?, visible?, locked? (visible: false or locked: true deselects every selected object hidden or locked after the call, the key object included), opacity?: 0..100, blend?: \"Multiply\"…, isolate?, knockout?: \"on\"|\"off\"|\"neutral\"|bool (true = on, false = neutral), knockoutShape?: bool, data?: {key: \"value\" | null (removes it)} (the object's own data, SVG data-* attributes: {pivot: \"100,180\"} is data-pivot; document.node → attrs.data)}. On a path directly in a graph series, the graph also stores the object's opacity and blend mode (exceptions: docs/mcp.md)",
|
||||
"{ids?|id?, name?, visible?, locked? (visible: false or locked: true deselects every selected object hidden or locked after the call, the key object included), opacity?: 0..100, blend?: \"Multiply\"…, isolate?, knockout?: \"on\"|\"off\"|\"neutral\"|bool (true = on, false = neutral), knockoutShape?: bool, data?: {key: \"value\" | null (removes it)} (the object's own data, SVG data-* attributes: {pivot: \"100,180\"} is data-pivot; document.node → attrs.data)}. On a path directly in a graph series, the graph also stores the object's opacity and blend mode (exceptions: docs/mcp.md); on a graph label, the graph stores the label's opacity and blend mode for every label of its kind",
|
||||
has_doc,
|
||||
set_props
|
||||
),
|
||||
|
||||
@@ -65,10 +65,11 @@ pub(super) fn set_props(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
})
|
||||
};
|
||||
let d = &s.doc()?.doc;
|
||||
// A graph keeps the object opacity and blend mode set on a mark of a series for the whole series, so a value a mark
|
||||
// already has still replaces the one another mark left on the graph.
|
||||
// A graph keeps the object opacity and blend mode set on a mark of a series for the whole series, and set on a label
|
||||
// for its kind of label, so a value a mark or label already has still replaces the one another left on the graph.
|
||||
let transparency = opacity.is_some() || blend.is_some();
|
||||
let stores = || transparency && crate::cmd::graph::series_object_opacity_differs(d, &ids);
|
||||
let stores =
|
||||
|| transparency && (crate::cmd::graph::series_object_opacity_differs(d, &ids) || crate::cmd::graph::label_object_opacity_differs(d, &ids));
|
||||
if ids.iter().all(|id| d.node(*id).is_some_and(has_values)) && !stores() {
|
||||
// Hidden or locked objects can't stay selected.
|
||||
if hides {
|
||||
@@ -124,6 +125,7 @@ pub(super) fn set_props(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
}
|
||||
if transparency {
|
||||
crate::cmd::graph::capture_series_object_opacity(d, &ids);
|
||||
crate::cmd::graph::capture_label_object_opacity(d, &ids);
|
||||
}
|
||||
// Hidden or locked objects can't stay selected.
|
||||
if hides {
|
||||
|
||||
@@ -135,9 +135,7 @@ pub(super) fn set_live_shape(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
.map_err(|_| bad("object.setLiveShape", format!("`kind` must be \"round\", \"invertedRound\" or \"chamfer\", not {v}")))
|
||||
})
|
||||
.transpose()?;
|
||||
let radius = p.get("radius").and_then(Value::as_f64);
|
||||
let relative_radii = p.get("relativeRadii").and_then(Value::as_bool);
|
||||
let sides = p.get("sides").and_then(Value::as_u64);
|
||||
// A number that must be `what` (None when absent).
|
||||
let number = |k: &str, ok: fn(f64) -> bool, what: &str| -> Result<Option<f64>> {
|
||||
match p.get(k) {
|
||||
@@ -151,6 +149,9 @@ pub(super) fn set_live_shape(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
};
|
||||
let angle = |k: &str| Ok::<_, EngineError>(number(k, |_| true, "an angle in degrees")?.map(|a| a.rem_euclid(360.0)));
|
||||
let length = |k: &str| number(k, |x| x > 0.0 && x <= crate::MAX_COORD, "a length in points above 0");
|
||||
let radius = number("radius", |r| r >= 0.0, "a radius of 0 or more")?;
|
||||
// A polygon's side count: a whole number from 3 to 1000.
|
||||
let sides = number("sides", |n| n.fract() == 0.0 && (3.0..=1000.0).contains(&n), "a whole number from 3 to 1000")?.map(|n| n as u64);
|
||||
// An ellipse's pie (Ellipse Properties: Pie Start and End Angle, degrees; Invert Pie swaps them).
|
||||
let (pie_start, pie_end) = (angle("pieStart")?, angle("pieEnd")?);
|
||||
let invert = p.get("invertPie").and_then(Value::as_bool) == Some(true);
|
||||
|
||||
@@ -21,7 +21,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Fill",
|
||||
[],
|
||||
None,
|
||||
"{color?: \"#rrggbb\"|[r,g,b]|{c,m,y,k}|{gray}|{l,a,b} (CIE Lab; #rrggbbaa and #rgba are refused), none?: true, swatch?: name (a global or spot colour stays linked, so swatch edits recolour it; a tint swatch links to its base at its tint; a gradient swatch is recorded as the gradient's swatch and fits each object, keeping its aspect; the built-in \"[Registration]\" prints on every plate), tint?: 0..100 (% of a global or spot `swatch`; default 100, or a tint swatch's own), gradient?: {kind?: linear|radial|conic|diamond|freeform (conic, also accepted as angle or conical: the stops sweep once around the center `start`, clockwise from the vector; diamond: the stops run from the center `start` out to a square with a corner at `end`), method?: classic|perceptual|hue-clockwise|hue-counterclockwise (as paint.editGradient; default classic), spread?: pad|repeat|reflect (as paint.editGradient; default pad), repeats?: 1..100 (as paint.editGradient; default 1), stops?: [{offset 0..1, color, opacity? 0..1 (or 0..100), midpoint? 0.13..0.87, swatch?: global or spot colour (or tint) swatch the stop links to (its colour comes from the swatch), tint?: 0..100}] (at least 2; default white→black), angle?: deg counter-clockwise (0: the gradient runs left to right), start?: [x,y], end?: [x,y] (the vector in document coordinates, both or neither; type objects keep it in text space), aspect?: % (radial, conic and diamond; a negative one mirrors a conic's sweep, counterclockwise, and is an error for the other kinds; without start/end the gradient is placed on each object's bounds), focal?: [x,y] (radial, with start/end: the focal point, where the first stop sits; an error with a conic or diamond), dither?: bool (linear, radial, conic and diamond fills: drawn with fine noise that hides banding, and as pixels in SVG and PDF exports; strokes ignore it), swatch?: linked gradient swatch name}, item?: appearance item index|null (omitted: the Appearance panel's active item if it is a fill, else the top fill), ids?|id?, focus?: true (false keeps the active proxy), keepModel?: false (in a CMYK document, RGB colours and gradient stops are stored as CMYK unless true; Gray stays Gray), target?: \"object\"|\"contents\", span?: \"object\" (default: each target fits a gradient to its own bounds)|\"selection\" (a linear, radial, conic or diamond `gradient` without start/end is placed once on the union of the targets' bounds, so it runs across them)} sets the selection's fill and the default (new art fits a gradient to itself); a solid colour recolours only the gradient mesh point last clicked with the Mesh tool or Direct Selection; while the Type tool has characters selected (and no ids or item are given), the paint goes to those characters only; without `item`, a grayscale or 1-bit image takes a solid color as the color it is drawn in (white pixels transparent; `none` takes it off; an image with an alpha channel takes only a spot color, black or white; a colorized image whose link is missing takes a solid color as its new colorize color, and refuses a process color while its color is a spot color, black or white); with an object of a dynamic symbol instance selected by Direct Selection and no ids, id or item, that object's fill changes for this instance only (solid, none, gradient or pattern) → {objects: how many objects took the paint (the objects inside a group, unless an appearance item of the group's own is targeted; 0 when only the default changed)}",
|
||||
"{color?: \"#rrggbb\"|[r,g,b]|{c,m,y,k}|{gray}|{l,a,b} (CIE Lab; #rrggbbaa and #rgba are refused), none?: true, swatch?: name (a global or spot colour stays linked, so swatch edits recolour it; a tint swatch links to its base at its tint; a gradient swatch is recorded as the gradient's swatch and fits each object, keeping its aspect; the built-in \"[Registration]\" prints on every plate), tint?: 0..100 (% of a global or spot `swatch`; default 100, or a tint swatch's own), gradient?: {kind?: linear|radial|conic|diamond|freeform (conic, also accepted as angle or conical: the stops sweep once around the center `start`, clockwise from the vector; diamond: the stops run from the center `start` out to a square with a corner at `end`), method?: classic|perceptual|hue-clockwise|hue-counterclockwise (as paint.editGradient; default classic), spread?: pad|repeat|reflect (as paint.editGradient; default pad), repeats?: 1..100 (as paint.editGradient; default 1), stops?: [{offset 0..1, color, opacity? 0..1 (or 0..100), midpoint? 0.13..0.87, swatch?: global or spot colour (or tint) swatch the stop links to (its colour comes from the swatch), tint?: 0..100}] (at least 2; default white→black), angle?: deg counter-clockwise (0: the gradient runs left to right), start?: [x,y], end?: [x,y] (the vector in document coordinates, both or neither; type objects keep it in text space), aspect?: % (radial, conic and diamond; a negative one mirrors a conic's sweep, counterclockwise, and is an error for the other kinds; without start/end the gradient is placed on each object's bounds), focal?: [x,y] (radial, with start/end: the focal point, where the first stop sits; an error with a conic or diamond), dither?: bool (linear, radial, conic and diamond fills: drawn with fine noise that hides banding, and as pixels in SVG and PDF exports; strokes ignore it), swatch?: linked gradient swatch name}, item?: appearance item index|null (omitted: the Appearance panel's active item if it is a fill, else the top fill), ids?|id?, focus?: true (false keeps the active proxy), keepModel?: false (in a CMYK document, RGB colours and gradient stops are stored as CMYK unless true; Gray stays Gray), target?: \"object\"|\"contents\", span?: \"object\" (default: each target fits a gradient to its own bounds)|\"selection\" (a linear, radial, conic or diamond `gradient` without start/end is placed once on the union of the targets' bounds, so it runs across them)} sets the selection's fill and the default (new art fits a gradient to itself); a solid colour recolours only the gradient mesh point last clicked with the Mesh tool or Direct Selection; while the Type tool has characters selected (and no ids or item are given), the paint goes to those characters only; without `item`, a grayscale or 1-bit image takes a solid color as the color it is drawn in (white pixels transparent; `none` takes it off; an image with an alpha channel takes only a spot color, black or white; a colorized image whose link is missing takes a solid color as its new colorize color, and refuses a process color while its color is a spot color, black or white); with an object of a dynamic symbol instance selected by Direct Selection and no ids, id or item, that object's fill changes for this instance only (solid, none, gradient or pattern). On a path directly in a graph series, the graph also stores the paint of the topmost visible fill when the command wrote that row or changed it (exceptions: docs/mcp.md); on a graph label, the graph stores the paint for every label of its kind, as a character fill when the characters took it and with the label's rows when a row did → {objects: how many objects took the paint (the objects inside a group, unless an appearance item of the group's own is targeted; 0 when only the default changed)}",
|
||||
has_doc,
|
||||
|s, p| set_paint(s, p, true)
|
||||
),
|
||||
@@ -30,7 +30,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Stroke",
|
||||
[],
|
||||
None,
|
||||
"{…as paint.setFill, item?: the stroke item to set} same as paint.setFill, for the stroke; with an object of a dynamic symbol instance selected by Direct Selection and no ids, id or item, that object's stroke changes for this instance only (solid, none, gradient or pattern)",
|
||||
"{…as paint.setFill, item?: the stroke item to set} same as paint.setFill, for the stroke; with an object of a dynamic symbol instance selected by Direct Selection and no ids, id or item, that object's stroke changes for this instance only (solid, none, gradient or pattern). On a path directly in a graph series, the graph also stores the paint and weight of the topmost visible stroke when the command wrote that row or changed it (exceptions: docs/mcp.md); on a graph label, as paint.setFill",
|
||||
has_doc,
|
||||
|s, p| { set_paint(s, p, false) }
|
||||
),
|
||||
@@ -64,16 +64,24 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
Ok(json!({ "fillActive": s.fill_active }))
|
||||
}
|
||||
),
|
||||
cmd!("paint.none", "None", [], Some("/"), "{} set the active proxy (fill or stroke) to None → {objects}", has_doc, |s, _| {
|
||||
let f = s.fill_active;
|
||||
set_paint(s, &json!({"none": true}), f)
|
||||
}),
|
||||
cmd!(
|
||||
"paint.none",
|
||||
"None",
|
||||
[],
|
||||
Some("/"),
|
||||
"{} set the active proxy (fill or stroke) to None, as paint.setFill or paint.setStroke with none does → {objects}",
|
||||
has_doc,
|
||||
|s, _| {
|
||||
let f = s.fill_active;
|
||||
set_paint(s, &json!({"none": true}), f)
|
||||
}
|
||||
),
|
||||
cmd!(
|
||||
"transparency.set",
|
||||
"Transparency",
|
||||
["Window", "Transparency"],
|
||||
None,
|
||||
"{ids?|id?, opacity?: 0..100, blend?: name, isolate?, knockout?: \"on\"|\"off\"|\"neutral\"|bool (true = on, false = neutral), knockoutShape?: bool, item?: appearance item index|null, target?: \"object\"|\"contents\", aligned?: bool (index and item are rows of the first target's stack: each other target edits its equal one, as the Appearance panel does for a mixed selection)} for `ids`, the selection, or the object whose opacity mask is being edited; opacity and blend go to the targeted fill/stroke item (omitted: the Appearance panel's active item, else the objects; a gradient mesh point last clicked with the Mesh tool or Direct Selection takes the opacity itself). With an object of a dynamic symbol instance selected by Direct Selection and no ids, id or item, opacity and blend change that object for this instance only (isolate, knockout, knockoutShape, aligned and target are refused). On a path directly in a graph series (for a fill or stroke item, a series group's id stands for those paths), the graph also stores the opacity and blend mode of the object and of the topmost visible fill and stroke (exceptions: docs/mcp.md)",
|
||||
"{ids?|id?, opacity?: 0..100, blend?: name, isolate?, knockout?: \"on\"|\"off\"|\"neutral\"|bool (true = on, false = neutral), knockoutShape?: bool, item?: appearance item index|null, target?: \"object\"|\"contents\", aligned?: bool (index and item are rows of the first target's stack: each other target edits its equal one, as the Appearance panel does for a mixed selection)} for `ids`, the selection, or the object whose opacity mask is being edited; opacity and blend go to the targeted fill/stroke item (omitted: the Appearance panel's active item, else the objects; a gradient mesh point last clicked with the Mesh tool or Direct Selection takes the opacity itself). With an object of a dynamic symbol instance selected by Direct Selection and no ids, id or item, opacity and blend change that object for this instance only (isolate, knockout, knockoutShape, aligned and target are refused). On a path directly in a graph series (for a fill or stroke item, a series group's id stands for those paths), the graph also stores the opacity and blend mode of the object and of the topmost visible fill and stroke (exceptions: docs/mcp.md); on a graph label, the graph stores the label's object opacity and blend mode for every label of its kind, and with `item` its topmost visible fill and stroke rows too",
|
||||
has_doc,
|
||||
transparency
|
||||
),
|
||||
@@ -467,6 +475,14 @@ pub(crate) fn apply_paint(s: &mut Session, p: &Value, paint: Paint, fill: bool)
|
||||
let ids: Vec<NodeId> = ranges.iter().map(|(f, _, _)| *f).collect();
|
||||
// When the type comes out as it was, the document and its undo history stay as they are.
|
||||
let before = s.doc()?.doc.clone();
|
||||
// The graph labels whose whole text takes the paint: a graph keeps it for their kind of label.
|
||||
let whole: Vec<NodeId> = ranges
|
||||
.iter()
|
||||
.filter(|(id, a, b)| {
|
||||
*a == 0 && matches!(before.node(*id).map(|n| &n.kind), Some(NodeKind::Text(t)) if *b >= vectorcraft_text::edit::runs_len(&t.runs))
|
||||
})
|
||||
.map(|(id, _, _)| *id)
|
||||
.collect();
|
||||
super::overprint::edit_counted(s, if fill { "Fill Color" } else { "Stroke Color" }, |d| {
|
||||
for &(id, a, b) in &ranges {
|
||||
let Some(NodeKind::Text(t)) = d.node_mut(id).map(|n| &mut n.kind) else { return Err(EngineError::NoNode(id)) };
|
||||
@@ -483,7 +499,17 @@ pub(crate) fn apply_paint(s: &mut Session, p: &Value, paint: Paint, fill: bool)
|
||||
});
|
||||
super::typecmd::refresh_bounds(t);
|
||||
}
|
||||
Ok(super::appearance::count_changed(d, &before, &ids))
|
||||
let labels = super::graph::capture_label_styles(d, &whole, &|st| {
|
||||
if fill {
|
||||
st.fill = unplaced(&paint);
|
||||
} else {
|
||||
if !paint.is_none() {
|
||||
run_stroke_weight(st);
|
||||
}
|
||||
st.stroke = unplaced(&paint);
|
||||
}
|
||||
});
|
||||
Ok(super::appearance::count_changed(d, &before, &ids) + usize::from(labels))
|
||||
})?;
|
||||
if focus {
|
||||
s.fill_active = fill;
|
||||
@@ -523,6 +549,12 @@ pub(crate) fn apply_paint(s: &mut Session, p: &Value, paint: Paint, fill: bool)
|
||||
vec![]
|
||||
};
|
||||
let label = if fill { "Fill Color" } else { "Stroke Color" };
|
||||
// Each object whose appearance took the paint, with the row it went to, for the paint a graph stores for its series
|
||||
// (a series mark is a path, so it always has a row here).
|
||||
let written = std::cell::RefCell::new(Vec::new());
|
||||
// The type objects whose characters took the paint (no row targeted): only those change what a graph's labels keep
|
||||
// for their characters; a row edit is kept with the label's rows.
|
||||
let characters = std::cell::RefCell::new(Vec::new());
|
||||
edit_items_then(
|
||||
s,
|
||||
&ids,
|
||||
@@ -531,6 +563,9 @@ pub(crate) fn apply_paint(s: &mut Session, p: &Value, paint: Paint, fill: bool)
|
||||
label,
|
||||
fill,
|
||||
|n, index| {
|
||||
if index.is_none() && matches!(n.kind, NodeKind::Text(_)) {
|
||||
characters.borrow_mut().push(n.id);
|
||||
}
|
||||
if index.is_none() && colorized.contains(&n.id) {
|
||||
n.extras_mut().colorize = paint.color().map(|_| Box::new(paint.clone()));
|
||||
return Ok(());
|
||||
@@ -581,18 +616,23 @@ pub(crate) fn apply_paint(s: &mut Session, p: &Value, paint: Paint, fill: bool)
|
||||
if !fill {
|
||||
super::object::snap_if_aligned(n);
|
||||
}
|
||||
let at = match index {
|
||||
Some(_) => n.appearance.item_of_kind(index, fill),
|
||||
None => n.appearance.items.iter().rposition(|it| it.is_fill() == fill),
|
||||
};
|
||||
written.borrow_mut().extend(at.map(|at| (n.id, at)));
|
||||
Ok(())
|
||||
},
|
||||
|_, d, changed| {
|
||||
let series = super::graph::capture_series_paints(d, changed, fill);
|
||||
let labels = super::graph::capture_label_styles(d, changed, &|st| {
|
||||
|before, d, changed| {
|
||||
let series = super::appearance::capture_series_paint_writes(before, d, changed, &written.borrow());
|
||||
let labels = super::graph::capture_label_styles(d, &characters.borrow(), &|st| {
|
||||
if fill {
|
||||
st.fill = paint.clone();
|
||||
st.fill = unplaced(&paint);
|
||||
} else {
|
||||
if !paint.is_none() {
|
||||
run_stroke_weight(st);
|
||||
}
|
||||
st.stroke = paint.clone();
|
||||
st.stroke = unplaced(&paint);
|
||||
}
|
||||
});
|
||||
Ok(series || labels)
|
||||
|
||||
@@ -345,15 +345,7 @@ pub(crate) fn artboard_copy(s: &mut Session, p: &Value, cut: bool) -> Result<Val
|
||||
let copied = clip.nodes.len();
|
||||
if cut {
|
||||
s.edit("Cut Artboard", |d, sel| {
|
||||
if indices.iter().any(|i| *i >= d.artboards.len()) {
|
||||
return Err(EngineError::Other("no such artboard".into()));
|
||||
}
|
||||
// The last first, so the others keep their places; their guides go with them.
|
||||
for &i in indices.iter().rev() {
|
||||
let gone = d.artboards.remove(i).id;
|
||||
d.retire_artboard_id(gone);
|
||||
d.retain_guides(sel, |g| g.artboard != Some(gone));
|
||||
}
|
||||
super::layer::remove_artboards(d, sel, &indices)?;
|
||||
d.remove_many(&art)?;
|
||||
sel.prune(d);
|
||||
Ok(())
|
||||
|
||||
@@ -8,7 +8,9 @@
|
||||
//! (`…settings as file.new`), or, naming none, any key (the options of the format an export picks). Objects inside a value aren't
|
||||
//! described key by key, so only the top level is checked, except in the options of an export, a
|
||||
//! save or Export for Screens: those are checked against the options of the formats the command
|
||||
//! can write, in `marks`, `bleed` and the other objects too ([`check_export_options`]).
|
||||
//! can write, in `marks`, `bleed` and the other objects too ([`check_export_options`]); and in the
|
||||
//! objects of an array whose keys `MEMBER_KEYS` lists, such as the `items` of
|
||||
//! `object.setLiveShape`.
|
||||
//!
|
||||
//! [`Session::execute`] answers a key the description doesn't name with a parameter error
|
||||
//! ([`check_params`]), for every caller (the app, the control channel, MCP, the CLI) and for the
|
||||
@@ -269,6 +271,41 @@ pub(crate) fn unknown(id: &str, key: &str, keys: &[&str]) -> crate::EngineError
|
||||
)
|
||||
}
|
||||
|
||||
/// Commands whose params hold an array of objects with keys of their own: the command, the key of
|
||||
/// the array and the keys each of its objects takes.
|
||||
const MEMBER_KEYS: &[(&str, &str, &[&str])] = &[("object.setLiveShape", "items", &["id", "corners"])];
|
||||
|
||||
/// Each object in the arrays [`MEMBER_KEYS`] names for command `id` has only the keys it lists, or
|
||||
/// the parameter error naming the first other one with its place (`items[0].corner`), and the
|
||||
/// nearest accepted key or the accepted ones. A member that isn't an object is the command's to
|
||||
/// report.
|
||||
fn check_member_keys(id: &str, params: &Value) -> Result<()> {
|
||||
for (_, list, keys) in MEMBER_KEYS.iter().filter(|(c, ..)| *c == id) {
|
||||
let Some(members) = params.get(*list).and_then(Value::as_array) else { continue };
|
||||
for (i, member) in members.iter().enumerate() {
|
||||
let Value::Object(given) = member else { continue };
|
||||
let Some(key) = given.keys().find(|k| !keys.contains(&k.as_str())) else { continue };
|
||||
let at = |k: &str| format!("{list}[{i}].{k}");
|
||||
let near = nearest(key, keys);
|
||||
let key = at(&shown_key(key));
|
||||
return Err(bad(
|
||||
id,
|
||||
match near {
|
||||
Some(near) => {
|
||||
let near = at(near);
|
||||
format!("unknown parameter `{key}`; did you mean `{near}`?")
|
||||
}
|
||||
None => {
|
||||
let keys = keys.iter().map(|k| at(k)).collect::<Vec<_>>().join(", ");
|
||||
format!("unknown parameter `{key}`; the command takes: {keys}")
|
||||
}
|
||||
},
|
||||
));
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// `params` of built-in effect `effect`, as command `id` was given them (#1592): only keys the
|
||||
/// effect takes (those of its dialog defaults and of its params description), each of the type
|
||||
/// the effect reads ([`check_effect_types`]), a `color` the effect can draw ([`check_effect_color`])
|
||||
@@ -526,7 +563,6 @@ pub const UNCHECKED: &[&str] = &[
|
||||
"object.rasterize",
|
||||
"object.repeat.options",
|
||||
"object.setBounds",
|
||||
"object.setLiveShape",
|
||||
"object.setOverprint",
|
||||
"object.setProps",
|
||||
"object.slice.divide",
|
||||
@@ -833,6 +869,7 @@ fn check_params_in(id: &str, params: &Value, in_batch: bool) -> Result<()> {
|
||||
return Ok(());
|
||||
}
|
||||
check_keys(id, spec.params, params)?;
|
||||
check_member_keys(id, params)?;
|
||||
if in_batch {
|
||||
crate::schemas::check(spec, params, false)?;
|
||||
}
|
||||
|
||||
@@ -803,35 +803,59 @@ fn offset_path(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let miter = number_given(p, "miterLimit", C)?.unwrap_or(4.0).clamp(1.0, 500.0);
|
||||
let roots = selected_roots(s)?;
|
||||
let ids = s.edit("Offset Path", |d, sel| {
|
||||
let (mut out, mut above) = (vec![], vec![]);
|
||||
let mut paths = false;
|
||||
// Each selected root, whether it is a group, and the style of each of its leaf paths; the
|
||||
// paths themselves go to `sources` in the same order.
|
||||
let (mut roots_found, mut sources) = (vec![], vec![]);
|
||||
for id in &roots {
|
||||
let Some(n) = d.node(*id).cloned() else { continue };
|
||||
let mut lv = vec![];
|
||||
leaves(&n, &mut lv);
|
||||
let Some(n) = d.node(*id) else { continue };
|
||||
let mut styles = vec![];
|
||||
each_leaf(n, &mut |l| {
|
||||
if !matches!(l.kind, NodeKind::Text(_))
|
||||
&& let Some((path, rule)) = node_path(l)
|
||||
{
|
||||
styles.push(Style::of(l));
|
||||
sources.push((path, rule, matches!(l.kind, NodeKind::Compound { .. })));
|
||||
}
|
||||
});
|
||||
roots_found.push((*id, matches!(n.kind, NodeKind::Group { .. }), styles));
|
||||
}
|
||||
let paths = !sources.is_empty();
|
||||
let offset = |(path, rule, compound): &(PathData, FillRule, bool)| {
|
||||
let normalized;
|
||||
let base = if *compound && path.is_closed() {
|
||||
normalized = po::normalize(path, *rule);
|
||||
&normalized
|
||||
} else {
|
||||
path
|
||||
};
|
||||
match po::try_offset_path(base, delta, join, miter) {
|
||||
Ok(p) => Ok(Some(p)),
|
||||
Err(po::PathOpsError::NonFinite) => Ok(None),
|
||||
Err(e) => Err(e),
|
||||
}
|
||||
};
|
||||
// The offsets are worked out together; the nodes are made here, in selection order, so
|
||||
// the ids, the stacking and the first error are those of one path after another.
|
||||
let (offsets, _) = ordered_map(&sources, offset_threads(), offset, std::result::Result::is_err);
|
||||
let mut offsets = offsets.into_iter();
|
||||
let (mut out, mut above) = (vec![], vec![]);
|
||||
for (id, group, styles) in roots_found {
|
||||
let mut made = vec![];
|
||||
for l in lv.iter().filter(|l| !matches!(l.kind, NodeKind::Text(_))) {
|
||||
let Some((path, rule)) = node_path(l) else { continue };
|
||||
paths = true;
|
||||
let base = if matches!(l.kind, NodeKind::Compound { .. }) && path.is_closed() { po::normalize(&path, rule) } else { path };
|
||||
let off = match po::try_offset_path(&base, delta, join, miter) {
|
||||
Ok(p) => p,
|
||||
Err(po::PathOpsError::NonFinite) => continue,
|
||||
Err(po::PathOpsError::TooComplex) => {
|
||||
for style in &styles {
|
||||
match offsets.next().flatten() {
|
||||
Some(Ok(Some(off))) if !off.is_empty() => made.push(shape_node_styled(d, off, style)),
|
||||
Some(Err(po::PathOpsError::TooComplex)) => {
|
||||
return Err(EngineError::Other("Offset Path: the path is too complex to offset".into()));
|
||||
}
|
||||
Err(e) => return Err(EngineError::Other(format!("Offset Path: {e}"))),
|
||||
};
|
||||
if off.is_empty() {
|
||||
continue;
|
||||
Some(Err(e)) => return Err(EngineError::Other(format!("Offset Path: {e}"))),
|
||||
_ => {}
|
||||
}
|
||||
made.push(shape_node(d, off, Some(l)));
|
||||
}
|
||||
if made.is_empty() {
|
||||
continue;
|
||||
}
|
||||
let new = if made.len() == 1
|
||||
&& !matches!(n.kind, NodeKind::Group { .. })
|
||||
&& !group
|
||||
&& let Some(one) = made.pop()
|
||||
{
|
||||
one
|
||||
@@ -840,7 +864,7 @@ fn offset_path(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
Node::group(gid, made.into_iter().map(Arc::new).collect())
|
||||
};
|
||||
out.push(new.id);
|
||||
above.push((*id, vec![new]));
|
||||
above.push((id, vec![new]));
|
||||
}
|
||||
if out.is_empty() && paths {
|
||||
return Err(EngineError::Other("Offset Path: the offset leaves nothing of the selected paths (an inset larger than the shapes)".into()));
|
||||
@@ -855,6 +879,86 @@ fn offset_path(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
Ok(ids_json(&ids))
|
||||
}
|
||||
|
||||
/// The threads [`offset_path`] works on: the logical CPUs, or one on the web.
|
||||
fn offset_threads() -> usize {
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
{
|
||||
1
|
||||
}
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
{
|
||||
std::thread::available_parallelism().map_or(1, std::num::NonZero::get)
|
||||
}
|
||||
}
|
||||
|
||||
/// `f` of each of `items`, in their order. The items are split into runs whose sizes differ by
|
||||
/// at most one, as many as `threads` or the items allow and at least one. This thread works
|
||||
/// on the first run, and a thread of its own works on each other run (this thread does when that
|
||||
/// one can't start). Once `stop` accepts a result, the items after it are left out (`None`); every
|
||||
/// item before the first such result has its result. Returns the results and the number of
|
||||
/// threads that worked on them.
|
||||
pub(crate) fn ordered_map<T: Sync, R: Send>(
|
||||
items: &[T],
|
||||
threads: usize,
|
||||
f: impl Fn(&T) -> R + Sync,
|
||||
stop: impl Fn(&R) -> bool + Sync,
|
||||
) -> (Vec<Option<R>>, usize) {
|
||||
use std::sync::atomic::{AtomicUsize, Ordering};
|
||||
let mut out: Vec<Option<R>> = std::iter::repeat_with(|| None).take(items.len()).collect();
|
||||
let stopped = AtomicUsize::new(usize::MAX);
|
||||
let run = |from: usize, its: &[T], out: &mut [Option<R>]| {
|
||||
for (i, (item, slot)) in its.iter().zip(out).enumerate() {
|
||||
let at = from + i;
|
||||
if at > stopped.load(Ordering::Relaxed) {
|
||||
return;
|
||||
}
|
||||
let r = f(item);
|
||||
if stop(&r) {
|
||||
stopped.fetch_min(at, Ordering::Relaxed);
|
||||
}
|
||||
*slot = Some(r);
|
||||
}
|
||||
};
|
||||
let runs = threads.clamp(1, items.len().max(1));
|
||||
if runs == 1 {
|
||||
run(0, items, &mut out);
|
||||
return (out, 1);
|
||||
}
|
||||
// Run `k` holds the items from `start(k)` up to `start(k + 1)`.
|
||||
let start = |k: usize| k * items.len() / runs;
|
||||
let mut unstarted = vec![];
|
||||
let used = std::thread::scope(|s| {
|
||||
let mut rest = out.as_mut_slice();
|
||||
let mut first = None;
|
||||
let mut used = 1;
|
||||
for k in 0..runs {
|
||||
let (from, to) = (start(k), start(k + 1));
|
||||
let Some((o, tail)) = std::mem::take(&mut rest).split_at_mut_checked(to - from) else { break };
|
||||
rest = tail;
|
||||
let its = items.get(from..to).unwrap_or_default();
|
||||
if k == 0 {
|
||||
first = Some((o, its));
|
||||
continue;
|
||||
}
|
||||
let run = &run;
|
||||
match std::thread::Builder::new().spawn_scoped(s, move || run(from, its, o)) {
|
||||
Ok(_) => used += 1,
|
||||
Err(_) => unstarted.push(k),
|
||||
}
|
||||
}
|
||||
if let Some((o, its)) = first {
|
||||
run(0, its, o);
|
||||
}
|
||||
used
|
||||
});
|
||||
for (from, to) in unstarted.into_iter().map(|k| (start(k), start(k + 1))) {
|
||||
if let (Some(o), Some(its)) = (out.get_mut(from..to), items.get(from..to)) {
|
||||
run(from, its, o);
|
||||
}
|
||||
}
|
||||
(out, used)
|
||||
}
|
||||
|
||||
// ---------- Outline Stroke ----------
|
||||
|
||||
/// Can Outline Stroke turn `st` into filled art?
|
||||
|
||||
@@ -32,6 +32,7 @@ pub(super) const ALLOWED: &[&str] = &[
|
||||
"addon.color.strokeToFill",
|
||||
"addon.color.swatchLegend",
|
||||
"addon.contour.set",
|
||||
"addon.cutting.banner",
|
||||
"addon.cutting.bridges",
|
||||
"addon.cutting.weed",
|
||||
"addon.image.applyColorMask",
|
||||
|
||||
@@ -576,13 +576,25 @@ pub fn store(s: &Session) -> Option<Arc<dyn RecoveryStore>> {
|
||||
}
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
{
|
||||
let folder = Some(s.prefs.recovery_folder.trim()).filter(|f| !f.is_empty()).map(str::to_string).or_else(|| s.recovery.default_folder.clone());
|
||||
folder.map(|f| Arc::new(FolderStore::new(f)) as Arc<dyn RecoveryStore>)
|
||||
folder(s).map(|(f, _)| Arc::new(FolderStore::new(f)) as Arc<dyn RecoveryStore>)
|
||||
}
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
None
|
||||
}
|
||||
|
||||
/// The folder copies go to now, and whether the `recoveryFolder` preference names it (else it is
|
||||
/// the folder the app sets). `None` when they go to a store the host installed (browser storage)
|
||||
/// or nowhere.
|
||||
pub fn folder(s: &Session) -> Option<(String, bool)> {
|
||||
if s.recovery.store.is_some() || cfg!(target_arch = "wasm32") {
|
||||
return None;
|
||||
}
|
||||
match s.prefs.recovery_folder.trim() {
|
||||
"" => s.recovery.default_folder.clone().map(|f| (f, false)),
|
||||
set => Some((set.to_string(), true)),
|
||||
}
|
||||
}
|
||||
|
||||
fn has_store(s: &Session) -> std::result::Result<(), String> {
|
||||
match store(s) {
|
||||
Some(_) => Ok(()),
|
||||
|
||||
@@ -22,7 +22,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Stroke Options",
|
||||
["Window", "Stroke"],
|
||||
None,
|
||||
"{weight?: pt, cap?: butt|round|square, join?: miter|round|bevel, miterLimit?, align?: center|inside|outside, dash?: [d,g,…]|null (a 0 dash with a round or projecting cap draws dots or squares; a new pattern keeps the current offset and alignment), dashOffset? (exact dashes only), alignDashes?: bool (true: dashes fitted to corners and path ends, every run between them holding whole periods with a dash centred on each corner and end; false, the default for a new pattern: exact lengths), startArrow?, endArrow?: Arrow|ArrowOpen|LineArrow|Barbed|Concave|DoubleArrow|HalfArrowLeft|HalfArrowRight|Chevron|Feather|Swallowtail|Triangle|TriangleOpen|TriangleReverse|TriangleBar|Kite|Leaf|Drop|Circle|CircleOpen|HalfCircle|Oval|Target|Square|SquareOpen|Tag|TagOpen|Diamond|DiamondOpen|Hexagon|HexagonOpen|Star|Cross|Plus|Bar|DoubleBar|DotOnBar|Slash|DoubleSlash|Bracket|Fork|null|{custom: name} (a custom arrowhead of the arrowheads file, stroke.arrowheads.list)|{Custom: {name, path}} (a custom head with its art, as document.inspect reports one; refused when the path fills no area, parts thinner than a millionth of a head unit counting as none, or has more than 4096 anchors, more than 4096 subpaths or a point more than 64 head units from the tip) (HalfArrowLeft/Right: one barb, left or right of the direction the head points), arrowAlign?: \"extend\" (tip past the end point, default)|\"tip\" (tip on the end point; the stroke is shortened), profile?: \"uniform\"|\"lens\"|\"taperStart\"|\"taperEnd\"|\"pinch\"|\"teardrop\"|\"wave\"|a saved profile's name (stroke.widthProfile.list), widthPoints?, gradientMode? (as document.inspect reports them in strokeOptions, so those read back unchanged; ignored here: a custom profile is set with the Width tool, gradientMode with paint.editGradient), item?: stroke item index|null (omitted: the Appearance panel's active item if it is a stroke, else the top stroke, created when missing), ids?|id?, target?: \"object\"|\"contents\", aligned?: bool (index and item are rows of the first target's stack: each other target edits its equal one, as the Appearance panel does for a mixed selection)} Without a stroke item, type takes weight, cap, join, miterLimit, align (around its glyph outlines; the type stays live) and the dash options as its characters' stroke (every run; text.setRangeStyle `strokeOptions` styles a range), and images and symbol instances (also in groups) are left alone. With nothing selected (and no ids) they set up the next object drawn (appearance.newArt; weight always does). On a path directly in a graph series, the graph also stores a weight it changes on the topmost visible stroke (docs/mcp.md) → {objects: how many objects took the stroke options}",
|
||||
"{weight?: pt, cap?: butt|round|square, join?: miter|round|bevel, miterLimit?, align?: center|inside|outside, dash?: [d,g,…]|null (a 0 dash with a round or projecting cap draws dots or squares; a new pattern keeps the current offset and alignment), dashOffset? (exact dashes only), alignDashes?: bool (true: dashes fitted to corners and path ends, every run between them holding whole periods with a dash centred on each corner and end; false, the default for a new pattern: exact lengths), startArrow?, endArrow?: Arrow|ArrowOpen|LineArrow|Barbed|Concave|DoubleArrow|HalfArrowLeft|HalfArrowRight|Chevron|Feather|Swallowtail|Triangle|TriangleOpen|TriangleReverse|TriangleBar|Kite|Leaf|Drop|Circle|CircleOpen|HalfCircle|Oval|Target|Square|SquareOpen|Tag|TagOpen|Diamond|DiamondOpen|Hexagon|HexagonOpen|Star|Cross|Plus|Bar|DoubleBar|DotOnBar|Slash|DoubleSlash|Bracket|Fork|null|{custom: name} (a custom arrowhead of the arrowheads file, stroke.arrowheads.list)|{Custom: {name, path}} (a custom head with its art, as document.inspect reports one; refused when the path fills no area, parts thinner than a millionth of a head unit counting as none, or has more than 4096 anchors, more than 4096 subpaths or a point more than 64 head units from the tip) (HalfArrowLeft/Right: one barb, left or right of the direction the head points), arrowAlign?: \"extend\" (tip past the end point, default)|\"tip\" (tip on the end point; the stroke is shortened), profile?: \"uniform\"|\"lens\"|\"taperStart\"|\"taperEnd\"|\"pinch\"|\"teardrop\"|\"wave\"|a saved profile's name (stroke.widthProfile.list), widthPoints?, gradientMode? (as document.inspect reports them in strokeOptions, so those read back unchanged; ignored here: a custom profile is set with the Width tool, gradientMode with paint.editGradient), item?: stroke item index|null (omitted: the Appearance panel's active item if it is a stroke, else the top stroke, created when missing), ids?|id?, target?: \"object\"|\"contents\", aligned?: bool (index and item are rows of the first target's stack: each other target edits its equal one, as the Appearance panel does for a mixed selection)} Without a stroke item, type takes weight, cap, join, miterLimit, align (around its glyph outlines; the type stays live) and the dash options as its characters' stroke (every run; text.setRangeStyle `strokeOptions` styles a range), and images and symbol instances (also in groups) are left alone. With nothing selected (and no ids) they set up the next object drawn (appearance.newArt; weight always does). On a path directly in a graph series, the graph also stores a weight it changes on the topmost visible stroke (docs/mcp.md); on a graph label's stroke row, the graph stores the label's topmost visible fill and stroke rows for every label of its kind → {objects: how many objects took the stroke options}",
|
||||
has_doc,
|
||||
stroke_set
|
||||
),
|
||||
|
||||
@@ -31,7 +31,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Apply Graphic Style",
|
||||
["Window", "Graphic Styles"],
|
||||
None,
|
||||
"{name, add?: bool, ids?|id? (layers too), target?: \"object\"|\"contents\"} give the objects (default: the selection; a group or layer itself, its fills and strokes painting its members and its effects applying to them as one piece; target contents: the objects inside instead) the style's appearance, opacity, blend mode, isolate and knockout, and link them to it (placed gradients land at the same place relative to each object's bounds); add: true (Alt-click) adds its fills, strokes and effects on top of the existing appearance instead and unlinks. On a path directly in a graph series (a series group's id stands for those paths), the graph also stores the paint, weight, opacity and blend mode of the topmost visible fill and stroke (with `add`: of the kinds of row the style shows) and, without `add`, the object's opacity and blend mode (exceptions: docs/mcp.md). With nothing selected (and no ids) the next object drawn takes the style instead (see appearance.newArt) → {newArt: true}",
|
||||
"{name, add?: bool, ids?|id? (layers too), target?: \"object\"|\"contents\"} give the objects (default: the selection; a group or layer itself, its fills and strokes painting its members and its effects applying to them as one piece; target contents: the objects inside instead) the style's appearance, opacity, blend mode, isolate and knockout, and link them to it (placed gradients land at the same place relative to each object's bounds); add: true (Alt-click) adds its fills, strokes and effects on top of the existing appearance instead and unlinks. On a path directly in a graph series (a series group's id stands for those paths), the graph also stores the paint, weight, opacity and blend mode of the topmost visible fill and stroke (with `add`: of the kinds of row the style shows) and, without `add`, the object's opacity and blend mode (exceptions: docs/mcp.md); on a graph label, the graph stores the label's topmost visible fill and stroke rows and object opacity and blend mode for every label of its kind. With nothing selected (and no ids) the next object drawn takes the style instead (see appearance.newArt) → {newArt: true}",
|
||||
has_doc,
|
||||
style_apply
|
||||
),
|
||||
@@ -68,7 +68,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Redefine Graphic Style",
|
||||
["Window", "Appearance"],
|
||||
None,
|
||||
"{name?, id?} replace the style (default: the one the object was last linked to) with the look of object `id` or the first selected object (a graph series group with no fill, stroke, effect, opacity, blend mode, isolation, knockout or style link of its own: the topmost path directly in it); the objects still linked to it update, and that object becomes linked. A linked graph series group passes the style to the paths directly in it, other than the source and paths linked to the style whose look was edited, and is left cleared (as appearance.clear) and unlinked. On a linked path directly in a graph series, the graph also stores the paint, weight, opacity and blend mode of the topmost visible fill and stroke and the object's opacity and blend mode (exceptions: docs/mcp.md) → {name}",
|
||||
"{name?, id?} replace the style (default: the one the object was last linked to) with the look of object `id` or the first selected object (a graph series group with no fill, stroke, effect, opacity, blend mode, isolation, knockout or style link of its own: the topmost path directly in it); the objects still linked to it update, and that object becomes linked. A linked graph series group passes the style to the paths directly in it, other than the source and paths linked to the style whose look was edited, and is left cleared (as appearance.clear) and unlinked. On a linked path directly in a graph series, the graph also stores the paint, weight, opacity and blend mode of the topmost visible fill and stroke and the object's opacity and blend mode (exceptions: docs/mcp.md); on a graph label, the graph stores the label's topmost visible fill and stroke rows and object opacity and blend mode for every label of its kind → {name}",
|
||||
has_doc,
|
||||
style_redefine
|
||||
),
|
||||
@@ -374,6 +374,8 @@ fn style_apply(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
super::appearance::clear_stood_for(d, &groups);
|
||||
super::appearance::capture_series_writes(&before, d, &ids, (true, true), (true, true), false, true);
|
||||
}
|
||||
// A label keeps its new rows for its kind of label.
|
||||
super::graph::capture_label_rows(d, &ids);
|
||||
Ok(())
|
||||
})?;
|
||||
ok()
|
||||
@@ -506,6 +508,7 @@ fn style_redefine(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
apply_style(d, *nid, &g, &mut shared, chars);
|
||||
}
|
||||
super::appearance::capture_series_writes(&before, d, &follow_marks, (true, true), (true, true), false, true);
|
||||
super::graph::capture_label_rows(d, &follow_marks);
|
||||
set_link(d, &[src], Some(id));
|
||||
Ok(())
|
||||
})?;
|
||||
|
||||
@@ -399,6 +399,11 @@ pub(crate) fn mark_continued(doc: &mut Document) {
|
||||
/// path; empty when `id` is in no thread or the thread has fewer than two such frames.
|
||||
pub(crate) fn story_of(d: &Document, id: NodeId) -> Vec<NodeId> {
|
||||
let Some(thread) = d.text_threads.iter().find(|t| t.contains(&id)) else { return vec![] };
|
||||
story_in(d, thread, id)
|
||||
}
|
||||
|
||||
/// [`story_of`] for `id` in `thread`, the first of the document's threads that lists it.
|
||||
pub(crate) fn story_in(d: &Document, thread: &[NodeId], id: NodeId) -> Vec<NodeId> {
|
||||
let frames: Vec<NodeId> = thread.iter().copied().filter(|f| text_of(d, *f).is_some()).collect();
|
||||
if frames.len() >= 2 && frames.contains(&id) { frames } else { vec![] }
|
||||
}
|
||||
|
||||
@@ -37,6 +37,9 @@ pub struct DocState {
|
||||
/// The objects Start Global Edit would take, highlighted before it starts
|
||||
/// ([`cmd::globaledit::similar`]), as of a revision. Not saved, not undoable.
|
||||
pub(crate) similar_outlines: cmd::globaledit::SimilarCache,
|
||||
/// Whether Start Global Edit supports the one selected object
|
||||
/// ([`cmd::globaledit::SupportCache`]), as of a revision. Not saved, not undoable.
|
||||
pub(crate) global_edit_support: cmd::globaledit::SupportCache,
|
||||
/// What leaving symbol editing mode goes back to ([`cmd::symboledit`]).
|
||||
pub(crate) symbol_edit_return: cmd::symboledit::Return,
|
||||
pub interaction: Option<Interaction>,
|
||||
@@ -311,6 +314,7 @@ impl DocState {
|
||||
isolation: None,
|
||||
global_edit: None,
|
||||
similar_outlines: Default::default(),
|
||||
global_edit_support: Default::default(),
|
||||
symbol_edit_return: Default::default(),
|
||||
interaction: None,
|
||||
undo_group: None,
|
||||
|
||||
@@ -3087,6 +3087,144 @@ fn live_paint_bucket_tool_fills_face() {
|
||||
assert_eq!(s.doc().unwrap().selection.objects, vec![f.id]);
|
||||
}
|
||||
|
||||
/// `tool.setOption` refuses an invalid value for the Live Paint tools, naming its key, whether the
|
||||
/// tool is active or not and in the `key`/`value` and `values` forms: a switch that is not true or
|
||||
/// false, a color that is not an RGB color (an alpha part included), a width that is not a number
|
||||
/// and an option the tool does not have. Nothing is set, also from the valid values sent with it,
|
||||
/// and the saved settings stay as they were.
|
||||
#[test]
|
||||
fn live_paint_tool_options_refuse_invalid_values_and_change_nothing() {
|
||||
let colors = [json!(3), json!("nope"), json!("#12345"), json!("#00ff0080"), json!("00ff0080"), json!("#0f08"), json!(null)];
|
||||
let widths = [json!("wide"), json!(null), json!(true)];
|
||||
let switches = [json!("true"), json!(null), json!(1)];
|
||||
for (tool, keys) in
|
||||
[("livePaintBucket", &["paintFills", "paintStrokes", "highlight"][..]), ("livePaintSelection", &["selectFills", "selectStrokes"][..])]
|
||||
{
|
||||
let mut bad: Vec<(&str, Value)> = keys.iter().flat_map(|k| switches.iter().map(move |v| (*k, v.clone()))).collect();
|
||||
bad.extend(colors.iter().map(|v| ("color", v.clone())));
|
||||
bad.extend(widths.iter().map(|v| ("width", v.clone())));
|
||||
bad.push(("colour", json!("#00ff00")));
|
||||
for active in [false, true] {
|
||||
let mut s = session();
|
||||
if active {
|
||||
s.select_tool(tool, ViewInfo::default()).unwrap();
|
||||
}
|
||||
let (options, prefs) = (s.tool_options_of(tool), s.prefs.clone());
|
||||
for (key, v) in &bad {
|
||||
let mut values = serde_json::Map::new();
|
||||
values.insert("width".into(), json!(9));
|
||||
values.insert(key.to_string(), v.clone());
|
||||
for p in [json!({"tool": tool, "values": values}), json!({"tool": tool, "key": key, "value": v})] {
|
||||
let e = s.set_tool_option_cmd(&p).unwrap_err();
|
||||
assert!(e.contains(&format!("`{key}`")), "{tool} {p}: {e}");
|
||||
assert_eq!((s.tool_options_of(tool), &s.prefs), (options.clone(), &prefs), "{tool} {p}");
|
||||
}
|
||||
}
|
||||
// A `key` left without a `value` is an invalid value too.
|
||||
assert!(s.set_tool_option_cmd(&json!({"tool": tool, "key": "color"})).is_err());
|
||||
s.select_tool(tool, ViewInfo::default()).unwrap();
|
||||
assert_eq!(s.tool_options_of(tool), options, "{tool}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The Live Paint tools' options take each color form they took before, and a width out of range
|
||||
/// is clamped to 1–20 rather than refused; the values are saved with the preferences.
|
||||
#[test]
|
||||
fn live_paint_tool_options_take_rgb_colors_and_clamp_the_width() {
|
||||
let mut s = session();
|
||||
for tool in ["livePaintBucket", "livePaintSelection"] {
|
||||
for (color, hex) in [("#00FF00", "#00ff00"), ("#0f0", "#00ff00"), ("0000ff", "#0000ff"), ("light blue", "#4f80ff"), ("white", "#ffffff")] {
|
||||
let o = s.set_tool_option_cmd(&json!({"tool": tool, "key": "color", "value": color})).unwrap();
|
||||
assert_eq!(o["color"], hex, "{tool} {color}");
|
||||
}
|
||||
for (width, set) in [(100.0, 20.0), (0.0, 1.0), (-3.0, 1.0), (7.5, 7.5)] {
|
||||
let o = s.set_tool_option_cmd(&json!({"tool": tool, "values": {"width": width}})).unwrap();
|
||||
assert_eq!(o["width"], set, "{tool} {width}");
|
||||
}
|
||||
assert_eq!(s.prefs.tool_settings[tool]["width"], 7.5);
|
||||
assert_eq!(s.prefs.tool_settings[tool]["color"], "#ffffff");
|
||||
}
|
||||
let o = s.set_tool_option_cmd(&json!({"tool": "livePaintBucket", "values": {"paintFills": false, "highlight": false}})).unwrap();
|
||||
assert_eq!((&o["paintFills"], &o["highlight"]), (&json!(false), &json!(false)));
|
||||
}
|
||||
|
||||
/// Saved Live Paint settings that hold an invalid value still load: the tool keeps its default for
|
||||
/// that option and takes the valid ones.
|
||||
#[test]
|
||||
fn live_paint_tools_load_saved_settings_with_invalid_values() {
|
||||
let mut s = session();
|
||||
let saved = json!({"paintFills": "yes", "highlight": false, "width": "wide", "color": "nope", "paintStrokes": true});
|
||||
s.prefs.tool_settings.insert("livePaintBucket".into(), saved.as_object().unwrap().clone());
|
||||
s.select_tool("livePaintBucket", ViewInfo::default()).unwrap();
|
||||
let o = s.tool_options_of("livePaintBucket");
|
||||
assert_eq!(
|
||||
(&o["paintFills"], &o["paintStrokes"], &o["highlight"], &o["width"], &o["color"]),
|
||||
(&json!(true), &json!(true), &json!(false), &json!(4.0), &json!("#ff2a2a"))
|
||||
);
|
||||
}
|
||||
|
||||
/// `tool.setOption` refuses an invalid value for the Shape Builder, naming its key, whether the
|
||||
/// tool is active or not and in the `key`/`value` and `values` forms: a switch that is not true or
|
||||
/// false, a `pickColorFrom` or `selection` that is not one of its names, a `gapLength` that is not
|
||||
/// a number, a `highlightColor` that is not an RGB color (an alpha part included) and an option the
|
||||
/// tool does not have. Nothing is set, also from the valid values sent with it, and the saved
|
||||
/// settings stay as they were.
|
||||
#[test]
|
||||
fn shape_builder_tool_options_refuse_invalid_values_and_change_nothing() {
|
||||
let tool = "shapeBuilder";
|
||||
let switches = ["openFilledAsClosed", "cursorSwatchPreview", "highlightFill", "gapDetection", "clickStrokeSplitsPath", "highlightStroke"];
|
||||
let mut bad: Vec<(&str, Value)> = switches.iter().flat_map(|k| [json!("true"), json!(null), json!(1)].map(move |v| (*k, v))).collect();
|
||||
bad.extend([json!("Swatches"), json!(true), json!(null), json!("")].map(|v| ("pickColorFrom", v)));
|
||||
bad.extend([json!("straight"), json!(1), json!(null)].map(|v| ("selection", v)));
|
||||
bad.extend([json!("wide"), json!(null), json!(true)].map(|v| ("gapLength", v)));
|
||||
let colors = [json!(3), json!("nope"), json!("#12345"), json!("#00ff0080"), json!("00ff0080"), json!("#0f08"), json!(null)];
|
||||
bad.extend(colors.map(|v| ("highlightColor", v)));
|
||||
bad.push(("color", json!("#00ff00")));
|
||||
for active in [false, true] {
|
||||
let mut s = session();
|
||||
if active {
|
||||
s.select_tool(tool, ViewInfo::default()).unwrap();
|
||||
}
|
||||
let (options, prefs) = (s.tool_options_of(tool), s.prefs.clone());
|
||||
for (key, v) in &bad {
|
||||
let mut values = serde_json::Map::new();
|
||||
values.insert("gapLength".into(), json!(9));
|
||||
values.insert(key.to_string(), v.clone());
|
||||
for p in [json!({"tool": tool, "values": values}), json!({"tool": tool, "key": key, "value": v})] {
|
||||
let e = s.set_tool_option_cmd(&p).unwrap_err();
|
||||
assert!(e.contains(&format!("`{key}`")), "{p}: {e}");
|
||||
assert_eq!((s.tool_options_of(tool), &s.prefs), (options.clone(), &prefs), "{p}");
|
||||
}
|
||||
}
|
||||
s.select_tool(tool, ViewInfo::default()).unwrap();
|
||||
assert_eq!(s.tool_options_of(tool), options);
|
||||
}
|
||||
}
|
||||
|
||||
/// The Shape Builder's options take each value they took before: the names of `pickColorFrom` and
|
||||
/// `selection`, each RGB color form, and a gap length out of range clamped rather than refused.
|
||||
#[test]
|
||||
fn shape_builder_tool_options_take_valid_values_and_clamp_the_gap() {
|
||||
let mut s = session();
|
||||
let tool = "shapeBuilder";
|
||||
for (color, hex) in [("#00FF00", "#00ff00"), ("#0f0", "#00ff00"), ("0000ff", "#0000ff"), ("light blue", "#4f80ff"), ("white", "#ffffff")] {
|
||||
let o = s.set_tool_option_cmd(&json!({"tool": tool, "key": "highlightColor", "value": color})).unwrap();
|
||||
assert_eq!(o["highlightColor"], hex, "{color}");
|
||||
}
|
||||
for (gap, set) in [(1000.0, vectorcraft_tools::builder::SHAPE_BUILDER_MAX_GAP), (-3.0, 0.0), (7.5, 7.5)] {
|
||||
let o = s.set_tool_option_cmd(&json!({"tool": tool, "values": {"gapLength": gap}})).unwrap();
|
||||
assert_eq!(o["gapLength"], set, "{gap}");
|
||||
}
|
||||
let o = s
|
||||
.set_tool_option_cmd(&json!({"tool": tool, "values": {"pickColorFrom": "swatches", "selection": "straightLine", "gapDetection": true}}))
|
||||
.unwrap();
|
||||
assert_eq!((&o["pickColorFrom"], &o["selection"], &o["gapDetection"]), (&json!("swatches"), &json!("straightLine"), &json!(true)));
|
||||
let o = s.set_tool_option_cmd(&json!({"tool": tool, "values": {"pickColorFrom": "artwork", "selection": "freeform"}})).unwrap();
|
||||
assert_eq!((&o["pickColorFrom"], &o["selection"]), (&json!("artwork"), &json!("freeform")));
|
||||
assert_eq!(s.prefs.tool_settings[tool]["gapLength"], 7.5);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn live_paint_bucket_alt_sample_paints_next_face_without_changing_selection() {
|
||||
let mut s = session();
|
||||
|
||||
@@ -1367,6 +1367,32 @@ fn smooth_tool_reduces_jagged_path() {
|
||||
assert_eq!(p.subpaths[0].anchors[0].p, Point::new(100.0, 200.0));
|
||||
}
|
||||
|
||||
/// The anchors a zigzag `amplitude` points high keeps after one `path.smoothRegion` drag along it
|
||||
/// at `fidelity`, with the path, the drag, its radius and the Fidelity all scaled by `scale`: the
|
||||
/// same drag on screen zoomed in 1 / `scale` times.
|
||||
fn smoothed_zigzag_anchors(amplitude: f64, fidelity: f64, scale: f64) -> usize {
|
||||
let mut s = session();
|
||||
let anchors: Vec<_> =
|
||||
(0..=40).map(|i| json!({"x": (100.0 + i as f64 * 5.0) * scale, "y": (200.0 + if i % 2 == 0 { 0.0 } else { amplitude }) * scale})).collect();
|
||||
let r = s.execute("path.create", &json!({"anchors": anchors})).unwrap();
|
||||
let id = NodeId(r["id"].as_u64().unwrap());
|
||||
let drag: Vec<_> = [(90.0, 200.0), (200.0, 200.0), (310.0, 200.0)].iter().map(|&(x, y)| json!([x * scale, y * scale])).collect();
|
||||
s.execute("path.smoothRegion", &json!({"points": drag, "radius": 12.0 * scale, "fidelity": fidelity * scale})).unwrap();
|
||||
path(&s, id).anchor_count()
|
||||
}
|
||||
|
||||
/// The Smooth tool sends its Fidelity (screen pixels) in points at Fidelity / zoom, so
|
||||
/// `path.smoothRegion` refits at the tolerance it is sent from the lowest Fidelity at the highest
|
||||
/// zoom (0.05 px at 64000 %) to the highest at the lowest zoom (100 px at 3.13 %): a drag smooths
|
||||
/// as much on screen at any zoom.
|
||||
#[test]
|
||||
fn smooth_region_refits_at_the_tolerance_sent_at_any_zoom() {
|
||||
for (amplitude, fidelity, scale) in [(1.5, 0.05, 1.0 / 640.0), (1.5, 0.05, 1.0 / 8.0), (5.0, 20.0, 1.0 / 0.0313), (5.0, 20.0, 4.0)] {
|
||||
let want = smoothed_zigzag_anchors(amplitude, fidelity, 1.0);
|
||||
assert_eq!(smoothed_zigzag_anchors(amplitude, fidelity, scale), want, "{amplitude} pt zigzag at {fidelity} px, zoom {}", 1.0 / scale);
|
||||
}
|
||||
}
|
||||
|
||||
/// A drag of the Smooth tool along a line jagged by up to 6 pt leaves no corner between a curve and
|
||||
/// a line: every anchor between the path's ends turns by at most 1°.
|
||||
#[test]
|
||||
|
||||
@@ -330,18 +330,103 @@ fn global_edit_needs_one_supported_object() {
|
||||
let e = s.execute("select.globalEdit", &json!({})).unwrap_err().to_string();
|
||||
assert!(e.contains("no similar objects") && e.contains("rotated"), "{e}");
|
||||
assert!(s.doc().unwrap().global_edit.is_none());
|
||||
// A group holding type is refused, though another such group exists.
|
||||
// A group holding type is not offered, though another such group exists.
|
||||
let group = |s: &mut Session, x: f64| {
|
||||
let p = rect(s, x, 100.0, 10.0, 10.0);
|
||||
let t = NodeId(s.execute("text.create", &json!({"x": x, "y": 150, "text": "Hi"})).unwrap()["id"].as_u64().unwrap());
|
||||
group_of(s, &[p, t])
|
||||
};
|
||||
let (g, _) = (group(&mut s, 600.0), group(&mut s, 700.0));
|
||||
assert_not_offered(&mut s, g, "a group holding type");
|
||||
}
|
||||
|
||||
fn enabled(s: &Session, id: &str) -> bool {
|
||||
command_specs().iter().find(|c| c.id == id).is_some_and(|c| (c.enabled)(s).is_ok())
|
||||
}
|
||||
|
||||
/// With `id` selected, Start Global Edit is disabled, and running it changes nothing.
|
||||
fn assert_not_offered(s: &mut Session, id: NodeId, what: &str) {
|
||||
sel(s, &[id]);
|
||||
assert!(!enabled(s, "select.globalEdit"), "{what}");
|
||||
let r = s.execute("select.globalEdit", &json!({}));
|
||||
assert!(matches!(&r, Err(EngineError::Disabled(_, why)) if why.contains("symbol instances and groups of them")), "{what}: {r:?}");
|
||||
assert!(s.doc().unwrap().global_edit.is_none(), "{what}");
|
||||
}
|
||||
|
||||
/// An embedded 4 × 4 image at `x`, in `parent` (the default layer when `None`) → its id. The
|
||||
/// selection stays as it is.
|
||||
fn image(s: &mut Session, x: f64, parent: Option<NodeId>) -> NodeId {
|
||||
s.edit("Place", |d, _| {
|
||||
let id = d.alloc_id();
|
||||
let im = vectorcraft_doc::ImageObject {
|
||||
key: "pic".into(),
|
||||
width: 4,
|
||||
height: 4,
|
||||
xf: Affine::translate((x, 0.0)) * Affine::scale(10.0),
|
||||
link: None,
|
||||
placement: Default::default(),
|
||||
pixels: None,
|
||||
};
|
||||
d.insert(parent.or(d.default_layer()), 0, Node::new(id, NodeKind::Image(Box::new(im))))?;
|
||||
Ok(id)
|
||||
})
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_enabled_check_follows_edits_to_the_selected_group() {
|
||||
let mut s = session();
|
||||
let (g, _) = card(&mut s, 0.0, 1.0);
|
||||
card(&mut s, 300.0, 1.0);
|
||||
sel(&mut s, &[g]);
|
||||
// The menu only checks that it is a group; starting refuses its contents.
|
||||
let e = s.execute("select.globalEdit", &json!({})).unwrap_err();
|
||||
assert!(e.to_string().contains("symbol instances and groups of them"), "{e}");
|
||||
assert!(s.doc().unwrap().global_edit.is_none());
|
||||
assert!(enabled(&s, "select.globalEdit"));
|
||||
// The answer is kept between frames, and an edit of the selected group changes it.
|
||||
assert!(enabled(&s, "select.globalEdit"));
|
||||
image(&mut s, 0.0, Some(g));
|
||||
assert_eq!(s.doc().unwrap().selection.objects, [g]);
|
||||
assert!(!enabled(&s, "select.globalEdit"), "an image put into the selected group");
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
assert_eq!(s.doc().unwrap().selection.objects, [g]);
|
||||
assert!(enabled(&s, "select.globalEdit"), "after Undo");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn start_global_edit_is_offered_only_for_a_group_of_supported_objects() {
|
||||
let mut s = session();
|
||||
// Groups holding an image, groups holding a group that holds type, and empty groups; two of
|
||||
// each, so that each would have a group like it.
|
||||
for x in [0.0, 300.0] {
|
||||
let p = rect(&mut s, x, 100.0, 10.0, 10.0);
|
||||
let i = image(&mut s, x, None);
|
||||
let g = group_of(&mut s, &[p, i]);
|
||||
assert_not_offered(&mut s, g, "a group holding an image");
|
||||
let (p, q) = (rect(&mut s, x, 200.0, 10.0, 10.0), rect(&mut s, x + 20.0, 200.0, 10.0, 10.0));
|
||||
let t = NodeId(s.execute("text.create", &json!({"x": x, "y": 250, "text": "Hi"})).unwrap()["id"].as_u64().unwrap());
|
||||
let inner = group_of(&mut s, &[q, t]);
|
||||
let g = group_of(&mut s, &[p, inner]);
|
||||
assert_not_offered(&mut s, g, "a group holding a group that holds type");
|
||||
let empty = s
|
||||
.edit("Group", |d, _| {
|
||||
let id = d.alloc_id();
|
||||
d.insert(d.default_layer(), 0, Node::group(id, vec![]))?;
|
||||
Ok(id)
|
||||
})
|
||||
.unwrap();
|
||||
assert_not_offered(&mut s, empty, "an empty group");
|
||||
}
|
||||
// A group of supported objects is offered and starts; while on, Stop is offered whatever is
|
||||
// selected.
|
||||
let p = rect(&mut s, 600.0, 400.0, 10.0, 10.0);
|
||||
let t = NodeId(s.execute("text.create", &json!({"x": 600, "y": 450, "text": "Hi"})).unwrap()["id"].as_u64().unwrap());
|
||||
let typed = group_of(&mut s, &[p, t]);
|
||||
let (g, _) = card(&mut s, 0.0, 1.0);
|
||||
let (copy, _) = card(&mut s, 300.0, 1.0);
|
||||
sel(&mut s, &[g]);
|
||||
assert!(enabled(&s, "select.globalEdit"));
|
||||
assert_eq!(start(&mut s, g, json!({})), [g, copy]);
|
||||
sel(&mut s, &[typed]);
|
||||
assert!(enabled(&s, "select.globalEdit"), "Stop");
|
||||
assert_eq!(s.execute("select.globalEdit", &json!({})).unwrap()["active"], false);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -89,6 +89,43 @@ fn bad_corners_and_kinds_are_errors() {
|
||||
assert_eq!(corners(&s, id), ([0.0; 4], [CornerKind::Round; 4], 4), "nothing changed");
|
||||
}
|
||||
|
||||
/// A side count that isn't a whole number from 3 to 1000, a negative radius and an unknown key
|
||||
/// (at the top level or in one of `items`) are errors naming the key; the shapes and the undo
|
||||
/// history stay as they were. A radius of 0 and the ends of the side range still work.
|
||||
#[test]
|
||||
fn bad_sides_radii_and_keys_are_errors() {
|
||||
let (mut s, id) = session();
|
||||
let poly = NodeId(s.execute("shape.polygon", &json!({"cx": 200, "cy": 200, "radius": 80, "sides": 6})).unwrap()["id"].as_u64().unwrap());
|
||||
let state = |s: &Session| {
|
||||
let st = s.doc().unwrap();
|
||||
(corners(s, id), st.doc.node(poly).unwrap().clone(), st.history.undo.len())
|
||||
};
|
||||
let before = state(&s);
|
||||
for (p, key) in [
|
||||
(json!({"ids": [poly.0], "sides": 2.5}), "`sides` must be a whole number from 3 to 1000, not 2.5"),
|
||||
(json!({"ids": [poly.0], "sides": -1}), "`sides` must be a whole number from 3 to 1000, not -1"),
|
||||
(json!({"ids": [poly.0], "sides": 2}), "`sides` must be a whole number from 3 to 1000, not 2"),
|
||||
(json!({"ids": [poly.0], "sides": 1001}), "`sides` must be a whole number from 3 to 1000, not 1001"),
|
||||
(json!({"ids": [id.0, poly.0], "radius": -4}), "`radius` must be a radius of 0 or more, not -4"),
|
||||
(json!({"items": [{"id": id.0, "corners": [0]}], "radius": -0.5}), "`radius` must be a radius of 0 or more, not -0.5"),
|
||||
(json!({"id": id.0, "path": [[0, 0]], "radius": 5}), "unknown parameter `path`"),
|
||||
(json!({"items": [{"id": id.0, "corner": [0]}], "radius": 5}), "unknown parameter `items[0].corner`; did you mean `items[0].corners`?"),
|
||||
] {
|
||||
match s.execute("object.setLiveShape", &p) {
|
||||
Err(EngineError::BadParams { cmd, msg }) => assert!(cmd == "object.setLiveShape" && msg.starts_with(key), "{p}: {msg}"),
|
||||
other => panic!("{p}: {other:?}"),
|
||||
}
|
||||
assert_eq!(state(&s), before, "{p}");
|
||||
}
|
||||
run(&mut s, json!({"ids": [poly.0], "sides": 3}));
|
||||
assert!(matches!(node_path(&s, poly).1, Some(LiveShape::Polygon { sides: 3, .. })));
|
||||
run(&mut s, json!({"ids": [poly.0], "sides": 1000.0}));
|
||||
assert!(matches!(node_path(&s, poly).1, Some(LiveShape::Polygon { sides: 1000, .. })));
|
||||
run(&mut s, json!({"id": id.0, "radius": 6}));
|
||||
run(&mut s, json!({"id": id.0, "radius": 0}));
|
||||
assert_eq!(corners(&s, id).0, [0.0; 4]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn direct_selected_corners_round_alone_and_stay_selected() {
|
||||
let (mut s, id) = session();
|
||||
|
||||
@@ -1683,6 +1683,95 @@ fn delete_empty_artboards_deletes_those_with_no_art_in_one_step() {
|
||||
assert_eq!(s.doc().unwrap().doc.ruler_guides().len(), 3);
|
||||
}
|
||||
|
||||
/// `n` artboards in a row, each with a guide, and a guide on no artboard; every guide is selected,
|
||||
/// and the key guide is the one on artboard `key`.
|
||||
fn artboards_with_selected_guides(n: usize, key: usize) -> Session {
|
||||
let mut s = row_of_artboards(n);
|
||||
s.edit("selection fixture", |d, sel| {
|
||||
d.add_ruler_guide(&vectorcraft_doc::Guide::new(false, 75.0));
|
||||
sel.guides = d.ruler_guides().iter().filter_map(|g| g.id).collect();
|
||||
let on_key = d.artboards[key].id;
|
||||
sel.key_guide = d.ruler_guides().iter().find(|g| g.artboard == Some(on_key)).and_then(|g| g.id);
|
||||
Ok(())
|
||||
})
|
||||
.unwrap();
|
||||
s
|
||||
}
|
||||
|
||||
/// A document's artboards, ruler guides, selected guides and key guide.
|
||||
type BoardsAndGuides = (Vec<vectorcraft_doc::Artboard>, Vec<vectorcraft_doc::Guide>, Vec<NodeId>, Option<NodeId>);
|
||||
|
||||
/// The artboards, guides, selected guides and key guide after deleting artboards `indices` of the
|
||||
/// open document one at a time, the last first. A clone shares the document's retired artboard id,
|
||||
/// so the clone retires none.
|
||||
fn deleted_one_at_a_time(s: &Session, indices: &[usize]) -> BoardsAndGuides {
|
||||
let st = s.doc().unwrap();
|
||||
let (mut d, mut sel) = ((*st.doc).clone(), st.selection.clone());
|
||||
for &i in indices.iter().rev() {
|
||||
let id = d.artboards.remove(i).id;
|
||||
d.retain_guides(&mut sel, |g| g.artboard != Some(id));
|
||||
}
|
||||
let guides = d.ruler_guides();
|
||||
(d.artboards, guides, sel.guides, sel.key_guide)
|
||||
}
|
||||
|
||||
/// The same as [`deleted_one_at_a_time`] for the open document as it is.
|
||||
fn artboards_and_guides(s: &Session) -> BoardsAndGuides {
|
||||
let st = s.doc().unwrap();
|
||||
(st.doc.artboards.clone(), st.doc.ruler_guides(), st.selection.guides.clone(), st.selection.key_guide)
|
||||
}
|
||||
|
||||
/// Delete Artboard with many artboards chosen walks the artboards once and the ruler guides once,
|
||||
/// and leaves the same artboards, guides and selected guides as deleting them one at a time, in
|
||||
/// one undo step.
|
||||
#[test]
|
||||
fn deleting_many_artboards_walks_the_artboards_and_guides_once() {
|
||||
use crate::cmd::layer::ARTBOARD_PASSES;
|
||||
let mut s = artboards_with_selected_guides(40, 7);
|
||||
let indices: Vec<usize> = (0..40).filter(|i| i % 2 == 1 || *i == 38).collect();
|
||||
let expected = deleted_one_at_a_time(&s, &indices);
|
||||
let steps = s.doc().unwrap().history.undo.len();
|
||||
ARTBOARD_PASSES.set(0);
|
||||
s.execute("artboard.delete", &json!({ "indices": indices })).unwrap();
|
||||
assert_eq!(ARTBOARD_PASSES.get(), 2, "one walk over the artboards and one over the guides");
|
||||
let now = artboards_and_guides(&s);
|
||||
assert_eq!(now, expected);
|
||||
assert_eq!(now.0.len(), 19);
|
||||
assert_eq!(now.1.len(), 20, "the kept artboards' guides and the guide on no artboard stay");
|
||||
assert_eq!((now.2.len(), now.3), (20, None), "the deleted guides leave the selection, the key guide with them");
|
||||
assert_eq!(s.doc().unwrap().history.undo.len(), steps + 1);
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
assert_eq!(s.doc().unwrap().doc.artboards.len(), 40);
|
||||
assert_eq!(s.doc().unwrap().doc.ruler_guides().len(), 41);
|
||||
}
|
||||
|
||||
/// Delete Empty Artboards and Cut Artboard delete their artboards in the same single pass, with the
|
||||
/// same result as deleting them one at a time.
|
||||
#[test]
|
||||
fn delete_empty_and_cut_artboards_walk_the_artboards_and_guides_once() {
|
||||
use crate::cmd::layer::ARTBOARD_PASSES;
|
||||
let mut s = artboards_with_selected_guides(30, 0);
|
||||
box_at(&mut s, 4010.0, 20.0);
|
||||
let empty: Vec<usize> = (0..30).filter(|i| *i != 10).collect();
|
||||
let expected = deleted_one_at_a_time(&s, &empty);
|
||||
ARTBOARD_PASSES.set(0);
|
||||
assert_eq!(s.execute("artboard.deleteEmpty", &json!({})).unwrap(), json!({ "deleted": empty }));
|
||||
assert_eq!(ARTBOARD_PASSES.get(), 2);
|
||||
assert_eq!(artboards_and_guides(&s), expected);
|
||||
assert_eq!(artboard_names(&s), ["Artboard 11"]);
|
||||
|
||||
let mut s = artboards_with_selected_guides(30, 4);
|
||||
let cut: Vec<usize> = (2..28).collect();
|
||||
let expected = deleted_one_at_a_time(&s, &cut);
|
||||
ARTBOARD_PASSES.set(0);
|
||||
s.execute("artboard.cut", &json!({ "indices": cut, "art": false })).unwrap();
|
||||
assert_eq!(ARTBOARD_PASSES.get(), 2);
|
||||
assert_eq!(artboards_and_guides(&s), expected);
|
||||
assert_eq!(s.doc().unwrap().doc.artboards.len(), 4);
|
||||
assert!(s.execute("artboard.cut", &json!({ "indices": [0, 1, 2, 3], "art": false })).is_err(), "a document keeps one artboard");
|
||||
assert_eq!(s.doc().unwrap().doc.artboards.len(), 4);
|
||||
}
|
||||
|
||||
/// What counts as art: hidden and locked objects, an object straddling two artboards and one
|
||||
/// that only touches an artboard's edge; a group counts by its members, not by its box.
|
||||
#[test]
|
||||
|
||||
@@ -400,6 +400,43 @@ fn stroke_set_refuses_an_unknown_key() {
|
||||
}
|
||||
}
|
||||
|
||||
/// `object.setLiveShape` names every key it reads, and each of its `items` takes `id` and
|
||||
/// `corners` alone: another key, at the top level, in an item or in a batch step, is refused
|
||||
/// instead of being ignored.
|
||||
#[test]
|
||||
fn set_live_shape_refuses_an_unknown_key() {
|
||||
const ID: &str = "object.setLiveShape";
|
||||
assert!(!UNCHECKED.contains(&ID));
|
||||
let e = refusal(ID, json!({"id": 3, "path": [[0, 0], [1, 1]], "radius": 5}));
|
||||
assert!(e.starts_with("unknown parameter `path`; the command takes: ") && e.contains("relativeRadii"), "{e}");
|
||||
let e = refusal(ID, json!({"radiuss": 5}));
|
||||
assert_eq!(e, "unknown parameter `radiuss`; did you mean `radius`?");
|
||||
let e = refusal(ID, json!({"items": [{"id": 3, "corners": [0]}, {"id": 4, "corner": [0]}], "radius": 5}));
|
||||
assert_eq!(e, "unknown parameter `items[1].corner`; did you mean `items[1].corners`?");
|
||||
let e = refusal(ID, json!({"items": [{"id": 3, "radius": 5}]}));
|
||||
assert_eq!(e, "unknown parameter `items[0].radius`; the command takes: items[0].id, items[0].corners");
|
||||
let step = |params| json!({"commands": [{"command": "select.all", "params": {}}, {"command": ID, "params": params}]});
|
||||
for (bad, key) in [(json!({"path": "x"}), "path"), (json!({"items": [{"id": 3, "kind": "chamfer"}]}), "items[0].kind")] {
|
||||
match check_params("command.batch", &step(bad.clone())) {
|
||||
Err(EngineError::BadParams { cmd, msg }) => {
|
||||
assert!(cmd == ID && msg.starts_with(&format!("unknown parameter `{key}`")), "{bad}: {cmd} {msg}")
|
||||
}
|
||||
other => panic!("{bad}: {other:?}"),
|
||||
}
|
||||
}
|
||||
for ok in [
|
||||
json!({"items": [{"id": 3, "corners": [0, 2]}, {"id": 4}], "radius": 5}),
|
||||
json!({"corners": [0], "kind": "chamfer", "radius": 2, "relativeRadii": false}),
|
||||
json!({"id": 3, "radius": 5}),
|
||||
json!({"ids": [3], "sides": 6, "polygonAngle": 15, "polygonRadius": 20, "sideLength": null, "makeSidesEqual": true}),
|
||||
json!({"pieStart": 30, "pieEnd": 90, "invertPie": true}),
|
||||
json!({"items": null}),
|
||||
] {
|
||||
assert!(check_params(ID, &ok).is_ok(), "{ok}");
|
||||
assert!(check_params("command.batch", &step(ok.clone())).is_ok(), "{ok}");
|
||||
}
|
||||
}
|
||||
|
||||
/// An object id given as something other than a whole number (`"5"`, `-1`) is refused as such,
|
||||
/// naming its key, not as a missing id; an id left out is still a missing one.
|
||||
#[test]
|
||||
|
||||
@@ -441,6 +441,98 @@ fn offset_open_path_whose_ends_meet_grows_the_shape() {
|
||||
assert_eq!(path_of(&s, r[0]).0.subpaths.len(), 1);
|
||||
}
|
||||
|
||||
/// The ids of the objects in the first layer, back to front.
|
||||
fn first_layer(s: &Session) -> Vec<NodeId> {
|
||||
s.doc().unwrap().doc.layers[0].children().unwrap().iter().map(|n| n.id).collect()
|
||||
}
|
||||
|
||||
/// Offset Path of several objects at once: each result goes directly above its object and takes
|
||||
/// the next ids in the objects' order, a group gives a group of its paths' offsets with their
|
||||
/// styles, a compound path gives a compound path, an inset that leaves nothing and type give
|
||||
/// nothing, and one undo gives the objects back.
|
||||
#[test]
|
||||
fn offset_path_of_several_objects_keeps_their_order() {
|
||||
let mut s = session();
|
||||
let a = rect(&mut s, 0.0, 0.0, 20.0, 20.0);
|
||||
let b = rect(&mut s, 40.0, 0.0, 10.0, 10.0);
|
||||
let c = rect(&mut s, 60.0, 0.0, 10.0, 10.0);
|
||||
set_fill(&mut s, b, Color::rgb(1.0, 0.0, 0.0));
|
||||
set_fill(&mut s, c, Color::rgb(0.0, 0.0, 1.0));
|
||||
s.execute("select.set", &json!({"ids": [b.0, c.0]})).unwrap();
|
||||
let g = NodeId(s.execute("object.group", &json!({})).unwrap()["id"].as_u64().unwrap());
|
||||
let tiny = rect(&mut s, 100.0, 0.0, 4.0, 4.0);
|
||||
let outer = rect(&mut s, 200.0, 0.0, 40.0, 40.0);
|
||||
let inner = rect(&mut s, 210.0, 10.0, 20.0, 20.0);
|
||||
s.execute("select.set", &json!({"ids": [outer.0, inner.0]})).unwrap();
|
||||
let cp = NodeId(s.execute("object.compoundPath.make", &json!({})).unwrap()["id"].as_u64().unwrap());
|
||||
let t = NodeId(s.execute("text.create", &json!({"x": 300, "y": 10, "text": "Hi"})).unwrap()["id"].as_u64().unwrap());
|
||||
let before = first_layer(&s);
|
||||
assert_eq!(before, [a, g, tiny, cp, t]);
|
||||
let first = s.doc().unwrap().doc.peek_next_id();
|
||||
s.execute("select.set", &json!({"ids": [a.0, g.0, tiny.0, cp.0, t.0]})).unwrap();
|
||||
let r = ids(&s.execute("object.path.offsetPath", &json!({"offset": -3, "joins": "miter"})).unwrap());
|
||||
// a's offset, then b's and c's and their group, then the compound path and its two subpaths.
|
||||
assert_eq!(r, [NodeId(first), NodeId(first + 3), NodeId(first + 4)]);
|
||||
assert_eq!(first_layer(&s), [a, r[0], g, r[1], tiny, cp, r[2], t]);
|
||||
assert_eq!(s.doc().unwrap().selection.objects, r);
|
||||
assert!(close(bounds(&s, r[0]), Rect::new(3.0, 3.0, 17.0, 17.0), 1e-2), "{:?}", bounds(&s, r[0]));
|
||||
let group = node(&s, r[1]);
|
||||
assert!(matches!(group.kind, NodeKind::Group { .. }), "{:?}", group.kind);
|
||||
let kids = group.children().unwrap();
|
||||
assert_eq!(kids.iter().map(|k| k.id).collect::<Vec<_>>(), [NodeId(first + 1), NodeId(first + 2)]);
|
||||
assert_eq!(kids[0].appearance.fill_paint(), Paint::solid(Color::rgb(1.0, 0.0, 0.0)));
|
||||
assert_eq!(kids[1].appearance.fill_paint(), Paint::solid(Color::rgb(0.0, 0.0, 1.0)));
|
||||
let rings = node(&s, r[2]);
|
||||
assert!(matches!(rings.kind, NodeKind::Compound { .. }), "{:?}", rings.kind);
|
||||
assert!(close(bounds(&s, r[2]), Rect::new(203.0, 3.0, 237.0, 37.0), 1e-2), "{:?}", bounds(&s, r[2]));
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
assert_eq!(first_layer(&s), before);
|
||||
}
|
||||
|
||||
/// Offset Path works out the offsets of several paths on more than one thread, and gives the
|
||||
/// results in the paths' order.
|
||||
#[test]
|
||||
fn offset_path_works_on_several_threads_in_order() {
|
||||
let seen = std::sync::Mutex::new(vec![]);
|
||||
let items: Vec<usize> = (0..8).collect();
|
||||
let work = |i: &usize| {
|
||||
if let Ok(mut seen) = seen.lock() {
|
||||
seen.push(std::thread::current().id());
|
||||
}
|
||||
i * 2
|
||||
};
|
||||
let (out, used) = crate::cmd::pathops::ordered_map(&items, 4, work, |_| false);
|
||||
assert_eq!(out, (0..8).map(|i| Some(i * 2)).collect::<Vec<_>>());
|
||||
assert_eq!(used, 4);
|
||||
let threads: std::collections::HashSet<_> = seen.into_inner().unwrap().into_iter().collect();
|
||||
assert_eq!(threads.len(), 4);
|
||||
// A count just past the threads still uses every thread.
|
||||
let (out, used) = crate::cmd::pathops::ordered_map(&items[..5], 4, |i| i * 2, |_| false);
|
||||
assert_eq!(out, (0..5).map(|i| Some(i * 2)).collect::<Vec<_>>());
|
||||
assert_eq!(used, 4);
|
||||
// One item, or one thread, is worked on here.
|
||||
let (out, used) = crate::cmd::pathops::ordered_map(&items[..1], 4, |i| i * 2, |_| false);
|
||||
assert_eq!((out, used), (vec![Some(0)], 1));
|
||||
let (out, used) = crate::cmd::pathops::ordered_map(&items, 1, |i| i * 2, |_| false);
|
||||
assert_eq!(used, 1);
|
||||
assert_eq!(out.len(), 8);
|
||||
}
|
||||
|
||||
/// Once a result stops the work (an offset that fails), the paths after it are left out, and every
|
||||
/// path before it has its result, so the first failure in the paths' order is the one reported.
|
||||
#[test]
|
||||
fn offset_path_stops_after_the_first_failure() {
|
||||
let items: Vec<usize> = (0..8).collect();
|
||||
let (out, _) = crate::cmd::pathops::ordered_map(&items, 1, |i| *i, |r| *r == 2 || *r == 5);
|
||||
assert_eq!(out, [Some(0), Some(1), Some(2), None, None, None, None, None]);
|
||||
for threads in [2, 4, 8] {
|
||||
let (out, _) = crate::cmd::pathops::ordered_map(&items, threads, |i| *i, |r| *r == 2 || *r == 5);
|
||||
assert_eq!(out[..3], [Some(0), Some(1), Some(2)], "{threads} threads");
|
||||
let first = out.iter().position(|r| r.is_some_and(|r| r == 2 || r == 5));
|
||||
assert_eq!(first, Some(2), "{threads} threads");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn outline_stroke_of_line() {
|
||||
let mut s = session();
|
||||
|
||||
@@ -164,7 +164,13 @@ fn long_pages_in_points_round_trips_through_presets_and_reopens_at_full_size() {
|
||||
let v = s.execute("document.exportPdf", &json!({"preset": "Banner"})).unwrap();
|
||||
let bytes = pdf(&v);
|
||||
assert!(String::from_utf8_lossy(&bytes).contains("/UserUnit 1.969"));
|
||||
assert!(warned(&v, "it is written with a UserUnit of 1.969, so apps that ignore UserUnit show it that many times smaller"), "{v}");
|
||||
assert!(
|
||||
warned(
|
||||
&v,
|
||||
"it is written with a UserUnit of 1.969, so apps that ignore UserUnit show it that many times smaller: turn on Write pages longer than 200 in at full size in points"
|
||||
),
|
||||
"{v}"
|
||||
);
|
||||
reopened(&bytes);
|
||||
for bad in [json!("yes"), json!(1)] {
|
||||
assert!(is_bad_params(s.execute("document.exportPdf", &json!({"longPagesInPoints": bad}))), "{bad}");
|
||||
|
||||
@@ -396,6 +396,23 @@ fn an_app_that_leaves_has_its_copies_offered_at_once() {
|
||||
}
|
||||
}
|
||||
|
||||
/// The folder copies go to: the `recoveryFolder` preference, trimmed, when it names one, else the
|
||||
/// folder the app sets; none with a store the host installed. The web build has no folders.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
#[test]
|
||||
fn the_folder_in_use_is_the_preference_or_the_apps() {
|
||||
let mut s = Session::new();
|
||||
assert_eq!(cmd::recovery::folder(&s), None);
|
||||
s.recovery.set_default_folder(Some("/app/recovery".into()));
|
||||
assert_eq!(cmd::recovery::folder(&s), Some(("/app/recovery".into(), false)));
|
||||
s.prefs.recovery_folder = " \t ".into();
|
||||
assert_eq!(cmd::recovery::folder(&s), Some(("/app/recovery".into(), false)));
|
||||
s.prefs.recovery_folder = " /chosen/copies ".into();
|
||||
assert_eq!(cmd::recovery::folder(&s), Some(("/chosen/copies".into(), true)));
|
||||
s.recovery.set_store(Arc::new(MemoryStore::default()));
|
||||
assert_eq!(cmd::recovery::folder(&s), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn folder_areas_are_locked_by_running_apps() {
|
||||
let dir = std::env::temp_dir().join(format!("vc-recovery-locks-{}", std::process::id()));
|
||||
|
||||
@@ -282,20 +282,24 @@ fn document_color_mode_converts_the_paints_a_graph_keeps() {
|
||||
for target in [series, label] {
|
||||
run(&mut s, "paint.setFill", json!({"color": "#ff0000", "ids": [target.0]}));
|
||||
}
|
||||
// A fill row on the label, which the graph keeps with the label's rows.
|
||||
run(&mut s, "appearance.addFill", json!({"ids": [label.0]}));
|
||||
let kept = |s: &Session| -> Vec<Color> {
|
||||
let g = doc(s).node(id).unwrap().extras().graph.as_deref().unwrap().clone();
|
||||
let rows = [&g.label_styles.values_rows, &g.label_styles.categories_rows, &g.label_styles.legend_rows];
|
||||
let rows: Vec<Color> = rows.into_iter().flatten().filter_map(|r| r.paint.fill.as_ref()?.color()).collect();
|
||||
let labels = [g.label_styles.values, g.label_styles.categories, g.label_styles.legend];
|
||||
let labels = labels.into_iter().flatten().map(|st| st.fill.color().unwrap());
|
||||
g.series_paints.iter().filter_map(|p| p.fill.as_ref()?.color()).chain(labels).collect()
|
||||
g.series_paints.iter().filter_map(|p| p.fill.as_ref()?.color()).chain(labels).chain(rows).collect()
|
||||
};
|
||||
let before = kept(&s);
|
||||
assert_eq!(before, [Color::rgb(1.0, 0.0, 0.0); 2]);
|
||||
assert_eq!(before, [Color::rgb(1.0, 0.0, 0.0); 3]);
|
||||
let undo = s.doc().unwrap().history.undo.len();
|
||||
|
||||
run(&mut s, "file.documentColorMode", json!({"mode": "cmyk"}));
|
||||
let cmyk = first_fill(doc(&s).node(red).unwrap());
|
||||
assert!(matches!(cmyk, Color::Cmyk { .. }), "{cmyk:?}");
|
||||
assert_eq!(kept(&s), [cmyk; 2]);
|
||||
assert_eq!(kept(&s), [cmyk; 3]);
|
||||
assert_eq!(s.doc().unwrap().history.undo.len(), undo + 1);
|
||||
// Drawn again from new data, the series keeps the converted color.
|
||||
run(&mut s, "graph.setData", json!({"id": id.0, "rows": [[2, 3], [4, 5]]}));
|
||||
|
||||
@@ -1311,14 +1311,12 @@ impl Session {
|
||||
{
|
||||
return Err(format!("unknown tool `{t}`"));
|
||||
}
|
||||
// The active tool's merge being computed is applied with the options it was started with.
|
||||
if tool.is_none_or(|t| t == self.tool.id()) {
|
||||
self.finish_tool_edit_later().map_err(|e| e.to_string())?;
|
||||
}
|
||||
let mut values = p.get("values").and_then(Value::as_object).cloned().unwrap_or_default();
|
||||
if let Some(k) = p.get("key").and_then(Value::as_str) {
|
||||
values.insert(k.to_string(), p.get("value").cloned().unwrap_or(Value::Null));
|
||||
}
|
||||
// Every value is checked before any is set, so an invalid one changes nothing.
|
||||
vectorcraft_tools::builder::check_builder_options(tool.unwrap_or(self.tool.id()), &values)?;
|
||||
// The Place queue is read later, by a click outside any automation scope: its files must
|
||||
// be readable now, under the roots of whoever sets it.
|
||||
if let Some(entries) = values.get("queue").and_then(|q| q.get("entries")).and_then(Value::as_array) {
|
||||
@@ -1336,6 +1334,10 @@ impl Session {
|
||||
return Err("Unknown dimension mode".into());
|
||||
}
|
||||
}
|
||||
// The active tool's merge being computed is applied with the options it was started with.
|
||||
if tool.is_none_or(|t| t == self.tool.id()) {
|
||||
self.finish_tool_edit_later().map_err(|e| e.to_string())?;
|
||||
}
|
||||
Ok(self.set_tool_options(tool, &values))
|
||||
}
|
||||
|
||||
|
||||
@@ -909,6 +909,11 @@ fn structured_junk() {
|
||||
("addon.cutting.weed", json!({"spacing": 1e-300, "box": "no", "direction": ["both"], "swatch": 5})),
|
||||
("addon.cutting.weed", json!({"spacing": "1 mm", "box": false, "direction": "vertical", "swatch": "no such swatch"})),
|
||||
("addon.cutting.weed", json!({"count": u64::MAX, "margin": "x", "gap": null, "weight": 0})),
|
||||
("addon.cutting.banner", json!({"hem": 1e5, "grommetInset": 1e5, "spacing": 1e-300, "diameter": 1000})),
|
||||
("addon.cutting.banner", json!({"hem": -1, "spacing": f64::MAX, "weight": 1e308, "top": "no", "allArtboards": 1})),
|
||||
("addon.cutting.banner", json!({"hem": 0, "grommetInset": 0, "spacing": 0.01, "allArtboards": true})),
|
||||
("addon.cutting.banner", json!({"spacing": "1 mm", "grommets": false, "swatch": 5, "left": null})),
|
||||
("addon.cutting.banner", json!({"top": false, "right": false, "bottom": false, "left": false, "allArtboards": false})),
|
||||
// Type Tools add-on.
|
||||
("addon.type.textTweaker", json!({"attribute": "size", "from": 1296, "to": 0.1, "shape": "random", "seed": u64::MAX})),
|
||||
("addon.type.textTweaker", json!({"attribute": "tracking", "from": f64::MAX, "to": -1e308, "shape": "spike"})),
|
||||
|
||||
@@ -186,6 +186,7 @@
|
||||
//! The data streams hold the program compressed with Zstandard after `%AI24_ZStandard_Data`, with
|
||||
//! the version numbers of the newest files users sent ([`private_data`]).
|
||||
|
||||
use std::collections::HashSet;
|
||||
use std::fmt::Write;
|
||||
use std::sync::Arc;
|
||||
|
||||
@@ -385,9 +386,15 @@ fn splice(text: &[u8], payloads: &[Vec<u8>]) -> Vec<u8> {
|
||||
}
|
||||
|
||||
/// `doc` with its geometry effects and group paint baked ([`vectorcraft_effects::bake_document`]),
|
||||
/// in its symbols' art and its patterns' tile art too (which instances and pattern fills are
|
||||
/// written as); `None` when nothing changes.
|
||||
/// in its symbols' art, its patterns' tile art (which instances and pattern fills are written as)
|
||||
/// and its opacity masks' art too ([`baked_masks`]); `None` when nothing changes.
|
||||
fn baked(doc: &Document) -> Option<Document> {
|
||||
let art = baked_art(doc);
|
||||
baked_masks(art.as_ref().unwrap_or(doc)).or(art)
|
||||
}
|
||||
|
||||
/// [`baked`] for the layers, symbols and patterns, leaving opacity masks' art as it is.
|
||||
fn baked_art(doc: &Document) -> Option<Document> {
|
||||
let baked = vectorcraft_effects::bake_document(doc);
|
||||
let d = baked.as_ref().unwrap_or(doc);
|
||||
let needs = vectorcraft_effects::needs_bake;
|
||||
@@ -416,6 +423,100 @@ fn baked(doc: &Document) -> Option<Document> {
|
||||
Some(out)
|
||||
}
|
||||
|
||||
/// `doc` with the art of the opacity masks in its layers', symbols' and patterns' art baked as
|
||||
/// [`baked_art`] bakes the layers. Each round bakes the art of the outermost masks whose art needs
|
||||
/// it, and the next round the masks inside that art, at most [`writer::MAX_NEST`] rounds (the
|
||||
/// writer leaves masks nested deeper out). Baked art keeps its id, by which the writer finds the
|
||||
/// art as it was before baking ([`writer::styled_objects`]). `None` when no mask's art needs baking.
|
||||
fn baked_masks(doc: &Document) -> Option<Document> {
|
||||
let mut out: Option<Document> = None;
|
||||
// The masks' art baked so far, by id: it isn't baked again.
|
||||
let mut done = HashSet::new();
|
||||
for _ in 0..writer::MAX_NEST {
|
||||
let d = out.as_ref().unwrap_or(doc);
|
||||
let roots = || d.layers.iter().chain(d.symbols.iter().map(|s| &s.art)).chain(d.patterns.iter().flat_map(|p| p.art.iter()));
|
||||
let mut arts = vec![];
|
||||
for n in roots() {
|
||||
mask_arts(n, &done, &mut arts);
|
||||
}
|
||||
if arts.is_empty() {
|
||||
break;
|
||||
}
|
||||
// Each art baked as a layer of a copy of the document, as [`baked_art`] bakes symbols.
|
||||
let mut copy = d.clone();
|
||||
copy.layers = arts.iter().map(|a| Arc::new(Node::group(vectorcraft_doc::NodeId(0), vec![a.clone()]))).collect();
|
||||
let Some(mut next) = vectorcraft_effects::bake_document(©) else { break };
|
||||
let layers = std::mem::replace(&mut next.layers, d.layers.clone());
|
||||
let mut baked = layers.iter().map(|l| l.children().and_then(|c| c.first().cloned()));
|
||||
for l in next.layers.iter_mut() {
|
||||
if let Some(n) = with_baked_masks(l, &done, &mut baked) {
|
||||
*l = n;
|
||||
}
|
||||
}
|
||||
for s in next.symbols.iter_mut() {
|
||||
if let Some(n) = with_baked_masks(&s.art, &done, &mut baked) {
|
||||
s.art = n;
|
||||
}
|
||||
}
|
||||
for p in next.patterns.iter_mut() {
|
||||
for a in p.art.iter_mut() {
|
||||
if let Some(n) = with_baked_masks(a, &done, &mut baked) {
|
||||
*a = n;
|
||||
}
|
||||
}
|
||||
}
|
||||
done.extend(arts.iter().map(|a| a.id));
|
||||
out = Some(next);
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Does [`baked_masks`] bake the art of mask `m` in this round?
|
||||
fn bakes_mask_art(m: &vectorcraft_doc::OpacityMask, done: &HashSet<vectorcraft_doc::NodeId>) -> bool {
|
||||
!done.contains(&m.art.id) && vectorcraft_effects::needs_bake(&m.art)
|
||||
}
|
||||
|
||||
/// The art of the opacity masks in `n` that [`baked_masks`] bakes in this round, in the order
|
||||
/// [`with_baked_masks`] puts them back: a mask's art before the object's members, and the masks
|
||||
/// inside art that isn't baked.
|
||||
fn mask_arts(n: &Node, done: &HashSet<vectorcraft_doc::NodeId>, out: &mut Vec<Arc<Node>>) {
|
||||
if let Some(m) = n.extras().mask.as_ref() {
|
||||
if bakes_mask_art(m, done) {
|
||||
out.push(m.art.clone());
|
||||
} else {
|
||||
mask_arts(&m.art, done, out);
|
||||
}
|
||||
}
|
||||
for c in n.children().into_iter().flatten() {
|
||||
mask_arts(c, done, out);
|
||||
}
|
||||
}
|
||||
|
||||
/// `n` with the art of its opacity masks taken from `baked` in the order [`mask_arts`] lists it;
|
||||
/// `None` when nothing in it changes.
|
||||
fn with_baked_masks(n: &Arc<Node>, done: &HashSet<vectorcraft_doc::NodeId>, baked: &mut dyn Iterator<Item = Option<Arc<Node>>>) -> Option<Arc<Node>> {
|
||||
let art =
|
||||
n.extras().mask.as_ref().and_then(|m| if bakes_mask_art(m, done) { baked.next().flatten() } else { with_baked_masks(&m.art, done, baked) });
|
||||
let members: Vec<Option<Arc<Node>>> = n.children().map(|c| c.iter().map(|c| with_baked_masks(c, done, baked)).collect()).unwrap_or_default();
|
||||
if art.is_none() && members.iter().all(Option::is_none) {
|
||||
return None;
|
||||
}
|
||||
let mut m = (**n).clone();
|
||||
if let Some(a) = art
|
||||
&& let Some(mask) = m.extras_if_any().and_then(|e| e.mask.as_mut())
|
||||
{
|
||||
mask.art = a;
|
||||
}
|
||||
if let Some(children) = m.children_mut() {
|
||||
for (c, new) in children.iter_mut().zip(members) {
|
||||
if let Some(new) = new {
|
||||
*c = new;
|
||||
}
|
||||
}
|
||||
}
|
||||
Some(Arc::new(m))
|
||||
}
|
||||
|
||||
/// The editing data of `doc` as a `.ai` file's pages carry it: the program compressed with
|
||||
/// Zstandard after `%AI24_ZStandard_Data`, its header as the `AIMetaData`, and the version numbers
|
||||
/// that go with that form; and what it couldn't hold.
|
||||
|
||||
@@ -6538,6 +6538,132 @@ fn effects_in_a_styled_objects_mask_are_warned_about() {
|
||||
assert!(shown(&out.program).contains("\n1 (Anon 1) XW\n"));
|
||||
}
|
||||
|
||||
/// A 200 × 100 document of an image whose opacity mask doesn't clip, of the art `art` makes.
|
||||
fn masked_image(art: impl FnOnce(&mut Document) -> Node) -> Document {
|
||||
let mut doc = Document::new(200.0, 100.0);
|
||||
let layer = doc.layers[0].id;
|
||||
let mut im = image_node(&mut doc, ImageBlob::png(rgb_png()), (3, 2), Affine::new([60.0, 0.0, 0.0, 40.0, 10.0, 10.0]), None);
|
||||
let art = art(&mut doc);
|
||||
im.extras_mut().mask = Some(OpacityMask { art: Arc::new(art), clip: false, invert: false, disabled: false, linked: true }.into());
|
||||
doc.insert(Some(layer), usize::MAX, im).unwrap();
|
||||
doc
|
||||
}
|
||||
|
||||
/// The opacity mask art of the first image in `doc` that has one.
|
||||
fn image_mask_art(doc: &Document) -> Arc<Node> {
|
||||
let mut art = None;
|
||||
doc.walk(|n| {
|
||||
if matches!(n.kind, NodeKind::Image(_))
|
||||
&& art.is_none()
|
||||
&& let Some(m) = n.extras().mask.as_ref()
|
||||
{
|
||||
art = Some(m.art.clone());
|
||||
}
|
||||
});
|
||||
art.expect("an image with an opacity mask")
|
||||
}
|
||||
|
||||
/// How many of the artboard's pixels `a` and `b` draw differently.
|
||||
fn pixels_apart(a: &Document, b: &Document) -> usize {
|
||||
let (a, b) = (vectorcraft_testkit::raster::render_artboard(a), vectorcraft_testkit::raster::render_artboard(b));
|
||||
assert_eq!((a.width, a.height), (b.width, b.height));
|
||||
(0..a.height).flat_map(|y| (0..a.width).map(move |x| (x, y))).filter(|&(x, y)| a.pixel(x, y) != b.pixel(x, y)).count()
|
||||
}
|
||||
|
||||
/// An opacity mask's art keeps its live effects as an art style: an image masked by a black path
|
||||
/// with Offset Path (hiding the image under it) reads back with the effect live on the mask's path,
|
||||
/// alone or as a member of a group of two, and draws as it did.
|
||||
#[test]
|
||||
fn a_masked_image_with_live_offset_path_round_trips() {
|
||||
let offset = fx("path.offsetPath", serde_json::json!({"offset": 8.0, "joins": "miter", "miterLimit": 4.0}));
|
||||
let black = solid(Color::rgb(0.0, 0.0, 0.0));
|
||||
for members in [1, 2] {
|
||||
let made = |effects: Vec<vectorcraft_doc::Effect>| {
|
||||
masked_image(|doc| {
|
||||
let grown = styled_rect(doc, "Grown", Rect::new(60.0, 30.0, 110.0, 70.0), black.clone(), Paint::None, effects);
|
||||
if members == 1 {
|
||||
return grown;
|
||||
}
|
||||
let plain = square(doc, Rect::new(130.0, 30.0, 170.0, 70.0), Color::rgb(0.0, 0.0, 0.0));
|
||||
group(doc, vec![grown, plain], false)
|
||||
})
|
||||
};
|
||||
let doc = made(vec![offset.clone()]);
|
||||
assert!(pixels_apart(&doc, &made(vec![])) > 0, "{members}: the effect draws nothing");
|
||||
let out = export_ai_editing(&doc, &opts()).unwrap();
|
||||
assert!(!out.warnings.iter().any(|w| w == writer::EFFECTS), "{members}: {:?}", out.warnings);
|
||||
let back = read_back(&doc);
|
||||
let art = image_mask_art(&back);
|
||||
let grown = if members == 1 {
|
||||
art.clone()
|
||||
} else {
|
||||
assert!(art.appearance.effects.is_empty(), "the group took the effect: {:?}", art.appearance.effects);
|
||||
let children = art.children().expect("a group of the mask's art");
|
||||
assert_eq!(children.len(), 2);
|
||||
assert!(children[1].appearance.effects.is_empty());
|
||||
children[0].clone()
|
||||
};
|
||||
assert!(matches!(grown.kind, NodeKind::Path { .. }), "{members}: {:?}", grown.kind);
|
||||
let [e] = grown.appearance.effects.as_slice() else { panic!("{members}: {:?}", grown.appearance.effects) };
|
||||
assert_eq!(e.id, "path.offsetPath");
|
||||
assert_eq!(e.params.get("offset").and_then(serde_json::Value::as_f64), Some(8.0), "{:?}", e.params);
|
||||
assert_eq!(pixels_apart(&doc, &back), 0, "{members}");
|
||||
}
|
||||
}
|
||||
|
||||
/// The art of a mask keeps Offset Path live when its root is hidden (the canvas draws it all the
|
||||
/// same) and when it is the art of a mask inside another mask's art (that clips).
|
||||
#[test]
|
||||
fn hidden_and_nested_mask_art_keep_live_offset_path() {
|
||||
let offset = fx("path.offsetPath", serde_json::json!({"offset": 6.0}));
|
||||
let grown = |doc: &mut Document, effects, gray: f32| {
|
||||
styled_rect(doc, "Grown", Rect::new(60.0, 30.0, 110.0, 70.0), solid(Color::rgb(gray, gray, gray)), Paint::None, effects)
|
||||
};
|
||||
let hidden = |effects| {
|
||||
masked_image(|doc| {
|
||||
let mut art = grown(doc, effects, 0.0);
|
||||
art.visible = false;
|
||||
art
|
||||
})
|
||||
};
|
||||
let nested = |effects| {
|
||||
masked_image(|doc| {
|
||||
let mut outer = square(doc, Rect::new(20.0, 15.0, 180.0, 85.0), Color::rgb(0.0, 0.0, 0.0));
|
||||
let inner = grown(doc, effects, 1.0);
|
||||
outer.extras_mut().mask = Some(OpacityMask { art: Arc::new(inner), clip: true, invert: false, disabled: false, linked: true }.into());
|
||||
outer
|
||||
})
|
||||
};
|
||||
for (case, made) in [("hidden", &hidden as &dyn Fn(Vec<vectorcraft_doc::Effect>) -> Document), ("nested", &nested)] {
|
||||
let doc = made(vec![offset.clone()]);
|
||||
assert!(pixels_apart(&doc, &made(vec![])) > 0, "{case}: the effect draws nothing");
|
||||
let back = read_back(&doc);
|
||||
let mut art = image_mask_art(&back);
|
||||
if case == "nested" {
|
||||
art = art.extras().mask.as_ref().expect("the mask inside the mask's art").art.clone();
|
||||
}
|
||||
let ids: Vec<&str> = art.appearance.effects.iter().map(|e| e.id.as_str()).collect();
|
||||
assert_eq!(ids, ["path.offsetPath"], "{case}");
|
||||
assert_eq!(pixels_apart(&doc, &back), 0, "{case}");
|
||||
}
|
||||
}
|
||||
|
||||
/// A geometry effect on an opacity mask's art that no art style holds (Roughen) is applied to the
|
||||
/// art written, so the mask reads back drawing what it drew, but for pixels on its edge that the
|
||||
/// written points' rounding moves.
|
||||
#[test]
|
||||
fn a_masks_art_with_an_effect_no_style_holds_is_written_as_it_draws() {
|
||||
let rough = fx("distort.roughen", serde_json::json!({"size": 6.0, "relative": false, "points": "corner"}));
|
||||
let black = solid(Color::rgb(0.0, 0.0, 0.0));
|
||||
let rect = |doc: &mut Document, effects| styled_rect(doc, "Rough", Rect::new(60.0, 30.0, 140.0, 70.0), black.clone(), Paint::None, effects);
|
||||
let doc = masked_image(|doc| rect(doc, vec![rough]));
|
||||
assert!(pixels_apart(&doc, &masked_image(|doc| rect(doc, vec![]))) > 0, "the effect draws nothing");
|
||||
let back = read_back(&doc);
|
||||
assert!(image_mask_art(&back).appearance.effects.is_empty());
|
||||
let apart = pixels_apart(&doc, &back);
|
||||
assert!(apart <= 4, "{apart} pixels differ");
|
||||
}
|
||||
|
||||
/// On a large canvas no art style is written (the reader scales the styles of a large canvas):
|
||||
/// objects with effects are written as their vector art with their geometry effects applied, with a
|
||||
/// warning.
|
||||
@@ -7637,8 +7763,8 @@ fn a_style_that_isnt_written_defines_no_gradient() {
|
||||
assert!(p.contains("%%BeginSetup\n0 Bn\n"), "{p}");
|
||||
}
|
||||
|
||||
/// An object in a plain group, a sublayer or a clipping group by a path keeps its style there. One
|
||||
/// in a symbol's art, in an opacity mask or in art cut to a clipping object other than a path is
|
||||
/// An object in a plain group, a sublayer, a clipping group by a path or an opacity mask keeps its
|
||||
/// style there. One in a symbol's art or in art cut to a clipping object other than a path is
|
||||
/// written as its drawn look, with a warning.
|
||||
#[test]
|
||||
fn where_a_styled_object_keeps_its_style() {
|
||||
@@ -7695,8 +7821,17 @@ fn where_a_styled_object_keeps_its_style() {
|
||||
n.extras_mut().mask = Some(OpacityMask { art: Arc::new(art), clip: true, invert: false, disabled: false, linked: true }.into());
|
||||
put(&mut doc, n);
|
||||
let out = export_ai_editing(&doc, &opts()).unwrap();
|
||||
assert!(out.warnings.iter().any(|w| w == writer::EFFECTS), "{:?}", out.warnings);
|
||||
assert!(!shown(&out.program).contains("(Anon 1) XW"));
|
||||
assert!(!out.warnings.iter().any(|w| w == writer::EFFECTS), "{:?}", out.warnings);
|
||||
let back = read_back(&doc);
|
||||
let mut art = None;
|
||||
back.walk(|n| {
|
||||
if let Some(m) = n.extras().mask.as_ref() {
|
||||
art = Some(m.art.clone());
|
||||
}
|
||||
});
|
||||
let art = art.expect("the opacity mask");
|
||||
assert_eq!(art.name.as_deref(), Some("Inside"));
|
||||
assert_eq!(art.appearance.effects.len(), 1);
|
||||
// In art cut to a clipping object other than a path: type.
|
||||
let mut doc = Document::new(200.0, 100.0);
|
||||
let t = vectorcraft_doc::TextObject::point(
|
||||
|
||||
@@ -47,7 +47,7 @@ pub(super) use variables::{ACTIVE, BINDINGS, LIMITS, VALUES};
|
||||
/// How deep symbols, live objects and pattern tiles may nest in one another (deeper art is left
|
||||
/// out). Groups and layers need no limit of their own: a document is never deeper than
|
||||
/// [`vectorcraft_doc::MAX_DEPTH`].
|
||||
const MAX_NEST: u32 = 8;
|
||||
pub(super) const MAX_NEST: u32 = 8;
|
||||
|
||||
pub(super) const TYPE: &str = "type in opacity masks, pattern tiles, symbols and art cut to a clipping object, mirrored type, area type and type on a path that is turned or unevenly scaled or whose frame or path has several parts, area type in an open frame, type on an open path whose ends meet, area type that flows around a text wrap, type of only white space, threads of type on a path, threads whose frames differ in orientation or hold a frame written as outlines, type of sizes past what the reader reads, stories past what the reader reads (1,048,576 bytes a story, 16,777,216 bytes in all, and the size of text document it reads), and type with inline graphics, ruby, gradient or pattern paint, or fills, strokes, effects or an opacity mask of the object's own, is written as its outlines in the editing data for other apps";
|
||||
pub(super) const PATTERNS: &str = "pattern fills whose tiles aren't a grid or whose art reaches past the tile, pattern fills in another pattern's tile or in a symbol, and pattern fills nested more than 8 deep are written as their tiles cut to the shape in the editing data for other apps";
|
||||
@@ -200,6 +200,9 @@ pub(super) struct Writer<'a> {
|
||||
/// How many parts of the data written on `%_` lines (opacity masks' art, patterns' tiles) the
|
||||
/// art being written is in.
|
||||
hidden: u32,
|
||||
/// The `hidden` and `nest` values at which the objects of the opacity mask's art being written
|
||||
/// are written: objects there may be written with their art styles.
|
||||
mask_level: Option<(u32, u32)>,
|
||||
/// The image just written was written as a placed file.
|
||||
placed: bool,
|
||||
/// The type just written was written as type, not as its outlines.
|
||||
@@ -289,6 +292,7 @@ impl<'a> Writer<'a> {
|
||||
canvas_origin: origin,
|
||||
payloads: vec![],
|
||||
hidden: 0,
|
||||
mask_level: None,
|
||||
placed: false,
|
||||
typed: false,
|
||||
bound: vec![],
|
||||
@@ -613,10 +617,10 @@ impl<'a> Writer<'a> {
|
||||
if self.in_symbol {
|
||||
self.wrote_fixed |= symbols::redrawn(n);
|
||||
}
|
||||
// On the layers, outside art cut to a clipping object: with its art style, when it has one.
|
||||
// On the layers or in an opacity mask's art, outside art cut to a clipping object: with its
|
||||
// art style, when it has one.
|
||||
if !self.in_look
|
||||
&& self.hidden == 0
|
||||
&& self.nest == 0
|
||||
&& ((self.hidden == 0 && self.nest == 0) || self.mask_level == Some((self.hidden, self.nest)))
|
||||
&& self.clip.is_none()
|
||||
&& !self.in_symbol
|
||||
&& let Some(orig) = self.styled.get(&n.id).cloned()
|
||||
@@ -843,21 +847,27 @@ impl<'a> Writer<'a> {
|
||||
};
|
||||
// A plain group of two or more objects, or of none, is the group the art is read into. A group
|
||||
// of one object is written as a group, because the reader makes one shown object the art.
|
||||
// A group that may be written with its art style is written as one object.
|
||||
let plain = matches!(art.kind, NodeKind::Group { clip: false, .. })
|
||||
&& Xy::of(art) == Xy::PLAIN
|
||||
&& art.visible
|
||||
&& !art.locked
|
||||
&& art.name.as_deref().is_none_or(str::is_empty)
|
||||
&& art.extras().mask.is_none();
|
||||
&& art.extras().mask.is_none()
|
||||
&& !self.styled.contains_key(&art.id);
|
||||
let members = match art.children() {
|
||||
Some(c) if plain && c.len() != 1 => c.as_slice(),
|
||||
_ => std::slice::from_ref(art),
|
||||
};
|
||||
self.nest += 1;
|
||||
self.hidden += 1;
|
||||
// The art's objects (not those inside its symbols, live objects or pattern tiles, which are
|
||||
// deeper) may be written with their art styles.
|
||||
let level = self.mask_level.replace((self.hidden, self.nest));
|
||||
for c in members {
|
||||
self.node(c);
|
||||
}
|
||||
self.mask_level = level;
|
||||
self.hidden -= 1;
|
||||
self.nest -= 1;
|
||||
let lines = std::mem::replace(&mut self.body, outer);
|
||||
|
||||
@@ -39,8 +39,9 @@ pub(in crate::ai_editing) fn style_links(doc: &Document) -> usize {
|
||||
doc.layers.iter().map(|l| count(l)).sum::<usize>() + doc.symbols.iter().map(|s| count(&s.art)).sum::<usize>()
|
||||
}
|
||||
|
||||
/// The objects of `doc`'s layers that may be written with an art style ([`Writer::styled_object`]),
|
||||
/// by id: paths and compound paths with points and groups, with live effects, linked to a graphic
|
||||
/// The objects of `doc`'s layers and of their opacity masks' art that may be written with an art
|
||||
/// style ([`Writer::styled_object`]), by id (a mask's art root shown, as [`Writer::mask`] writes
|
||||
/// it): paths and compound paths with points and groups, with live effects, linked to a graphic
|
||||
/// style or (paths and compound paths) with a brush on a stroke, and embedded images with one Warp
|
||||
/// ([`warped_image`]), without Opacity & Mask Define
|
||||
/// Knockout Shape (the reader takes an object's transparency
|
||||
@@ -52,6 +53,13 @@ pub(in crate::ai_editing) fn styled_objects(doc: &Document) -> HashMap<NodeId, A
|
||||
if candidate(n) {
|
||||
out.insert(n.id, n.clone());
|
||||
}
|
||||
if let Some(m) = n.extras().mask.as_ref() {
|
||||
let mut art = m.art.clone();
|
||||
if !art.visible {
|
||||
Arc::make_mut(&mut art).visible = true;
|
||||
}
|
||||
walk(std::slice::from_ref(&art), out);
|
||||
}
|
||||
// A group's members are written with a style of their own only when the group is written
|
||||
// without one. Baking a container with effects of its own changes its members, which
|
||||
// keep their ids: they are written as baking left them.
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -60,6 +60,9 @@ pub(crate) struct Exporter<'a> {
|
||||
images: HashMap<String, Option<Image>>,
|
||||
/// `Some` while opacity mask art is written: whether an image in it was left out.
|
||||
image_left_out: Option<bool>,
|
||||
/// While the pieces of a mesh-warped image are written: the size its pixels are resampled for
|
||||
/// ([`Exporter::warp_image_size`]).
|
||||
warp_size: Option<images::SharedSize>,
|
||||
/// The brush library, parsed when the first brushed stroke is written.
|
||||
brushes: Option<Vec<vectorcraft_brush::Brush>>,
|
||||
/// Whether the group being written is a knockout group (what its neutral children inherit).
|
||||
|
||||
@@ -31,6 +31,7 @@ impl<'a> Exporter<'a> {
|
||||
warnings: vec![],
|
||||
images: HashMap::new(),
|
||||
image_left_out: None,
|
||||
warp_size: None,
|
||||
brushes: None,
|
||||
knockout: doc.page_knockout,
|
||||
lab_spots: vec![],
|
||||
|
||||
+101
-10
@@ -1,5 +1,5 @@
|
||||
use super::exporter::{UNDECODED, UNWRITABLE_IMAGE, q, writable, xf};
|
||||
use super::{Arc, BlendMode, Color, Exporter, Image, NodeKind, Paint, Rect, Size, Surface, cmyk, krilla};
|
||||
use super::{Arc, BlendMode, Color, Exporter, Image, Node, NodeKind, Paint, Rect, Size, Surface, cmyk, krilla};
|
||||
use krilla::color::device_n::{Colorant, DeviceNSpace, Process};
|
||||
use krilla::image::{BitsPerComponent, CustomImage, ImageColorspace};
|
||||
|
||||
@@ -59,13 +59,17 @@ impl Exporter<'_> {
|
||||
/// the Output settings write them. The coverage is resampled as the Compression settings ask
|
||||
/// ([`crate::images::coverage_sized`]). `None` when the paint isn't a solid color, the image is
|
||||
/// missing or left out, its pixels can't be decoded, or its color channel exceeds
|
||||
/// [`vectorcraft_doc::MAX_RASTER_ALLOC`]; image rejections are cached like [`Self::load_image`].
|
||||
/// [`vectorcraft_doc::MAX_RASTER_ALLOC`]; cached by key and paint (and size when images are
|
||||
/// resampled), image rejections too.
|
||||
/// With `interpolate` (and the export's own setting), viewers may smooth it.
|
||||
pub(super) fn colorized_image(&mut self, im: &vectorcraft_doc::ImageObject, paint: &Paint, interpolate: bool) -> Option<Image> {
|
||||
let Paint::Solid { color, swatch, tint } = paint else { return None };
|
||||
let size = placed_size(im);
|
||||
let size = self.image_size(im);
|
||||
let interpolate = interpolate && self.interpolate;
|
||||
let mut cache = format!("{}\u{0}{paint:?}\u{0}{:.3}x{:.3}", im.key, size.0, size.1);
|
||||
let mut cache = format!("{}\u{0}{paint:?}", im.key);
|
||||
if self.downsampled() {
|
||||
cache.push_str(&format!("\u{0}{:.3}x{:.3}", size.0, size.1));
|
||||
}
|
||||
if !interpolate && self.interpolate {
|
||||
cache.push_str("\u{0}pixelated");
|
||||
}
|
||||
@@ -124,9 +128,8 @@ impl Exporter<'_> {
|
||||
fn load_image(&mut self, im: &vectorcraft_doc::ImageObject, size: (f64, f64), interpolate: bool) -> Option<Image> {
|
||||
let key = im.key.as_str();
|
||||
let c = self.compression;
|
||||
let sized = [c.color.downsample, c.gray.downsample, c.mono.downsample].iter().any(|d| *d != crate::Downsample::None);
|
||||
let interpolate = interpolate && self.interpolate;
|
||||
let mut cache = if sized { format!("{key}\u{0}{:.3}x{:.3}", size.0, size.1) } else { key.to_string() };
|
||||
let mut cache = if self.downsampled() { format!("{key}\u{0}{:.3}x{:.3}", size.0, size.1) } else { key.to_string() };
|
||||
if !interpolate && self.interpolate {
|
||||
cache.push_str("\u{0}pixelated");
|
||||
}
|
||||
@@ -173,9 +176,12 @@ impl Exporter<'_> {
|
||||
if m.invert || !plain || (mi.xf, mi.width, mi.height) != (im.xf, im.width, im.height) {
|
||||
return None;
|
||||
}
|
||||
let size = placed_size(im);
|
||||
let size = self.image_size(im);
|
||||
let interpolate = interpolate && self.interpolate;
|
||||
let mut cache = format!("{}\u{0}{}\u{0}{}\u{0}{:.3}x{:.3}", im.key, mi.key, m.clip, size.0, size.1);
|
||||
let mut cache = format!("{}\u{0}{}\u{0}{}", im.key, mi.key, m.clip);
|
||||
if self.downsampled() {
|
||||
cache.push_str(&format!("\u{0}{:.3}x{:.3}", size.0, size.1));
|
||||
}
|
||||
if !interpolate && self.interpolate {
|
||||
cache.push_str("\u{0}pixelated");
|
||||
}
|
||||
@@ -206,7 +212,7 @@ impl Exporter<'_> {
|
||||
}
|
||||
return;
|
||||
}
|
||||
let Some(img) = img.or_else(|| self.load_image(im, placed_size(im), !pixelated)) else {
|
||||
let Some(img) = img.or_else(|| self.load_image(im, self.image_size(im), !pixelated)) else {
|
||||
self.image_left_out = self.image_left_out.map(|_| true);
|
||||
return;
|
||||
};
|
||||
@@ -221,6 +227,56 @@ impl Exporter<'_> {
|
||||
s.draw_image(img, size);
|
||||
s.pop();
|
||||
}
|
||||
|
||||
/// Whether the Compression settings resample any kind of image.
|
||||
fn downsampled(&self) -> bool {
|
||||
let c = self.compression;
|
||||
[c.color.downsample, c.gray.downsample, c.mono.downsample].iter().any(|d| *d != crate::Downsample::None)
|
||||
}
|
||||
|
||||
/// The size image `im` is resampled for: [`Self::warp_image_size`]'s while the pieces of a
|
||||
/// mesh-warped image of the same pixels are written, else the size it is placed at.
|
||||
fn image_size(&self, im: &vectorcraft_doc::ImageObject) -> (f64, f64) {
|
||||
match &self.warp_size {
|
||||
Some(w) if (w.key.as_str(), w.width, w.height) == (im.key.as_str(), im.width, im.height) => w.size,
|
||||
_ => placed_size(im),
|
||||
}
|
||||
}
|
||||
|
||||
/// When `children` are the pieces of a mesh-warped image ([`Node::warp_piece_image`]) and the
|
||||
/// Compression settings resample images: the image they show, and the widest and highest any
|
||||
/// piece places it, which every piece's image is resampled for, so that the pieces paint one
|
||||
/// image. `None` for other children, pieces of different images, or a size that isn't finite
|
||||
/// and positive.
|
||||
pub(super) fn warp_image_size(&self, children: &[Arc<Node>]) -> Option<SharedSize> {
|
||||
if !self.downsampled() {
|
||||
return None;
|
||||
}
|
||||
let mut shared: Option<SharedSize> = None;
|
||||
for c in children {
|
||||
let n = c.warp_piece_image()?;
|
||||
let NodeKind::Image(im) = &n.kind else { return None };
|
||||
let im = self.doc.shown_image(n, im);
|
||||
let (w, h) = placed_size(&im);
|
||||
match &mut shared {
|
||||
Some(s) if (s.key.as_str(), s.width, s.height) == (im.key.as_str(), im.width, im.height) => {
|
||||
s.size = (s.size.0.max(w), s.size.1.max(h));
|
||||
}
|
||||
Some(_) => return None,
|
||||
None => shared = Some(SharedSize { key: im.key.clone(), width: im.width, height: im.height, size: (w, h) }),
|
||||
}
|
||||
}
|
||||
shared.filter(|s| s.size.0.is_finite() && s.size.1.is_finite() && s.size.0 > 0.0 && s.size.1 > 0.0)
|
||||
}
|
||||
}
|
||||
|
||||
/// The size the pixels of image `key` (`width` × `height` pixels) are resampled for while the
|
||||
/// pieces of a mesh-warped image are written ([`Exporter::warp_image_size`]).
|
||||
pub(super) struct SharedSize {
|
||||
key: String,
|
||||
width: u32,
|
||||
height: u32,
|
||||
size: (f64, f64),
|
||||
}
|
||||
|
||||
/// The size image `im` is placed at: its pixel grid's sides through its transform.
|
||||
@@ -320,13 +376,48 @@ mod tests {
|
||||
let image =
|
||||
ImageObject { key: key.into(), width: 1, height: 1, xf: Affine::IDENTITY, link: None, placement: Default::default(), pixels: None };
|
||||
assert!(exporter.colorized_image(&image, &paint, true).is_none(), "{key}");
|
||||
let cache = format!("{key}\u{0}{paint:?}\u{0}1.000x1.000");
|
||||
let cache = format!("{key}\u{0}{paint:?}");
|
||||
assert!(matches!(exporter.images.get(&cache), Some(None)), "rejection for {key} was not cached");
|
||||
assert!(exporter.colorized_image(&image, &paint, true).is_none(), "second call for {key}");
|
||||
}
|
||||
assert_eq!(exporter.images.len(), 3);
|
||||
}
|
||||
|
||||
/// Without downsampling, a colorized image and a CMYK image with a grid mask are made once for
|
||||
/// every size they are placed at.
|
||||
#[test]
|
||||
fn colorized_and_grid_masked_images_are_made_once_for_every_size_without_downsampling() {
|
||||
let gray = |pixels: Vec<u8>| {
|
||||
let mut png = vec![];
|
||||
image::GrayImage::from_raw(2, 2, pixels).unwrap().write_to(&mut std::io::Cursor::new(&mut png), image::ImageFormat::Png).unwrap();
|
||||
ImageBlob::png(png)
|
||||
};
|
||||
let mut doc = Document::new(100.0, 100.0);
|
||||
doc.images.insert("pic".into(), gray(vec![0, 255, 128, 64]));
|
||||
doc.images.insert("alpha".into(), gray(vec![255, 128, 0, 64]));
|
||||
let inks = vectorcraft_doc::cmyk::Inks::new(2, 2, [0u8, 102, 255, 0].repeat(4)).unwrap();
|
||||
doc.images.insert("inks".into(), ImageBlob::cmyk_tiff(&inks).unwrap());
|
||||
let settings = crate::PdfSettings::default();
|
||||
let mut exporter = Exporter::new(&doc, &settings);
|
||||
let paint = Paint::solid(Color::rgb(1.0, 0.0, 0.0));
|
||||
let image = |key: &str, scale: f64| ImageObject {
|
||||
key: key.into(),
|
||||
width: 2,
|
||||
height: 2,
|
||||
xf: Affine::scale(scale),
|
||||
link: None,
|
||||
placement: Default::default(),
|
||||
pixels: None,
|
||||
};
|
||||
for scale in [10.0, 20.0] {
|
||||
assert!(exporter.colorized_image(&image("pic", scale), &paint, true).is_some(), "colorized at {scale}");
|
||||
let art = vectorcraft_doc::Node::new(doc.layers[0].id, vectorcraft_doc::NodeKind::Image(Box::new(image("alpha", scale))));
|
||||
let mask = vectorcraft_doc::OpacityMask::new(art, true);
|
||||
assert!(exporter.masked_image(&image("inks", scale), &mask, true).is_some(), "masked at {scale}");
|
||||
}
|
||||
assert_eq!(exporter.images.len(), 2, "{:?}", exporter.images.keys().collect::<Vec<_>>());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tinted_color_rejects_oversized_and_overflowing_buffers() {
|
||||
let max = usize::try_from(vectorcraft_doc::MAX_RASTER_ALLOC).unwrap_or(usize::MAX);
|
||||
|
||||
@@ -100,6 +100,17 @@ impl Exporter<'_> {
|
||||
}
|
||||
}
|
||||
|
||||
/// [`Self::measured_children`] of a plain group or layer. When they are the pieces of a
|
||||
/// mesh-warped image, every piece, on the page or not, takes part in the one size its pixels
|
||||
/// are resampled for ([`Self::warp_image_size`]).
|
||||
fn group_children(&mut self, s: &mut Surface, children: &[std::sync::Arc<Node>], page: Rect, measured: &[Measured]) {
|
||||
let outer = self.warp_image_size(children).map(|w| self.warp_size.replace(w));
|
||||
self.measured_children(s, children, page, measured);
|
||||
if let Some(outer) = outer {
|
||||
self.warp_size = outer;
|
||||
}
|
||||
}
|
||||
|
||||
/// A luminosity mask that is 1 − the knockout shape of `c`, whose box is in `measured`: white,
|
||||
/// then black through an alpha mask of `c` (at full object opacity without its own mask, unless
|
||||
/// those define its shape).
|
||||
@@ -268,7 +279,7 @@ impl Exporter<'_> {
|
||||
}
|
||||
match &n.kind {
|
||||
NodeKind::Layer { children, clip: false, .. } | NodeKind::Group { children, clip: false } => {
|
||||
self.measured_children(s, children, page, measured.map_or(&[], |m| m.members.as_slice()));
|
||||
self.group_children(s, children, page, measured.map_or(&[], |m| m.members.as_slice()));
|
||||
}
|
||||
NodeKind::Group { children, clip: true } | NodeKind::Layer { children, clip: true, .. } => {
|
||||
// The region every output clips to; nothing to clip by hides the clipped art. The
|
||||
|
||||
@@ -133,6 +133,39 @@ fn colorized_images_are_downsampled() {
|
||||
assert!(text.contains("/Width 150") && !text.contains("/Width 600"), "resampled to 150 ppi");
|
||||
}
|
||||
|
||||
/// The pieces of a colorized image under a mesh warp, placed at different widths, paint one
|
||||
/// colorized image and its one soft mask, with downsampling off and on.
|
||||
#[test]
|
||||
fn a_colorized_warped_image_is_written_once_for_all_its_pieces() {
|
||||
for compression in [serde_json::json!({}), serde_json::json!({"gray": {"downsample": "bicubic", "ppi": 150}})] {
|
||||
let mut d = Document::new(100.0, 100.0);
|
||||
let gray = image::GrayImage::from_fn(600, 600, |x, y| image::Luma([((x + y) % 200) as u8]));
|
||||
let mut png = vec![];
|
||||
gray.write_to(&mut std::io::Cursor::new(&mut png), image::ImageFormat::Png).unwrap();
|
||||
d.images.insert("pic".into(), ImageBlob::png(png));
|
||||
let im = ImageObject {
|
||||
key: "pic".into(),
|
||||
width: 600,
|
||||
height: 600,
|
||||
xf: Affine::scale(72.0 / 600.0),
|
||||
link: None,
|
||||
placement: Default::default(),
|
||||
pixels: None,
|
||||
};
|
||||
let mut n = Node::new(d.alloc_id(), NodeKind::Image(Box::new(im.clone())));
|
||||
n.extras_mut().colorize = Some(Box::new(Paint::solid(Color::rgb(0.0, 0.0, 1.0))));
|
||||
// 0.5 to 1.5 times as wide from left to right.
|
||||
let stretch = |p: vectorcraft_geom::Point| vectorcraft_geom::Point::new(p.x * (0.5 + p.x / 144.0), p.y);
|
||||
let warped = vectorcraft_doc::live::mesh_warp_image(&n, &im, &stretch, 4, false, false);
|
||||
d.insert(d.default_layer(), 0, warped).unwrap();
|
||||
let settings: PdfSettings = serde_json::from_value(serde_json::json!({ "compression": compression })).unwrap();
|
||||
let out = export_with_report(&d, &PdfOptions { settings, ..Default::default() }).unwrap().bytes;
|
||||
let found = images(&out);
|
||||
assert_eq!(found.len(), 2, "{compression}: the colorized image and its soft mask, not {found:?}");
|
||||
assert!(found.contains(&("DeviceRGB".into(), vec![], true)), "{compression}: {found:?}");
|
||||
}
|
||||
}
|
||||
|
||||
/// Printed separations keep a colorized image's pixels on every plate and separate its color:
|
||||
/// a spot-colorized image prints on its ink's plate only. An uncolorized image of the same pixels
|
||||
/// is separated as before.
|
||||
|
||||
@@ -5,10 +5,11 @@
|
||||
|
||||
use std::io::Cursor;
|
||||
|
||||
use craft_pdf::syntax::object::{Name, Object};
|
||||
use image::{DynamicImage, ImageFormat};
|
||||
use serde_json::{Value, json};
|
||||
use vectorcraft_doc::{Document, ImageBlob, ImageObject, Node, NodeKind};
|
||||
use vectorcraft_geom::Affine;
|
||||
use vectorcraft_geom::{Affine, Point};
|
||||
|
||||
use crate::*;
|
||||
|
||||
@@ -72,6 +73,55 @@ fn images_above_the_threshold_are_resampled_to_the_target_resolution() {
|
||||
assert!(text.contains("/Width 100") && text.contains("/DeviceGray"), "grey at 100 ppi");
|
||||
}
|
||||
|
||||
/// The width and height of each image XObject in `pdf`.
|
||||
fn image_sizes(pdf: &[u8]) -> Vec<(u32, u32)> {
|
||||
let file = craft_pdf::syntax::Pdf::new(std::sync::Arc::new(pdf.to_vec())).unwrap();
|
||||
file.objects()
|
||||
.into_iter()
|
||||
.filter_map(|o| match o {
|
||||
Object::Stream(s) if s.dict().get::<Name<'_>>(b"Subtype").is_some_and(|n| n.as_ref() == b"Image") => {
|
||||
Some((s.dict().get::<u32>(b"Width").unwrap_or(0), s.dict().get::<u32>(b"Height").unwrap_or(0)))
|
||||
}
|
||||
_ => None,
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// A mesh warp that stretches the image across more toward its right side cuts it into pieces
|
||||
/// placed at different widths. With downsampling on, every piece paints one image, resampled for
|
||||
/// the widest piece; another placement of the image keeps its own resampling.
|
||||
#[test]
|
||||
fn a_warped_image_is_written_once_for_all_its_pieces() {
|
||||
let compression = json!({"color": {"downsample": "bicubic", "ppi": 150, "abovePpi": 225}});
|
||||
for masked in [false, true] {
|
||||
let mut d = doc(encoded(colour(), ImageFormat::Png), "image/png");
|
||||
let id = d.layers[0].children().unwrap()[0].id;
|
||||
let n = d.node(id).unwrap().clone();
|
||||
let NodeKind::Image(im) = &n.kind else { panic!("the image") };
|
||||
// 0.5 to 1.5 times as wide from left to right, within the page.
|
||||
let stretch = |p: Point| Point::new(p.x * (0.5 + p.x / 144.0), p.y);
|
||||
*d.node_mut(id).unwrap() = vectorcraft_doc::live::mesh_warp_image(&n, im, &stretch, 4, false, masked);
|
||||
let mut settings: PdfSettings = serde_json::from_value(json!({ "compression": compression })).unwrap();
|
||||
settings.compression.compress_text = false;
|
||||
let bytes = export_with_report(&d, &PdfOptions { settings: settings.clone(), ..Default::default() }).unwrap().bytes;
|
||||
let sizes = image_sizes(&bytes);
|
||||
assert_eq!(sizes.len(), 1, "masked {masked}: one image for 32 pieces, not {sizes:?}");
|
||||
// One inch high (150 pixels at 150 ppi), and wider than one inch at its widest.
|
||||
let (w, h) = sizes[0];
|
||||
assert!(h == 150 && w > 150 && w < 600, "masked {masked}: {w} × {h}");
|
||||
assert!(String::from_utf8_lossy(&bytes).matches(" Do").count() >= 32, "masked {masked}: every piece paints the image");
|
||||
|
||||
// The image placed again, two inches wide and high, is resampled for that placement.
|
||||
let plain = Node::new(d.alloc_id(), NodeKind::Image(Box::new(ImageObject { xf: Affine::scale(144.0 / 600.0), ..(**im).clone() })));
|
||||
let l = d.layers[0].id;
|
||||
d.insert(Some(l), 1, plain).unwrap();
|
||||
let bytes = export_with_report(&d, &PdfOptions { settings, ..Default::default() }).unwrap().bytes;
|
||||
let mut sizes = image_sizes(&bytes);
|
||||
sizes.sort_unstable();
|
||||
assert_eq!(sizes, [(w, h), (300, 300)], "masked {masked}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn jpeg_zip_and_automatic_compression() {
|
||||
let png = doc(encoded(colour(), ImageFormat::Png), "image/png");
|
||||
|
||||
@@ -210,6 +210,60 @@ fn a_documents_own_composite_font_draws_without_being_registered() {
|
||||
assert!(db.composite_font(NAME).is_none(), "given back after each render");
|
||||
}
|
||||
|
||||
/// A placed document draws its type in its own definition of a composite font that the document
|
||||
/// placing it defines differently.
|
||||
#[test]
|
||||
fn a_placed_document_draws_its_own_definition_of_a_name_the_page_defines() {
|
||||
use vectorcraft_doc::{CompositeFont, CompositePart, CompositeParts, ImageBlob, LinkInfo, PlacedDocument};
|
||||
let _adding = adding_fonts();
|
||||
const NAME: &str = "CF Placed Twice";
|
||||
let definition = |roman: &str| {
|
||||
let mut parts = CompositeParts::all(CompositePart::new("Source Sans 3", "Regular"));
|
||||
parts.roman = CompositePart::new(roman, "Regular");
|
||||
CompositeFont { name: NAME.into(), parts }
|
||||
};
|
||||
let typed = |family: &str| {
|
||||
let mut d = Document::new(300.0, 150.0);
|
||||
let style = CharStyle { size: 60.0, font_family: family.into(), ..Default::default() };
|
||||
let n = Node::new(d.alloc_id(), NodeKind::Text(Box::new(TextObject::point(Point::new(10.0, 100.0), "Hamburg", style))));
|
||||
let l = d.layers[0].id;
|
||||
let id = d.insert(Some(l), 0, n).unwrap();
|
||||
(d, id)
|
||||
};
|
||||
let opts = RenderOptions { background: Some([255, 255, 255, 255]), ..Default::default() };
|
||||
// A page placing `d` (drawn exactly), defining `fonts`.
|
||||
let page = |d: &Document, id: NodeId, key: &str, fonts: Vec<CompositeFont>| {
|
||||
let blob = ImageBlob::new("application/json", key.as_bytes().to_vec());
|
||||
let p = PlacedDocument {
|
||||
link: LinkInfo::new("/art/type.vectorcraft"),
|
||||
key: key.into(),
|
||||
bounding: false,
|
||||
page_box: Default::default(),
|
||||
width: 300.0,
|
||||
height: 150.0,
|
||||
xf: Affine::IDENTITY,
|
||||
placement: Default::default(),
|
||||
};
|
||||
let art = d.node(id).unwrap().clone();
|
||||
vectorcraft_doc::placed_document::prime(&p, blob.bytes.clone(), (d.clone(), art, Rect::new(0.0, 0.0, 300.0, 150.0)));
|
||||
let mut page = Document::new(300.0, 150.0);
|
||||
page.composite_fonts.extend(fonts);
|
||||
page.images.insert(key.into(), blob);
|
||||
let n = Node::new(page.alloc_id(), NodeKind::PlacedDocument(Box::new(p)));
|
||||
let l = page.layers[0].id;
|
||||
page.insert(Some(l), 0, n).unwrap();
|
||||
Renderer::new().render(&page, 300, 150, Affine::IDENTITY, &opts).pixels
|
||||
};
|
||||
let (mut placed, id) = typed(NAME);
|
||||
placed.composite_fonts.push(definition("JetBrains Mono"));
|
||||
let (plain, plain_id) = typed("JetBrains Mono");
|
||||
let (inter, inter_id) = typed("Inter");
|
||||
let own = page(&placed, id, "fontchange-placed-twice", vec![definition("Inter")]);
|
||||
assert!(own == page(&plain, plain_id, "fontchange-placed-twice-plain", vec![]), "drawn in the placed document's definition");
|
||||
assert!(own != page(&inter, inter_id, "fontchange-placed-twice-inter", vec![]), "not in the page's");
|
||||
assert!(vectorcraft_text::FontDb::global().composite_font(NAME).is_none());
|
||||
}
|
||||
|
||||
/// Type at 80% opacity, in Multiply and with a drop shadow redraws in a font that becomes
|
||||
/// available: the glyph geometry kept for it is laid out again.
|
||||
#[test]
|
||||
|
||||
@@ -20,7 +20,7 @@ use text::TextSlots;
|
||||
|
||||
pub use depth::MAX_ELEMENT_DEPTH;
|
||||
pub(crate) use depth::xml_too_deep;
|
||||
pub use files::MAX_NESTED_SVG_IMAGES;
|
||||
pub use files::{MAX_NESTED_SVG_IMAGES, MAX_NESTED_SVG_READS, MAX_NESTED_SVG_TEXT};
|
||||
pub(crate) use fx::BLEND;
|
||||
|
||||
/// Most objects an imported SVG may expand to: a guard against `<use>` and nested-image
|
||||
|
||||
@@ -23,6 +23,13 @@ use crate::{ImportOptions, MAX_ELEMENT_DEPTH, MAX_SVGZ, SvgError};
|
||||
/// Most nested SVG images an imported SVG may decode (#3564).
|
||||
pub const MAX_NESTED_SVG_IMAGES: usize = 256;
|
||||
|
||||
/// Most nested SVG images an imported SVG may read, decoded or not: each one read is checked and
|
||||
/// parsed, and an image that fails is read again wherever it is shown.
|
||||
pub const MAX_NESTED_SVG_READS: usize = 1024;
|
||||
|
||||
/// Most bytes of nested SVG image text an imported SVG may read, decoded or not, all reads together.
|
||||
pub const MAX_NESTED_SVG_TEXT: u64 = 512 << 20;
|
||||
|
||||
/// The files an SVG's images link to.
|
||||
#[derive(Default)]
|
||||
pub(in crate::import) struct Files {
|
||||
@@ -48,6 +55,12 @@ pub(in crate::import) struct Files {
|
||||
svg_images: AtomicUsize,
|
||||
/// SVG images were left out for exceeding [`MAX_NESTED_SVG_IMAGES`].
|
||||
too_many_svg_images: AtomicBool,
|
||||
/// How many nested SVG images were read so far, decoded or not.
|
||||
svg_reads: AtomicUsize,
|
||||
/// The bytes of text those reads took.
|
||||
svg_text: AtomicU64,
|
||||
/// SVG images were left out for exceeding [`MAX_NESTED_SVG_READS`] or [`MAX_NESTED_SVG_TEXT`].
|
||||
too_much_svg_read: AtomicBool,
|
||||
/// The CMYK TIFF stored in place of a turned CMYK image, by the address of the PNG usvg got
|
||||
/// (kept here too, so the address stays its own).
|
||||
cmyk: Mutex<HashMap<usize, (Bytes, Bytes)>>,
|
||||
@@ -253,6 +266,12 @@ impl Files {
|
||||
if self.too_many_svg_images.load(Ordering::Relaxed) {
|
||||
notes.push(format!("SVG images were left out: more than {MAX_NESTED_SVG_IMAGES} nested SVG images"));
|
||||
}
|
||||
if self.too_much_svg_read.load(Ordering::Relaxed) {
|
||||
notes.push(format!(
|
||||
"SVG images were left out: nested SVG images, valid or not, are read at most {MAX_NESTED_SVG_READS} times and {} MB of text",
|
||||
MAX_NESTED_SVG_TEXT >> 20
|
||||
));
|
||||
}
|
||||
notes
|
||||
}
|
||||
|
||||
@@ -308,11 +327,33 @@ impl Files {
|
||||
|
||||
/// The SVG image `text` as usvg reads it with `opts`. `None`, the image left out as one usvg
|
||||
/// can't read, when it isn't SVG or when its elements nest deeper than the SVGs around it
|
||||
/// leave: usvg reads it while it reads them, on the same stack.
|
||||
/// leave: usvg reads it while it reads them, on the same stack. Only a decoded image, with the
|
||||
/// images decoded inside it, counts toward [`MAX_NESTED_SVG_IMAGES`]; every image read counts
|
||||
/// toward [`MAX_NESTED_SVG_READS`] and [`MAX_NESTED_SVG_TEXT`].
|
||||
fn svg(&self, text: &str, opts: &usvg::Options) -> Option<ImageKind> {
|
||||
if self.svg_images.fetch_add(1, Ordering::Relaxed) >= MAX_NESTED_SVG_IMAGES {
|
||||
let decoded = self.svg_images.load(Ordering::Relaxed);
|
||||
if decoded >= MAX_NESTED_SVG_IMAGES {
|
||||
return leave_out(&self.too_many_svg_images, true);
|
||||
}
|
||||
let reads = self.svg_reads.load(Ordering::Relaxed);
|
||||
let read = self.svg_text.load(Ordering::Relaxed).saturating_add(text.len() as u64);
|
||||
if reads >= MAX_NESTED_SVG_READS || read > MAX_NESTED_SVG_TEXT {
|
||||
return leave_out(&self.too_much_svg_read, true);
|
||||
}
|
||||
self.svg_reads.store(reads + 1, Ordering::Relaxed);
|
||||
self.svg_text.store(read, Ordering::Relaxed);
|
||||
// The image's place is taken while usvg reads it, so the images inside it get what is left.
|
||||
// usvg reads them all on this thread, so no other read comes between these steps.
|
||||
self.svg_images.store(decoded + 1, Ordering::Relaxed);
|
||||
let tree = self.svg_tree(text, opts);
|
||||
if tree.is_none() {
|
||||
self.svg_images.store(decoded, Ordering::Relaxed);
|
||||
}
|
||||
tree
|
||||
}
|
||||
|
||||
/// [`Files::svg`]'s image once it is counted.
|
||||
fn svg_tree(&self, text: &str, opts: &usvg::Options) -> Option<ImageKind> {
|
||||
// Measured as the SVG holding it is (`import`): the text before the XML reader.
|
||||
let used = self.depth.load(Ordering::Relaxed);
|
||||
let left = MAX_ELEMENT_DEPTH.saturating_sub(used);
|
||||
@@ -323,11 +364,12 @@ impl Files {
|
||||
let Some(height) = depth::height(&xml, left) else { return leave_out(&self.too_deep, true) };
|
||||
// usvg's parser would overflow on it, as on the SVG holding it (`import`).
|
||||
super::convolve_matrices_ok(&xml).ok()?;
|
||||
self.note_text(text);
|
||||
self.depth.store(used + height, Ordering::Relaxed);
|
||||
let tree = usvg::Tree::from_data_nested(text.as_bytes(), opts);
|
||||
self.depth.store(used, Ordering::Relaxed);
|
||||
tree.ok().map(ImageKind::SVG)
|
||||
let tree = tree.ok()?;
|
||||
self.note_text(text);
|
||||
Some(ImageKind::SVG(tree))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -587,4 +629,34 @@ mod tests {
|
||||
assert!(!read("2147483647"));
|
||||
assert!(read("1"));
|
||||
}
|
||||
|
||||
/// The nested SVG images read, decoded or not, take at most [`MAX_NESTED_SVG_TEXT`] bytes of
|
||||
/// text together: an image whose text goes past it is left out and noted. A broken image's
|
||||
/// text counts too, and its decoded place is given back.
|
||||
#[test]
|
||||
fn svg_images_share_the_text_cap() {
|
||||
let svg = r#"<svg xmlns="http://www.w3.org/2000/svg" width="2" height="2"><rect width="1" height="1"/></svg>"#;
|
||||
let broken = &svg[..20];
|
||||
let files = Files::default();
|
||||
let resolver = files.resolver(1);
|
||||
let opts = usvg::Options::default();
|
||||
let read =
|
||||
|text: &str| matches!((resolver.resolve_data)("image/svg+xml", Arc::new(text.as_bytes().to_vec()), &opts), Some(ImageKind::SVG(_)));
|
||||
assert!(!read(broken));
|
||||
assert_eq!(files.svg_text.load(Ordering::Relaxed), broken.len() as u64);
|
||||
assert_eq!((files.svg_reads.load(Ordering::Relaxed), files.svg_images.load(Ordering::Relaxed)), (1, 0));
|
||||
files.svg_text.store(MAX_NESTED_SVG_TEXT - svg.len() as u64, Ordering::Relaxed);
|
||||
assert!(read(svg));
|
||||
assert_eq!((files.svg_reads.load(Ordering::Relaxed), files.svg_images.load(Ordering::Relaxed)), (2, 1));
|
||||
assert!(files.left_out().is_empty());
|
||||
assert!(!read(svg) && !read(broken));
|
||||
assert_eq!(files.svg_text.load(Ordering::Relaxed), MAX_NESTED_SVG_TEXT, "past the cap, nothing is read");
|
||||
assert_eq!(
|
||||
files.left_out(),
|
||||
[format!(
|
||||
"SVG images were left out: nested SVG images, valid or not, are read at most {MAX_NESTED_SVG_READS} times and {} MB of text",
|
||||
MAX_NESTED_SVG_TEXT >> 20
|
||||
)]
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -576,7 +576,7 @@ pub fn svg_filters(svg: &str, room: usize, taken: impl Fn(&str) -> bool) -> Resu
|
||||
}
|
||||
|
||||
pub use glyph::glyph_art;
|
||||
pub use import::{MAX_ELEMENT_DEPTH, MAX_FOREIGN, MAX_FOREIGN_VALUE, MAX_NESTED_SVG_IMAGES, MAX_NODES};
|
||||
pub use import::{MAX_ELEMENT_DEPTH, MAX_FOREIGN, MAX_FOREIGN_VALUE, MAX_NESTED_SVG_IMAGES, MAX_NESTED_SVG_READS, MAX_NESTED_SVG_TEXT, MAX_NODES};
|
||||
|
||||
/// Import an SVG document reading the files it links to as `opts` says, also returning warnings.
|
||||
pub fn import_with(svg: &str, opts: &ImportOptions) -> Result<(Document, Vec<String>), SvgError> {
|
||||
|
||||
@@ -6,7 +6,7 @@ use std::path::{Path, PathBuf};
|
||||
|
||||
use vectorcraft_doc::{Document, ImageObject, LinkInfo, Node, NodeKind};
|
||||
use vectorcraft_geom::Rect;
|
||||
use vectorcraft_svg::{ImportOptions, MAX_NESTED_SVG_IMAGES, SvgError, import_with, import_with_report, import_within};
|
||||
use vectorcraft_svg::{ImportOptions, MAX_NESTED_SVG_IMAGES, MAX_NESTED_SVG_READS, SvgError, import_with, import_with_report, import_within};
|
||||
use vectorcraft_testkit::raster::{Image, assert_similar, render_artboard};
|
||||
|
||||
fn svg(body: &str) -> String {
|
||||
@@ -683,6 +683,87 @@ fn nested_svg_images_past_count_cap_are_left_out() {
|
||||
assert!(warnings.iter().any(|w| w.contains(&format!("more than {MAX_NESTED_SVG_IMAGES} nested SVG images"))));
|
||||
}
|
||||
|
||||
/// Nested SVG images that can't be read: text that isn't XML, and an SVG without a size.
|
||||
const BROKEN_SVG_IMAGES: [&str; 2] =
|
||||
[r#"<svg xmlns="http://www.w3.org/2000/svg" width="10"#, r#"<svg xmlns="http://www.w3.org/2000/svg" width="0" height="0"/>"#];
|
||||
const SMALL_SVG_IMAGE: &str = r#"<svg xmlns="http://www.w3.org/2000/svg" width="10" height="10"><rect width="5" height="5"/></svg>"#;
|
||||
|
||||
/// `broken` images showing the broken SVG images in turn from data URLs, then `good` images
|
||||
/// showing a rectangle.
|
||||
fn broken_then_good(broken: usize, good: usize) -> String {
|
||||
let broken: String = (0..broken).map(|i| image_of("image/svg+xml", BROKEN_SVG_IMAGES[i % 2])).collect();
|
||||
format!(r#"<svg {SVG_NS} width="500" height="500">{broken}{}</svg>"#, image_of("image/svg+xml", SMALL_SVG_IMAGE).repeat(good))
|
||||
}
|
||||
|
||||
/// Only nested SVG images that decode count toward `MAX_NESTED_SVG_IMAGES`: an image after that
|
||||
/// many broken ones, from data URLs or a linked file shown again and again, is imported, and the
|
||||
/// images past that many decoded ones are still left out.
|
||||
#[test]
|
||||
fn broken_nested_svg_images_do_not_use_up_the_image_allowance() {
|
||||
let allowance = |w: &[String]| w.iter().any(|w| w.contains(&format!("more than {MAX_NESTED_SVG_IMAGES} nested SVG images")));
|
||||
let (d, w) = import_with(&broken_then_good(MAX_NESTED_SVG_IMAGES + 10, 1), &ImportOptions::default()).unwrap();
|
||||
assert_eq!(rects(&d), 1, "{w:?}");
|
||||
assert!(!w.iter().any(|w| w.contains("left out")), "{w:?}");
|
||||
let (d, w) = import_with(&broken_then_good(10, MAX_NESTED_SVG_IMAGES + 1), &ImportOptions::default()).unwrap();
|
||||
assert_eq!(rects(&d), MAX_NESTED_SVG_IMAGES, "{w:?}");
|
||||
assert!(allowance(&w), "{w:?}");
|
||||
|
||||
let dir = folder("brokensvgimages");
|
||||
std::fs::write(dir.join("img").join("broken.svg"), BROKEN_SVG_IMAGES[0]).unwrap();
|
||||
std::fs::write(dir.join("img").join("good.svg"), SMALL_SVG_IMAGE).unwrap();
|
||||
let reads = std::sync::atomic::AtomicUsize::new(0);
|
||||
let counted = |path: &str| {
|
||||
reads.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
|
||||
read(path)
|
||||
};
|
||||
let body = format!(
|
||||
r#"{}<image href="img/good.svg" width="10" height="10"/>"#,
|
||||
r#"<image href="img/broken.svg" width="10" height="10"/>"#.repeat(MAX_NESTED_SVG_IMAGES + 10)
|
||||
);
|
||||
let folder = dir.to_string_lossy();
|
||||
let (d, w) = import_with(&svg(&body), &ImportOptions { folder: Some(&folder), read: Some(&counted) }).unwrap();
|
||||
assert_eq!(rects(&d), 1, "{w:?}");
|
||||
assert!(!w.iter().any(|w| w.contains("left out")), "{w:?}");
|
||||
assert_eq!(reads.into_inner(), 2, "each file is read once");
|
||||
}
|
||||
|
||||
/// Every nested SVG image read, decoded or not, counts toward `MAX_NESTED_SVG_READS`: past it,
|
||||
/// images are left out with a warning, also when `<use>` shows one broken linked file many times.
|
||||
#[test]
|
||||
fn nested_svg_images_are_read_at_most_max_nested_svg_reads_times() {
|
||||
let past = |w: &[String]| w.iter().any(|w| w.contains(&format!("read at most {MAX_NESTED_SVG_READS} times")));
|
||||
let (d, w) = import_with(&broken_then_good(MAX_NESTED_SVG_READS - 1, 1), &ImportOptions::default()).unwrap();
|
||||
assert_eq!(rects(&d), 1, "{w:?}");
|
||||
assert!(!past(&w), "{w:?}");
|
||||
let (d, w) = import_with(&broken_then_good(MAX_NESTED_SVG_READS, 1), &ImportOptions::default()).unwrap();
|
||||
assert_eq!(rects(&d), 0, "{w:?}");
|
||||
assert!(past(&w), "{w:?}");
|
||||
assert!(!w.iter().any(|w| w.contains(&format!("more than {MAX_NESTED_SVG_IMAGES} nested SVG images"))), "{w:?}");
|
||||
|
||||
let dir = folder("brokensvguses");
|
||||
std::fs::write(dir.join("img").join("broken.svg"), BROKEN_SVG_IMAGES[0]).unwrap();
|
||||
std::fs::write(dir.join("img").join("good.svg"), SMALL_SVG_IMAGE).unwrap();
|
||||
let uses = r##"<use href="#broken"/>"##.repeat(2 * MAX_NESTED_SVG_READS);
|
||||
let body = format!(
|
||||
r#"<defs><g id="broken"><image href="img/broken.svg" width="10" height="10"/></g></defs>{uses}<image href="img/good.svg" width="10" height="10"/>"#
|
||||
);
|
||||
let (d, w) = open_in(&dir, &body);
|
||||
assert_eq!(rects(&d), 0, "{w:?}");
|
||||
assert!(past(&w), "{w:?}");
|
||||
}
|
||||
|
||||
/// A nested SVG image that can't be read and is left out gives no warning about its text.
|
||||
#[test]
|
||||
fn broken_nested_svg_images_do_not_warn_about_their_text() {
|
||||
let text = |svg: &str| {
|
||||
let file = format!(r#"<svg {SVG_NS} width="50" height="50">{}</svg>"#, image_of("image/svg+xml", svg));
|
||||
let (_, w) = import_with(&file, &ImportOptions::default()).unwrap();
|
||||
w.iter().any(|w| w.contains("text inside an SVG image"))
|
||||
};
|
||||
assert!(!text(r#"<svg xmlns="http://www.w3.org/2000/svg" width="0" height="0"><text>Hi</text></svg>"#));
|
||||
assert!(text(r#"<svg xmlns="http://www.w3.org/2000/svg" width="10" height="10"><text>Hi</text></svg>"#));
|
||||
}
|
||||
|
||||
/// #3566: entity references in element content count toward the expansion limit, as in
|
||||
/// attributes. A 10 KB entity, a second entity that refers to it 250 times, and eight references
|
||||
/// to the second inside `<desc>` would expand to about 20 MB from a file of a few KB: the import
|
||||
|
||||
@@ -116,10 +116,9 @@ pub struct FontDb {
|
||||
/// Faces instanced at the axis settings of type's Variable Font sliders ([`FontDb::instance`]).
|
||||
instances: Mutex<Instances>,
|
||||
/// Composite fonts (Type › Composite Fonts…) by lowercased name ([`FontDb::set_composite_font`]):
|
||||
/// type naming one as its family is set in its parts' fonts.
|
||||
/// type naming one as its family is set in its parts' fonts. A thread's guards can lend others
|
||||
/// in their place ([`FontDb::lend_composite_fonts`]).
|
||||
composites: RwLock<HashMap<String, Arc<vectorcraft_doc::CompositeFont>>>,
|
||||
/// The composite fonts [`FontDb::lend_composite_fonts`] put in, by lowercased name.
|
||||
composite_loans: Mutex<HashMap<String, composite::CompositeLoan>>,
|
||||
}
|
||||
|
||||
/// Where a database's system font scan looks: folders (read with their subfolders) and font files.
|
||||
|
||||
@@ -2,6 +2,12 @@
|
||||
//! font menus and resolves to its base (Kanji) font, and layout sets each character of type in one
|
||||
//! in the font of the character's class ([`FontDb::composite_split`]).
|
||||
|
||||
use std::cell::RefCell;
|
||||
use std::collections::HashMap;
|
||||
use std::hash::{DefaultHasher, Hash, Hasher};
|
||||
use std::marker::PhantomData;
|
||||
use std::sync::atomic::{AtomicU64, Ordering};
|
||||
|
||||
use unicode_segmentation::UnicodeSegmentation;
|
||||
use vectorcraft_doc::{CharClass, CompositeFont, TextObject, TextRun};
|
||||
|
||||
@@ -11,6 +17,42 @@ use super::{Arc, FontDb, FontFace, FontMatch};
|
||||
/// A face [`FontDb::resolve`] found and how it matched.
|
||||
type Resolved = Option<(Arc<FontFace>, FontMatch)>;
|
||||
|
||||
thread_local! {
|
||||
/// The composite fonts the guards of this thread lend ([`FontDb::lend_composite_fonts`]), the
|
||||
/// latest loan last.
|
||||
static LENT: RefCell<Vec<Loan>> = const { RefCell::new(Vec::new()) };
|
||||
}
|
||||
|
||||
/// The number of the next loan.
|
||||
static NEXT_LOAN: AtomicU64 = AtomicU64::new(1);
|
||||
|
||||
/// The composite fonts one guard lends, on the thread that made it.
|
||||
struct Loan {
|
||||
/// The lending database's [`FontDb::id`].
|
||||
db: u64,
|
||||
/// Tells this loan from the others.
|
||||
number: u64,
|
||||
/// The fonts, by lowercased name.
|
||||
fonts: HashMap<String, Arc<CompositeFont>>,
|
||||
/// The database's generation when they were lent, when each was then the font the database
|
||||
/// holds by its name.
|
||||
same_at: Option<u64>,
|
||||
/// A number for the definitions lent: loans of the same definitions have the same one.
|
||||
print: u64,
|
||||
}
|
||||
|
||||
/// Feed composite font `f`'s name and parts to `h`.
|
||||
fn hash_font(f: &CompositeFont, h: &mut DefaultHasher) {
|
||||
f.name.hash(h);
|
||||
let p = &f.parts;
|
||||
for part in [&p.kanji, &p.kana, &p.punctuation, &p.symbols, &p.roman, &p.numerals] {
|
||||
(&part.font, &part.style, part.scale_at_center).hash(h);
|
||||
for v in [part.size, part.baseline, part.h_scale, part.v_scale] {
|
||||
v.to_bits().hash(h);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// How a composite font draws some text ([`FontDb::composite_coverage`]).
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct CompositeCoverage {
|
||||
@@ -25,19 +67,13 @@ pub struct CompositeCoverage {
|
||||
impl FontDb {
|
||||
/// Make `font` available by its name, in place of a composite font of that name (any case).
|
||||
/// The font menus and anything else that lists fonts change only when the definition does.
|
||||
/// Whether it did.
|
||||
/// Whether it did. A font of that name a guard lends ([`FontDb::lend_composite_fonts`]) stays
|
||||
/// in use on the guard's thread.
|
||||
pub fn set_composite_font(&self, font: &CompositeFont) -> bool {
|
||||
let key = font.name.to_lowercase();
|
||||
{
|
||||
let mut map = self.composites.write().unwrap_or_else(|e| e.into_inner());
|
||||
if map.get(&key).is_some_and(|f| **f == *font) {
|
||||
// The same definition a guard lends: it stays when the guard drops.
|
||||
let mut loans = self.composite_loans.lock().unwrap_or_else(|e| e.into_inner());
|
||||
if let Some(loan) = loans.get_mut(&key)
|
||||
&& map.get(&key).is_some_and(|have| Arc::ptr_eq(have, &loan.font))
|
||||
{
|
||||
loan.adopted = true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
map.insert(key, Arc::new(font.clone()));
|
||||
@@ -55,8 +91,16 @@ impl FontDb {
|
||||
removed
|
||||
}
|
||||
|
||||
/// The composite font named `name` (any case).
|
||||
/// The composite font named `name` (any case): the one the latest guard of this thread that
|
||||
/// lends that name lends ([`FontDb::lend_composite_fonts`]), else the one the database holds.
|
||||
pub fn composite_font(&self, name: &str) -> Option<Arc<CompositeFont>> {
|
||||
if self.lends() {
|
||||
let key = name.to_lowercase();
|
||||
if let Some(f) = self.lent_font(&key) {
|
||||
return Some(f);
|
||||
}
|
||||
return self.composites.read().unwrap_or_else(|e| e.into_inner()).get(&key).cloned();
|
||||
}
|
||||
let map = self.composites.read().unwrap_or_else(|e| e.into_inner());
|
||||
if map.is_empty() {
|
||||
return None;
|
||||
@@ -64,6 +108,39 @@ impl FontDb {
|
||||
map.get(&name.to_lowercase()).cloned()
|
||||
}
|
||||
|
||||
/// Whether a guard of this thread lends composite fonts of this database.
|
||||
fn lends(&self) -> bool {
|
||||
LENT.try_with(|l| l.borrow().iter().any(|loan| loan.db == self.id)).unwrap_or(false)
|
||||
}
|
||||
|
||||
/// The composite font named `key` (lowercased) that the latest guard of this thread lending that
|
||||
/// name lends.
|
||||
fn lent_font(&self, key: &str) -> Option<Arc<CompositeFont>> {
|
||||
LENT.try_with(|l| l.borrow().iter().rev().filter(|loan| loan.db == self.id).find_map(|loan| loan.fonts.get(key).cloned())).ok().flatten()
|
||||
}
|
||||
|
||||
/// [`FontDb::generation`] on this thread, from the database's own generation `g`: `g` while each
|
||||
/// font the guards of this thread lend is the one the database held by its name when it was lent
|
||||
/// and the database has not changed since, else a number made from `g` and the definitions
|
||||
/// lent, so what was laid out in one document's definitions is not taken for another's.
|
||||
pub(super) fn lent_generation(&self, g: u64) -> u64 {
|
||||
LENT.try_with(|l| {
|
||||
let l = l.borrow();
|
||||
let loans = l.iter().filter(|loan| loan.db == self.id);
|
||||
if loans.clone().all(|loan| loan.same_at == Some(g)) {
|
||||
return g;
|
||||
}
|
||||
let mut h = DefaultHasher::new();
|
||||
g.hash(&mut h);
|
||||
for loan in loans {
|
||||
loan.print.hash(&mut h);
|
||||
}
|
||||
// The database's generations count up from 0 and never reach the top bit.
|
||||
h.finish() | 1 << 63
|
||||
})
|
||||
.unwrap_or(g)
|
||||
}
|
||||
|
||||
/// The face composite font `name` sets character `c` in (its class's part's), and whether
|
||||
/// that part's font is missing. `None` when `name` is no composite font.
|
||||
pub fn composite_face(&self, name: &str, c: char) -> Option<(bool, u32)> {
|
||||
@@ -168,48 +245,48 @@ impl FontDb {
|
||||
}
|
||||
}
|
||||
|
||||
/// Make each of the composite fonts `fonts` available while the returned guard lives, in place
|
||||
/// of a composite font of the same name that the database holds. An app's session registers
|
||||
/// the composite fonts of its front document; with the guard, a document exported without a
|
||||
/// session, or one that is not in front, draws its type in its own. Dropping the guard puts
|
||||
/// back the font each one replaced, or forgets it, unless another guard still lends the same
|
||||
/// definition or a font of that name has been set since. While one guard lends a font, another
|
||||
/// guard's different definition of that name is not lent.
|
||||
/// Make each of the composite fonts `fonts` (a document's) available on this thread while the
|
||||
/// returned guard lives, in place of a composite font of the same name that the database holds
|
||||
/// or that an earlier guard of this thread lends. An app's session registers the composite fonts
|
||||
/// of its front document; with the guard, a document exported without a session, one that is not
|
||||
/// in front, or one placed in another document draws its type in its own, also while another
|
||||
/// thread draws another document that defines the same name differently, or the session
|
||||
/// registers another document's. Of fonts that share a name (in any case), the first is lent.
|
||||
/// Other threads, and the fonts the database holds and lists, are left as they are. The guard
|
||||
/// stays on its thread and ends its loan when it drops, also when the thread unwinds.
|
||||
pub fn lend_composite_fonts(&self, fonts: &[CompositeFont]) -> LentCompositeFonts<'_> {
|
||||
let mut lent = Vec::new();
|
||||
let mut changed = false;
|
||||
// A font a document can't use (its name an installed family's, or a value out of range)
|
||||
// is not lent: an app leaves it out when it opens the document.
|
||||
let usable: Vec<&CompositeFont> = fonts.iter().filter(|f| f.check().is_ok() && !self.has_installed_family(&f.name)).collect();
|
||||
if !usable.is_empty() {
|
||||
let mut map = self.composites.write().unwrap_or_else(|e| e.into_inner());
|
||||
let mut loans = self.composite_loans.lock().unwrap_or_else(|e| e.into_inner());
|
||||
if usable.is_empty() {
|
||||
return LentCompositeFonts { number: None, thread: PhantomData };
|
||||
}
|
||||
let mut lent: HashMap<String, Arc<CompositeFont>> = HashMap::with_capacity(usable.len());
|
||||
let mut h = DefaultHasher::new();
|
||||
let mut same = true;
|
||||
let generation = {
|
||||
let map = self.composites.read().unwrap_or_else(|e| e.into_inner());
|
||||
for f in usable {
|
||||
let key = f.name.to_lowercase();
|
||||
// A loan still in force: lend the same definition again, and leave another as it is.
|
||||
if let Some(loan) = loans.get_mut(&key)
|
||||
&& map.get(&key).is_some_and(|have| Arc::ptr_eq(have, &loan.font))
|
||||
{
|
||||
if *loan.font == *f {
|
||||
loan.guards += 1;
|
||||
lent.push((key, loan.font.clone()));
|
||||
if lent.contains_key(&key) {
|
||||
continue;
|
||||
}
|
||||
hash_font(f, &mut h);
|
||||
let font = match map.get(&key) {
|
||||
Some(have) if **have == *f => have.clone(),
|
||||
_ => {
|
||||
same = false;
|
||||
Arc::new(f.clone())
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if map.get(&key).is_some_and(|have| **have == *f) {
|
||||
continue;
|
||||
}
|
||||
let font = Arc::new((*f).clone());
|
||||
let previous = map.insert(key.clone(), font.clone());
|
||||
loans.insert(key.clone(), CompositeLoan { font: font.clone(), previous, guards: 1, adopted: false });
|
||||
lent.push((key, font));
|
||||
changed = true;
|
||||
};
|
||||
lent.insert(key, font);
|
||||
}
|
||||
}
|
||||
if changed {
|
||||
self.changed();
|
||||
}
|
||||
LentCompositeFonts { db: self, lent }
|
||||
self.generation.load(Ordering::Relaxed)
|
||||
};
|
||||
let number = NEXT_LOAN.fetch_add(1, Ordering::Relaxed);
|
||||
let loan = Loan { db: self.id, number, fonts: lent, same_at: same.then_some(generation), print: h.finish() };
|
||||
let pushed = LENT.try_with(|l| l.borrow_mut().push(loan)).is_ok();
|
||||
LentCompositeFonts { number: pushed.then_some(number), thread: PhantomData }
|
||||
}
|
||||
|
||||
/// The family and style composite font `f` sets characters of `class` in: its part's, or the
|
||||
@@ -219,7 +296,7 @@ impl FontDb {
|
||||
if self.composite_font(&p.font).is_some() { (FALLBACK_FAMILY.to_string(), "Regular".to_string()) } else { (p.font.clone(), p.style.clone()) }
|
||||
}
|
||||
|
||||
/// The names of the composite fonts, sorted.
|
||||
/// The names of the composite fonts the database holds (not those guards lend), sorted.
|
||||
pub fn composite_font_names(&self) -> Vec<String> {
|
||||
let mut v: Vec<String> = self.composites.read().unwrap_or_else(|e| e.into_inner()).values().map(|f| f.name.clone()).collect();
|
||||
v.sort_by_key(|n| n.to_lowercase());
|
||||
@@ -241,7 +318,7 @@ impl FontDb {
|
||||
/// its first character. Also the index of the run of `t` each run came from. `None` when no run
|
||||
/// is set in a composite font.
|
||||
pub fn composite_split(&self, t: &TextObject) -> Option<(TextObject, Vec<usize>)> {
|
||||
if self.composites.read().unwrap_or_else(|e| e.into_inner()).is_empty() {
|
||||
if !self.lends() && self.composites.read().unwrap_or_else(|e| e.into_inner()).is_empty() {
|
||||
return None;
|
||||
}
|
||||
let fonts: Vec<Option<Arc<CompositeFont>>> =
|
||||
@@ -299,59 +376,24 @@ impl FontDb {
|
||||
}
|
||||
}
|
||||
|
||||
/// A composite font [`FontDb::lend_composite_fonts`] put in the database.
|
||||
pub(super) struct CompositeLoan {
|
||||
/// The font lent.
|
||||
font: Arc<CompositeFont>,
|
||||
/// The font of that name it replaced, put back when the last guard lending it drops.
|
||||
previous: Option<Arc<CompositeFont>>,
|
||||
/// How many guards lend it.
|
||||
guards: usize,
|
||||
/// Whether [`FontDb::set_composite_font`] set the same definition while it was lent: the last
|
||||
/// guard to drop then leaves it in place.
|
||||
adopted: bool,
|
||||
}
|
||||
|
||||
/// The composite fonts [`FontDb::lend_composite_fonts`] made available, given back when it drops.
|
||||
/// The composite fonts [`FontDb::lend_composite_fonts`] made available on its thread, until it
|
||||
/// drops. It is neither `Send` nor `Sync`: the loan belongs to the thread that made it.
|
||||
#[must_use = "the fonts are given back when the guard drops"]
|
||||
pub struct LentCompositeFonts<'a> {
|
||||
db: &'a FontDb,
|
||||
/// The lowercased names this guard lends, each with the font lent.
|
||||
lent: Vec<(String, Arc<CompositeFont>)>,
|
||||
/// The loan's number, when it lends any font.
|
||||
number: Option<u64>,
|
||||
thread: PhantomData<(&'a FontDb, *const ())>,
|
||||
}
|
||||
|
||||
impl Drop for LentCompositeFonts<'_> {
|
||||
fn drop(&mut self) {
|
||||
if self.lent.is_empty() {
|
||||
return;
|
||||
}
|
||||
let mut changed = false;
|
||||
{
|
||||
let mut map = self.db.composites.write().unwrap_or_else(|e| e.into_inner());
|
||||
let mut loans = self.db.composite_loans.lock().unwrap_or_else(|e| e.into_inner());
|
||||
for (key, font) in &self.lent {
|
||||
// A later loan of that name is another guard's.
|
||||
let Some(loan) = loans.get_mut(key).filter(|l| Arc::ptr_eq(&l.font, font)) else { continue };
|
||||
loan.guards -= 1;
|
||||
if loan.guards > 0 {
|
||||
continue;
|
||||
}
|
||||
let previous = loan.previous.take();
|
||||
let adopted = loan.adopted;
|
||||
loans.remove(key);
|
||||
// Given back unless a font of that name has been set (the same definition included)
|
||||
// or removed since.
|
||||
if !adopted && map.get(key).is_some_and(|have| Arc::ptr_eq(have, font)) {
|
||||
match previous {
|
||||
Some(p) => map.insert(key.clone(), p),
|
||||
None => map.remove(key),
|
||||
};
|
||||
changed = true;
|
||||
}
|
||||
let Some(number) = self.number else { return };
|
||||
// Fails only while the thread's own storage is being destroyed, which drops the loans.
|
||||
let _ = LENT.try_with(|l| {
|
||||
let mut l = l.borrow_mut();
|
||||
if let Some(at) = l.iter().rposition(|loan| loan.number == number) {
|
||||
l.remove(at);
|
||||
}
|
||||
}
|
||||
if changed {
|
||||
self.db.changed();
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -74,7 +74,6 @@ impl FontDb {
|
||||
stamp: Mutex::new(None),
|
||||
instances: Mutex::new(Instances::default()),
|
||||
composites: RwLock::new(HashMap::new()),
|
||||
composite_loans: Mutex::new(HashMap::new()),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -127,9 +126,11 @@ impl FontDb {
|
||||
}
|
||||
|
||||
/// A number that changes whenever the available fonts do (fonts added, system fonts
|
||||
/// rescanned): lists built from [`FontDb::family_list`] are current while it doesn't.
|
||||
/// rescanned): lists built from [`FontDb::family_list`] are current while it doesn't. While a
|
||||
/// guard of this thread lends composite fonts other than the ones the database holds
|
||||
/// ([`FontDb::lend_composite_fonts`]), it is a number of its own for those definitions.
|
||||
pub fn generation(&self) -> u64 {
|
||||
self.generation.load(Ordering::Relaxed)
|
||||
self.lent_generation(self.generation.load(Ordering::Relaxed))
|
||||
}
|
||||
|
||||
/// A number no other database made in this process has: with [`FontDb::generation`], it tells
|
||||
|
||||
@@ -343,7 +343,7 @@ impl Ctx<'_> {
|
||||
let pen = left + para.first_line_indent + marker.iter().map(|g| g.adv).sum::<f64>();
|
||||
let to_indent = left - pen;
|
||||
let stop_before_indent = para.tabs.iter().any(|t| t.position > pen + EPS && t.position < left - EPS);
|
||||
tab.adv = if to_indent > EPS && !stop_before_indent { to_indent } else { tab_advance(¶.tabs, 0.0, pen, sg).0 };
|
||||
tab.adv = if to_indent > EPS && !stop_before_indent { to_indent } else { tab_advance(¶.tabs, 0.0, pen, sg.iter()).0 };
|
||||
if para.list.is_some_and(|l| l.align == ListAlign::Right) {
|
||||
marker.insert(0, tab);
|
||||
} else {
|
||||
|
||||
@@ -204,11 +204,17 @@ impl Pen<'_> {
|
||||
|
||||
/// Advance of a tab at pen position `x`: up to the next stop after it (explicit stops first, then
|
||||
/// every ½ inch), and the leader of that stop (default stops have none). Right, centre and decimal
|
||||
/// stops align the text that follows (`rest`, up to the next tab) on the stop.
|
||||
pub(super) fn tab_advance<'t>(tabs: &'t [vectorcraft_doc::TabStop], origin: f64, x: f64, rest: &[SGlyph]) -> (f64, &'t str) {
|
||||
/// stops align the text drawn after the tab (`rest`, in drawing order, up to the next tab) on the
|
||||
/// stop.
|
||||
pub(super) fn tab_advance<'t, 'g>(
|
||||
tabs: &'t [vectorcraft_doc::TabStop],
|
||||
origin: f64,
|
||||
x: f64,
|
||||
rest: impl IntoIterator<Item = &'g SGlyph>,
|
||||
) -> (f64, &'t str) {
|
||||
use vectorcraft_doc::TabAlign;
|
||||
let rel = x - origin;
|
||||
let seg: Vec<&SGlyph> = rest.iter().take_while(|g| g.ch != '\t' && g.ch != '\u{2028}').collect();
|
||||
let seg: Vec<&SGlyph> = rest.into_iter().take_while(|g| g.ch != '\t' && g.ch != '\u{2028}').collect();
|
||||
let width: f64 = seg.iter().map(|g| g.adv).sum();
|
||||
let target = |pos: f64, align: TabAlign, on: char| match align {
|
||||
TabAlign::Left => pos,
|
||||
@@ -228,17 +234,28 @@ pub(super) fn tab_advance<'t>(tabs: &'t [vectorcraft_doc::TabStop], origin: f64,
|
||||
((at - rel).max(0.0), leader)
|
||||
}
|
||||
|
||||
/// The width of glyphs `from..to` of `sg` set on a line whose content starts `x_start` from the
|
||||
/// tab origin: tabs of the text take what [`tab_advance`] gives them, not the width of their
|
||||
/// glyph. The tab of a list marker (length 0) keeps its own width.
|
||||
pub(super) fn set_width(sg: &[SGlyph], from: usize, to: usize, tabs: &[vectorcraft_doc::TabStop], x_start: f64) -> f64 {
|
||||
/// The glyphs of `sg` drawn after position `k` of `drawn` (indices into `sg`, in drawing order).
|
||||
pub(super) fn drawn_after_tab<'g>(sg: &'g [SGlyph], drawn: &'g [usize], k: usize) -> impl Iterator<Item = &'g SGlyph> {
|
||||
drawn.iter().skip(k + 1).filter_map(move |&j| sg.get(j))
|
||||
}
|
||||
|
||||
/// The width of the glyphs of `sg` at `drawn` (indices into `sg`, in the order the line draws
|
||||
/// them) set on a line whose content starts `x_start` from the tab origin: tabs of the text take
|
||||
/// what [`tab_advance`] gives them from where the glyphs drawn before them end, aligning the glyphs
|
||||
/// drawn after them, not the width of their glyph. The tab of a list marker (length 0) keeps its
|
||||
/// own width.
|
||||
pub(super) fn drawn_width(sg: &[SGlyph], drawn: &[usize], tabs: &[vectorcraft_doc::TabStop], x_start: f64) -> f64 {
|
||||
let mut x = x_start;
|
||||
for (j, g) in sg.iter().enumerate().take(to).skip(from) {
|
||||
x += if g.ch == '\t' && g.len > 0 { tab_advance(tabs, 0.0, x, sg.get(j + 1..to).unwrap_or_default()).0 } else { g.adv };
|
||||
for (k, g) in drawn.iter().enumerate().filter_map(|(k, &j)| sg.get(j).map(|g| (k, g))) {
|
||||
x += if g.ch == '\t' && g.len > 0 { tab_advance(tabs, 0.0, x, drawn_after_tab(sg, drawn, k)).0 } else { g.adv };
|
||||
}
|
||||
x - x_start
|
||||
}
|
||||
|
||||
/// Most breaks, or hyphenation points, [`break_line`] measures as drawn one by one from the latest
|
||||
/// back before bisecting the earlier ones.
|
||||
const MAX_DRAWN_CHECKS: usize = 256;
|
||||
|
||||
/// Most copies of a leader drawn in one tab: a tiny type size and a far stop must not make
|
||||
/// millions of outlines.
|
||||
const MAX_LEADER_COPIES: f64 = 4096.0;
|
||||
@@ -336,6 +353,12 @@ pub(super) fn word_width(g: &[SGlyph], i: usize, line_end: &[f64], kinsoku: Kins
|
||||
/// Margin Alignment or Roman Hanging Punctuation is on, and `vertical` in vertical type.
|
||||
/// `line_end[k]` is how much narrower a line ending with glyph `k` is set (Mojikumi); a cluster
|
||||
/// that fits only that narrower stays on the line when the line may end after it (empty: none).
|
||||
/// `fits`, for a line drawn in another order than its text's (right to left), is whether the line
|
||||
/// from `i` ending before glyph `end` (with a hyphen when `hyphenated`) fits `width` as it is drawn:
|
||||
/// once the line holds a tab, whose width depends on what is drawn before it, the line reads on
|
||||
/// while the least it can be drawn at fits `width`, then ends at the latest legal break where it
|
||||
/// fits as drawn (past [`MAX_DRAWN_CHECKS`] breaks back from the latest, the earlier ones without a
|
||||
/// hyphen are bisected); hyphenation points are measured the same way.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub(super) fn break_line(
|
||||
text: &str,
|
||||
@@ -351,7 +374,29 @@ pub(super) fn break_line(
|
||||
hanging: bool,
|
||||
vertical: bool,
|
||||
line_end: &[f64],
|
||||
fits: Option<&dyn Fn(usize, bool) -> bool>,
|
||||
) -> (usize, bool) {
|
||||
// The line ending at `chosen`, once it holds a tab, when it fits as drawn; otherwise the latest
|
||||
// of the legal `breaks` before it that fits as drawn, measuring at most MAX_DRAWN_CHECKS of
|
||||
// them from the latest back and bisecting the earlier ones without a hyphen; the first of them
|
||||
// when none of those measured fits.
|
||||
let settle = |tabbed: bool, chosen: (usize, bool), breaks: &[(usize, bool)]| -> (usize, bool) {
|
||||
let Some(fits) = fits.filter(|_| tabbed) else { return chosen };
|
||||
if fits(chosen.0, chosen.1) {
|
||||
return chosen;
|
||||
}
|
||||
let earlier = breaks.get(breaks.partition_point(|b| b.0 <= i)..breaks.partition_point(|b| b.0 < chosen.0)).unwrap_or_default();
|
||||
if let Some(&b) = earlier.iter().rev().take(MAX_DRAWN_CHECKS).find(|b| fits(b.0, b.1)) {
|
||||
return b;
|
||||
}
|
||||
// Without a hyphen, a line that ends later is drawn no narrower, so those breaks bisect.
|
||||
let rest = earlier.get(..earlier.len().saturating_sub(MAX_DRAWN_CHECKS)).unwrap_or_default();
|
||||
let plain: Vec<&(usize, bool)> = rest.iter().filter(|b| !b.1).collect();
|
||||
let fitting = plain.partition_point(|b| fits(b.0, b.1));
|
||||
fitting.checked_sub(1).and_then(|k| plain.get(k).copied()).or(earlier.first()).copied().unwrap_or(chosen)
|
||||
};
|
||||
let mut breaks: Vec<(usize, bool)> = Vec::new();
|
||||
let mut tabbed = false;
|
||||
if !width.is_finite() {
|
||||
if let Some(pos) = g[i..].iter().position(|gl| gl.ch == '\u{2028}') {
|
||||
return (i + pos + 1, false);
|
||||
@@ -359,6 +404,27 @@ pub(super) fn break_line(
|
||||
return (g.len(), false);
|
||||
}
|
||||
let mut x = 0.0;
|
||||
// The width of the line's glyphs other than its tabs, how far those with a negative advance step
|
||||
// back in all, and how many tabs it holds.
|
||||
let mut ink = 0.0;
|
||||
let mut back = 0.0;
|
||||
let mut tabs_read = 0usize;
|
||||
// The `n`th of the stops a tab can go to, in order: the explicit stops, then every default
|
||||
// stop after the last of them (0 when `n` is 0).
|
||||
let mut explicit: Vec<f64> = if fits.is_some() { tabs.iter().map(|t| t.position).filter(|p| p.is_finite()).collect() } else { Vec::new() };
|
||||
explicit.sort_by(f64::total_cmp);
|
||||
explicit.dedup();
|
||||
let last_explicit = tabs.iter().map(|t| t.position).fold(0.0, f64::max).max(0.0);
|
||||
let nth_stop = |n: usize| -> f64 {
|
||||
use vectorcraft_doc::text::DEFAULT_TAB_INTERVAL;
|
||||
match n.checked_sub(1) {
|
||||
None => 0.0,
|
||||
Some(k) => explicit
|
||||
.get(k)
|
||||
.copied()
|
||||
.unwrap_or_else(|| ((last_explicit / DEFAULT_TAB_INTERVAL).floor() + (n - explicit.len()) as f64) * DEFAULT_TAB_INTERVAL),
|
||||
}
|
||||
};
|
||||
let mut last_break: Option<(usize, bool)> = None;
|
||||
// A list marker (length 0) is a cluster of its own; the line holds it and at least the first
|
||||
// cluster after it.
|
||||
@@ -385,19 +451,32 @@ pub(super) fn break_line(
|
||||
}
|
||||
}
|
||||
// A list marker's glyphs, its tab included, keep their own widths.
|
||||
let cluster_width: f64 = if gl.ch == '\t' && gl.len > 0 {
|
||||
let is_tab = gl.ch == '\t' && gl.len > 0;
|
||||
let cluster_width: f64 = if is_tab {
|
||||
tabbed = true;
|
||||
tabs_read += 1;
|
||||
tab_advance(tabs, 0.0, x_start + x, g.get(cluster_end..).unwrap_or_default()).0
|
||||
} else {
|
||||
g[j..cluster_end].iter().map(|glyph| glyph.adv).sum()
|
||||
};
|
||||
let cluster_ink = if is_tab { 0.0 } else { cluster_width };
|
||||
let cluster_back: f64 = if is_tab { 0.0 } else { g[j..cluster_end].iter().map(|glyph| (-glyph.adv).max(0.0)).sum() };
|
||||
if gl.ch == '\u{2028}' {
|
||||
return (cluster_end, false);
|
||||
return settle(tabbed, (cluster_end, false), &breaks);
|
||||
}
|
||||
// Measured as the line is set when it ends with this cluster: only the cluster's last glyph
|
||||
// is narrowed, and only where the line may end.
|
||||
let end_credit =
|
||||
Some(line_end_credit(g, line_end, cluster_end)).filter(|&c| c > 0.0 && may_end_after(g, cluster_end, kinsoku)).unwrap_or(0.0);
|
||||
if j > first && !gl.is_space() && x + cluster_width - end_credit > width + EPS {
|
||||
// Once the line holds a tab and is measured as drawn, it reads on while the least it can be
|
||||
// drawn at fits, and where it ends is settled then. That least is its glyphs other than the
|
||||
// tabs and, while no glyph steps back, the stop its tabs reach: each tab then goes to a later
|
||||
// stop than the one before and the text aligned on it ends at or past that stop, so the
|
||||
// line's tabs, all before this cluster, reach at least the stop as many places along as
|
||||
// there are tabs.
|
||||
let stops_reached = if back + cluster_back > 0.0 { f64::NEG_INFINITY } else { nth_stop(tabs_read) - x_start };
|
||||
let measured = if tabbed && fits.is_some() { (ink + cluster_ink).max(stops_reached) } else { x + cluster_width };
|
||||
if j > first && !gl.is_space() && measured - end_credit > width + EPS {
|
||||
// It ends the line with the spaces after it.
|
||||
if burasagari != Burasagari::None && hangs(gl) && kinsoku_allows(g, cluster_end - 1, kinsoku) {
|
||||
let mut end = cluster_end;
|
||||
@@ -414,20 +493,23 @@ pub(super) fn break_line(
|
||||
break;
|
||||
}
|
||||
x += cluster_width;
|
||||
ink += cluster_ink;
|
||||
back += cluster_back;
|
||||
let last = &g[cluster_end - 1];
|
||||
if last.break_after() && last.len > 0 && kinsoku_allows(g, cluster_end - 1, kinsoku) && !splits_no_break(g, cluster_end) {
|
||||
if last.is_soft_hyphen() {
|
||||
if x + hyphen_glyph(last).adv <= width + EPS {
|
||||
last_break = Some((cluster_end, true));
|
||||
// Once the line holds a tab, whether a break fits as drawn is settled when the line ends.
|
||||
let hyphenated = last.is_soft_hyphen();
|
||||
if !hyphenated || (tabbed && fits.is_some()) || x + hyphen_glyph(last).adv <= width + EPS {
|
||||
last_break = Some((cluster_end, hyphenated));
|
||||
if fits.is_some() {
|
||||
breaks.push((cluster_end, hyphenated));
|
||||
}
|
||||
} else {
|
||||
last_break = Some((cluster_end, false));
|
||||
}
|
||||
}
|
||||
j = cluster_end;
|
||||
}
|
||||
if j >= g.len() {
|
||||
return (g.len(), false);
|
||||
return settle(tabbed, (g.len(), false), &breaks);
|
||||
}
|
||||
// Hyphenate the word that overflows. A list marker starting the line is not part of a word.
|
||||
let word_start = last_break.map_or(first, |b| b.0);
|
||||
@@ -451,22 +533,31 @@ pub(super) fn break_line(
|
||||
.into_iter()
|
||||
.filter(|&k| k > 0 && kinsoku_allows(g, k - 1, kinsoku) && !splits_no_break(g, k))
|
||||
.collect();
|
||||
for &k in pts.iter().rev() {
|
||||
if k <= j && k > i {
|
||||
let w = x_ws + g[word_start..k].iter().map(|g| g.adv).sum::<f64>() + hyphen_glyph(&g[k - 1]).adv;
|
||||
if w <= width + EPS {
|
||||
return (k, true);
|
||||
}
|
||||
// The points the line may end at, the latest first. Measured as drawn, at most
|
||||
// MAX_DRAWN_CHECKS of them are measured one by one, and the earlier ones are bisected:
|
||||
// each ends with a hyphen, so a later one is drawn no narrower.
|
||||
let points: Vec<usize> = pts.iter().rev().copied().filter(|&k| k <= j && k > i).collect();
|
||||
let drawn = fits.filter(|_| tabbed);
|
||||
for &k in points.iter().take(if drawn.is_some() { MAX_DRAWN_CHECKS } else { usize::MAX }) {
|
||||
let w = x_ws + g[word_start..k].iter().map(|g| g.adv).sum::<f64>() + hyphen_glyph(&g[k - 1]).adv;
|
||||
if drawn.map_or(w <= width + EPS, |fits| fits(k, true)) {
|
||||
return (k, true);
|
||||
}
|
||||
}
|
||||
if let Some(fits) = drawn {
|
||||
let earlier: Vec<usize> = points.iter().skip(MAX_DRAWN_CHECKS).rev().copied().collect();
|
||||
let fitting = earlier.partition_point(|&k| fits(k, true));
|
||||
if let Some(&k) = fitting.checked_sub(1).and_then(|n| earlier.get(n)) {
|
||||
return (k, true);
|
||||
}
|
||||
}
|
||||
}
|
||||
// Break candidates and greedy overflow are cluster boundaries; hyphenation also returns only
|
||||
// cluster starts, so the resulting end always keeps each source cluster together.
|
||||
let (end, hy) = match last_break {
|
||||
Some(b) if b.0 > i => b,
|
||||
match last_break {
|
||||
Some(b) if b.0 > i => settle(tabbed, b, &breaks),
|
||||
_ => (j, false),
|
||||
};
|
||||
(end, hy)
|
||||
}
|
||||
}
|
||||
|
||||
/// Can glyph `g` hang outside the line (burasagari)? An East Asian comma or full stop, full width
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
use super::bidi::{is_rtl, para_bidi_with, visual_order};
|
||||
use super::breaking::{
|
||||
Metrics, PUSH_IN_SQUEEZE, Pen, break_line, candidates, hangs, leader_outline, optical_hangs, optical_hangs_rtl, optical_punct, optical_share,
|
||||
roman_share, set_width, squeeze_em, tab_advance, word_width,
|
||||
Metrics, PUSH_IN_SQUEEZE, Pen, break_line, candidates, drawn_after_tab, drawn_width, hangs, leader_outline, optical_hangs, optical_hangs_rtl,
|
||||
optical_punct, optical_share, roman_share, squeeze_em, tab_advance, word_width,
|
||||
};
|
||||
use super::region::Region;
|
||||
use super::vertical::{
|
||||
@@ -344,6 +344,8 @@ fn prepare_paragraph<'a, 'text>(
|
||||
struct PlacementContext<'a> {
|
||||
para: &'a ParaStyle,
|
||||
pr: &'a Range<usize>,
|
||||
/// [`ParagraphData::bidi`].
|
||||
bidi: Option<&'a BidiInfo<'a>>,
|
||||
sg: &'a mut [SGlyph],
|
||||
/// [`ParagraphData::line_end`].
|
||||
line_end: &'a [f64],
|
||||
@@ -378,6 +380,7 @@ fn place_flow_line(cx: &mut Ctx<'_>, pen: &mut Pen<'_>, context: &mut PlacementC
|
||||
let PlacementContext {
|
||||
para,
|
||||
pr,
|
||||
bidi,
|
||||
sg,
|
||||
line_end,
|
||||
continued,
|
||||
@@ -429,11 +432,32 @@ fn place_flow_line(cx: &mut Ctx<'_>, pen: &mut Pen<'_>, context: &mut PlacementC
|
||||
let (end, hyph, pushed_in) = match composed.as_ref().and_then(|lines| lines.get(*para_line)) {
|
||||
Some(&(end, hyph)) if end > *i => (end, hyph, false),
|
||||
_ if *i < n => {
|
||||
let start = *i;
|
||||
let glyphs: &[SGlyph] = sg;
|
||||
// A horizontal line with right-to-left text is drawn in another order than its
|
||||
// text's, and its tabs take their stops in the order they are drawn: whether the line
|
||||
// ending before glyph `end` fits is measured as it is drawn ([`drawn_width`]).
|
||||
let drawn_fits = |end: usize, hyphenated: bool| {
|
||||
let mut trimmed = end;
|
||||
while trimmed > start && glyphs.get(trimmed - 1).is_some_and(|g| g.is_space() || g.ch == '\u{2028}') {
|
||||
trimmed -= 1;
|
||||
}
|
||||
let bytes = glyphs.get(start).map_or(pr.start, |g| g.byte)..glyphs.get(end).map_or(pr.end, |g| g.byte);
|
||||
let drawn: Vec<usize> = visual_order(*bidi, pr.start, bytes, glyphs.get(start..end).unwrap_or_default())
|
||||
.into_iter()
|
||||
.map(|offset| start + offset)
|
||||
.filter(|&j| j < trimmed)
|
||||
.collect();
|
||||
let hyphen = if hyphenated { end.checked_sub(1).and_then(|k| glyphs.get(k)).map_or(0.0, |g| hyphen_glyph(g).adv) } else { 0.0 };
|
||||
let credit = trimmed.checked_sub(1).filter(|&k| k >= start).and_then(|k| line_end.get(k)).copied().unwrap_or(0.0);
|
||||
drawn_width(glyphs, &drawn, ¶.tabs, ind_l) + hyphen - credit <= width + EPS
|
||||
};
|
||||
let fits: Option<&dyn Fn(usize, bool) -> bool> = (!cx.vertical && bidi.is_some_and(|b| b.has_rtl())).then_some(&drawn_fits);
|
||||
let line = |ty| {
|
||||
break_line(
|
||||
cx.text,
|
||||
sg,
|
||||
*i,
|
||||
glyphs,
|
||||
start,
|
||||
width,
|
||||
¶.tabs,
|
||||
ind_l,
|
||||
@@ -444,6 +468,7 @@ fn place_flow_line(cx: &mut Ctx<'_>, pen: &mut Pen<'_>, context: &mut PlacementC
|
||||
*hanging,
|
||||
cx.vertical,
|
||||
line_end,
|
||||
fits,
|
||||
)
|
||||
};
|
||||
let (end, hyph) = line(para.kinsoku_type);
|
||||
@@ -500,6 +525,14 @@ struct LineGeometry {
|
||||
last_of_para: bool,
|
||||
}
|
||||
|
||||
impl LineGeometry {
|
||||
/// The line's glyphs before its trailing spaces (indices into the paragraph's glyphs), in the
|
||||
/// order they are drawn: the glyphs its tabs are measured over.
|
||||
fn measured(&self) -> Vec<usize> {
|
||||
self.order.iter().map(|&offset| self.index + offset).filter(|&j| j < self.trimmed).collect()
|
||||
}
|
||||
}
|
||||
|
||||
struct LineJustification {
|
||||
per_space: f64,
|
||||
per_gap: f64,
|
||||
@@ -540,6 +573,7 @@ fn run_paragraph_lines(cx: &mut Ctx<'_>, pen: &mut Pen<'_>, t: &TextObject, mut
|
||||
let mut placement = PlacementContext {
|
||||
para: paragraph.para,
|
||||
pr: ¶graph.pr,
|
||||
bidi: paragraph.bidi.as_ref(),
|
||||
sg: &mut paragraph.sg,
|
||||
line_end: ¶graph.line_end,
|
||||
continued: paragraph.continued,
|
||||
@@ -611,10 +645,13 @@ fn prepare_line_geometry(cx: &Ctx<'_>, t: &TextObject, paragraph: &ParagraphData
|
||||
.checked_sub(1)
|
||||
.filter(|&k| k >= index && rules.trims_line_end())
|
||||
.map_or(0.0, |k| line_end_trim(sg, ¶graph.compressed, ¶graph.wakan, rules, k));
|
||||
let measured_width = set_width(sg, index, trimmed, ¶.tabs, position.ind_l) + hyphen.as_ref().map_or(0.0, |g| g.adv) - end_trim;
|
||||
let line_start = sg.get(index).map_or(paragraph.pr.start, |g| g.byte);
|
||||
let line_end = sg.get(position.end).map_or(paragraph.pr.end, |g| g.byte);
|
||||
let order = visual_order(paragraph.bidi.as_ref().filter(|_| !cx.vertical), paragraph.pr.start, line_start..line_end, &sg[index..position.end]);
|
||||
// The line is as wide as it is drawn: its glyphs before its trailing spaces in drawing order,
|
||||
// each tab up to its stop from where the glyphs drawn before it end.
|
||||
let measured: Vec<usize> = order.iter().map(|&offset| index + offset).filter(|&j| j < trimmed).collect();
|
||||
let measured_width = drawn_width(sg, &measured, ¶.tabs, position.ind_l) + hyphen.as_ref().map_or(0.0, |g| g.adv) - end_trim;
|
||||
// The glyphs before `stop` in visual order, each with the advance it is set with. A list
|
||||
// marker does not hang: it is counted with no width.
|
||||
let visible = |stop: usize| -> (Vec<usize>, Vec<(&SGlyph, f64)>) {
|
||||
@@ -861,15 +898,16 @@ fn is_tab_fixed(fixed: &[bool], index: usize, glyph: usize) -> bool {
|
||||
|
||||
// The natural width of justified area type `line` with a tab, as [`emit_flow_line`] draws it from
|
||||
// the line's start: its glyphs in drawing order, each tab up to its stop from where the glyphs
|
||||
// drawn before it end, then the line's hyphen.
|
||||
// drawn before it end, aligning the glyphs drawn after it, then the line's hyphen.
|
||||
fn tabbed_natural_width(paragraph: &ParagraphData<'_, '_>, line: &LineGeometry, fixed: &[bool]) -> f64 {
|
||||
let sg = ¶graph.sg;
|
||||
let start = line.ax0 - line.opt_l;
|
||||
let mut x = start;
|
||||
for &j in &line.drawn {
|
||||
let measured = line.measured();
|
||||
for (k, &j) in measured.iter().enumerate().filter(|&(_, &j)| j < line.body) {
|
||||
let Some(g) = sg.get(j) else { continue };
|
||||
x += if g.ch == '\t' && g.len > 0 && is_tab_fixed(fixed, line.index, j) {
|
||||
tab_advance(¶graph.para.tabs, line.position.x0, x + line.opt_l, sg.get(j + 1..line.trimmed).unwrap_or_default()).0
|
||||
tab_advance(¶graph.para.tabs, line.position.x0, x + line.opt_l, drawn_after_tab(sg, &measured, k)).0
|
||||
} else if j + 1 == line.trimmed {
|
||||
g.adv - line.end_trim
|
||||
} else {
|
||||
@@ -1041,8 +1079,9 @@ fn justify_flow_line(cx: &Ctx<'_>, paragraph: &ParagraphData<'_, '_>, line: &Lin
|
||||
LineJustification { per_space, per_gap, per_cjk, squeeze, fit, start_x, after, kashidas, last_tab, trailing, fixed }
|
||||
}
|
||||
|
||||
/// The advance and leader of tab `glyph_index` of `line`, at pen position `x`: up to its stop
|
||||
/// ([`tab_advance`], measured from `tab_origin` less `tab_shift`), or, for the last tab of
|
||||
/// The advance and leader of tab `glyph_index` of `line`, drawn at `order_index` of
|
||||
/// [`LineGeometry::order`], at pen position `x`: up to its stop ([`tab_advance`], measured from
|
||||
/// `tab_origin` less `tab_shift`, aligning the glyphs drawn after it), or, for the last tab of
|
||||
/// right-aligned area type ([`LineJustification::last_tab`]), up to where the rest of the line
|
||||
/// ends on the right edge. The tab after a list marker keeps the width `prepend_marker` gave it
|
||||
/// (`advance`).
|
||||
@@ -1050,7 +1089,7 @@ fn tab_glyph<'p>(
|
||||
paragraph: &'p ParagraphData<'_, '_>,
|
||||
line: &LineGeometry,
|
||||
justification: &LineJustification,
|
||||
glyph_index: usize,
|
||||
(glyph_index, order_index): (usize, usize),
|
||||
x: f64,
|
||||
(tab_origin, tab_shift): (f64, f64),
|
||||
advance: f64,
|
||||
@@ -1060,7 +1099,8 @@ fn tab_glyph<'p>(
|
||||
if !sg.get(glyph_index).is_some_and(|g| g.len > 0) {
|
||||
return (advance, "");
|
||||
}
|
||||
let (advance, leader) = tab_advance(¶graph.para.tabs, tab_origin, x - tab_shift, sg.get(glyph_index + 1..line.trimmed).unwrap_or_default());
|
||||
let rest = line.order.iter().skip(order_index + 1).map(|&offset| line.index + offset).filter(|&j| j < line.trimmed).filter_map(|j| sg.get(j));
|
||||
let (advance, leader) = tab_advance(¶graph.para.tabs, tab_origin, x - tab_shift, rest);
|
||||
if justification.last_tab != Some(glyph_index) {
|
||||
return (advance, leader);
|
||||
}
|
||||
@@ -1128,13 +1168,14 @@ fn emit_flow_line(cx: &mut Ctx<'_>, pen: &mut Pen<'_>, paragraph: &ParagraphData
|
||||
let line_em = sg.get(index..position.end).map_or(0.0, |glyphs| glyphs.iter().map(glyph_em).fold(0.0, f64::max));
|
||||
// Tab stops are measured from the frame's left edge (point type: the origin).
|
||||
let tab_origin = if paragraph.regions.is_some() { position.x0 } else { 0.0 };
|
||||
// A line with tabs is measured as if it started at the left (see `set_width`), then
|
||||
// A line with tabs is measured as if it started at the left (see `drawn_width`), then
|
||||
// aligned as a whole: its tabs stop where they were measured, however it is aligned.
|
||||
let tab_shift = start_x - if paragraph.regions.is_some() { ax0 } else { position.ind_l };
|
||||
// RTL's logical trailing spaces precede the visible content. Keep them outside
|
||||
// the aligned content extent, just as LTR trailing spaces extend to its right.
|
||||
let leading_space_width: f64 =
|
||||
order.iter().take_while(|&&offset| index + offset >= trimmed).filter_map(|&offset| sg.get(index + offset)).map(|glyph| glyph.adv).sum();
|
||||
// the aligned content extent, just as LTR trailing spaces extend to its right. A tab among
|
||||
// them keeps its own width.
|
||||
let leading = order.iter().take_while(|&&offset| index + offset >= trimmed).count();
|
||||
let leading_space_width: f64 = order.iter().take(leading).filter_map(|&offset| sg.get(index + offset)).map(|glyph| glyph.adv).sum();
|
||||
let mut x = start_x - leading_space_width;
|
||||
let mut x_end = start_x;
|
||||
// The glyphs drawn for the list marker, and the index of its tab's glyph, when the line starts the paragraph.
|
||||
@@ -1152,8 +1193,8 @@ fn emit_flow_line(cx: &mut Ctx<'_>, pen: &mut Pen<'_>, paragraph: &ParagraphData
|
||||
// Glyph scaling a justified line adds to the glyph (1: none).
|
||||
let mut scale = 1.0;
|
||||
let mut leader = "";
|
||||
if glyph.ch == '\t' {
|
||||
(advance, leader) = tab_glyph(paragraph, line, justification, glyph_index, x, (tab_origin, tab_shift), advance);
|
||||
if glyph.ch == '\t' && order_index >= leading {
|
||||
(advance, leader) = tab_glyph(paragraph, line, justification, (glyph_index, order_index), x, (tab_origin, tab_shift), advance);
|
||||
} else if glyph_index < body && !is_tab_fixed(&justification.fixed, index, glyph_index) {
|
||||
let flex = flow_line_flex(glyph_index, ¶graph.flex, &line.held, &line.hung);
|
||||
if is_word_space(glyph) {
|
||||
|
||||
@@ -166,20 +166,36 @@ fn subtract_intervals(a: &[(f64, f64)], b: &[(f64, f64)]) -> Vec<(f64, f64)> {
|
||||
out
|
||||
}
|
||||
|
||||
/// `a` ∩ `b`.
|
||||
/// `a` ∩ `b` (both sorted left to right, each interval starting no earlier than the one before it
|
||||
/// ends, as [`union_intervals`] and [`band_intervals`] give them), left to right. It walks both
|
||||
/// lists once, stepping past whichever current interval ends first (both
|
||||
/// when they end together), so it looks at no more than `a.len() + b.len()` pairs.
|
||||
fn intersect_intervals(a: &[(f64, f64)], b: &[(f64, f64)]) -> Vec<(f64, f64)> {
|
||||
let mut out = vec![];
|
||||
for &(s, e) in a {
|
||||
for &(c, d) in b {
|
||||
let (x, y) = (s.max(c), e.min(d));
|
||||
if y > x {
|
||||
out.push((x, y));
|
||||
}
|
||||
let (mut i, mut j) = (0, 0);
|
||||
while let (Some(&(s, e)), Some(&(c, d))) = (a.get(i), b.get(j)) {
|
||||
#[cfg(test)]
|
||||
INTERSECT_PAIRS.set(INTERSECT_PAIRS.get() + 1);
|
||||
let (x, y) = (s.max(c), e.min(d));
|
||||
if y > x {
|
||||
out.push((x, y));
|
||||
}
|
||||
// Ends that are equal, or not numbers, step past both, so the walk always ends.
|
||||
match e.partial_cmp(&d) {
|
||||
Some(std::cmp::Ordering::Less) => i += 1,
|
||||
Some(std::cmp::Ordering::Greater) => j += 1,
|
||||
_ => (i, j) = (i + 1, j + 1),
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
thread_local! {
|
||||
/// How many pairs of intervals [`intersect_intervals`] looked at on this thread.
|
||||
static INTERSECT_PAIRS: std::cell::Cell<usize> = const { std::cell::Cell::new(0) };
|
||||
}
|
||||
|
||||
/// The most pairs of edges [`slant_boundaries`] compares for one band.
|
||||
const MAX_EDGE_PAIRS: usize = 1 << 14;
|
||||
|
||||
@@ -792,6 +808,80 @@ fn an_inset_wider_than_a_shaped_frame_leaves_no_room() {
|
||||
}
|
||||
}
|
||||
|
||||
/// `a` ∩ `b` by comparing every interval of `a` with every interval of `b`: the result
|
||||
/// [`intersect_intervals`] is checked against.
|
||||
#[cfg(test)]
|
||||
fn intersect_every_pair(a: &[(f64, f64)], b: &[(f64, f64)]) -> Vec<(f64, f64)> {
|
||||
a.iter().flat_map(|&(s, e)| b.iter().map(move |&(c, d)| (s.max(c), e.min(d)))).filter(|(x, y)| y > x).collect()
|
||||
}
|
||||
|
||||
/// The intersection of two lists of intervals, each sorted with no two overlapping, is the one
|
||||
/// every pair of their intervals gives, in the same order, left to right and of positive width, for
|
||||
/// every pair of such lists whose ends are among the points 0 to 6: empty lists, intervals that
|
||||
/// only touch (in one list or across the two), that end together, that hold one another and that
|
||||
/// overlap several others.
|
||||
#[cfg(test)]
|
||||
#[test]
|
||||
fn intervals_intersect_as_every_pair_of_them_does() {
|
||||
// Every list is a shorter list with one more interval after its last.
|
||||
let mut lists: Vec<Vec<(f64, f64)>> = vec![vec![]];
|
||||
let mut k = 0;
|
||||
while let Some(list) = lists.get(k).cloned() {
|
||||
let from = list.last().map_or(0, |l: &(f64, f64)| l.1 as u8);
|
||||
for s in from..6 {
|
||||
for e in s + 1..=6 {
|
||||
let mut next = list.clone();
|
||||
next.push((f64::from(s), f64::from(e)));
|
||||
lists.push(next);
|
||||
}
|
||||
}
|
||||
k += 1;
|
||||
}
|
||||
assert_eq!(lists.len(), 233);
|
||||
for a in &lists {
|
||||
for b in &lists {
|
||||
let got = intersect_intervals(a, b);
|
||||
assert_eq!(got, intersect_every_pair(a, b), "{a:?} ∩ {b:?}");
|
||||
assert!(got.iter().all(|(x, y)| y > x) && got.windows(2).all(|w| w[0].1 <= w[1].0), "{got:?}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Intersecting two unions of 3,000 and 2,000 intervals that interleave looks at no more than
|
||||
/// 5,000 pairs of intervals, not every one of the 6,000,000 pairs.
|
||||
#[cfg(test)]
|
||||
#[test]
|
||||
fn intersecting_intervals_looks_at_each_interval_once() {
|
||||
let a: Vec<(f64, f64)> = (0..3000).map(|i| f64::from(i) * 4.0).map(|x| (x, x + 3.0)).collect();
|
||||
let b: Vec<(f64, f64)> = (0..2000).map(|i| f64::from(i) * 6.0).map(|x| (x + 1.0, x + 5.0)).collect();
|
||||
INTERSECT_PAIRS.set(0);
|
||||
let got = intersect_intervals(&a, &b);
|
||||
assert!(INTERSECT_PAIRS.get() <= a.len() + b.len(), "{} pairs", INTERSECT_PAIRS.get());
|
||||
assert_eq!(got, intersect_every_pair(&a, &b));
|
||||
assert!(!got.is_empty());
|
||||
}
|
||||
|
||||
/// A line in area type with an Invert Wrap comb of 500 teeth and an offset keeps a span inside
|
||||
/// each tooth, left to right, through every piece of its band, and intersecting the spans with each
|
||||
/// piece looks at each span once.
|
||||
#[cfg(test)]
|
||||
#[test]
|
||||
fn a_line_inside_an_invert_wrap_comb_keeps_a_span_in_each_tooth() {
|
||||
let frame = Rect::new(0.0, 0.0, 1000.0, 100.0).to_path(0.1);
|
||||
let teeth: Vec<Vec<(f64, f64)>> =
|
||||
(0..500).map(|k| f64::from(k) * 2.0).map(|x| vec![(x, 0.0), (x + 1.0, 0.0), (x + 1.0, 100.0), (x, 100.0)]).collect();
|
||||
let teeth: Vec<&[(f64, f64)]> = teeth.iter().map(Vec::as_slice).collect();
|
||||
let path = PathData::from_bezpath(&closed(&teeth));
|
||||
let wrap = vectorcraft_doc::WrapShape { path, wrap: vectorcraft_doc::TextWrap { offset: 0.25, invert: true } };
|
||||
let cells = Region::cells(&frame, &LayoutOptions::default(), &[wrap]);
|
||||
INTERSECT_PAIRS.set(0);
|
||||
let got = cells[0].spans(40.0, 60.0);
|
||||
let want: Vec<(f64, f64)> = (0..500).map(|k| f64::from(k) * 2.0).map(|x| ((x - 0.25).max(0.0), x + 1.25)).collect();
|
||||
assert_spans(&got, &want);
|
||||
// At most 64 pieces, each looking at no more than the spans so far and the tooth spans.
|
||||
assert!(INTERSECT_PAIRS.get() <= 64 * (500 + 500), "{} pairs", INTERSECT_PAIRS.get());
|
||||
}
|
||||
|
||||
/// Along the line, text keeps the inset beside a wrap object, around it and, with Invert Wrap,
|
||||
/// inside it.
|
||||
#[cfg(test)]
|
||||
|
||||
@@ -98,6 +98,7 @@ fn indic_hyphenation_reaches_both_composers_without_splitting_marks() {
|
||||
false,
|
||||
false,
|
||||
&[],
|
||||
None,
|
||||
);
|
||||
assert_eq!((end, hyphen), (4, true), "{tag}");
|
||||
let params = Params { justify_last: false, ragged: true, tolerance: 1.0, uniform_from: 0, hyphen_limit: 0 };
|
||||
@@ -170,7 +171,7 @@ fn greedy_wrap_keeps_an_oversized_first_cluster_and_its_source_range() {
|
||||
let glyphs = synthetic_glyphs(&[(0, 4, 2.0, 'ب'), (0, 4, 2.0, 'ب'), (4, 2, 1.0, 'ت')]);
|
||||
|
||||
let (end, hyphenated) =
|
||||
break_line(text, &glyphs, 0, 3.0, &[], 0.0, None, Burasagari::None, Kinsoku::Hard, KinsokuShoriType::PushOutOnly, false, false, &[]);
|
||||
break_line(text, &glyphs, 0, 3.0, &[], 0.0, None, Burasagari::None, Kinsoku::Hard, KinsokuShoriType::PushOutOnly, false, false, &[], None);
|
||||
|
||||
assert!(!hyphenated);
|
||||
assert_eq!(end, 2, "oversized base-plus-mark cluster must stay together");
|
||||
@@ -184,12 +185,26 @@ fn greedy_wrap_keeps_an_oversized_cluster_after_a_line_start_atomic() {
|
||||
let glyphs = synthetic_glyphs(&[(0, 4, 1.0, 'ب'), (4, 4, 2.0, 'ت'), (4, 4, 2.0, 'ت'), (8, 2, 1.0, 'ث')]);
|
||||
|
||||
let (first_end, _) =
|
||||
break_line(text, &glyphs, 0, 3.0, &[], 0.0, None, Burasagari::None, Kinsoku::Hard, KinsokuShoriType::PushOutOnly, false, false, &[]);
|
||||
break_line(text, &glyphs, 0, 3.0, &[], 0.0, None, Burasagari::None, Kinsoku::Hard, KinsokuShoriType::PushOutOnly, false, false, &[], None);
|
||||
assert_eq!(first_end, 1);
|
||||
assert_eq!(source_range(&glyphs, 0, first_end), 0..4);
|
||||
|
||||
let (second_end, hyphenated) =
|
||||
break_line(text, &glyphs, first_end, 3.0, &[], 0.0, None, Burasagari::None, Kinsoku::Hard, KinsokuShoriType::PushOutOnly, false, false, &[]);
|
||||
let (second_end, hyphenated) = break_line(
|
||||
text,
|
||||
&glyphs,
|
||||
first_end,
|
||||
3.0,
|
||||
&[],
|
||||
0.0,
|
||||
None,
|
||||
Burasagari::None,
|
||||
Kinsoku::Hard,
|
||||
KinsokuShoriType::PushOutOnly,
|
||||
false,
|
||||
false,
|
||||
&[],
|
||||
None,
|
||||
);
|
||||
|
||||
assert!(!hyphenated);
|
||||
assert_eq!(second_end, 3, "oversized cluster at the next line start must stay together");
|
||||
@@ -245,7 +260,7 @@ fn no_break_keeps_marked_characters_on_one_line() {
|
||||
let text = "A B";
|
||||
let plain = synthetic_glyphs(&[(0, 1, 10.0, 'A'), (1, 1, 4.0, ' '), (2, 1, 10.0, 'B')]);
|
||||
let (end, hyphen) =
|
||||
break_line(text, &plain, 0, 16.0, &[], 0.0, None, Burasagari::None, Kinsoku::None, KinsokuShoriType::PushOutOnly, false, false, &[]);
|
||||
break_line(text, &plain, 0, 16.0, &[], 0.0, None, Burasagari::None, Kinsoku::None, KinsokuShoriType::PushOutOnly, false, false, &[], None);
|
||||
assert!(!hyphen);
|
||||
assert_eq!(end, 2, "without the mark the line ends after the space");
|
||||
|
||||
@@ -254,7 +269,7 @@ fn no_break_keeps_marked_characters_on_one_line() {
|
||||
g.no_break = true;
|
||||
}
|
||||
let (end, hyphen) =
|
||||
break_line(text, &marked, 0, 16.0, &[], 0.0, None, Burasagari::None, Kinsoku::None, KinsokuShoriType::PushOutOnly, false, false, &[]);
|
||||
break_line(text, &marked, 0, 16.0, &[], 0.0, None, Burasagari::None, Kinsoku::None, KinsokuShoriType::PushOutOnly, false, false, &[], None);
|
||||
assert!(!hyphen);
|
||||
assert_eq!(end, 3, "the marked words stay together even though they are wider than the line");
|
||||
|
||||
@@ -264,10 +279,10 @@ fn no_break_keeps_marked_characters_on_one_line() {
|
||||
glyphs[2].no_break = true;
|
||||
glyphs[3].no_break = true;
|
||||
let (end, _) =
|
||||
break_line(text, &glyphs, 0, 22.0, &[], 0.0, None, Burasagari::None, Kinsoku::None, KinsokuShoriType::PushOutOnly, false, false, &[]);
|
||||
break_line(text, &glyphs, 0, 22.0, &[], 0.0, None, Burasagari::None, Kinsoku::None, KinsokuShoriType::PushOutOnly, false, false, &[], None);
|
||||
assert_eq!(end, 2, "the marked pair moves to the next line together");
|
||||
let (end, _) =
|
||||
break_line(text, &glyphs, end, 15.0, &[], 0.0, None, Burasagari::None, Kinsoku::None, KinsokuShoriType::PushOutOnly, false, false, &[]);
|
||||
break_line(text, &glyphs, end, 15.0, &[], 0.0, None, Burasagari::None, Kinsoku::None, KinsokuShoriType::PushOutOnly, false, false, &[], None);
|
||||
assert_eq!(end, 4, "on its own line the pair stays together even when it is wider than the line");
|
||||
|
||||
let breaks = candidates(text, &glyphs, None, Kinsoku::None);
|
||||
@@ -337,7 +352,7 @@ fn full_width(text: &str) -> Vec<SGlyph> {
|
||||
fn kinsoku_shori_type_pushes_a_closing_bracket_in_or_out() {
|
||||
use KinsokuShoriType::{PushInFirst, PushOutFirst, PushOutOnly};
|
||||
let hung = |text: &str, width: f64, kinsoku: Kinsoku, ty: KinsokuShoriType, hanging: bool| {
|
||||
break_line(text, &full_width(text), 0, width, &[], 0.0, None, Burasagari::None, kinsoku, ty, hanging, false, &[]).0
|
||||
break_line(text, &full_width(text), 0, width, &[], 0.0, None, Burasagari::None, kinsoku, ty, hanging, false, &[], None).0
|
||||
};
|
||||
let end = |text: &str, width: f64, kinsoku: Kinsoku, ty: KinsokuShoriType| hung(text, width, kinsoku, ty, false);
|
||||
let text = "一二三四五六七八九十一二三四五」六";
|
||||
@@ -403,7 +418,7 @@ fn kinsoku_shori_type_counts_the_gaps_that_close() {
|
||||
let mut glyphs = full_width(mixed);
|
||||
glyphs[3].level = unicode_bidi::Level::rtl();
|
||||
let mixed_end =
|
||||
|ty: KinsokuShoriType| break_line(mixed, &glyphs, 0, 150.0, &[], 0.0, None, Burasagari::None, Kinsoku::Hard, ty, false, false, &[]).0;
|
||||
|ty: KinsokuShoriType| break_line(mixed, &glyphs, 0, 150.0, &[], 0.0, None, Burasagari::None, Kinsoku::Hard, ty, false, false, &[], None).0;
|
||||
assert_eq!(mixed_end(PushInFirst), 14, "right-to-left text on the line: 五 is pushed out with 」");
|
||||
assert_eq!(mixed_end(PushOutOnly), 14);
|
||||
|
||||
@@ -415,7 +430,7 @@ fn kinsoku_shori_type_counts_the_gaps_that_close() {
|
||||
g.sy *= 2.0;
|
||||
}
|
||||
let tall_end =
|
||||
|ty: KinsokuShoriType| break_line(tall, &glyphs, 0, 50.0, &[], 0.0, None, Burasagari::None, Kinsoku::Hard, ty, false, false, &[]).0;
|
||||
|ty: KinsokuShoriType| break_line(tall, &glyphs, 0, 50.0, &[], 0.0, None, Burasagari::None, Kinsoku::Hard, ty, false, false, &[], None).0;
|
||||
assert_eq!(tall_end(PushInFirst), 4, "Vertical Scale does not widen the gaps: 五 is pushed out with 」");
|
||||
|
||||
// No gap closes inside a shaped cluster. 二 is two glyphs here, so the line has five gaps to
|
||||
@@ -425,7 +440,7 @@ fn kinsoku_shori_type_counts_the_gaps_that_close() {
|
||||
let half = glyphs[1].clone();
|
||||
glyphs.insert(2, half);
|
||||
let split_end =
|
||||
|width: f64| break_line(tall, &glyphs, 0, width, &[], 0.0, None, Burasagari::None, Kinsoku::Hard, PushInFirst, false, false, &[]).0;
|
||||
|width: f64| break_line(tall, &glyphs, 0, width, &[], 0.0, None, Burasagari::None, Kinsoku::Hard, PushInFirst, false, false, &[], None).0;
|
||||
assert_eq!(split_end(54.0), 5, "五 is pushed out with 」");
|
||||
assert_eq!(split_end(55.0), 7, "five gaps close by 5 pt: 」 is pushed in");
|
||||
}
|
||||
@@ -439,12 +454,198 @@ fn kinsoku_shori_type_counts_no_gap_at_a_format_control() {
|
||||
let mut glyphs = full_width(text);
|
||||
glyphs[2].adv = 0.0;
|
||||
let end = |width: f64| {
|
||||
break_line(text, &glyphs, 0, width, &[], 0.0, None, Burasagari::None, Kinsoku::Hard, KinsokuShoriType::PushInFirst, false, false, &[]).0
|
||||
break_line(text, &glyphs, 0, width, &[], 0.0, None, Burasagari::None, Kinsoku::Hard, KinsokuShoriType::PushInFirst, false, false, &[], None).0
|
||||
};
|
||||
assert_eq!(end(54.0), 5, "五 is pushed out with 」");
|
||||
assert_eq!(end(55.0), 7, "five gaps close by 5 pt: 」 is pushed in");
|
||||
}
|
||||
|
||||
/// A line with a tab that does not fit as drawn ends at the latest break that does, even when an
|
||||
/// earlier break does not fit.
|
||||
#[test]
|
||||
fn a_tabbed_line_ends_at_the_latest_break_that_fits_as_drawn() {
|
||||
let text = "a\tb c d e";
|
||||
let specs: Vec<_> = text.char_indices().map(|(byte, ch)| (byte, ch.len_utf8(), 1.0, ch)).collect();
|
||||
let glyphs = synthetic_glyphs(&specs);
|
||||
// Breaks after the spaces end the line before glyphs 4, 6 and 8; the paragraph ends at 9.
|
||||
let drawn_fits = |end: usize, _hyphenated: bool| end == 4 || end == 8;
|
||||
let fits: Option<&dyn Fn(usize, bool) -> bool> = Some(&drawn_fits);
|
||||
let ended =
|
||||
break_line(text, &glyphs, 0, 100.0, &[], 0.0, None, Burasagari::None, Kinsoku::None, KinsokuShoriType::PushOutOnly, false, false, &[], fits);
|
||||
assert_eq!(ended, (8, false));
|
||||
}
|
||||
|
||||
/// Past the breaks a tabbed line measures one by one, it ends at the latest break without a hyphen
|
||||
/// that fits as drawn, whatever the breaks with a hyphen between them measure.
|
||||
#[test]
|
||||
fn a_long_tabbed_line_ends_at_the_latest_plain_break_that_fits_as_drawn() {
|
||||
// a (0), tab (1), then b, a soft hyphen, b and a space for each u: breaks before 4 + 4u (with a
|
||||
// hyphen) and 6 + 4u.
|
||||
let text = format!("a\t{}", "b\u{AD}b ".repeat(400));
|
||||
let specs: Vec<_> = text.char_indices().map(|(byte, ch)| (byte, ch.len_utf8(), 1.0, ch)).collect();
|
||||
let glyphs = synthetic_glyphs(&specs);
|
||||
let drawn_fits = |end: usize, hyphenated: bool| !hyphenated && end <= 606;
|
||||
let fits: Option<&dyn Fn(usize, bool) -> bool> = Some(&drawn_fits);
|
||||
let ended =
|
||||
break_line(&text, &glyphs, 0, 1e6, &[], 0.0, None, Burasagari::None, Kinsoku::None, KinsokuShoriType::PushOutOnly, false, false, &[], fits);
|
||||
assert_eq!(ended, (606, false));
|
||||
}
|
||||
|
||||
/// A line with a tab that overflows in the order of its text at many clusters stays whole when it
|
||||
/// fits as drawn.
|
||||
#[test]
|
||||
fn a_tabbed_line_that_fits_as_drawn_stays_whole_past_many_clusters() {
|
||||
use vectorcraft_doc::{TabAlign, TabStop};
|
||||
let text = format!("a\t{}", "b ".repeat(600));
|
||||
let specs: Vec<_> = text.char_indices().map(|(byte, ch)| (byte, ch.len_utf8(), 0.1, ch)).collect();
|
||||
let glyphs = synthetic_glyphs(&specs);
|
||||
let tabs = [TabStop { position: 500.0, align: TabAlign::Left, leader: String::new(), align_on: '.' }];
|
||||
// In the text's order the line is 500 pt and 120 pt of text wide; drawn, it fits.
|
||||
let drawn_fits = |_end: usize, _hyphenated: bool| true;
|
||||
let fits: Option<&dyn Fn(usize, bool) -> bool> = Some(&drawn_fits);
|
||||
let ended = break_line(
|
||||
&text,
|
||||
&glyphs,
|
||||
0,
|
||||
600.0,
|
||||
&tabs,
|
||||
0.0,
|
||||
None,
|
||||
Burasagari::None,
|
||||
Kinsoku::None,
|
||||
KinsokuShoriType::PushOutOnly,
|
||||
false,
|
||||
false,
|
||||
&[],
|
||||
fits,
|
||||
);
|
||||
assert_eq!(ended, (glyphs.len(), false));
|
||||
}
|
||||
|
||||
/// A line of tabs and glyphs without width stops reading once its tabs reach past the frame: the
|
||||
/// rest of the paragraph is not measured.
|
||||
#[test]
|
||||
fn a_tabbed_line_stops_reading_where_its_tabs_reach_past_the_frame() {
|
||||
let text = format!("a{}", "\tx ".repeat(2000));
|
||||
let specs: Vec<_> = text.char_indices().map(|(byte, ch)| (byte, ch.len_utf8(), if ch == 'a' { 1.0 } else { 0.0 }, ch)).collect();
|
||||
let glyphs = synthetic_glyphs(&specs);
|
||||
let furthest = std::cell::Cell::new(0);
|
||||
let drawn_fits = |end: usize, _hyphenated: bool| {
|
||||
furthest.set(furthest.get().max(end));
|
||||
end <= 4
|
||||
};
|
||||
let fits: Option<&dyn Fn(usize, bool) -> bool> = Some(&drawn_fits);
|
||||
let ended =
|
||||
break_line(&text, &glyphs, 0, 100.0, &[], 0.0, None, Burasagari::None, Kinsoku::None, KinsokuShoriType::PushOutOnly, false, false, &[], fits);
|
||||
assert_eq!(ended, (4, false));
|
||||
assert!(furthest.get() < 100, "measured up to glyph {}", furthest.get());
|
||||
}
|
||||
|
||||
/// A tab to an explicit stop past the frame stops a line from reading on: the rest of the paragraph
|
||||
/// is not measured.
|
||||
#[test]
|
||||
fn a_tab_to_a_stop_past_the_frame_stops_the_line_reading() {
|
||||
use vectorcraft_doc::{TabAlign, TabStop};
|
||||
let text = format!("a b\t{}", "x ".repeat(2000));
|
||||
let specs: Vec<_> = text.char_indices().map(|(byte, ch)| (byte, ch.len_utf8(), if ch == 'x' { 0.0 } else { 1.0 }, ch)).collect();
|
||||
let glyphs = synthetic_glyphs(&specs);
|
||||
let tabs = [TabStop { position: 500.0, align: TabAlign::Left, leader: String::new(), align_on: '.' }];
|
||||
let furthest = std::cell::Cell::new(0);
|
||||
let drawn_fits = |end: usize, _hyphenated: bool| {
|
||||
furthest.set(furthest.get().max(end));
|
||||
end <= 2
|
||||
};
|
||||
let fits: Option<&dyn Fn(usize, bool) -> bool> = Some(&drawn_fits);
|
||||
let ended = break_line(
|
||||
&text,
|
||||
&glyphs,
|
||||
0,
|
||||
100.0,
|
||||
&tabs,
|
||||
0.0,
|
||||
None,
|
||||
Burasagari::None,
|
||||
Kinsoku::None,
|
||||
KinsokuShoriType::PushOutOnly,
|
||||
false,
|
||||
false,
|
||||
&[],
|
||||
fits,
|
||||
);
|
||||
assert_eq!(ended, (2, false));
|
||||
assert!(furthest.get() < 100, "measured up to glyph {}", furthest.get());
|
||||
}
|
||||
|
||||
/// A tabbed line that ends in a word with more hyphenation points than are measured one by one ends
|
||||
/// at the latest point that fits as drawn.
|
||||
#[test]
|
||||
fn a_tabbed_line_ends_at_the_latest_hyphenation_point_that_fits_as_drawn() {
|
||||
let opts = HyphenationOptions { words_longer_than: 0, after_first: 1, before_last: 1, ..Default::default() };
|
||||
// a (0), tab (1), then a word of 400 syllables of two glyphs from glyph 2: points before 4, 6, ...
|
||||
let text = format!("a\t{}", "कि".repeat(400));
|
||||
let specs: Vec<_> = text.char_indices().map(|(byte, ch)| (byte, ch.len_utf8(), 1.0, ch)).collect();
|
||||
let mut glyphs = synthetic_glyphs(&specs);
|
||||
for glyph in &mut glyphs {
|
||||
glyph.hyphen_language = crate::hyphen::Language::from_tag(Some("hi"));
|
||||
glyph.sx = 0.001;
|
||||
}
|
||||
let drawn_fits = |end: usize, _hyphenated: bool| end <= 50;
|
||||
let fits: Option<&dyn Fn(usize, bool) -> bool> = Some(&drawn_fits);
|
||||
let ended = break_line(
|
||||
&text,
|
||||
&glyphs,
|
||||
0,
|
||||
600.0,
|
||||
&[],
|
||||
0.0,
|
||||
Some((&opts, 0.0)),
|
||||
Burasagari::None,
|
||||
Kinsoku::None,
|
||||
KinsokuShoriType::PushOutOnly,
|
||||
false,
|
||||
false,
|
||||
&[],
|
||||
fits,
|
||||
);
|
||||
assert_eq!(ended, (50, true));
|
||||
}
|
||||
|
||||
/// A glyph with a negative advance after a tab draws the line short of the tab's stop: a line that
|
||||
/// fits as drawn stays whole.
|
||||
#[test]
|
||||
fn a_negative_advance_after_a_tab_keeps_a_fitting_line_whole() {
|
||||
let text = "a\tb";
|
||||
let glyphs = synthetic_glyphs(&[(0, 1, 1.0, 'a'), (1, 1, 1.0, '\t'), (2, 1, -20.0, 'b')]);
|
||||
let drawn_fits = |_end: usize, _hyphenated: bool| true;
|
||||
let fits: Option<&dyn Fn(usize, bool) -> bool> = Some(&drawn_fits);
|
||||
let ended =
|
||||
break_line(text, &glyphs, 0, 30.0, &[], 0.0, None, Burasagari::None, Kinsoku::None, KinsokuShoriType::PushOutOnly, false, false, &[], fits);
|
||||
assert_eq!(ended, (3, false));
|
||||
// Two right tabs can align on the same stop when the text after the second steps back: the
|
||||
// second tab does not reach the next stop.
|
||||
use vectorcraft_doc::{TabAlign, TabStop};
|
||||
let text = "a\tb\tc";
|
||||
let glyphs = synthetic_glyphs(&[(0, 1, 1.0, 'a'), (1, 1, 1.0, '\t'), (2, 1, 1.0, 'b'), (3, 1, 1.0, '\t'), (4, 1, -10.0, 'c')]);
|
||||
let tabs = [100.0, 500.0].map(|position| TabStop { position, align: TabAlign::Right, leader: String::new(), align_on: '.' });
|
||||
let ended = break_line(
|
||||
text,
|
||||
&glyphs,
|
||||
0,
|
||||
200.0,
|
||||
&tabs,
|
||||
0.0,
|
||||
None,
|
||||
Burasagari::None,
|
||||
Kinsoku::None,
|
||||
KinsokuShoriType::PushOutOnly,
|
||||
false,
|
||||
false,
|
||||
&[],
|
||||
fits,
|
||||
);
|
||||
assert_eq!(ended, (5, false));
|
||||
}
|
||||
|
||||
/// Mojikumi: the greedy breaker measures a line as it is set, with its last character narrowed
|
||||
/// (`line_end`): 」 set half width ends a measure of three and a half characters. Only the last
|
||||
/// glyph of the last cluster counts, and only where the line may end.
|
||||
@@ -456,7 +657,7 @@ fn greedy_wrap_counts_the_narrower_line_end() {
|
||||
let mut credit = vec![0.0; glyphs.len()];
|
||||
credit[3] = 5.0;
|
||||
let end = |glyphs: &[SGlyph], width: f64, burasagari: Burasagari, line_end: &[f64]| {
|
||||
break_line(text, glyphs, 0, width, &[], 0.0, None, burasagari, Kinsoku::Hard, PushOutOnly, false, false, line_end).0
|
||||
break_line(text, glyphs, 0, width, &[], 0.0, None, burasagari, Kinsoku::Hard, PushOutOnly, false, false, line_end, None).0
|
||||
};
|
||||
assert_eq!(end(&glyphs, 35.0, Burasagari::None, &credit), 4, "」 half width fits");
|
||||
assert_eq!(end(&glyphs, 35.0, Burasagari::None, &[]), 2, "」 full width does not: 三 goes with it");
|
||||
@@ -472,7 +673,7 @@ fn greedy_wrap_counts_the_narrower_line_end() {
|
||||
let comma = "一二三、";
|
||||
let glyphs = full_width(comma);
|
||||
let end = |line_end: &[f64]| {
|
||||
break_line(comma, &glyphs, 0, 25.0, &[], 0.0, None, Burasagari::Standard, Kinsoku::Hard, PushOutOnly, false, false, line_end).0
|
||||
break_line(comma, &glyphs, 0, 25.0, &[], 0.0, None, Burasagari::Standard, Kinsoku::Hard, PushOutOnly, false, false, line_end, None).0
|
||||
};
|
||||
assert_eq!(end(&[0.0, 0.0, 5.0, 0.0]), 2);
|
||||
|
||||
@@ -481,7 +682,7 @@ fn greedy_wrap_counts_the_narrower_line_end() {
|
||||
let pushed = "一二三四」。六";
|
||||
let glyphs = full_width(pushed);
|
||||
let end = |ty: KinsokuShoriType, line_end: &[f64]| {
|
||||
break_line(pushed, &glyphs, 0, 50.0, &[], 0.0, None, Burasagari::None, Kinsoku::Hard, ty, false, false, line_end).0
|
||||
break_line(pushed, &glyphs, 0, 50.0, &[], 0.0, None, Burasagari::None, Kinsoku::Hard, ty, false, false, line_end, None).0
|
||||
};
|
||||
let stop = [0.0, 0.0, 0.0, 0.0, 0.0, 5.0, 0.0];
|
||||
assert_eq!(end(PushInFirst, &stop), 6, "。 is pushed in");
|
||||
@@ -491,7 +692,7 @@ fn greedy_wrap_counts_the_narrower_line_end() {
|
||||
let pushed = format!("{}二」「四", "一".repeat(10));
|
||||
let glyphs = full_width(&pushed);
|
||||
let end = |line_end: &[f64]| {
|
||||
break_line(&pushed, &glyphs, 0, 130.0, &[], 0.0, None, Burasagari::None, Kinsoku::Hard, PushOutFirst, false, false, line_end).0
|
||||
break_line(&pushed, &glyphs, 0, 130.0, &[], 0.0, None, Burasagari::None, Kinsoku::Hard, PushOutFirst, false, false, line_end, None).0
|
||||
};
|
||||
let mut bracket = vec![0.0; glyphs.len()];
|
||||
bracket[11] = 5.0;
|
||||
@@ -504,8 +705,9 @@ fn greedy_wrap_counts_the_narrower_line_end() {
|
||||
glyphs[2].adv = 5.0;
|
||||
let half = glyphs[2].clone();
|
||||
glyphs.insert(3, half);
|
||||
let end =
|
||||
|line_end: &[f64]| break_line(text, &glyphs, 0, 26.0, &[], 0.0, None, Burasagari::None, Kinsoku::None, PushOutOnly, false, false, line_end).0;
|
||||
let end = |line_end: &[f64]| {
|
||||
break_line(text, &glyphs, 0, 26.0, &[], 0.0, None, Burasagari::None, Kinsoku::None, PushOutOnly, false, false, line_end, None).0
|
||||
};
|
||||
assert_eq!(end(&[0.0, 0.0, 4.0, 0.0]), 2, "the cluster stays whole and goes to the next line");
|
||||
assert_eq!(end(&[0.0, 0.0, 0.0, 4.0]), 4, "its last glyph's credit keeps it on the line");
|
||||
}
|
||||
@@ -785,8 +987,9 @@ fn korean(text: &str, anywhere: bool) -> Vec<SGlyph> {
|
||||
fn korean_words_break_at_spaces_unless_line_break_anywhere() {
|
||||
use KinsokuShoriType::PushOutOnly;
|
||||
let text = "가나다 라마바";
|
||||
let greedy =
|
||||
|g: &[SGlyph], width: f64| break_line(text, g, 0, width, &[], 0.0, None, Burasagari::None, Kinsoku::Hard, PushOutOnly, false, false, &[]).0;
|
||||
let greedy = |g: &[SGlyph], width: f64| {
|
||||
break_line(text, g, 0, width, &[], 0.0, None, Burasagari::None, Kinsoku::Hard, PushOutOnly, false, false, &[], None).0
|
||||
};
|
||||
let ends = |g: &[SGlyph]| candidates(text, g, None, Kinsoku::Hard).iter().map(|b| b.end).collect::<Vec<_>>();
|
||||
// 50 pt holds "가나다 라" and not "가나다 라마".
|
||||
assert_eq!(greedy(&korean(text, false), 50.0), 4, "off: the line ends at the space");
|
||||
@@ -805,7 +1008,7 @@ fn korean_words_break_at_spaces_unless_line_break_anywhere() {
|
||||
// A soft return still ends the line.
|
||||
let soft = "가나\u{2028}다라";
|
||||
assert_eq!(
|
||||
break_line(soft, &korean(soft, false), 0, 100.0, &[], 0.0, None, Burasagari::None, Kinsoku::Hard, PushOutOnly, false, false, &[]).0,
|
||||
break_line(soft, &korean(soft, false), 0, 100.0, &[], 0.0, None, Burasagari::None, Kinsoku::Hard, PushOutOnly, false, false, &[], None).0,
|
||||
3
|
||||
);
|
||||
}
|
||||
@@ -826,7 +1029,8 @@ fn korean_words_keep_their_letters_and_break_after_punctuation() {
|
||||
for g in &mut glyphs[1..3] {
|
||||
g.no_break = true;
|
||||
}
|
||||
let end = break_line(text, &glyphs, 0, 25.0, &[], 0.0, None, Burasagari::None, Kinsoku::Hard, KinsokuShoriType::PushOutOnly, false, false, &[]).0;
|
||||
let end =
|
||||
break_line(text, &glyphs, 0, 25.0, &[], 0.0, None, Burasagari::None, Kinsoku::Hard, KinsokuShoriType::PushOutOnly, false, false, &[], None).0;
|
||||
assert_eq!(end, 1, "No Break keeps 나다 together");
|
||||
}
|
||||
|
||||
@@ -853,6 +1057,7 @@ fn korean_words_take_no_hyphen() {
|
||||
false,
|
||||
false,
|
||||
&[],
|
||||
None,
|
||||
);
|
||||
assert_eq!(line, (2, false));
|
||||
assert!(candidates(text, &glyphs, Some(&opts), Kinsoku::Hard).iter().all(|b| b.hyphen == 0.0));
|
||||
@@ -898,8 +1103,9 @@ fn bunri_kinshi_keeps_jlreq_pairs_in_both_composers() {
|
||||
let marked = |text: &str| pairs(text, true).iter().filter(|g| g.keeps_next).count();
|
||||
assert_eq!((marked("……‥‥〳〵〴〵"), marked("—\u{2028}—"), marked("—〵"), marked("〵〳")), (4, 0, 0, 0));
|
||||
// 35 pt holds "あい—" and not "あい——".
|
||||
let greedy =
|
||||
|g: &[SGlyph], width: f64| break_line(text, g, 0, width, &[], 0.0, None, Burasagari::None, Kinsoku::Hard, PushOutOnly, false, false, &[]).0;
|
||||
let greedy = |g: &[SGlyph], width: f64| {
|
||||
break_line(text, g, 0, width, &[], 0.0, None, Burasagari::None, Kinsoku::Hard, PushOutOnly, false, false, &[], None).0
|
||||
};
|
||||
assert_eq!(greedy(&pairs(text, false), 35.0), 3, "off: the line ends between the dashes");
|
||||
assert_eq!(greedy(&pairs(text, true), 35.0), 2, "on: both dashes go to the next line");
|
||||
assert_eq!(breaking::word_width(&pairs("——あ", true), 0, &[], Kinsoku::Hard), 20.0);
|
||||
@@ -917,7 +1123,7 @@ fn bunri_kinshi_keeps_jlreq_pairs_in_both_composers() {
|
||||
fn bunri_kinshi_progresses_with_an_overlong_pair_and_no_break() {
|
||||
use KinsokuShoriType::{PushInFirst, PushOutFirst, PushOutOnly};
|
||||
let line = |text: &str, g: &[SGlyph], i: usize, width: f64, kinsoku: Kinsoku, ty: KinsokuShoriType| {
|
||||
break_line(text, g, i, width, &[], 0.0, None, Burasagari::None, kinsoku, ty, false, false, &[]).0
|
||||
break_line(text, g, i, width, &[], 0.0, None, Burasagari::None, kinsoku, ty, false, false, &[], None).0
|
||||
};
|
||||
let text = "——あ";
|
||||
assert_eq!(line(text, &pairs(text, true), 0, 15.0, Kinsoku::Hard, PushOutOnly), 2, "the pair overflows the line whole");
|
||||
|
||||
@@ -101,9 +101,9 @@ fn ruby_over_composite_type_is_set_in_its_own_parts() {
|
||||
assert!(rise > 8.0 && rise < 15.0, "the reading is {rise} pt above the base's baseline");
|
||||
}
|
||||
|
||||
/// Lent composite fonts are available while a guard lends them: a second guard lends one again, a
|
||||
/// font of the same name the database holds is replaced and put back afterwards, and a font named
|
||||
/// like an installed family is not lent.
|
||||
/// Lent composite fonts are available while a guard lends them, in place of a font of the same name
|
||||
/// the database holds, which the database keeps. A font named like an installed family is not
|
||||
/// lent, and of two fonts of one name the first is.
|
||||
#[test]
|
||||
fn lent_composite_fonts_last_as_long_as_a_guard() {
|
||||
let db = FontDb::with_font_dirs(vec![]);
|
||||
@@ -115,6 +115,7 @@ fn lent_composite_fonts_last_as_long_as_a_guard() {
|
||||
assert!(db.composite_font("lent").is_some());
|
||||
assert_eq!(db.composite_font("Held").unwrap().parts.roman.size, 75.0);
|
||||
assert!(db.composite_font("Inter").is_none());
|
||||
assert_eq!(db.composite_font_names(), ["Held"], "the database's own fonts stay");
|
||||
let second = db.lend_composite_fonts(&fonts);
|
||||
drop(first);
|
||||
assert!(db.composite_font("Lent").is_some());
|
||||
@@ -122,29 +123,97 @@ fn lent_composite_fonts_last_as_long_as_a_guard() {
|
||||
drop(second);
|
||||
assert!(db.composite_font("Lent").is_none());
|
||||
assert_eq!(db.composite_font("Held").unwrap().parts.roman.size, 50.0);
|
||||
// A font set in its place while lent stays when the guard drops.
|
||||
// A font set in the database while one of that name is lent doesn't change the one in use, and
|
||||
// is used once the guard drops.
|
||||
let lent = db.lend_composite_fonts(&fonts[..1]);
|
||||
let mut replaced = mixed("Lent");
|
||||
replaced.parts.roman.size = 60.0;
|
||||
db.set_composite_font(&replaced);
|
||||
assert_eq!(db.composite_font("Lent").unwrap().parts.roman.size, 50.0);
|
||||
drop(lent);
|
||||
assert_eq!(db.composite_font("Lent").unwrap().parts.roman.size, 60.0);
|
||||
// So does the same definition set while lent, after every guard lending it drops.
|
||||
assert!(db.remove_composite_font("Lent"));
|
||||
let (one, two) = (db.lend_composite_fonts(&fonts[..1]), db.lend_composite_fonts(&fonts[..1]));
|
||||
assert!(!db.set_composite_font(&fonts[0]));
|
||||
drop(one);
|
||||
drop(two);
|
||||
assert!(db.composite_font("Lent").is_some());
|
||||
// A guard whose font was removed doesn't give back a later guard's loan of that name.
|
||||
assert!(db.remove_composite_font("Lent"));
|
||||
let old = db.lend_composite_fonts(&fonts[..1]);
|
||||
assert!(db.remove_composite_font("Lent"));
|
||||
let new = db.lend_composite_fonts(&fonts[..1]);
|
||||
drop(old);
|
||||
assert!(db.composite_font("Lent").is_some());
|
||||
drop(new);
|
||||
assert!(db.composite_font("Lent").is_none());
|
||||
let mut other = mixed("LENT");
|
||||
other.parts.roman.size = 70.0;
|
||||
let lent = db.lend_composite_fonts(&[mixed("Lent"), other]);
|
||||
assert_eq!(db.composite_font("lent").unwrap().parts.roman.size, 50.0);
|
||||
drop(lent);
|
||||
}
|
||||
|
||||
/// A composite font `name` whose Roman part is set at `size` percent.
|
||||
fn roman_at(name: &str, size: f64) -> CompositeFont {
|
||||
let mut f = mixed(name);
|
||||
f.parts.roman.size = size;
|
||||
f
|
||||
}
|
||||
|
||||
/// The advance of `A` set at `size` points in `family`.
|
||||
fn advance(db: &FontDb, family: &str, size: f64) -> f64 {
|
||||
layout(db, &TextObject::point(Point::ZERO, "A", style(family, size))).glyphs[0].advance
|
||||
}
|
||||
|
||||
/// Two documents that define one name differently each lay out in their own definition, also when
|
||||
/// one's guard lends inside the other's (a document placed in another): the inner definition is
|
||||
/// used until its guard drops, then the outer one again, also after a panic inside the inner one.
|
||||
/// The generation tells the layouts apart, and lending changes nothing the database holds.
|
||||
#[test]
|
||||
fn nested_loans_of_one_name_use_each_documents_definition() {
|
||||
let db = FontDb::with_font_dirs(vec![]);
|
||||
let (a, b) = (roman_at("CF Shared", 50.0), roman_at("CF Shared", 75.0));
|
||||
db.set_composite_font(&a);
|
||||
let (half, three_quarters) = (advance(&db, "JetBrains Mono", 10.0), advance(&db, "JetBrains Mono", 15.0));
|
||||
// Lending looks the name up among the installed families, which catalogs them the first time.
|
||||
assert!(!db.has_installed_family("CF Shared"));
|
||||
let plain = db.generation();
|
||||
let outer = db.lend_composite_fonts(std::slice::from_ref(&a));
|
||||
assert_eq!(db.generation(), plain, "the database's own definition");
|
||||
assert!((advance(&db, "CF Shared", 20.0) - half).abs() < 1e-9);
|
||||
{
|
||||
let _inner = db.lend_composite_fonts(std::slice::from_ref(&b));
|
||||
assert_eq!(db.composite_font("cf shared").unwrap().parts.roman.size, 75.0);
|
||||
assert!((advance(&db, "CF Shared", 20.0) - three_quarters).abs() < 1e-9);
|
||||
assert_ne!(db.generation(), plain, "another definition lays out under another generation");
|
||||
let elsewhere =
|
||||
std::thread::scope(|s| s.spawn(|| (db.generation(), db.composite_font("CF Shared").unwrap().parts.roman.size)).join().unwrap());
|
||||
assert_eq!(elsewhere, (plain, 50.0), "other threads keep the database's");
|
||||
}
|
||||
assert!((advance(&db, "CF Shared", 20.0) - half).abs() < 1e-9);
|
||||
assert_eq!(db.generation(), plain);
|
||||
let caught = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
|
||||
let _inner = db.lend_composite_fonts(std::slice::from_ref(&b));
|
||||
panic!("a render that fails");
|
||||
}));
|
||||
assert!(caught.is_err());
|
||||
assert_eq!(db.composite_font("CF Shared").unwrap().parts.roman.size, 50.0);
|
||||
drop(outer);
|
||||
let next = db.lend_composite_fonts(std::slice::from_ref(&b));
|
||||
assert_eq!(db.composite_font("CF Shared").unwrap().parts.roman.size, 75.0);
|
||||
drop(next);
|
||||
assert_eq!(db.composite_font("CF Shared").unwrap().parts.roman.size, 50.0);
|
||||
assert_eq!(db.generation(), plain);
|
||||
}
|
||||
|
||||
/// Two threads that lend different definitions of one name at once each lay out in their own.
|
||||
#[test]
|
||||
fn threads_lending_one_name_at_once_use_their_own_definitions() {
|
||||
let db = FontDb::with_font_dirs(vec![]);
|
||||
let (half, three_quarters) = (advance(&db, "JetBrains Mono", 10.0), advance(&db, "JetBrains Mono", 15.0));
|
||||
let (both_lent, both_read) = (std::sync::Barrier::new(2), std::sync::Barrier::new(2));
|
||||
let lend = |size: f64| {
|
||||
let _lent = db.lend_composite_fonts(&[roman_at("CF Threads", size)]);
|
||||
both_lent.wait();
|
||||
let read = (advance(&db, "CF Threads", 20.0), db.generation());
|
||||
both_read.wait();
|
||||
read
|
||||
};
|
||||
let ((a, ga), (b, gb)) = std::thread::scope(|s| {
|
||||
let a = s.spawn(|| lend(50.0));
|
||||
let b = s.spawn(|| lend(75.0));
|
||||
(a.join().unwrap(), b.join().unwrap())
|
||||
});
|
||||
assert!((a - half).abs() < 1e-9, "{a} vs {half}");
|
||||
assert!((b - three_quarters).abs() < 1e-9, "{b} vs {three_quarters}");
|
||||
assert_ne!(ga, gb);
|
||||
assert!(db.composite_font("CF Threads").is_none());
|
||||
}
|
||||
|
||||
/// The fonts a composite font sets text in are those of the parts its characters fall in, and
|
||||
|
||||
@@ -540,6 +540,134 @@ fn a_justified_right_to_left_line_with_a_tab_spans_its_measure() {
|
||||
assert!((l.lines[0].x1 - 300.0).abs() < 1e-6, "{}", l.lines[0].x1);
|
||||
}
|
||||
|
||||
/// Right-to-left area type `text` in a frame `width` wide, aligned as `justify`, with tab stops at
|
||||
/// `stops` (position and alignment).
|
||||
fn rtl_tabbed(text: &str, width: f64, stops: &[(f64, TabAlign)], justify: Justify) -> TextLayout {
|
||||
let mut t = area(text, width, justify, no_ranges());
|
||||
t.para.tabs = stops.iter().map(|&(position, align)| TabStop { position, align, leader: String::new(), align_on: '.' }).collect();
|
||||
layout(db(), &t)
|
||||
}
|
||||
|
||||
/// Where the glyphs of line `k` that are not spaces start and end, as drawn.
|
||||
fn drawn_extent(l: &TextLayout, text: &str, k: usize) -> (f64, f64) {
|
||||
let ink = l.glyphs.iter().filter(|g| g.line == k && !text[g.byte..].starts_with(' '));
|
||||
ink.fold((f64::INFINITY, f64::NEG_INFINITY), |(lo, hi), g| (lo.min(g.origin.x), hi.max(g.origin.x + g.advance)))
|
||||
}
|
||||
|
||||
/// A right-to-left line with a tab is measured as it is drawn: right aligned (and Auto, which
|
||||
/// aligns it right) it ends on the frame's right edge, and centered it leaves as much room at
|
||||
/// each side.
|
||||
#[test]
|
||||
fn a_right_to_left_line_with_a_tab_is_aligned_as_it_is_drawn() {
|
||||
let text = "אב גד\tהו זח";
|
||||
for justify in [Justify::Right, Justify::Auto, Justify::Center] {
|
||||
let l = rtl_tabbed(text, 300.0, &[(150.0, TabAlign::Left)], justify);
|
||||
assert_eq!(l.lines.len(), 1, "{justify:?}");
|
||||
let (left, right) = drawn_extent(&l, text, 0);
|
||||
if justify == Justify::Center {
|
||||
assert!((left - (300.0 - right)).abs() < 1e-6, "{justify:?}: centered as drawn: {left} {right}");
|
||||
} else {
|
||||
assert!((right - 300.0).abs() < 1e-6, "{justify:?}: ends on the right edge: {right}");
|
||||
}
|
||||
assert!((l.lines[0].x1 - right).abs() < 1e-6, "{justify:?}: the line's record ends where it is drawn: {}", l.lines[0].x1);
|
||||
}
|
||||
}
|
||||
|
||||
/// Point type: a right-aligned right-to-left line with a tab ends at the origin as drawn.
|
||||
#[test]
|
||||
fn right_aligned_right_to_left_point_type_with_a_tab_ends_at_its_origin() {
|
||||
let text = "אב גד\tהו זח";
|
||||
let mut t = point(text, no_ranges());
|
||||
t.para.justify = Justify::Right;
|
||||
t.para.tabs = vec![TabStop { position: 150.0, align: TabAlign::Left, leader: String::new(), align_on: '.' }];
|
||||
let l = layout(db(), &t);
|
||||
let (_, right) = drawn_extent(&l, text, 0);
|
||||
assert!(right.abs() < 1e-6, "ends at the origin: {right}");
|
||||
}
|
||||
|
||||
/// A right-to-left line with a tab breaks where it fits as drawn: in a frame it fits only in the
|
||||
/// order of its text, it wraps, and every line stays inside the frame.
|
||||
#[test]
|
||||
fn a_right_to_left_line_with_a_tab_wraps_when_it_does_not_fit_as_drawn() {
|
||||
// א (0), ב (2), space (4), ג (5), ד (7), tab (9), ה (10), ו (12), space (14), ז (15), ח (17).
|
||||
let text = "אב גד\tהו זח";
|
||||
let wide = rtl_tabbed(text, 1000.0, &[(150.0, TabAlign::Left)], Justify::Left);
|
||||
let span = |from: usize, to: usize| -> f64 { wide.glyphs.iter().filter(|g| (from..to).contains(&g.byte)).map(|g| g.advance).sum() };
|
||||
// Drawn, the text before the tab comes after it: the line is 150 pt and that text wide.
|
||||
let (before, after) = (span(0, 9), span(10, 19));
|
||||
assert!(before > after + 1.0, "the test needs the text before the tab wider: {before} {after}");
|
||||
let width = 150.0 + (before + after) / 2.0;
|
||||
let l = rtl_tabbed(text, width, &[(150.0, TabAlign::Left)], Justify::Right);
|
||||
assert!(l.lines.len() >= 2, "wraps: {} line(s)", l.lines.len());
|
||||
for k in 0..l.lines.len() {
|
||||
let (left, right) = drawn_extent(&l, text, k);
|
||||
assert!(left >= -1e-6 && right <= width + 1e-6, "line {k} stays in the frame: {left} {right} (frame {width})");
|
||||
}
|
||||
}
|
||||
|
||||
/// Right, center and decimal stops align the text drawn after the tab, which in a right-to-left
|
||||
/// line is the text before it.
|
||||
#[test]
|
||||
fn right_to_left_tab_stops_align_the_text_drawn_after_them() {
|
||||
// מ (0), מ (2), tab (4), ו (5), ו (7): drawn, וו comes first and ממ after the tab.
|
||||
let text = "ממ\tוו";
|
||||
let mem = |l: &TextLayout| -> (f64, f64) {
|
||||
l.glyphs
|
||||
.iter()
|
||||
.filter(|g| g.byte < 4)
|
||||
.fold((f64::INFINITY, f64::NEG_INFINITY), |(lo, hi), g| (lo.min(g.origin.x), hi.max(g.origin.x + g.advance)))
|
||||
};
|
||||
let right = rtl_tabbed(text, 300.0, &[(150.0, TabAlign::Right)], Justify::Left);
|
||||
assert!((mem(&right).1 - 150.0).abs() < 1e-6, "right stop: ends on the stop: {:?}", mem(&right));
|
||||
let center = rtl_tabbed(text, 300.0, &[(150.0, TabAlign::Center)], Justify::Left);
|
||||
let (lo, hi) = mem(¢er);
|
||||
assert!(((lo + hi) / 2.0 - 150.0).abs() < 1e-6, "center stop: centered on the stop: {lo} {hi}");
|
||||
// 1 (0), . (1), 5 (2), tab (3), מ (4), מ (6): drawn, ממ comes first and 1.5 after the tab.
|
||||
let decimal = rtl_tabbed("1.5\tממ", 300.0, &[(150.0, TabAlign::Decimal)], Justify::Left);
|
||||
assert!((at(&decimal, 1).origin.x - 150.0).abs() < 1e-6, "decimal stop: the point on the stop: {}", at(&decimal, 1).origin.x);
|
||||
}
|
||||
|
||||
/// Two tabs around a left-to-right run in a right-to-left line: each takes its stop in drawing
|
||||
/// order, and the right-aligned line ends on the right edge as drawn.
|
||||
#[test]
|
||||
fn a_right_to_left_line_with_two_tabs_and_a_latin_run_ends_on_the_right_edge() {
|
||||
// מ (0), מ (2), tab (4), A B space C D (5..10), tab (10), ו (11), ו (13).
|
||||
let text = "ממ\tAB CD\tוו";
|
||||
let l = rtl_tabbed(text, 300.0, &[(80.0, TabAlign::Left), (160.0, TabAlign::Left)], Justify::Right);
|
||||
assert_eq!(l.lines.len(), 1);
|
||||
let (_, right) = drawn_extent(&l, text, 0);
|
||||
assert!((right - 300.0).abs() < 1e-6, "ends on the right edge: {right}");
|
||||
assert!(at(&l, 5).origin.x < at(&l, 6).origin.x, "the Latin run keeps its order");
|
||||
}
|
||||
|
||||
/// A right-to-left line with a tab and more breaks than are measured as drawn stays one line when
|
||||
/// it fits the frame.
|
||||
#[test]
|
||||
fn a_long_right_to_left_line_with_a_tab_that_fits_stays_one_line() {
|
||||
let text = format!("א\t{}", "ב ".repeat(400));
|
||||
let l = rtl_tabbed(&text, 100_000.0, &[(150.0, TabAlign::Left)], Justify::Right);
|
||||
assert_eq!(l.lines.len(), 1, "one line");
|
||||
}
|
||||
|
||||
/// A right-to-left line with a tab and more breaks than are measured one by one still wraps when
|
||||
/// it fits only in the order of its text, and every line stays inside the frame.
|
||||
#[test]
|
||||
fn a_right_to_left_line_with_a_tab_and_many_breaks_wraps_when_it_does_not_fit_as_drawn() {
|
||||
// אב גד, a tab, הו, 400 soft hyphens (each a break), a space and זח.
|
||||
let text = format!("אב גד\tהו{} זח", "\u{AD}".repeat(400));
|
||||
let wide = rtl_tabbed(&text, 10_000.0, &[(150.0, TabAlign::Left)], Justify::Left);
|
||||
let span = |from: usize, to: usize| -> f64 { wide.glyphs.iter().filter(|g| (from..to).contains(&g.byte)).map(|g| g.advance).sum() };
|
||||
let (before, after) = (span(0, 9), span(10, text.len()));
|
||||
assert!(before > after + 1.0, "the test needs the text before the tab wider: {before} {after}");
|
||||
let width = 150.0 + (before + after) / 2.0;
|
||||
let l = rtl_tabbed(&text, width, &[(150.0, TabAlign::Left)], Justify::Right);
|
||||
assert!(l.lines.len() >= 2, "wraps: {} line(s)", l.lines.len());
|
||||
for k in 0..l.lines.len() {
|
||||
let (left, right) = drawn_extent(&l, &text, k);
|
||||
assert!(left >= -1e-6 && right <= width + 1e-6, "line {k} stays in the frame: {left} {right} (frame {width})");
|
||||
}
|
||||
}
|
||||
|
||||
/// Point type has no measure to fill: a justified line with a tab keeps its natural width.
|
||||
#[test]
|
||||
fn justified_point_type_with_a_tab_keeps_its_width() {
|
||||
|
||||
@@ -279,10 +279,13 @@ impl<'a> Rest<'a> {
|
||||
///
|
||||
/// Type on a path is measured the same way when its line starts at the start bracket whatever text
|
||||
/// follows: no Dynamic Text, Optical Margin Alignment or Roman Hanging Punctuation, the first
|
||||
/// paragraph aligned Left, Justify Left or Auto, and no right-to-left text (`no_rtl`, and the first
|
||||
/// paragraph's direction not right to left) unless the type is vertical. Each glyph's step along the
|
||||
/// path then depends only on the glyphs before it, so when the first glyph that does not fit comes
|
||||
/// before the last paragraph break measured, the path holds the same bytes with the whole rest.
|
||||
/// paragraph aligned Left, Justify Left, Justify All or Auto, and no right-to-left text (`no_rtl`, and
|
||||
/// the first paragraph's direction not right to left) unless the type is vertical. Each glyph's step
|
||||
/// along the path then depends only on the glyphs before it, so when the first glyph that does not
|
||||
/// fit comes before the last paragraph break measured, the path holds the same bytes with the whole
|
||||
/// rest. A line aligned to the center or the right, or Auto right to left, starts where the width of
|
||||
/// all its glyphs puts it, and a glyph's advance can be negative (negative tracking), so a shorter
|
||||
/// text can start further along than the whole rest does; such type is measured with the whole rest.
|
||||
///
|
||||
/// Other type, and a rest that ends first, is measured with the whole rest.
|
||||
fn fit_rest(db: &FontDb, probe: impl Fn(Vec<TextRun>) -> TextObject, frame: &TextObject, rest: Rest<'_>, mut want: usize, no_rtl: bool) -> usize {
|
||||
@@ -313,7 +316,7 @@ fn path_starts_at_bracket(head: &TextObject, no_rtl: bool) -> bool {
|
||||
!head.dynamic_text
|
||||
&& !head.optical_margin
|
||||
&& !para.roman_hanging
|
||||
&& matches!(para.justify, Justify::Auto | Justify::Left | Justify::JustifyLeft)
|
||||
&& matches!(para.justify, Justify::Auto | Justify::Left | Justify::JustifyLeft | Justify::JustifyAll)
|
||||
&& (head.vertical || (no_rtl && para.direction != Some(ParaDirection::RightToLeft)))
|
||||
}
|
||||
|
||||
|
||||
@@ -27,6 +27,13 @@ fn curved_path_frame() -> TextObject {
|
||||
t
|
||||
}
|
||||
|
||||
/// Type on a closed path, a circle about 314 round.
|
||||
fn closed_path_frame() -> TextObject {
|
||||
let mut t = path_frame(300.0);
|
||||
t.kind = TextKind::OnPath { path: shapes::ellipse(Rect::new(0.0, 0.0, 100.0, 100.0)), start: 0.0, end: None };
|
||||
t
|
||||
}
|
||||
|
||||
/// Type on a path that overflows ends its line where its text stops fitting, as area type's
|
||||
/// last line does, so a story flows on from there through area type and type on a path alike.
|
||||
#[test]
|
||||
@@ -724,7 +731,7 @@ fn frames_take_the_shares_the_whole_rest_gives_them() {
|
||||
]
|
||||
.concat();
|
||||
type Edit = fn(&mut TextObject);
|
||||
let edits: [(&str, Edit); 15] = [
|
||||
let edits: [(&str, Edit); 18] = [
|
||||
("path", |_| {}),
|
||||
("path, Left", |t| t.para.justify = Justify::Left),
|
||||
("path, Auto", |t| t.para.justify = Justify::Auto),
|
||||
@@ -732,6 +739,20 @@ fn frames_take_the_shares_the_whole_rest_gives_them() {
|
||||
("path, Center", |t| t.para.justify = Justify::Center),
|
||||
("path, Right", |t| t.para.justify = Justify::Right),
|
||||
("path, Justify All", |t| t.para.justify = Justify::JustifyAll),
|
||||
("path, Justify All, spacing", |t| {
|
||||
t.para.justify = Justify::JustifyAll;
|
||||
t.path_spacing = 8.0;
|
||||
}),
|
||||
("path, Justify All, negative spacing", |t| {
|
||||
t.para.justify = Justify::JustifyAll;
|
||||
t.path_spacing = -8.0;
|
||||
}),
|
||||
("closed path, Justify All", |t| {
|
||||
t.para.justify = Justify::JustifyAll;
|
||||
if let TextKind::OnPath { path, .. } = &mut t.kind {
|
||||
*path = shapes::ellipse(Rect::new(0.0, 0.0, 100.0, 100.0));
|
||||
}
|
||||
}),
|
||||
("path, spacing", |t| t.path_spacing = 8.0),
|
||||
("path, Optical Margin Alignment", |t| t.optical_margin = true),
|
||||
("path, Roman Hanging Punctuation", |t| t.para.roman_hanging = true),
|
||||
@@ -757,6 +778,35 @@ fn frames_take_the_shares_the_whole_rest_gives_them() {
|
||||
// The story's last character does not hang, the last one measured does.
|
||||
took += same_as_whole_rest(db, &refs(&on), &story(&ends_unhung), &[], &format!("{case}, story ending in a letter"));
|
||||
}
|
||||
// Glyphs whose advance is negative, and punctuation and quotes that end paragraphs, some of
|
||||
// them alone after empty paragraphs.
|
||||
let tight = CharStyle { tracking: -1200.0, ..Default::default() };
|
||||
let text = paragraphs(120);
|
||||
let mut negative: Vec<TextRun> =
|
||||
text.split_inclusive(' ').enumerate().map(|(i, w)| TextRun::new(w, if i % 4 == 0 { tight.clone() } else { plain.clone() })).collect();
|
||||
normalize(&mut negative);
|
||||
let punctuated = (1..=150)
|
||||
.map(|i| match i % 3 {
|
||||
0 => ".".to_string(),
|
||||
1 => format!("Item {i}: the \u{201c}fox.\u{201d}"),
|
||||
_ => String::new(),
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n");
|
||||
for justify in [Justify::Left, Justify::JustifyAll, Justify::Center, Justify::Right] {
|
||||
let mut on = paths(|_| {});
|
||||
on.push(closed_path_frame());
|
||||
for t in &mut on {
|
||||
t.para.justify = justify;
|
||||
}
|
||||
took += same_as_whole_rest(db, &refs(&on), &negative, &[], &format!("path, {justify:?}, negative tracking"));
|
||||
took += same_as_whole_rest(db, &refs(&on), &story(&punctuated), &[], &format!("path, {justify:?}, punctuation at paragraph ends"));
|
||||
let mut spaced = on.clone();
|
||||
for t in &mut spaced {
|
||||
t.path_spacing = -8.0;
|
||||
}
|
||||
took += same_as_whole_rest(db, &refs(&spaced), &negative, &[], &format!("path, {justify:?}, negative tracking and spacing"));
|
||||
}
|
||||
let on = paths(|_| {});
|
||||
took += same_as_whole_rest(db, &refs(&on), &styled, &[], "path, style runs");
|
||||
took += same_as_whole_rest(db, &refs(&on), &story(¶graphs(120)), &listed, "path, lists");
|
||||
@@ -877,7 +927,7 @@ fn many_frames_measure_about_the_text_they_take() {
|
||||
}
|
||||
measures_about_the_text_taken(db, &shaped, &format!("{align:?}, ellipses"));
|
||||
}
|
||||
for justify in [Justify::Left, Justify::JustifyLeft, Justify::Auto] {
|
||||
for justify in [Justify::Left, Justify::JustifyLeft, Justify::JustifyAll, Justify::Auto] {
|
||||
let paths: Vec<TextObject> = (0..40)
|
||||
.map(|_| {
|
||||
let mut t = path_frame(300.0);
|
||||
@@ -887,6 +937,34 @@ fn many_frames_measure_about_the_text_they_take() {
|
||||
.collect();
|
||||
measures_about_the_text_taken(db, &paths, &format!("path, {justify:?}"));
|
||||
}
|
||||
// Justify All on curved, bracketed and closed paths, and with Spacing.
|
||||
type Make = fn() -> TextObject;
|
||||
let kinds: [(&str, Make); 4] = [
|
||||
("curved", curved_path_frame),
|
||||
("bracketed", || {
|
||||
let mut t = path_frame(300.0);
|
||||
if let TextKind::OnPath { start, end, .. } = &mut t.kind {
|
||||
(*start, *end) = (0.1, Some(0.8));
|
||||
}
|
||||
t
|
||||
}),
|
||||
("closed", closed_path_frame),
|
||||
("spaced", || {
|
||||
let mut t = curved_path_frame();
|
||||
t.path_spacing = -20.0;
|
||||
t
|
||||
}),
|
||||
];
|
||||
for (shape, make) in kinds {
|
||||
let paths: Vec<TextObject> = (0..40)
|
||||
.map(|_| {
|
||||
let mut t = make();
|
||||
t.para.justify = Justify::JustifyAll;
|
||||
t
|
||||
})
|
||||
.collect();
|
||||
measures_about_the_text_taken(db, &paths, &format!("{shape} path, Justify All"));
|
||||
}
|
||||
let vertical: Vec<TextObject> = (0..40)
|
||||
.map(|_| {
|
||||
let mut t = path_frame(300.0);
|
||||
|
||||
@@ -514,8 +514,9 @@ fn steps(o: &Extrude) -> Vec<Step> {
|
||||
} else {
|
||||
out.push(Step { inset: 0.0, t: 0.0, face: 1.0 });
|
||||
}
|
||||
// Twist and taper bend the walls: cut them into slices.
|
||||
let slices = if o.twist != 0.0 || o.taper != 1.0 { ((o.twist.abs() / 6.0).ceil() as usize).clamp(8, 64) } else { 1 };
|
||||
// A twist bends the walls: cut them into slices. A taper alone keeps each wall flat (its edge
|
||||
// only scales toward the center as it goes back), so it needs none.
|
||||
let slices = if o.twist != 0.0 { ((o.twist.abs() / 6.0).ceil() as usize).clamp(8, 64) } else { 1 };
|
||||
let back_height = if bevel.is_some_and(|b| b.both_sides) { height } else { 0.0 };
|
||||
let (t0, t1) = (height / d, 1.0 - back_height / d);
|
||||
for k in 0..=slices {
|
||||
|
||||
@@ -154,6 +154,50 @@ fn taper_shrinks_the_back_and_twist_turns_it() {
|
||||
assert!(open.len() > 2 * 8, "{}", open.len());
|
||||
}
|
||||
|
||||
/// A taper only scales each wall's far edge toward the center, so the wall stays one flat face;
|
||||
/// cut into slices, each joint between them showed a light tick. A twist bends the walls, which
|
||||
/// are still cut into slices.
|
||||
#[test]
|
||||
fn a_tapered_wall_is_one_flat_face_and_a_twisted_one_is_sliced() {
|
||||
let (p, b) = shape(&square(0.0, 0.0, 100.0));
|
||||
let v = View { rotation_x: -35.0, rotation_y: 25.0, ..front() };
|
||||
// Every side facet, the ones turned away included.
|
||||
let sides = |o: Extrude, v: View| -> Vec<Facet> {
|
||||
extrude(&p, FillRule::NonZero, b, Extrude { hidden: true, ..o }, v).unwrap().into_iter().filter(|f| f.side).collect()
|
||||
};
|
||||
let o = Extrude { depth: 40.0, taper: 0.475, ..Extrude::default() };
|
||||
let walls = sides(o, v);
|
||||
assert_eq!(walls.len(), 4, "one face for each of a square's four walls");
|
||||
for f in &walls {
|
||||
let [ring] = &f.view[..] else { panic!("{} contours", f.view.len()) };
|
||||
let n = super::build::newell(std::slice::from_ref(ring));
|
||||
let len = n.0.hypot(n.1).hypot(n.2);
|
||||
let off = |q: &V3| ((q.0 - ring[0].0) * n.0 + (q.1 - ring[0].1) * n.1 + (q.2 - ring[0].2) * n.2) / len;
|
||||
assert!(ring.iter().all(|q| off(q).abs() < 1e-9), "a flat wall: {:?}", ring.iter().map(off).collect::<Vec<_>>());
|
||||
}
|
||||
// Beveled, in perspective or hollow, a taper adds no faces either.
|
||||
let bevel = Bevel { width: 8.0, height: 8.0, ..Bevel::default() };
|
||||
let perspective = View { perspective: 60.0, ..v };
|
||||
for (o, v) in [
|
||||
(Extrude { bevel: Some(bevel), ..o }, v),
|
||||
(Extrude { bevel: Some(Bevel { both_sides: true, ..bevel }), ..o }, v),
|
||||
(Extrude { bevel: Some(Bevel { inside: true, shape: BevelShape::Step, ..bevel }), ..o }, v),
|
||||
(o, perspective),
|
||||
(Extrude { cap: false, ..o }, perspective),
|
||||
] {
|
||||
assert_eq!(sides(o, v).len(), sides(Extrude { taper: 1.0, ..o }, v).len(), "{o:?} {v:?}");
|
||||
}
|
||||
// A curved outline's walls take one band each too: a taper adds no faces to a circle's.
|
||||
let (cp, cb) = shape(&circle(Point::new(50.0, 50.0), 50.0));
|
||||
let round = |o: Extrude| extrude(&cp, FillRule::NonZero, cb, Extrude { hidden: true, ..o }, v).unwrap().into_iter().filter(|f| f.side).count();
|
||||
assert_eq!(round(o), round(Extrude { taper: 1.0, ..o }), "a tapered circle's walls");
|
||||
// A twist still cuts each wall into at least 8 slices, tapered or not.
|
||||
for taper in [1.0, o.taper] {
|
||||
let twisted = sides(Extrude { twist: 30.0, taper, ..o }, v).len();
|
||||
assert!(twisted >= 4 * 8, "{twisted} faces for a twisted square's walls");
|
||||
}
|
||||
}
|
||||
|
||||
/// A flat facet carries its plane: the depth it gives at each corner of its drawing is the corner's
|
||||
/// view-space depth, with and without perspective. A piece of a twisted wall whose corners lie off
|
||||
/// one plane has none.
|
||||
|
||||
@@ -35,11 +35,11 @@ mod map;
|
||||
use cache::{BACKGROUND_SEGMENTS, CacheKey, RegionCache};
|
||||
#[cfg(test)]
|
||||
use live_paint::LpHover;
|
||||
use live_paint::lp_color;
|
||||
pub use live_paint::{
|
||||
LP_BUCKET_EDGE_FRACTION, LP_BUCKET_WIDTH, LP_BUCKET_WIDTH_RANGE, LP_FILL_CHUNK_POINTS, LP_SELECT_WIDTH, LP_SELECT_WIDTH_RANGE,
|
||||
LivePaintBucketTool, LivePaintSelectionTool,
|
||||
};
|
||||
use live_paint::{check_live_paint_options, lp_color, lp_rgb_color};
|
||||
pub use lookup::{
|
||||
EDGE_NAME, FACE_NAME, Face, LIVE_PAINT_NAME, LIVE_PAINT_PREFIX, SOURCES_NAME, builder_members, drawn_outlines, edge_near, edges, face_at, faces,
|
||||
is_live_paint, leaf_shapes, live_paint_at, live_paint_takes, node_outline, outline_snap, path_contains, shape_builder_shapes, shapes_for,
|
||||
@@ -777,6 +777,39 @@ impl Tool for ShapeBuilderTool {
|
||||
}
|
||||
}
|
||||
|
||||
/// Why `tool.setOption` cannot give the builder-family tool `tool` the options `values`, checked
|
||||
/// before any of them is set. For `shapeBuilder`: an option the tool does not have, a switch that
|
||||
/// is not true or false, a `pickColorFrom` other than `artwork` or `swatches`, a `selection` other
|
||||
/// than `freeform` or `straightLine`, a `highlightColor` that is not an RGB color without an alpha
|
||||
/// part and a `gapLength` that is not a finite number are errors. A finite gap length out of 0 to
|
||||
/// [`SHAPE_BUILDER_MAX_GAP`] is not an error: the tool clamps it. The Live Paint tools are checked
|
||||
/// by [`check_live_paint_options`]; Ok for every other tool. Saved settings go to the tools'
|
||||
/// `set_option` instead, which skips a value it cannot read.
|
||||
pub fn check_builder_options(tool: &str, values: &serde_json::Map<String, Value>) -> Result<(), String> {
|
||||
if tool != "shapeBuilder" {
|
||||
return check_live_paint_options(tool, values);
|
||||
}
|
||||
let one_of = |key: &str, v: &Value, names: &[&str]| {
|
||||
(!v.as_str().is_some_and(|s| names.contains(&s))).then(|| format!("`{key}` must be one of {}", names.join(", ")))
|
||||
};
|
||||
for (key, v) in values {
|
||||
let error = match key.as_str() {
|
||||
"openFilledAsClosed" | "cursorSwatchPreview" | "highlightFill" | "gapDetection" | "clickStrokeSplitsPath" | "highlightStroke" => {
|
||||
(!v.is_boolean()).then(|| format!("`{key}` must be true or false"))
|
||||
}
|
||||
"pickColorFrom" => one_of(key, v, &["artwork", "swatches"]),
|
||||
"selection" => one_of(key, v, &["freeform", "straightLine"]),
|
||||
"gapLength" => (!v.as_f64().is_some_and(f64::is_finite)).then(|| format!("`{key}` must be a number")),
|
||||
"highlightColor" => (!lp_rgb_color(v)).then(|| format!("`{key}` must be a #rrggbb color or a layer color name")),
|
||||
_ => Some(format!("unknown Shape Builder option `{key}`")),
|
||||
};
|
||||
if let Some(e) = error {
|
||||
return Err(e);
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Create a builder-family tool by id (None = not one of ours).
|
||||
pub fn create(id: &str) -> Option<Box<dyn Tool>> {
|
||||
Some(match id {
|
||||
|
||||
@@ -594,6 +594,42 @@ pub(super) fn lp_color(v: &Value) -> Option<[u8; 3]> {
|
||||
Some([r, g, b])
|
||||
}
|
||||
|
||||
/// Whether `v` is a highlight color [`lp_color`] reads without an alpha part: `#rrggbbaa` (with
|
||||
/// or without `#`) is not one.
|
||||
pub(super) fn lp_rgb_color(v: &Value) -> bool {
|
||||
let Some(s) = v.as_str() else { return false };
|
||||
let digits = s.trim().trim_start_matches('#');
|
||||
let alpha = digits.len() == 8 && digits.chars().all(|c| c.is_ascii_hexdigit());
|
||||
!alpha && lp_color(v).is_some()
|
||||
}
|
||||
|
||||
/// Why `tool.setOption` cannot give the Live Paint tool `tool` (`livePaintBucket` or
|
||||
/// `livePaintSelection`) the options `values`, checked before any of them is set: an option the
|
||||
/// tool does not have, a switch that is not true or false, a `color` that is not an RGB color
|
||||
/// ([`lp_rgb_color`]) and a `width` that is not a finite number are errors. A finite width out of
|
||||
/// [`LP_BUCKET_WIDTH_RANGE`] or [`LP_SELECT_WIDTH_RANGE`] is not an error: the tool clamps it.
|
||||
/// Ok for every other tool. Saved settings go to the tools' `set_option` instead, which skips a
|
||||
/// value it cannot read.
|
||||
pub fn check_live_paint_options(tool: &str, values: &serde_json::Map<String, Value>) -> Result<(), String> {
|
||||
let switches: &[&str] = match tool {
|
||||
"livePaintBucket" => &["paintFills", "paintStrokes", "highlight"],
|
||||
"livePaintSelection" => &["selectFills", "selectStrokes"],
|
||||
_ => return Ok(()),
|
||||
};
|
||||
for (key, v) in values {
|
||||
let error = match key.as_str() {
|
||||
"color" => (!lp_rgb_color(v)).then(|| format!("`{key}` must be a #rrggbb color or a layer color name")),
|
||||
"width" => (!v.as_f64().is_some_and(f64::is_finite)).then(|| format!("`{key}` must be a number")),
|
||||
k if switches.contains(&k) => (!v.is_boolean()).then(|| format!("`{key}` must be true or false")),
|
||||
_ => Some(format!("unknown Live Paint option `{key}`")),
|
||||
};
|
||||
if let Some(e) = error {
|
||||
return Err(e);
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
impl Tool for LivePaintSelectionTool {
|
||||
fn id(&self) -> &'static str {
|
||||
"livePaintSelection"
|
||||
|
||||
@@ -39,9 +39,17 @@ pub const MIN_FIT_TOLERANCE: f64 = MIN_FIDELITY / 640.0;
|
||||
/// A change to either range changes this bound with it.
|
||||
pub const MAX_FIT_TOLERANCE: f64 = 100.0 / 0.0313;
|
||||
|
||||
/// The lowest Fidelity (points) the Smooth tool takes.
|
||||
/// The lowest Fidelity (screen pixels) the Smooth tool takes.
|
||||
pub const SMOOTH_MIN_FIDELITY: f64 = 0.05;
|
||||
|
||||
/// The finest tolerance (points) `path.smoothRegion` refits at: the Smooth tool's lowest Fidelity at
|
||||
/// the highest zoom (64000 %), whose screen pixels the tools send in points.
|
||||
pub const SMOOTH_MIN_TOLERANCE: f64 = SMOOTH_MIN_FIDELITY / 640.0;
|
||||
|
||||
/// The coarsest tolerance (points) `path.smoothRegion` refits at: the highest Fidelity (100 screen
|
||||
/// pixels) at the lowest zoom (3.13 %).
|
||||
pub const SMOOTH_MAX_TOLERANCE: f64 = 100.0 / 0.0313;
|
||||
|
||||
/// Whether segment `i` of `sp` is straight, so the Smooth tool leaves it as it is: its handles lie
|
||||
/// on the line through its ends, and the segment runs from one end to the other without turning back.
|
||||
pub fn segment_is_straight(sp: &SubPath, i: usize) -> bool {
|
||||
|
||||
@@ -72,7 +72,7 @@ const BAND_WORK_PER_CALL: usize = 20_000;
|
||||
/// a fit's cost grows as its Fidelity shrinks (at 0.01 pt a long pen stroke lagged, #4565), and
|
||||
/// a preview within half a pixel of the stroke looks the same. The release fits it at its Fidelity.
|
||||
const PREVIEW_FIDELITY_PX: f64 = 0.5;
|
||||
/// The Smooth tool's Fidelity until Smooth Tool Options sets another.
|
||||
/// The Smooth tool's Fidelity (screen pixels) until Smooth Tool Options sets another.
|
||||
const SMOOTH_FIDELITY: f64 = 2.5;
|
||||
|
||||
/// The `fidelity` (points) the Pencil and the Paintbrush send `path.freehand` for `px` screen pixels
|
||||
@@ -107,8 +107,8 @@ pub struct GestureTool {
|
||||
/// Paths' distance in points); the stroke redraws the section it ends on (#2850).
|
||||
redraw: Option<(NodeId, f64)>,
|
||||
/// Fit tolerance (Pencil/Paintbrush/Smooth/Blob Brush "Fidelity"): in screen pixels for the
|
||||
/// Pencil and the Paintbrush, so a stroke keeps as many points at any zoom (#5729), in points
|
||||
/// for the others.
|
||||
/// Pencil and the Paintbrush, so a stroke keeps as many points at any zoom (#5729), and for the
|
||||
/// Smooth tool, so a drag smooths as much at any zoom; in points for the Blob Brush.
|
||||
pub fidelity: f64,
|
||||
/// Brush / eraser diameter in points (the Eraser's across its tip's long axis).
|
||||
pub size: f64,
|
||||
@@ -139,8 +139,8 @@ pub struct GestureTool {
|
||||
/// preview shows the points joined by straight segments and the release fits them (Pencil
|
||||
/// only, on by default).
|
||||
pub live_fit: bool,
|
||||
/// The Smooth tool's Fidelity, used when Alt switches the Pencil, Paintbrush or Blob Brush to Smooth.
|
||||
/// The engine sets it from Smooth Tool Options (`smoothFidelity`).
|
||||
/// The Smooth tool's Fidelity (screen pixels), used when Alt switches the Pencil, Paintbrush or
|
||||
/// Blob Brush to Smooth. The engine sets it from Smooth Tool Options (`smoothFidelity`).
|
||||
smooth_fidelity: f64,
|
||||
/// Active drag is smoothing with the Pencil, Paintbrush or Blob Brush (Alt held).
|
||||
smoothing: bool,
|
||||
@@ -1184,7 +1184,7 @@ impl GestureTool {
|
||||
if pts.len() >= 2 && has_selected_smooth_path(cx) {
|
||||
return vec![Action::Exec(
|
||||
"path.smoothRegion".into(),
|
||||
json!({"points": points_json(pts), "radius": radius, "fidelity": self.smooth_fidelity}),
|
||||
json!({"points": points_json(pts), "radius": radius, "fidelity": cx.tol(self.smooth_fidelity)}),
|
||||
)];
|
||||
}
|
||||
return vec![];
|
||||
@@ -1234,7 +1234,10 @@ impl GestureTool {
|
||||
)],
|
||||
"knife" if pts.len() >= 2 => vec![Action::Exec("path.knife".into(), json!({"points": points_json(pts)}))],
|
||||
"smooth" if pts.len() >= 2 && has_selected_smooth_path(cx) => {
|
||||
vec![Action::Exec("path.smoothRegion".into(), json!({"points": points_json(pts), "radius": radius, "fidelity": self.fidelity}))]
|
||||
vec![Action::Exec(
|
||||
"path.smoothRegion".into(),
|
||||
json!({"points": points_json(pts), "radius": radius, "fidelity": cx.tol(self.fidelity)}),
|
||||
)]
|
||||
}
|
||||
"pathEraser" if has_sel => vec![Action::Exec("path.eraseSegments".into(), json!({"points": points_json(pts), "width": cx.tol(8.0)}))],
|
||||
"join" if pts.len() >= 2 => vec![Action::Exec("path.joinScrub".into(), json!({"points": points_json(pts), "tolerance": tol}))],
|
||||
|
||||
@@ -593,7 +593,7 @@ fn blob_brush_alt_switches_to_the_smooth_tool() {
|
||||
let acts = drag_held(&mut t, &cx, &stroke, alt);
|
||||
assert!(acts[..acts.len() - 1].iter().all(|a| a.is_empty()), "{acts:?}");
|
||||
let v = smoothed(&acts).unwrap_or_else(|| panic!("{acts:?}"));
|
||||
assert_eq!(v, json!({"points": [[50.0, 150.0], [150.0, 150.0], [250.0, 160.0]], "radius": cx.tol(12.0), "fidelity": SMOOTH_FIDELITY}));
|
||||
assert_eq!(v, json!({"points": [[50.0, 150.0], [150.0, 150.0], [250.0, 160.0]], "radius": cx.tol(12.0), "fidelity": cx.tol(SMOOTH_FIDELITY)}));
|
||||
// Mid-drag the cursor stays the Smooth tool's when Alt is let go, and the line is thin with
|
||||
// no length label.
|
||||
t.pointer(&cx, &PointerEvent::new(PointerKind::Down, 50.0, 150.0).with_mods(alt));
|
||||
@@ -1507,6 +1507,37 @@ fn alt_smoothing_uses_the_smooth_tools_fidelity() {
|
||||
}
|
||||
}
|
||||
|
||||
/// The Smooth tool's Fidelity is in screen pixels: the Smooth tool and an Alt smoothing drag with
|
||||
/// the Pencil, Paintbrush or Blob Brush send it in points at Fidelity / zoom, so a drag smooths as
|
||||
/// much on screen at any zoom.
|
||||
#[test]
|
||||
fn smooth_fidelity_is_in_screen_pixels_at_any_zoom() {
|
||||
let (d, id) = doc_with_rect();
|
||||
let mut s = Selection::default();
|
||||
s.set([id]);
|
||||
let p = paint();
|
||||
let mut cx = cx(&d, &s, &p);
|
||||
cx.smart_guides = false;
|
||||
let pts = [(50.0, 150.0), (150.0, 150.0), (250.0, 160.0)];
|
||||
let fidelity = |acts: &[Vec<Action>]| match acts.last().and_then(|a| a.first()) {
|
||||
Some(Action::Exec(c, v)) if c == "path.smoothRegion" => v["fidelity"].as_f64().unwrap(),
|
||||
a => panic!("expected path.smoothRegion, got: {a:?}"),
|
||||
};
|
||||
let alt = Mods { alt: true, ..Default::default() };
|
||||
for zoom in [0.25, 1.0, 8.0] {
|
||||
cx.zoom = zoom;
|
||||
let mut t = GestureTool::new("smooth");
|
||||
t.set_option("fidelity", &json!(4.0));
|
||||
assert_eq!(fidelity(&drag(&mut t, &cx, &pts, Mods::default())), 4.0 / zoom, "smooth at {zoom}x");
|
||||
for (id, alt_smooth) in [("pencil", true), ("blobBrush", false), ("paintbrush", false)] {
|
||||
let mut t = GestureTool::new(id);
|
||||
t.set_option("altSmooth", &json!(alt_smooth));
|
||||
t.set_option("smoothFidelity", &json!(4.0));
|
||||
assert_eq!(fidelity(&drag_held(&mut t, &cx, &pts, alt)), 4.0 / zoom, "{id} at {zoom}x");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Alt pressed after a Paintbrush stroke begins closes it, and an empty selection still draws.
|
||||
#[test]
|
||||
fn paintbrush_alt_after_press_closes_without_smoothing_and_empty_selection_draws() {
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
//! Bridges… opens the generic command dialog (`ui.paramDialog`, `dialogs::command`) on the options
|
||||
//! of `addon.cutting.bridges`. Weed Box and Lines… opens it on the options of `addon.cutting.weed`,
|
||||
//! with a Preview, and runs the command on OK. A Spacing above 0 is sent instead of the Count.
|
||||
//! Banner Hems and Grommets… opens it on the options of `addon.cutting.banner`, with a Preview.
|
||||
|
||||
use serde_json::json;
|
||||
|
||||
@@ -15,6 +16,8 @@ pub const BRIDGE_DIRECTIONS: &[(&str, &str)] = &[("Vertical", "vertical"), ("Hor
|
||||
pub const WEED: &str = "addon.cutting.weed";
|
||||
/// Weed Box and Lines › Direction: what the command's `direction` takes, by name.
|
||||
pub const WEED_DIRECTIONS: &[(&str, &str)] = &[("Horizontal", "horizontal"), ("Vertical", "vertical"), ("Both", "both")];
|
||||
/// Banner Hems and Grommets' command.
|
||||
pub const BANNER: &str = "addon.cutting.banner";
|
||||
|
||||
/// Object › Plug-ins › Cutting Tools.
|
||||
pub fn menu() -> Vec<Item> {
|
||||
@@ -25,6 +28,14 @@ pub fn menu() -> Vec<Item> {
|
||||
WEED,
|
||||
json!({"margin": 9, "box": true, "direction": "both", "count": 1, "spacing": 0, "gap": 0, "weight": 0.3, "swatch": "[Registration]"}),
|
||||
),
|
||||
Item::Cmd(
|
||||
"Banner Hems and Grommets…",
|
||||
BANNER,
|
||||
json!({
|
||||
"hem": 72, "top": true, "right": true, "bottom": true, "left": true, "grommets": true, "spacing": 1728, "grommetInset": 36,
|
||||
"diameter": 27, "weight": 0.3, "swatch": "[Registration]"
|
||||
}),
|
||||
),
|
||||
]
|
||||
}
|
||||
|
||||
|
||||
@@ -143,3 +143,70 @@ fn weed_box_and_lines_previews_cancels_and_runs_on_ok() {
|
||||
app.run("edit.undo", json!({})).unwrap();
|
||||
}
|
||||
}
|
||||
|
||||
/// Object › Plug-ins › Cutting Tools › Banner Hems and Grommets… opens the command dialog on its
|
||||
/// defaults with Preview off, previews the marks, takes them away on Cancel and keeps them as one
|
||||
/// step on OK. With nothing selected it marks the artboard.
|
||||
#[test]
|
||||
fn banner_hems_and_grommets_previews_cancels_and_runs_on_ok() {
|
||||
let mut app = VectorcraftApp::new(Session::new(), Default::default());
|
||||
app.run("file.new", json!({"width": 600, "height": 400})).unwrap();
|
||||
app.run("select.none", json!({})).unwrap();
|
||||
let steps = |app: &VectorcraftApp| app.session.doc().unwrap().history.undo.len();
|
||||
let count = |app: &VectorcraftApp| app.session.doc().unwrap().doc.node_count();
|
||||
let (start, n, nodes) = (app.session.doc().unwrap().doc.clone(), steps(&app), count(&app));
|
||||
let open = |app: &mut VectorcraftApp| {
|
||||
let entries = menus::menu_entries(app);
|
||||
let item = entries.iter().find(|e| e.path == ["Object", "Plug-ins", "Cutting Tools"] && e.label == "Banner Hems and Grommets…").unwrap();
|
||||
assert!(item.enabled);
|
||||
let (cmd, p) = menus::click_target(&item.label, item.command.as_deref().unwrap(), &item.params);
|
||||
menus::invoke(app, &cmd, p);
|
||||
};
|
||||
|
||||
open(&mut app);
|
||||
let d = app.ui.dialog.as_ref().expect("a dialog opens");
|
||||
assert_eq!((d.str("__command"), d.str("__label")), (super::BANNER.to_string(), "Banner Hems and Grommets".to_string()));
|
||||
assert_eq!((d.bool("grommets"), d.bool("top"), d.bool("preview")), (true, true, false));
|
||||
let shown = painted_text(&mut app, |app, ui| crate::dialogs::show(app, ui.ctx()));
|
||||
for s in [
|
||||
"Banner Hems and Grommets",
|
||||
"Hem",
|
||||
"Top",
|
||||
"Right",
|
||||
"Bottom",
|
||||
"Left",
|
||||
"Grommets",
|
||||
"Spacing",
|
||||
"Grommet Inset",
|
||||
"Diameter",
|
||||
"Weight",
|
||||
"Swatch",
|
||||
"Preview",
|
||||
] {
|
||||
assert!(shown.contains(s), "{s} in {shown}");
|
||||
}
|
||||
crate::i18n::set_current(crate::i18n::Lang::from_code("de").expect("de registered"));
|
||||
let german = painted_text(&mut app, |app, ui| crate::dialogs::show(app, ui.ctx()));
|
||||
crate::i18n::set_current(crate::i18n::Lang::EN);
|
||||
for s in ["Bannersäume und Ösen", "Saum:", "Ösen:", "Randabstand der Ösen:", "Durchmesser:"] {
|
||||
assert!(german.contains(s), "{s} in {german}");
|
||||
}
|
||||
assert_eq!(count(&app), nodes, "no preview without the checkbox");
|
||||
|
||||
// 600 × 400 pt with grommets at most 1728 pt apart: one at each corner. The group holds four
|
||||
// hem lines and four grommets of three paths each.
|
||||
let added = 1 + 4 + 4 * 4;
|
||||
crate::dialogs::set_field(app.ui.dialog.as_mut().unwrap(), "preview".into(), json!(true));
|
||||
painted_text(&mut app, |app, ui| crate::dialogs::show(app, ui.ctx()));
|
||||
assert_eq!(count(&app), nodes + added, "previewed");
|
||||
crate::dialogs::cancel(&mut app);
|
||||
assert_eq!(app.session.doc().unwrap().doc, start, "Cancel takes the preview away");
|
||||
assert_eq!(steps(&app), n, "and adds no undo step");
|
||||
|
||||
open(&mut app);
|
||||
crate::dialogs::set_field(app.ui.dialog.as_mut().unwrap(), "spacing".into(), json!("100 pt"));
|
||||
crate::dialogs::confirm(&mut app).unwrap();
|
||||
assert_eq!(steps(&app), n + 1, "one undo step");
|
||||
// 528 pt along the top and bottom need 6 gaps, 328 pt down the sides 4: 20 grommets.
|
||||
assert_eq!(count(&app), nodes + 1 + 4 + 20 * 4);
|
||||
}
|
||||
|
||||
@@ -1236,7 +1236,8 @@ pub fn handle(app: &mut VectorcraftApp, ctx: &egui::Context, req: &ControlReques
|
||||
}
|
||||
|
||||
/// Hides in `reply` the libraries hidden now and in `before`, and the paths outside the read root that
|
||||
/// `sent`, the request's params, holds ([`handle`]). The `path` and `files` entries of the reply's
|
||||
/// `sent`, the request's params, holds ([`handle`]), and the recovery folders outside the read root
|
||||
/// that are in use or that a failure message named. The `path` and `files` entries of the reply's
|
||||
/// `result`, at any depth, that name a file the request gave inside the write roots, the file it
|
||||
/// wrote, stay as given ([`written_paths`]).
|
||||
fn hide_in_reply(app: &VectorcraftApp, reply: &mut Value, sent: &Value, before: Hiding) {
|
||||
@@ -1250,6 +1251,11 @@ fn hide_in_reply(app: &VectorcraftApp, reply: &mut Value, sent: &Value, before:
|
||||
hiding.merge(before);
|
||||
let mut paths = Vec::new();
|
||||
sent_paths(sent, &mut paths);
|
||||
// The status text of a recovery copy that failed names the recovery folder it went to.
|
||||
let current = vectorcraft_engine::cmd::recovery::folder(&app.session).map(|(f, _)| f);
|
||||
for folder in app.recovery.named.iter().chain(current.as_ref()) {
|
||||
collect_sent_paths(&json!(folder), true, &mut paths);
|
||||
}
|
||||
hiding.set_hidden(paths, cfg!(windows));
|
||||
hide_library_ids(reply, &hiding);
|
||||
restore_written_paths(reply, kept);
|
||||
|
||||
@@ -415,6 +415,29 @@ fn a_hidden_library_at_the_top_of_the_file_system_keeps_its_shown_id() {
|
||||
assert_eq!(docking["result"]["flyout"], "library:swatches:loaded/b.svg", "{docking}");
|
||||
}
|
||||
|
||||
/// `ui.inspect` shows a recovery folder outside the read root that the status text names (a failed
|
||||
/// recovery copy) by its folder name, also in the paths under it: the folder in use, and one a
|
||||
/// failure named before the preference moved on. A readable one stays.
|
||||
#[test]
|
||||
fn a_recovery_folder_outside_the_read_root_is_hidden_in_the_status() {
|
||||
let mut app = VectorcraftApp::new(Session::new(), Services::default());
|
||||
app.session.recovery.set_default_folder(Some("/outside/recovery".into()));
|
||||
let root = crate::tests_folder::Folder::new("recovery-status");
|
||||
let roots = vectorcraft_engine::file_access::AutomationRoots::new(&[root.as_path()], &[]).unwrap();
|
||||
let inspect = |app: &mut VectorcraftApp, status: String| {
|
||||
app.ui.status = status;
|
||||
let reply = vectorcraft_engine::file_access::confine(roots.as_ref(), || input_request(app, "ui.inspect", json!({})));
|
||||
reply["result"]["ui"]["status"].clone()
|
||||
};
|
||||
let status = "Couldn't save recovery data: /outside/recovery/a-1 is not a folder. Copies go to /outside/recovery".to_string();
|
||||
assert_eq!(inspect(&mut app, status), "Couldn't save recovery data: recovery/a-1 is not a folder. Copies go to recovery");
|
||||
let inside = root.as_path().join("copies").to_string_lossy().into_owned();
|
||||
app.session.prefs.recovery_folder = inside.clone();
|
||||
app.recovery.named = vec!["/outside/earlier".into()];
|
||||
assert_eq!(inspect(&mut app, "Copies go to /outside/earlier".into()), "Copies go to earlier");
|
||||
assert_eq!(inspect(&mut app, format!("Copies go to {inside}")), json!(format!("Copies go to {inside}")));
|
||||
}
|
||||
|
||||
/// A path outside the read root that a request sent is shown by its file name in a reply that succeeded
|
||||
/// as in one that failed.
|
||||
#[test]
|
||||
|
||||
@@ -65,7 +65,8 @@ pub(super) const SPEC: DialogSpec = DialogSpec {
|
||||
/// grid options lists one, and the radial and mirror pages do not. Type on a Path Options does
|
||||
/// too (#5360), as public tutorials of the dialog show, and Type Tools › Release Type on a Path
|
||||
/// works the same way (#5532), and so do Image Tools › Remove Background and Color Mask and
|
||||
/// Cutting Tools › Weed Box and Lines. (Cancel rolls a preview back whichever dialog started it.)
|
||||
/// Cutting Tools › Weed Box and Lines and Banner Hems and Grommets. (Cancel rolls a preview back
|
||||
/// whichever dialog started it.)
|
||||
fn previews(d: &Dialog) -> bool {
|
||||
match d.str("__command").as_str() {
|
||||
"object.repeat.options" => d.fields.contains_key("gridType"),
|
||||
@@ -73,7 +74,8 @@ fn previews(d: &Dialog) -> bool {
|
||||
| crate::addons::typetools::RELEASE
|
||||
| crate::addons::image::REMOVE_BACKGROUND
|
||||
| crate::addons::image::COLOR_MASK
|
||||
| crate::addons::cutting::WEED => true,
|
||||
| crate::addons::cutting::WEED
|
||||
| crate::addons::cutting::BANNER => true,
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
@@ -120,6 +122,7 @@ fn lengths(command: &str) -> &'static [&'static str] {
|
||||
"shape.flare" => &["diameter", "pathLength"],
|
||||
"perspective.grid.set" => &["cell", "distance"],
|
||||
crate::addons::cutting::WEED => &["margin", "spacing", "gap", "weight"],
|
||||
crate::addons::cutting::BANNER => &["hem", "spacing", "grommetInset", "diameter", "weight"],
|
||||
"object.repeat.options" => &["radius", "hSpacing", "vSpacing"],
|
||||
"text.areaOptions" => &["width", "height", "gutter", "rowGutter", "inset", "firstBaselineMin", "lineGap"],
|
||||
"type.pathOptions" => &["spacing"],
|
||||
@@ -134,6 +137,9 @@ fn lengths(command: &str) -> &'static [&'static str] {
|
||||
fn order(command: &str) -> &'static [&'static str] {
|
||||
match command {
|
||||
crate::addons::cutting::WEED => &["margin", "box", "direction", "count", "spacing", "gap", "weight", "swatch"],
|
||||
crate::addons::cutting::BANNER => {
|
||||
&["hem", "top", "right", "bottom", "left", "grommets", "spacing", "grommetInset", "diameter", "weight", "swatch"]
|
||||
}
|
||||
// The public Trap options' order.
|
||||
"object.pathfinder.trap" => &["thickness", "heightWidth", "tintReduction", "trapsWithProcessColor", "reverseTraps"],
|
||||
"addon.color.printFixes" => &["inkLimit", "excludePureBlack", "removeWhiteOverprint", "overprintBlack", "textOnly", "fill", "stroke"],
|
||||
|
||||
@@ -105,7 +105,7 @@ fn body(app: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) -> bool {
|
||||
d.fields.insert("radius".into(), json!(n));
|
||||
}
|
||||
} else {
|
||||
form::length(ui, d, "radius", unit, 120.0);
|
||||
form::length_within(ui, d, "radius", unit, 120.0, Some(0.0..=f64::INFINITY));
|
||||
}
|
||||
ui.end_row();
|
||||
});
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user