mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-11-15 21:57:04 +00:00
Merge main into fix/symbol-instances-are-clicked-and-marquee-touched
This commit is contained in:
Generated
+1
@@ -6824,6 +6824,7 @@ version = "0.24.1"
|
||||
dependencies = [
|
||||
"craft-docpath",
|
||||
"craft-svg",
|
||||
"data-url",
|
||||
"flate2",
|
||||
"image",
|
||||
"kurbo 0.13.1",
|
||||
|
||||
+3
-1
@@ -2,7 +2,7 @@
|
||||
|
||||
**Stage: alpha** · next: beta, ~13 points (ready for real work ~62% → ~75%, and reliable `.ai` exchange) and ~170–270 h away
|
||||
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-10 · **Change:** minor (docs tidy-up, no content change: stray notes at the top moved into the revision table; scores and estimates unchanged; previously minor: Raster effects 85% → 88%, gallery thumbnails and in-dialog preview in; headline numbers unchanged; Symbols area ~87% → ~88%: dynamic instance parts take opacity, blend, gradient and pattern on the canvas; G5 docs sync for Overlap and Pattern auto corners; previously minor: Tools stays beside the canvas with left-side panels; Hindi catalog draft complete under the translation tests; contributor loanword terminology reviewed; wider proofreading welcome; Compound Shapes add-on, beyond Illustrator; scores and estimates unchanged; Contour add-on, beyond Illustrator; scores and estimates unchanged; new gradients follow document color mode; scores and estimates unchanged) · **Target:** Adobe Illustrator 2026 (30.x)
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-14 · **Change:** minor (Tech Circuit brush library, G5; previously: docs tidy-up, no content change: stray notes at the top moved into the revision table; scores and estimates unchanged; previously minor: Raster effects 85% → 88%, gallery thumbnails and in-dialog preview in; headline numbers unchanged; Symbols area ~87% → ~88%: dynamic instance parts take opacity, blend, gradient and pattern on the canvas; G5 docs sync for Overlap and Pattern auto corners; previously minor: Tools stays beside the canvas with left-side panels; Hindi catalog draft complete under the translation tests; contributor loanword terminology reviewed; wider proofreading welcome; Compound Shapes add-on, beyond Illustrator; scores and estimates unchanged; Contour add-on, beyond Illustrator; scores and estimates unchanged; new gradients follow document color mode; scores and estimates unchanged) · **Target:** Adobe Illustrator 2026 (30.x)
|
||||
|
||||
VectorCraft is a clean-room, open-source, pure-Rust reimplementation of the Adobe Illustrator workflow. It runs
|
||||
on macOS, Windows, Linux, FreeBSD and the web (WASM), and agents can drive all of it over MCP, the CLI and a JSON
|
||||
@@ -125,6 +125,7 @@ Ranked; detail in [docs/roadmap.md](docs/roadmap.md) and [docs/gaps.md](docs/gap
|
||||
## Progress log
|
||||
|
||||
- 2026-11-14: Built-in symbol library Weather Icons: adds a sixth procedural symbol library (Window › Symbol Libraries, the Symbols panel's Symbol Libraries Menu) containing six original vector symbols (Sun, Moon, Cloud, Rain, Thunder, Snow) generated in code without default 9-slice guides (part of Gap 5). Headline numbers unchanged.
|
||||
- 2026-11-14: Built-in Tech Circuit brush library: Circuit Trace (chamfered art with 45-degree elbows and terminal pads), Bus Lines (parallel conductor pattern) and Terminal Nodes (repeating solder pads, test points and leads), all original geometry generated in code. Ten built-in brush libraries; G5 remaining estimate 3–13 h (estimated), headline numbers unchanged.
|
||||
|
||||
- 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.
|
||||
@@ -224,6 +225,7 @@ The full earlier record of what landed is kept in [docs/roadmap.md](docs/roadmap
|
||||
|
||||
| Date | Change | Summary |
|
||||
|---|---|---|
|
||||
| 2026-11-14 | minor | Tech Circuit adds three original procedural brushes (Circuit Trace, Bus Lines, Terminal Nodes); ten built-in brush libraries. G5 remaining estimate 3–13 h (estimated); parity scores and headline totals unchanged. |
|
||||
| 2026-11-14 | minor | Gujarati (#6613): contributor proofreading completed for all 387 additions, with 11 wording corrections; 4,673 reviewed catalog rows. Status/error messages remain partial; web selection requires a bundled Gujarati face. Scores and estimates unchanged. |
|
||||
| 2026-11-10 | minor | Tidy-up, no content change: the unheaded change bullets at the top of the file and a revision row inside the headline-numbers table move to this table under their commit dates. Scores and estimates unchanged. |
|
||||
| 2026-11-10 | minor | Raster effects: Effect Gallery thumbnails and an in-dialog preview (an original drawing through each filter). Features-table Raster effects 85% → 88%, hours 1–2 → none. Headline numbers unchanged, so the progress log is unchanged. |
|
||||
|
||||
@@ -172,6 +172,21 @@ impl IccProfile {
|
||||
Some(dst.map(|x| x.clamp(0.0, 1.0)))
|
||||
}
|
||||
|
||||
/// Device pixels `v` (RGB / CMYK / Gray, 0..1, the profile's channels a pixel, any number of
|
||||
/// pixels) → sRGB in `dst` (three values a pixel), converted with `intent` in one pass of the
|
||||
/// cached transform. `None` when `v` doesn't hold whole pixels, `dst` doesn't hold three values
|
||||
/// for each, or no transform can be built.
|
||||
pub fn to_srgb_pixels(&self, v: &[f32], dst: &mut [f32], intent: Intent) -> Option<()> {
|
||||
let n = self.channels();
|
||||
if !v.len().is_multiple_of(n) || dst.len() != v.len() / n * 3 {
|
||||
return None;
|
||||
}
|
||||
let xf = self.to_xf(intent)?;
|
||||
xf.transform(v, dst).ok()?;
|
||||
dst.iter_mut().for_each(|x| *x = x.clamp(0.0, 1.0));
|
||||
Some(())
|
||||
}
|
||||
|
||||
/// sRGB → device values.
|
||||
pub fn from_srgb(&self, rgb: [f32; 3], intent: Intent) -> Option<Vec<f32>> {
|
||||
let xf = self.inverse_xf(intent)?;
|
||||
|
||||
@@ -48,6 +48,28 @@ fn gray_is_srgb_toned() {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn many_pixels_convert_as_each_one_does() {
|
||||
let p = IccProfile::from_bytes(None, &icc_bytes(DISPLAY_P3).unwrap()).unwrap();
|
||||
let px = [[1.0f32, 0.0, 0.0], [0.2, 0.6, 0.9], [0.5, 0.5, 0.5], [0.0, 1.0, 0.0]];
|
||||
for intent in Intent::ALL {
|
||||
let v: Vec<f32> = px.iter().flatten().copied().collect();
|
||||
let mut dst = vec![0.0; v.len()];
|
||||
p.to_srgb_pixels(&v, &mut dst, intent).unwrap();
|
||||
for (i, rgb) in px.iter().enumerate() {
|
||||
let one = p.to_srgb(rgb, intent).unwrap();
|
||||
assert!(dst[i * 3..i * 3 + 3].iter().zip(one).all(|(a, b)| (a - b).abs() < 1e-5), "{intent:?} {rgb:?}: {dst:?} vs {one:?}");
|
||||
}
|
||||
}
|
||||
// Lengths that aren't whole pixels, or a destination of another size, are refused.
|
||||
assert!(p.to_srgb_pixels(&[0.5; 4], &mut [0.0; 3], Intent::Perceptual).is_none());
|
||||
assert!(p.to_srgb_pixels(&[0.5; 3], &mut [0.0; 6], Intent::Perceptual).is_none());
|
||||
let gray = IccProfile::from_bytes(None, &icc_bytes(GRAY).unwrap()).unwrap();
|
||||
let mut dst = [0.0; 6];
|
||||
gray.to_srgb_pixels(&[0.25, 0.75], &mut dst, Intent::RelativeColorimetric).unwrap();
|
||||
assert!(dst.iter().zip([0.25, 0.25, 0.25, 0.75, 0.75, 0.75]).all(|(a, b)| (a - b).abs() < 0.01), "{dst:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn encoded_profiles_are_reproducible() {
|
||||
assert_eq!(icc::builtin_rgb_bytes(DISPLAY_P3).unwrap(), icc::builtin_rgb_bytes(DISPLAY_P3).unwrap());
|
||||
|
||||
@@ -120,6 +120,7 @@ pub use style_libs::StyleLibrary;
|
||||
pub use text::FontHeightReference;
|
||||
pub use text::GlyphChoice;
|
||||
pub use text::GlyphForm;
|
||||
pub use text::PATH_LEAD_MAX;
|
||||
pub use text::WarichuAlign;
|
||||
pub use text::{
|
||||
AreaFit, AreaOptions, Burasagari, CharAlign, CharPosition, CharStyle, Composer, Digits, FirstBaseline, HyphenationOptions, InlineArt,
|
||||
|
||||
@@ -173,6 +173,10 @@ pub struct PatternDef {
|
||||
/// Size Tile to Art: the tile is the art bounds grown by the spacing.
|
||||
#[serde(default)]
|
||||
pub size_tile_to_art: bool,
|
||||
/// Move Tile with Art: in pattern editing mode, a move of all of the tile art moves the tile by
|
||||
/// the same offset.
|
||||
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
|
||||
pub move_tile_with_art: bool,
|
||||
#[serde(default)]
|
||||
pub h_spacing: f64,
|
||||
#[serde(default)]
|
||||
@@ -202,6 +206,7 @@ impl PatternDef {
|
||||
art,
|
||||
overlap: Overlap::default(),
|
||||
size_tile_to_art: false,
|
||||
move_tile_with_art: false,
|
||||
h_spacing: 0.0,
|
||||
v_spacing: 0.0,
|
||||
copies: 5,
|
||||
|
||||
@@ -29,6 +29,7 @@ pub use para_style::{
|
||||
Burasagari, Composer, FirstBaseline, HyphenationOptions, JustificationOptions, Justify, Kashida, Kinsoku, KinsokuShoriType, LeadingModel,
|
||||
Mojikumi, ParaDirection, ParaStyle, SingleWordJustify,
|
||||
};
|
||||
pub use runs::PATH_LEAD_MAX;
|
||||
pub use runs::{INLINE_CHAR, INLINE_MAX_SCALE, InlineArt, RubyAlign, RubyPosition, RubySpan, TextKind, TextRun, TextStyleDef, TypeAntiAlias};
|
||||
pub use tabs_lists::{
|
||||
Bullet, DEFAULT_TAB_INTERVAL, LIST_HANGING_INDENT, LIST_LEVEL_INDENT, LIST_LEVELS, ListAlign, ListCounts, NumberStyle, ParaList, TabAlign,
|
||||
|
||||
@@ -298,6 +298,15 @@ impl GlyphForm {
|
||||
GlyphForm::QuarterWidth => b"qwid",
|
||||
})
|
||||
}
|
||||
|
||||
/// Is this one of the monospaced width forms (`fwid`, `hwid`, `twid`, `qwid`)? A font may
|
||||
/// draw them with other glyphs (`GSUB`) or with other widths for the same glyphs (`GPOS`), and
|
||||
/// they set the characters on a fixed width, so Proportional Metrics (`palt`) is not applied
|
||||
/// with them.
|
||||
/// <https://learn.microsoft.com/en-us/typography/opentype/spec/features_fj#tag-fwid>
|
||||
pub fn is_width(self) -> bool {
|
||||
matches!(self, GlyphForm::FullWidth | GlyphForm::HalfWidth | GlyphForm::ThirdWidth | GlyphForm::QuarterWidth)
|
||||
}
|
||||
}
|
||||
|
||||
/// A glyph chosen for one character ([`CharStyle::glyph_alternate`]): the glyph id and the face it
|
||||
@@ -785,6 +794,15 @@ impl CharStyle {
|
||||
self.position = self.position.in_range();
|
||||
self.small_caps = self.small_caps.map(small_caps_in_range);
|
||||
}
|
||||
/// Sets the East Asian glyph form ([`CharStyle::glyph_form`]). A form other than the default
|
||||
/// also takes away the character's glyph choice ([`CharStyle::glyph_alternate`]), which would
|
||||
/// otherwise keep drawing its own glyph; the default form keeps it.
|
||||
pub fn set_glyph_form(&mut self, form: GlyphForm) {
|
||||
self.glyph_form = form;
|
||||
if form != GlyphForm::Default {
|
||||
self.glyph_alternate = None;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl crate::Document {
|
||||
|
||||
@@ -352,7 +352,10 @@ impl TextObject {
|
||||
}
|
||||
/// Flip type on a path to the other side of its path (Type on a Path Options › Flip, or its
|
||||
/// centre bracket dragged across the path): the path runs the other way and the brackets swap
|
||||
/// ends, so the type keeps its stretch of the path. False, changing nothing, for other type.
|
||||
/// ends, so the type keeps its stretch of the path. Type that starts before an open path's start
|
||||
/// ([`TextKind::path_text_start`]) starts at its start bracket after the flip: the part of its
|
||||
/// span before the path's start would come after the path's end. False, changing nothing, for
|
||||
/// other type.
|
||||
pub fn flip_on_path(&mut self) -> bool {
|
||||
let Some((s, e)) = self.kind.path_span() else { return false };
|
||||
let TextKind::OnPath { path, start, end } = &mut self.kind else { return false };
|
||||
|
||||
@@ -93,8 +93,10 @@ pub enum TextKind {
|
||||
/// Area type flowed inside `frame` (document coordinates, untransformed by `xf`).
|
||||
Area { frame: PathData },
|
||||
/// Type on a path, flowing from its start bracket `start` to its end bracket `end` (0..1 of the
|
||||
/// path length; no `end`: the end of the path, or once round a closed path). See
|
||||
/// [`TextKind::path_span`].
|
||||
/// path length; no `end`: the end of the path, or once round a closed path). On an open path
|
||||
/// `start` may be below 0: the type starts that far before the path's start, and the characters
|
||||
/// whose middle falls before the path's start are not drawn ([`TextKind::path_text_start`]).
|
||||
/// See [`TextKind::path_span`].
|
||||
OnPath {
|
||||
path: PathData,
|
||||
start: f64,
|
||||
@@ -117,8 +119,22 @@ impl TextKind {
|
||||
(s, end.map_or(1.0, |e| fraction(e).max(s)))
|
||||
})
|
||||
}
|
||||
/// Where type on a path's text starts, as a fraction of its path's length: its start bracket
|
||||
/// ([`Self::path_span`]), or on an open path a start before the path's start (below 0, at most
|
||||
/// [`PATH_LEAD_MAX`] path lengths before it). None for other type.
|
||||
pub fn path_text_start(&self) -> Option<f64> {
|
||||
let TextKind::OnPath { path, start, .. } = self else { return None };
|
||||
if !path.is_closed() && start.is_finite() && *start < 0.0 { Some(start.max(-PATH_LEAD_MAX)) } else { self.path_span().map(|(s, _)| s) }
|
||||
}
|
||||
/// Does type on an open path start before its path's start ([`Self::path_text_start`])?
|
||||
pub fn starts_before_path(&self) -> bool {
|
||||
self.path_text_start().is_some_and(|s| s < 0.0)
|
||||
}
|
||||
}
|
||||
|
||||
/// How far before its path's start type on an open path may start, in lengths of the path.
|
||||
pub const PATH_LEAD_MAX: f64 = 1e6;
|
||||
|
||||
/// Move anchors `refs` of `path`, in text space, by `d` in document space (`to_text`: the
|
||||
/// document → text map). False when an anchor doesn't exist.
|
||||
pub(super) fn move_anchors(path: &mut PathData, to_text: Affine, refs: &[(usize, usize)], d: Vec2) -> bool {
|
||||
|
||||
@@ -563,6 +563,33 @@ fn area_anchors_move_in_document_space() {
|
||||
assert_eq!(t, before);
|
||||
}
|
||||
|
||||
/// Type on an open path may start before the path's start: the start is kept, saved and read back,
|
||||
/// while the start bracket stays at the path's start. On a closed path, and for a start that is not
|
||||
/// finite, the start is the start bracket as before.
|
||||
#[test]
|
||||
fn type_on_an_open_path_starts_before_the_path() {
|
||||
let pts = [Point::new(0.0, 0.0), Point::new(100.0, 0.0), Point::new(100.0, 100.0)];
|
||||
let on = |closed: bool, start: f64| TextKind::OnPath {
|
||||
path: PathData::new(vec![vectorcraft_geom::SubPath::polyline(&pts, closed)]),
|
||||
start,
|
||||
end: None,
|
||||
};
|
||||
let open = on(false, -0.25);
|
||||
assert_eq!(open.path_text_start(), Some(-0.25));
|
||||
assert!(open.starts_before_path());
|
||||
assert_eq!(open.path_span(), Some((0.0, 1.0)));
|
||||
let saved = serde_json::to_value(&open).unwrap();
|
||||
assert_eq!(saved["start"], -0.25);
|
||||
assert_eq!(serde_json::from_value::<TextKind>(saved).unwrap(), open);
|
||||
assert_eq!(on(false, -1e9).path_text_start(), Some(-PATH_LEAD_MAX));
|
||||
assert_eq!(on(false, f64::NEG_INFINITY).path_text_start(), Some(0.0));
|
||||
assert_eq!(on(false, 0.25).path_text_start(), Some(0.25));
|
||||
assert!(!on(false, 0.0).starts_before_path());
|
||||
assert_eq!(on(true, -0.25).path_text_start(), Some(0.0));
|
||||
assert!(!on(true, -0.25).starts_before_path());
|
||||
assert_eq!(TextKind::Point.path_text_start(), None);
|
||||
}
|
||||
|
||||
/// Builds from before [`Justify::Auto`] read new type's alignment as Left or Right (and ignore
|
||||
/// `justify_auto`); this build reads Auto back.
|
||||
#[test]
|
||||
|
||||
@@ -61,7 +61,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Release Type on a Path…",
|
||||
["Object", "Plug-ins", "Type Tools"],
|
||||
None,
|
||||
"{mode?: \"both\" (point type and the path) | \"path\" (the path only) | \"text\" (point type only) (\"both\"), keepOriginal?: false (true: the type on a path stays and the released objects go just above it), ids?: [id…], id? (instead of ids; neither: the selection)} split each type on a path among the objects (inside groups too) into point type and the path it was set on. The point type holds the same text with the same character and paragraph attributes, on one straight line whose baseline starts where the first character sat on the path; Type on a Path Options (effect, align to path, spacing, flip, brackets) don't apply to point type and are dropped; type with Dynamic Text on (type.dynamicText) comes out with it off, at the sizes it was drawn at. The path keeps its exact shape (open or closed, anchors and handles, the object's transform) and comes out with no fill and no stroke, in the type's layer and place in the stacking order. Other objects are left alone, and with no type on a path among them nothing changes and no undo step is made. One undo step; the released objects are selected → {ids (released, bottom to top), paths, texts}",
|
||||
"{mode?: \"both\" (point type and the path) | \"path\" (the path only) | \"text\" (point type only) (\"both\"), keepOriginal?: false (true: the type on a path stays and the released objects go just above it), ids?: [id…], id? (instead of ids; neither: the selection)} split each type on a path among the objects (inside groups too) into point type and the path it was set on. The point type holds the same text with the same character and paragraph attributes, on one straight line whose baseline starts where the first character sat on the path (when the type starts before an open path's start, the first character drawn on the path stays where it was drawn and the characters that were not drawn come before it); Type on a Path Options (effect, align to path, spacing, flip, brackets) don't apply to point type and are dropped; type with Dynamic Text on (type.dynamicText) comes out with it off, at the sizes it was drawn at. The path keeps its exact shape (open or closed, anchors and handles, the object's transform) and comes out with no fill and no stroke, in the type's layer and place in the stacking order. Other objects are left alone, and with no type on a path among them nothing changes and no undo step is made. One undo step; the released objects are selected → {ids (released, bottom to top), paths, texts}",
|
||||
has_selection,
|
||||
release_path
|
||||
),
|
||||
@@ -229,6 +229,28 @@ fn first_pen(lay: &vectorcraft_text::TextLayout) -> Option<Point> {
|
||||
lay.glyphs.iter().filter(|g| g.origin.x.is_finite() && g.origin.y.is_finite()).min_by_key(|g| (g.byte, g.len != 0)).map(|g| g.origin)
|
||||
}
|
||||
|
||||
/// A glyph of a character: its byte, whether it stands for characters (a list marker does not),
|
||||
/// and its place among the glyphs of that byte and kind (an All Caps ß is two glyphs).
|
||||
type GlyphKey = (usize, bool, usize);
|
||||
|
||||
/// The key and pen position of the first glyph drawn in `lay`, in text order: characters before an
|
||||
/// open path's start are not drawn ([`vectorcraft_text::PositionedGlyph::off_path`]). None without
|
||||
/// a drawn glyph.
|
||||
fn first_drawn_pen(lay: &vectorcraft_text::TextLayout) -> Option<(GlyphKey, Point)> {
|
||||
let (gi, g) = (lay.glyphs.iter().enumerate())
|
||||
.filter(|(_, g)| !g.off_path && g.origin.x.is_finite() && g.origin.y.is_finite())
|
||||
.min_by_key(|(gi, g)| (g.byte, g.len != 0, *gi))?;
|
||||
let kind = g.len != 0;
|
||||
let nth = lay.glyphs.iter().take(gi).filter(|h| h.byte == g.byte && (h.len != 0) == kind).count();
|
||||
Some(((g.byte, kind, nth), g.origin))
|
||||
}
|
||||
|
||||
/// The pen position of the glyph `key` names in `lay`. None without one.
|
||||
fn pen_at(lay: &vectorcraft_text::TextLayout, (byte, kind, nth): GlyphKey) -> Option<Point> {
|
||||
let g = lay.glyphs.iter().filter(|g| g.byte == byte && (g.len != 0) == kind).nth(nth)?;
|
||||
(g.origin.x.is_finite() && g.origin.y.is_finite()).then_some(g.origin)
|
||||
}
|
||||
|
||||
/// `t`, type on a path, as point type on one straight line: the same runs and character and
|
||||
/// paragraph attributes, its baseline starting where its first character sat on the path (in the
|
||||
/// object's own text space, so its transform stays). Type on a Path Options are dropped, and
|
||||
@@ -237,13 +259,14 @@ fn first_pen(lay: &vectorcraft_text::TextLayout) -> Option<Point> {
|
||||
fn released_text(t: &TextObject) -> Option<TextObject> {
|
||||
let TextKind::OnPath { path, .. } = &t.kind else { return None };
|
||||
let db = vectorcraft_text::FontDb::global();
|
||||
// Where the first character sat. Text that didn't fit on the path has no glyph there: it
|
||||
// starts at the start bracket.
|
||||
let at = first_pen(&vectorcraft_text::layout(db, t)).or_else(|| {
|
||||
// Where the first drawn character sat; characters before an open path's start come before it
|
||||
// on the line. Text that didn't fit on the path has no glyph there: it starts at the start
|
||||
// bracket.
|
||||
let (key, at) = first_drawn_pen(&vectorcraft_text::layout(db, t)).map(|(b, p)| (Some(b), p)).or_else(|| {
|
||||
let ap = ArcPath::new(path);
|
||||
let (from, _) = t.kind.path_span()?;
|
||||
let (p, _) = ap.at(from * ap.len());
|
||||
(p.x.is_finite() && p.y.is_finite()).then_some(p)
|
||||
(p.x.is_finite() && p.y.is_finite()).then_some((None, p))
|
||||
})?;
|
||||
let mut out = t.clone();
|
||||
// Point type has no brackets to fit: it keeps the sizes the path drew it at.
|
||||
@@ -254,9 +277,10 @@ fn released_text(t: &TextObject) -> Option<TextObject> {
|
||||
out.path_spacing = 0.0;
|
||||
// Point type is in no thread: its first paragraph starts in it.
|
||||
out.first_para_continues = false;
|
||||
// Where the point type puts its own first character; the origin moves so it lands at `at`,
|
||||
// whatever the paragraph's alignment and direction.
|
||||
let own = first_pen(&vectorcraft_text::layout(db, &out)).unwrap_or(Point::ZERO);
|
||||
// Where the point type puts that glyph; the origin moves so it lands at `at`, whatever the
|
||||
// paragraph's alignment and direction.
|
||||
let own_lay = vectorcraft_text::layout(db, &out);
|
||||
let own = key.and_then(|k| pen_at(&own_lay, k)).or_else(|| first_pen(&own_lay)).unwrap_or(Point::ZERO);
|
||||
let shift = at - own;
|
||||
if !(shift.x.is_finite() && shift.y.is_finite()) {
|
||||
return None;
|
||||
|
||||
@@ -482,6 +482,44 @@ mod release {
|
||||
}
|
||||
}
|
||||
|
||||
/// Type that starts before its open path: the first drawn character stays where it was drawn,
|
||||
/// and the characters that were not drawn come before it on the line.
|
||||
#[test]
|
||||
fn type_starting_before_its_open_path_keeps_its_drawn_characters_in_place() {
|
||||
let mut s = session();
|
||||
let id = path_type(&mut s, "M100 300 L500 300", [100.0, 300.0], "Hidden start");
|
||||
s.execute("type.pathOptions", &json!({"start": -0.05})).unwrap();
|
||||
let before = text(&s, id);
|
||||
let lay = vectorcraft_text::layout(vectorcraft_text::FontDb::global(), &before);
|
||||
let first = lay.glyphs.iter().filter(|g| !g.off_path).min_by_key(|g| g.byte).unwrap();
|
||||
let (byte, drawn) = (first.byte, before.xf * first.origin);
|
||||
assert!(byte > 0, "some characters are before the path");
|
||||
s.execute(C, &json!({"mode": "text"})).unwrap();
|
||||
let after = text(&s, id);
|
||||
assert_eq!(after.kind, TextKind::Point);
|
||||
assert_eq!(after.plain_text(), before.plain_text());
|
||||
let back = vectorcraft_text::layout(vectorcraft_text::FontDb::global(), &after);
|
||||
let same = back.glyphs.iter().find(|g| g.byte == byte).unwrap();
|
||||
assert!(close(after.xf * same.origin, drawn), "{:?} vs {drawn:?}", after.xf * same.origin);
|
||||
let lead = back.glyphs.iter().find(|g| g.byte == 0).unwrap();
|
||||
assert!((after.xf * lead.origin).x < drawn.x, "the hidden characters come before it");
|
||||
// An All Caps ß is two glyphs: with the first before the path's start, the second stays
|
||||
// where it was drawn.
|
||||
let id = path_type(&mut s, "M100 400 L500 400", [100.0, 400.0], "ße");
|
||||
s.execute("text.setRangeStyle", &json!({"id": id.0, "start": 0, "end": 4, "allCaps": true})).unwrap();
|
||||
let adv = vectorcraft_text::layout(vectorcraft_text::FontDb::global(), &text(&s, id)).glyphs[0].advance;
|
||||
s.execute("type.pathOptions", &json!({"start": -1.2 * adv / 400.0})).unwrap();
|
||||
let before = text(&s, id);
|
||||
let lay = vectorcraft_text::layout(vectorcraft_text::FontDb::global(), &before);
|
||||
assert!(lay.glyphs[0].byte == 0 && lay.glyphs[1].byte == 0 && lay.glyphs[0].off_path && !lay.glyphs[1].off_path);
|
||||
let drawn = before.xf * lay.glyphs[1].origin;
|
||||
s.execute(C, &json!({"mode": "text"})).unwrap();
|
||||
let after = text(&s, id);
|
||||
let back = vectorcraft_text::layout(vectorcraft_text::FontDb::global(), &after);
|
||||
let second = back.glyphs.iter().filter(|g| g.byte == 0).nth(1).unwrap();
|
||||
assert!(close(after.xf * second.origin, drawn), "{:?} vs {drawn:?}", after.xf * second.origin);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn flipped_type_releases_its_path_as_it_runs() {
|
||||
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); 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?|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, and the art of a graph design in a series, which is left out: 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); 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?|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, and the art of a graph design in a series, which is left out: 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); 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?|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, and the art of a graph design in a series, which is left out: 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); 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?|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, and the art of a graph design in a series, which is left out: 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); 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: 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; with the art of a graph design in a series given first, rows of the art's stack: docs/mcp.md)} 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, and the art of a graph design in a series, which is left out: 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); 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 | 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; with the art of a graph design in a series given first, rows of the art's stack: docs/mcp.md)} 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, and the art of a graph design in a series, which is left out: 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
|
||||
),
|
||||
@@ -102,7 +102,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Add Effect",
|
||||
[],
|
||||
None,
|
||||
"{…as effect.apply} same as effect.apply (an alias): {effect: id, params?: {…}, item?: appearance item index|null, ids?, target?} → {ids, index, item}",
|
||||
"{…as effect.apply} same as effect.apply (an alias): {effect: id, params?: {…}, item?: appearance item index|null, ids?, target?, aligned?} → {ids, index, item}",
|
||||
has_doc,
|
||||
super::effectcmd::apply
|
||||
),
|
||||
@@ -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); 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 | 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; with the art of a graph design in a series given first, rows of the art's stack: docs/mcp.md)} 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, and the art of a graph design in a series, which is left out: 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); 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}",
|
||||
"{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, or differ from that of a graph design's art given first)} 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, and the art of a graph design in a series, which is left out: 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); 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}",
|
||||
"{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, and the art of a graph design in a series, which is left out: 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); 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}",
|
||||
"{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, and the art of a graph design in a series, which is left out: 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); 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}",
|
||||
"{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, and the art of a graph design in a series, which is left out: 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
|
||||
),
|
||||
@@ -183,14 +183,21 @@ pub(crate) struct ActiveItem {
|
||||
impl Session {
|
||||
/// The method returns explicit target parameters and the selected appearance effect list.
|
||||
pub fn appearance_effect_target(&self, params: &Value) -> Option<(Value, &[vectorcraft_doc::Effect])> {
|
||||
let mut ids = appearance_targets(self, params).ok()?;
|
||||
let mut ids = drop_design_art(&self.active()?.doc, appearance_targets(self, params).ok()?);
|
||||
let item = item_target(self, params, "effect.apply").ok()?;
|
||||
let anchor = item.anchor(self, &ids);
|
||||
let node = self.active()?.doc.node(*ids.get(anchor)?)?;
|
||||
// Aligned effects given with a graph design's art first are effects of the art's stack, which stays first; the
|
||||
// Appearance panel's active row is a row of the first selected object's.
|
||||
let of = match item {
|
||||
ItemTarget::Item { explicit: false, .. } => None,
|
||||
_ => design_reference(self, params, "effect.apply").ok()?,
|
||||
};
|
||||
let own = *ids.get(anchor)?;
|
||||
let node = self.active()?.doc.node(of.unwrap_or(own))?;
|
||||
let at = item.effects_item(&node.appearance, "effect.apply").ok()?;
|
||||
let effects = node.appearance.effects_at(at)?;
|
||||
ids.swap(0, anchor);
|
||||
let ids: Vec<u64> = ids.into_iter().map(|id| id.0).collect();
|
||||
let ids: Vec<u64> = with_reference(of, &ids).into_iter().map(|id| id.0).collect();
|
||||
let aligned = match item {
|
||||
ItemTarget::Item { aligned, .. } => aligned,
|
||||
ItemTarget::Top => aligned(params, "effect.apply").ok()?,
|
||||
@@ -253,6 +260,10 @@ pub(crate) fn item_target_at(s: &Session, p: &Value, key: &str, cmd: &str) -> Re
|
||||
.ok_or_else(|| bad(cmd, format!("`{key}` must be an appearance item index (paint order) or null"))),
|
||||
// The active item is a row of the first selected object's own stack.
|
||||
None if p.get("ids").is_some() || p.get("id").is_some() || targets_contents(p)? => Ok(ItemTarget::Top),
|
||||
// A row of a graph design's art in a series stands for nothing: the commands leave that art out.
|
||||
None if s.active().and_then(|st| st.selection.subjects().first().map(|id| super::graph::is_design_art(&st.doc, *id))) == Some(true) => {
|
||||
Ok(ItemTarget::Top)
|
||||
}
|
||||
None => Ok(s.appearance_item().map_or(ItemTarget::Top, |index| ItemTarget::Item { index, explicit: false, aligned: true })),
|
||||
}
|
||||
}
|
||||
@@ -300,6 +311,21 @@ impl ItemTarget {
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// [`ItemTarget::per_target`], with an explicit aligned item a row of the stack of `of` (see [`design_reference`]),
|
||||
/// which is not among `ids`, when there is one: each target edits the item equal to it there, and a target without
|
||||
/// an equal item is left out (`None`).
|
||||
pub(crate) fn per_target_of(self, s: &Session, of: Option<NodeId>, ids: &[NodeId]) -> Vec<Option<ItemTarget>> {
|
||||
let ItemTarget::Item { index, explicit: true, aligned: true } = self else { return self.per_target(s, ids) };
|
||||
let Some(d) = s.active().map(|st| &st.doc) else { return self.per_target(s, ids) };
|
||||
let Some(r) = of.and_then(|of| d.node(of)) else { return self.per_target(s, ids) };
|
||||
ids.iter()
|
||||
.map(|id| {
|
||||
let index = SharedStack::of(&[&*r.appearance, &*d.node(*id)?.appearance]).item_in(index, 1)?;
|
||||
Some(ItemTarget::Item { index, explicit: true, aligned: true })
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Where among `ids` (the targets, in paint order) the object whose stack this item is a row of
|
||||
/// is: the first selected object (or the compound path holding it) for the active item, which
|
||||
/// need not paint first; the first target otherwise, or when that object is not among them.
|
||||
@@ -384,6 +410,32 @@ pub(crate) fn items_per_target(d: &vectorcraft_doc::Document, ids: &[NodeId], id
|
||||
}
|
||||
}
|
||||
|
||||
/// The art of a graph design in a graph series whose stack the aligned rows of `p` (`aligned: true`) are rows of: the
|
||||
/// first of the [`appearance_targets`] when it is such art, which the edit leaves out ([`without_design_art`]). A
|
||||
/// command reads it after its own target checks.
|
||||
pub(crate) fn design_reference(s: &Session, p: &Value, cmd: &str) -> Result<Option<NodeId>> {
|
||||
if !aligned(p, cmd)? {
|
||||
return Ok(None);
|
||||
}
|
||||
let ids = appearance_targets(s, p)?;
|
||||
let d = &s.doc()?.doc;
|
||||
Ok(ids.first().copied().filter(|id| super::graph::is_design_art(d, *id)))
|
||||
}
|
||||
|
||||
/// `ids` after `of` (see [`design_reference`]), when there is one.
|
||||
pub(crate) fn with_reference(of: Option<NodeId>, ids: &[NodeId]) -> Vec<NodeId> {
|
||||
of.into_iter().chain(ids.iter().copied()).collect()
|
||||
}
|
||||
|
||||
/// [`items_per_target`] of `ids`, with the aligned rows `idx` taken from the stack of `of` (see [`design_reference`])
|
||||
/// when there is one: a row a target has no equal of is left out of that target.
|
||||
pub(crate) fn rows_per_target(d: &Document, of: Option<NodeId>, ids: &[NodeId], idx: &[usize], aligned: bool) -> Vec<Vec<usize>> {
|
||||
match of {
|
||||
Some(of) => items_per_target(d, &with_reference(Some(of), ids), idx, true).into_iter().skip(1).collect(),
|
||||
None => items_per_target(d, ids, idx, aligned),
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether the `target` param asks for the contents of groups and layers (see the module docs).
|
||||
fn targets_contents(p: &Value) -> Result<bool> {
|
||||
match p.get("target") {
|
||||
@@ -424,22 +476,44 @@ const DESIGN_ART: &str = "the art of a graph design in a graph series is drawn a
|
||||
|
||||
/// The [`appearance_targets`] whose rows and effects a command edits: the art of graph designs drawn in graph series
|
||||
/// ([`super::graph::is_design_art`]) is left out, as the graph draws it again from the design. A command whose targets
|
||||
/// are all such art is refused.
|
||||
pub(crate) fn edit_targets(s: &Session, p: &Value) -> Result<Vec<NodeId>> {
|
||||
/// are all such art is refused as an error of `cmd`.
|
||||
pub(crate) fn edit_targets(s: &Session, p: &Value, cmd: &str) -> Result<Vec<NodeId>> {
|
||||
let ids = appearance_targets(s, p)?;
|
||||
without_design_art(&s.doc()?.doc, ids, "appearance")
|
||||
without_design_art(&s.doc()?.doc, ids, cmd)
|
||||
}
|
||||
|
||||
/// `ids` without the art of graph designs drawn in graph series; `cmd` is refused when `ids` held nothing else.
|
||||
pub(crate) fn without_design_art(d: &Document, mut ids: Vec<NodeId>, cmd: &str) -> Result<Vec<NodeId>> {
|
||||
pub(crate) fn without_design_art(d: &Document, ids: Vec<NodeId>, cmd: &str) -> Result<Vec<NodeId>> {
|
||||
let given = ids.len();
|
||||
ids.retain(|id| !super::graph::is_design_art(d, *id));
|
||||
let ids = drop_design_art(d, ids);
|
||||
if given > 0 && ids.is_empty() {
|
||||
return Err(bad(cmd, DESIGN_ART));
|
||||
}
|
||||
Ok(ids)
|
||||
}
|
||||
|
||||
/// `ids` without the art of graph designs drawn in graph series, for the paint commands, which leave it out without an
|
||||
/// error.
|
||||
pub(crate) fn drop_design_art(d: &Document, mut ids: Vec<NodeId>) -> Vec<NodeId> {
|
||||
ids.retain(|id| !super::graph::is_design_art(d, *id));
|
||||
ids
|
||||
}
|
||||
|
||||
/// The outermost art of graph designs drawn in graph series inside `roots`: the objects directly in a series group that
|
||||
/// [`super::graph::is_design_art`] accepts.
|
||||
pub(crate) fn design_art_in(d: &Document, roots: &[NodeId]) -> Vec<NodeId> {
|
||||
let mut out = vec![];
|
||||
for id in roots {
|
||||
let Some(n) = d.node(*id) else { continue };
|
||||
n.walk(&mut |m| {
|
||||
if m.extras().series_index.is_some() {
|
||||
out.extend(m.children().into_iter().flatten().map(|c| c.id).filter(|c| super::graph::is_design_art(d, *c)));
|
||||
}
|
||||
});
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// `ids` with each graph series group that has no fill or stroke row of its own replaced by the paths directly inside
|
||||
/// it ([`super::graph::series_group_marks`]). A series group with rows, as a document saved earlier can hold, stays:
|
||||
/// the Appearance panel lists those rows and edits them by index.
|
||||
@@ -476,15 +550,15 @@ pub(crate) fn look_holder<'a>(d: &'a Document, n: &'a Node) -> &'a Node {
|
||||
|
||||
/// Objects whose fill and stroke rows a command edits: the [`edit_targets`], with a graph series group standing for the
|
||||
/// paths directly inside it ([`rowless_series_marks`]), so the graph can store the edit.
|
||||
pub(crate) fn row_targets(s: &Session, p: &Value) -> Result<Vec<NodeId>> {
|
||||
let ids = edit_targets(s, p)?;
|
||||
pub(crate) fn row_targets(s: &Session, p: &Value, cmd: &str) -> Result<Vec<NodeId>> {
|
||||
let ids = edit_targets(s, p, cmd)?;
|
||||
Ok(rowless_series_marks(&s.doc()?.doc, &ids))
|
||||
}
|
||||
|
||||
/// Objects whose whole appearance a command edits: the [`row_targets`], then each graph series group they stand for,
|
||||
/// which can hold effects and a graphic style link of its own.
|
||||
pub(crate) fn whole_targets(s: &Session, p: &Value) -> Result<Vec<NodeId>> {
|
||||
let own = edit_targets(s, p)?;
|
||||
pub(crate) fn whole_targets(s: &Session, p: &Value, cmd: &str) -> Result<Vec<NodeId>> {
|
||||
let own = edit_targets(s, p, cmd)?;
|
||||
let mut ids = rowless_series_marks(&s.doc()?.doc, &own);
|
||||
let groups = stood_for(&own, &ids);
|
||||
ids.extend(groups);
|
||||
@@ -513,7 +587,7 @@ pub(crate) fn clear_stood_for(d: &mut Document, groups: &[NodeId]) -> bool {
|
||||
|
||||
/// [`whole_targets`] of `cmd`, which needs at least one.
|
||||
fn require_whole_targets(s: &Session, p: &Value, cmd: &str) -> Result<Vec<NodeId>> {
|
||||
let ids = whole_targets(s, p)?;
|
||||
let ids = whole_targets(s, p, cmd)?;
|
||||
if ids.is_empty() {
|
||||
return Err(bad(cmd, "select objects or give ids"));
|
||||
}
|
||||
@@ -522,7 +596,7 @@ fn require_whole_targets(s: &Session, p: &Value, cmd: &str) -> Result<Vec<NodeId
|
||||
|
||||
/// [`row_targets`] of `cmd`, which needs at least one.
|
||||
fn require_targets(s: &Session, p: &Value, cmd: &str) -> Result<Vec<NodeId>> {
|
||||
let ids = row_targets(s, p)?;
|
||||
let ids = row_targets(s, p, cmd)?;
|
||||
if ids.is_empty() {
|
||||
return Err(bad(cmd, "select objects or give ids"));
|
||||
}
|
||||
@@ -608,8 +682,8 @@ pub(crate) fn edits_stroke(s: &Session, p: &Value, item: ItemTarget, default: bo
|
||||
return Ok(b);
|
||||
}
|
||||
let ItemTarget::Item { index, .. } = item else { return Ok(default) };
|
||||
let ids = appearance_targets(s, p)?;
|
||||
let d = &s.doc()?.doc;
|
||||
let ids = drop_design_art(d, appearance_targets(s, p)?);
|
||||
Ok(ids.get(item.anchor(s, &ids)).and_then(|id| d.node(*id)?.appearance.items.get(index)).map_or(default, |it| !it.is_fill()))
|
||||
}
|
||||
|
||||
@@ -1032,7 +1106,7 @@ fn set_item(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
check_numbers(p, &["weight", "opacity"], "appearance.setItem")?;
|
||||
check_switches(p, &["visible"], "appearance.setItem")?;
|
||||
let paint = paint_from(s, p)?;
|
||||
let ids = row_targets(s, p)?;
|
||||
let ids = row_targets(s, p, "appearance.setItem")?;
|
||||
if ids.is_empty() {
|
||||
return Err(bad("appearance.setItem", "select objects or give ids"));
|
||||
}
|
||||
@@ -1040,7 +1114,8 @@ fn set_item(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
// When every object comes out as it was (for example, when none has item `index`), the document
|
||||
// and its undo history stay as they are.
|
||||
let before = s.doc()?.doc.clone();
|
||||
let per = items_per_target(&before, &ids, &[idx], aligned(p, "appearance.setItem")?);
|
||||
let of = design_reference(s, p, "appearance.setItem")?;
|
||||
let per = rows_per_target(&before, of, &ids, &[idx], aligned(p, "appearance.setItem")?);
|
||||
super::overprint::edit_counted(s, "Appearance", |d| {
|
||||
let mut edited = Vec::new();
|
||||
for (id, at) in ids.iter().zip(&per) {
|
||||
@@ -1127,7 +1202,8 @@ fn remove_item(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
// When no object has an item at any of the indices, the document and its undo history stay as
|
||||
// they are.
|
||||
let before = s.doc()?.doc.clone();
|
||||
let per = items_per_target(&before, &ids, &idx, aligned(p, "appearance.removeItem")?);
|
||||
let of = design_reference(s, p, "appearance.removeItem")?;
|
||||
let per = rows_per_target(&before, of, &ids, &idx, aligned(p, "appearance.removeItem")?);
|
||||
super::overprint::edit_counted(s, "Remove Item", |d| {
|
||||
for (id, idx) in ids.iter().zip(&per) {
|
||||
if let Some(n) = d.node_mut(*id) {
|
||||
@@ -1168,12 +1244,19 @@ fn duplicate_item(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let mut first_at = None;
|
||||
let active = s.active_appearance_item.as_ref().and_then(|a| a.objects.first().copied());
|
||||
let mut active_at = None;
|
||||
// Aligned rows of a mixed selection are copied right above themselves in each object.
|
||||
if aligned(p, C)? && to.is_some() && shared_stack(&s.doc()?.doc, &ids).is_some() {
|
||||
// Aligned rows of a mixed selection are copied right above themselves in each object. Rows of a graph design's art,
|
||||
// which is left out, go to `to` in an object whose stack is the same as the art's.
|
||||
let of = design_reference(s, p, C)?;
|
||||
if aligned(p, C)?
|
||||
&& to.is_some()
|
||||
&& (shared_stack(&s.doc()?.doc, &ids).is_some() || (of.is_some() && appearances_differ(&s.doc()?.doc, &with_reference(of, &ids))))
|
||||
{
|
||||
return Err(bad(C, "`to` takes the rows of one object's stack, not aligned rows"));
|
||||
}
|
||||
let before = s.doc()?.doc.clone();
|
||||
let per = items_per_target(&before, &ids, &idx, aligned(p, C)?);
|
||||
// With `to`, the art's stack is the same as the object's, so its rows are the object's, each of which must exist.
|
||||
let per =
|
||||
if to.is_some() { items_per_target(&before, &ids, &idx, aligned(p, C)?) } else { rows_per_target(&before, of, &ids, &idx, aligned(p, C)?) };
|
||||
s.edit("Duplicate Item", |d, _| {
|
||||
let mut any = false;
|
||||
// Copies that became the topmost visible row of their kind: the series stores their paint even when the object
|
||||
@@ -1280,15 +1363,19 @@ fn move_item(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let aligned = aligned(p, C)?;
|
||||
if p.get("from").and_then(Value::as_str) == Some("contents") {
|
||||
// A graph series group's own Contents row, not its paths, which have none.
|
||||
let ids = edit_targets(s, p)?;
|
||||
let ids = edit_targets(s, p, C)?;
|
||||
if ids.is_empty() {
|
||||
return Err(bad(C, "select objects or give ids"));
|
||||
}
|
||||
// The Contents row's place counts the rows below it, which isn't one place in several different stacks either.
|
||||
if aligned && appearances_differ(&s.doc()?.doc, &with_reference(design_reference(s, p, C)?, &ids)) {
|
||||
return Err(bad(C, "rows of objects whose appearances differ can't be reordered together"));
|
||||
}
|
||||
return move_contents(s, &ids, to);
|
||||
}
|
||||
let ids = require_targets(s, p, C)?;
|
||||
// Where an aligned row would go in each of several different stacks isn't one place.
|
||||
if aligned && appearances_differ(&s.doc()?.doc, &ids) {
|
||||
if aligned && appearances_differ(&s.doc()?.doc, &with_reference(design_reference(s, p, C)?, &ids)) {
|
||||
return Err(bad(C, "rows of objects whose appearances differ can't be reordered together"));
|
||||
}
|
||||
if p.get("indices").is_some() {
|
||||
|
||||
@@ -178,6 +178,7 @@ pub const BUILTIN: &[BuiltinBrushLibrary] = &[
|
||||
BuiltinBrushLibrary { id: "game-trails", name: "Game Trails", make: game_trails },
|
||||
BuiltinBrushLibrary { id: "energy-beams", name: "Energy Beams", make: energy_beams },
|
||||
BuiltinBrushLibrary { id: "dry-media", name: "Dry Media", make: dry_media },
|
||||
BuiltinBrushLibrary { id: "tech-circuit", name: "Tech Circuit", make: tech_circuit },
|
||||
];
|
||||
|
||||
/// Built-in library `id`, made once.
|
||||
@@ -397,6 +398,59 @@ fn border(name: &str, side: Node, corner: Option<Node>, fit: PatternFit) -> Brus
|
||||
Brush { name: name.into(), kind }
|
||||
}
|
||||
|
||||
/// Tech Circuit: original conductor geometry, drawn as filled paths so widths and chamfers
|
||||
/// survive brush warping. Pattern tiles meet at x = 0 and 24 with identical lead cross-sections.
|
||||
fn tech_circuit() -> BrushLibrary {
|
||||
let pad = |x: f64| {
|
||||
black(polygon(&[
|
||||
(x - 4.0, -2.0),
|
||||
(x - 2.0, -4.0),
|
||||
(x + 2.0, -4.0),
|
||||
(x + 4.0, -2.0),
|
||||
(x + 4.0, 2.0),
|
||||
(x + 2.0, 4.0),
|
||||
(x - 2.0, 4.0),
|
||||
(x - 4.0, 2.0),
|
||||
]))
|
||||
};
|
||||
// 45-degree jogs lift the conductor to a parallel run and bring it back.
|
||||
let trace = group(vec![
|
||||
black(polygon(&[
|
||||
(4.0, -1.0),
|
||||
(23.0, -1.0),
|
||||
(33.0, -11.0),
|
||||
(61.0, -11.0),
|
||||
(71.0, -1.0),
|
||||
(96.0, -1.0),
|
||||
(96.0, 1.0),
|
||||
(70.0, 1.0),
|
||||
(60.0, -9.0),
|
||||
(34.0, -9.0),
|
||||
(24.0, 1.0),
|
||||
(4.0, 1.0),
|
||||
])),
|
||||
pad(4.0),
|
||||
pad(96.0),
|
||||
]);
|
||||
let bus = group([-4.0, 0.0, 4.0].into_iter().map(|y| black(shapes::rectangle(Rect::new(0.0, y - 0.75, 24.0, y + 0.75)))).collect());
|
||||
let terminals = group(vec![
|
||||
// Overlap the ring's rim without filling the test point's hole.
|
||||
black(shapes::rectangle(Rect::new(0.0, -0.6, 15.5, 0.6))),
|
||||
black(shapes::rectangle(Rect::new(18.5, -0.6, 24.0, 0.6))),
|
||||
pad(6.0),
|
||||
ring(Point::new(17.0, 0.0), 3.0, 1.4),
|
||||
]);
|
||||
built(
|
||||
"Tech Circuit",
|
||||
Document::new(100.0, 100.0),
|
||||
vec![
|
||||
art("Circuit Trace", trace, Colorization::Tints),
|
||||
border("Bus Lines", bus, None, PatternFit::Stretch),
|
||||
border("Terminal Nodes", terminals, None, PatternFit::Stretch),
|
||||
],
|
||||
)
|
||||
}
|
||||
|
||||
/// An invisible rectangle that fixes a tile's width.
|
||||
fn spacer(r: Rect) -> Node {
|
||||
Node::path(NodeId(0), shapes::rectangle(r), Appearance::default())
|
||||
|
||||
@@ -64,7 +64,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Assign Profile…",
|
||||
["Edit"],
|
||||
None,
|
||||
"{rgb?: profile|null, cmyk?: profile|null} tag the document with profiles (color numbers are kept; null: the working space). Colors then show and convert through the profiles assigned; the working spaces stay as edit.colorSettings set them → {rgb, cmyk}",
|
||||
"{rgb?: profile|null, cmyk?: profile|null} tag the document with profiles (color numbers are kept; null: no profile, Don't Color Manage This Document, so colors show through the working space; the working space's name: that profile stays assigned when the working space changes). Colors then show and convert through the profiles assigned; the working spaces stay as edit.colorSettings set them → {rgb, cmyk}",
|
||||
has_doc,
|
||||
assign_profile
|
||||
),
|
||||
|
||||
@@ -92,7 +92,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Eraser",
|
||||
[],
|
||||
None,
|
||||
"{points: [[x,y]…] or [[x,y,pressure]…] (pen pressure 0..1), size?: pt (10), sizeMode?: \"fixed\" (default) | \"pressure\" (Eraser Tool Options' Size set to Pressure: the tip is size − sizeVariation across where the pen presses lightest and size + sizeVariation where it presses hardest, as path.blob), sizeVariation?: pt (0), angle?: ° (0: the tip's long axis counter-clockwise from level), roundness?: % (100, kept within 1–100)} erase the stroke of an elliptical tip from paths (selected, or all) → {ids}. The tip is `size` across its long axis, which runs at `angle` counter-clockwise from horizontal; its short axis is `roundness` percent of the long one",
|
||||
"{points: [[x,y]…] or [[x,y,pressure]…] or [[x,y,pressure,tiltX,tiltY,rotation]…] (pen pressure 0..1, tilt −90..90°, barrel rotation ° clockwise, as path.freehand), size?: pt (10), angle?: ° (0: the tip's long axis counter-clockwise from level), roundness?: % (100, kept within 1–100), angleMode?, roundnessMode?, sizeMode?: \"fixed\" (default) | \"random\" | \"pressure\" | \"tilt\" | \"bearing\" | \"rotation\" (Eraser Tool Options' variation menus; a varied angle turns either way, clockwise or counter-clockwise), angleVariation?: ° (0–180, default 0, either way: clockwise or counter-clockwise), roundnessVariation?: % (0–100, default 0), sizeVariation?: pt (0 or more, default 0)} erase the stroke of an elliptical tip from paths (selected, or all) → {ids}. The tip is `size` across its long axis, which runs at `angle` counter-clockwise from horizontal; its short axis is `roundness` percent of the long one. A value whose mode is not fixed varies by up to its variation either way: random draws one value for the drag; pressure goes from the value less the variation where the pen presses lightest to the value plus it where it presses hardest (size on pressure as path.blob); tilt from less with the pen lying flat to more with it upright; bearing and rotation from less at −180° to more at 180° of the pen's lean direction or barrel turn. A drag without that pen reading (a mouse) erases at the value itself. An unknown mode, or a variation below 0 or past its range, is an error and erases nothing",
|
||||
has_doc,
|
||||
erase_region
|
||||
),
|
||||
|
||||
@@ -1,11 +1,14 @@
|
||||
use super::common::{
|
||||
dist_to_polyline, fill_targets, ids_json, path_mut, path_of, points_param, poly_bounds, pressure_region, replace_with_pieces, samples_param,
|
||||
sibling_with, size_pressure, target_paths, usize_opt,
|
||||
PenRecord, Sample, dist_to_polyline, fill_targets, ids_json, path_mut, path_of, points_param, poly_bounds, pressure_region, replace_with_pieces,
|
||||
samples_param, sibling_with, size_pressure, target_paths, usize_opt,
|
||||
};
|
||||
use super::{
|
||||
Anchor, AnchorKind, Beside, BezPath, BoolOp, Cap, EngineError, FillRule, Join, NodeId, ParamCurve, PathData, Point, Result, Session, SubPath,
|
||||
Value, bad, eraser_area, eraser_tip, f64_or, fill_contains, json, number_or, object_param, po,
|
||||
Anchor, AnchorKind, Beside, BezPath, BoolOp, Cap, EngineError, FillRule, Join, NodeId, Paint, ParamCurve, PathData, Point, Result, Session,
|
||||
SubPath, Value, Vec2, bad, eraser_area, eraser_tip, f64_or, fill_contains, json, number_or, object_param, po,
|
||||
};
|
||||
use vectorcraft_brush::{Brush, BrushKind, Calligraphic, Variation};
|
||||
use vectorcraft_doc::StrokeLayer;
|
||||
use vectorcraft_tools::draw2::MIN_BRUSH_SIZE;
|
||||
|
||||
/// Split a filled path into connected pieces (each outer contour with its holes).
|
||||
pub(in super::super) fn components(pd: &PathData) -> Vec<PathData> {
|
||||
@@ -278,7 +281,11 @@ pub(super) fn erase_region(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
}
|
||||
let angle = number_or(p, "angle", 0.0, C)?;
|
||||
let roundness = number_or(p, "roundness", 100.0, C)?;
|
||||
let pressure = size_pressure(p, &samples, C)?;
|
||||
let vary = eraser_variations(p, C)?;
|
||||
if varies_beyond_size_pressure(&vary) {
|
||||
return erase_varied(s, &samples, [angle, roundness, size], vary);
|
||||
}
|
||||
let pressure = if vary[2].0 == Variation::Pressure { size_pressure(p, &samples, C)? } else { None };
|
||||
let pts: Vec<Point> = samples.into_iter().map(|s| s.pt).collect();
|
||||
let r = size / 2.0;
|
||||
// The tip's short axis (its narrowest with Pressure): an open path is sampled at least every half of it.
|
||||
@@ -318,6 +325,101 @@ pub(super) fn erase_region(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
apply_plan(s, "Eraser", plan)
|
||||
}
|
||||
|
||||
/// The variation modes `path.eraseRegion` takes for each of `angleMode`, `roundnessMode` and
|
||||
/// `sizeMode`.
|
||||
const ERASER_MODES: &str = "\"fixed\", \"random\", \"pressure\", \"tilt\", \"bearing\" or \"rotation\"";
|
||||
|
||||
/// Eraser Tool Options' Angle, Roundness and Size variations: for each of angle (`angleMode` with
|
||||
/// `angleVariation` in degrees, at most 180), roundness (`roundnessMode` with `roundnessVariation`
|
||||
/// in percent, at most 100) and size (`sizeMode` with `sizeVariation` in points), its mode and how
|
||||
/// far it varies either way. A mode left out or null is Fixed, and a variation left out or null
|
||||
/// is 0; any other mode, or a variation that is negative or past its most, is refused.
|
||||
fn eraser_variations(p: &Value, cmd: &str) -> Result<[(Variation, f64); 3]> {
|
||||
const KEYS: [(&str, &str, f64); 3] =
|
||||
[("angleMode", "angleVariation", 180.0), ("roundnessMode", "roundnessVariation", 100.0), ("sizeMode", "sizeVariation", f64::INFINITY)];
|
||||
let mut out = [(Variation::Fixed, 0.0); 3];
|
||||
for (slot, (mode_key, var_key, most)) in out.iter_mut().zip(KEYS) {
|
||||
let mode = match p.get(mode_key).filter(|v| !v.is_null()) {
|
||||
None => Variation::Fixed,
|
||||
Some(Value::String(id)) => Variation::parse(id).ok_or_else(|| bad(cmd, format!("{mode_key} is one of {ERASER_MODES}, not `{id}`")))?,
|
||||
Some(other) => return Err(bad(cmd, format!("{mode_key} is one of {ERASER_MODES}, not {other}"))),
|
||||
};
|
||||
let variation = number_or(p, var_key, 0.0, cmd)?;
|
||||
if !(variation.is_finite() && variation >= 0.0) {
|
||||
return Err(bad(cmd, format!("{var_key} must be 0 or more")));
|
||||
}
|
||||
if variation > most {
|
||||
return Err(bad(cmd, format!("{var_key} must be at most {most}")));
|
||||
}
|
||||
*slot = (mode, variation);
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// Does any of the Eraser's Angle, Roundness and Size vary with something other than Size on
|
||||
/// Pressure? A mode whose variation is 0 does not vary.
|
||||
fn varies_beyond_size_pressure(vary: &[(Variation, f64); 3]) -> bool {
|
||||
vary.iter().enumerate().any(|(i, (mode, v))| *v > 0.0 && *mode != Variation::Fixed && !(i == 2 && *mode == Variation::Pressure))
|
||||
}
|
||||
|
||||
/// `path.eraseRegion` with its tip varying along the drag ([`eraser_variations`]): the area the
|
||||
/// tip covers is swept as a Calligraphic brush nib of the same angle, roundness, size, modes and
|
||||
/// variations, with what the pen recorded along the drag. Random draws one value for the drag
|
||||
/// and the pen modes follow the pen at each point; a drag without that recording (a mouse) takes
|
||||
/// the value itself. `tip`: the angle, roundness and size.
|
||||
fn erase_varied(s: &mut Session, samples: &[Sample], tip: [f64; 3], vary: [(Variation, f64); 3]) -> Result<Value> {
|
||||
let [angle, roundness, size] = tip;
|
||||
let mut pts: Vec<Point> = samples.iter().map(|s| s.pt).collect();
|
||||
// A press without a drag is a dab: a sweep needs some length.
|
||||
if let [only] = pts[..] {
|
||||
pts.push(only + Vec2::new(0.01, 0.0));
|
||||
}
|
||||
// A nib's size is kept within 1296 pt and the stroke's width scales it: a larger tip is swept
|
||||
// at a fraction of its size on a stroke as many times wider.
|
||||
let [(am, av), (rm, rv), (sm, sv)] = vary;
|
||||
let sv = if sm == Variation::Fixed { 0.0 } else { sv };
|
||||
let scale = ((size + sv) / Calligraphic::RANGES[2].1).max(1.0);
|
||||
let nib = Calligraphic {
|
||||
angle,
|
||||
roundness: roundness.clamp(1.0, 100.0),
|
||||
size: size / scale,
|
||||
variation: [av, rv, sv / scale],
|
||||
modes: Some([am, rm, sm]),
|
||||
};
|
||||
let brush = Brush { name: "Eraser".into(), kind: BrushKind::Calligraphic(nib) };
|
||||
let mut stroke = StrokeLayer::new(Paint::None, scale);
|
||||
PenRecord::of_samples(samples).write(&mut stroke);
|
||||
let spine = SubPath::polyline(&pts, false);
|
||||
let st = s.doc()?;
|
||||
let pieces = vectorcraft_brush::stroke_pieces(&st.doc, &brush, &PathData::single(spine).to_bezpath(), &stroke);
|
||||
let areas: Vec<PathData> = pieces.iter().filter_map(|n| n.path_data().cloned()).collect();
|
||||
if areas.is_empty() {
|
||||
return Ok(json!({ "ids": [] }));
|
||||
}
|
||||
let region =
|
||||
po::try_unite_all(&areas.iter().map(|a| (a, FillRule::NonZero)).collect::<Vec<_>>()).map_err(|e| EngineError::Other(e.to_string()))?;
|
||||
let bp = region.to_bezpath();
|
||||
// The narrowest the tip gets across: an open path is sampled at least every half of it.
|
||||
let low = |i: usize| match vary[i] {
|
||||
(Variation::Fixed, _) => tip[i],
|
||||
(_, v) => tip[i] - v,
|
||||
};
|
||||
let short = low(2).max(MIN_BRUSH_SIZE) * low(1).clamp(1.0, 100.0) / 100.0;
|
||||
let reach = (size + sv) / 2.0;
|
||||
let mut plan: Vec<(NodeId, Vec<PathData>)> = vec![];
|
||||
for (id, pd, rule) in fill_targets(&st.doc, &st.selection, poly_bounds(&pts, reach))? {
|
||||
if pd.is_closed() {
|
||||
if po::area(&po::boolean(&pd, rule, ®ion, FillRule::NonZero, BoolOp::Intersect), FillRule::NonZero) <= 1e-6 {
|
||||
continue;
|
||||
}
|
||||
plan.push((id, components(&po::boolean(&pd, rule, ®ion, FillRule::NonZero, BoolOp::Difference))));
|
||||
} else if let Some(pieces) = erase_outline(&pd, short / 2.0, &|q| fill_contains(&bp, FillRule::NonZero, q)) {
|
||||
plan.push((id, pieces));
|
||||
}
|
||||
}
|
||||
apply_plan(s, "Eraser", plan)
|
||||
}
|
||||
|
||||
fn apply_plan(s: &mut Session, label: &str, plan: Vec<(NodeId, Vec<PathData>)>) -> Result<Value> {
|
||||
if plan.is_empty() {
|
||||
return Ok(json!({ "ids": [] }));
|
||||
|
||||
@@ -13,7 +13,9 @@ use vectorcraft_doc::{Document, Effect, Node, NodeKind, StrokeLayer};
|
||||
use vectorcraft_geom::{FillRule, PathData};
|
||||
use vectorcraft_render::effects;
|
||||
|
||||
use super::appearance::{ItemTarget, aligned, appearance_targets, edit_targets, index_param, item_indices, item_target, item_target_at};
|
||||
use super::appearance::{
|
||||
ItemTarget, aligned, appearance_targets, design_reference, edit_targets, index_param, item_indices, item_target, item_target_at, with_reference,
|
||||
};
|
||||
use super::expand::has_seeded;
|
||||
use super::rasterfx;
|
||||
use super::{CommandSpec, EngineError, NodeId, Result, Session, always, bad, check_switches, cmd, fileio, has_doc, str_param, switch_or};
|
||||
@@ -49,7 +51,7 @@ fn effect_specs() -> Vec<CommandSpec> {
|
||||
"Apply Effect",
|
||||
[],
|
||||
None,
|
||||
"{effect: id (see effect.list, e.g. \"stylize.dropShadow\", \"distort.roughen\", \"warp.arc\", or \"plugin.<id>\" for an installed effect plug-in), params?: {…} (missing keys take the dialog defaults), item?: appearance item index|null (apply to that fill/stroke only; omitted: the Appearance panel's active item, else the whole object), ids?|id?: [..] (layers too), target?: \"object\"|\"contents\" (contents: the objects inside groups and layers)} append a live effect to each selected object's appearance (a group's or layer's apply to its members as one piece: one combined shadow; a Pathfinder effect, which combines a group's contents, groups several loose selected objects first, in the same undo step); the art of a graph design in a graph series is left out, and objects that are all such art are an error (docs/mcp.md) → {ids, index, item, grouped?}",
|
||||
"{effect: id (see effect.list, e.g. \"stylize.dropShadow\", \"distort.roughen\", \"warp.arc\", or \"plugin.<id>\" for an installed effect plug-in), params?: {…} (missing keys take the dialog defaults), item?: appearance item index|null (apply to that fill/stroke only; omitted: the Appearance panel's active item, else the whole object), ids?|id?: [..] (layers too), target?: \"object\"|\"contents\" (contents: the objects inside groups and layers), aligned?: bool (item is a row of the first target's stack: each other target gets the effect on its equal one, as the Appearance panel does for a mixed selection; with the art of a graph design in a series given first, a row of the art's stack: docs/mcp.md)} append a live effect to each selected object's appearance (a group's or layer's apply to its members as one piece: one combined shadow; a Pathfinder effect, which combines a group's contents, groups several loose selected objects first, in the same undo step); the art of a graph design in a graph series is left out, and objects that are all such art are an error (docs/mcp.md) → {ids, index, item, grouped?}",
|
||||
has_doc,
|
||||
apply
|
||||
),
|
||||
@@ -67,7 +69,7 @@ fn effect_specs() -> Vec<CommandSpec> {
|
||||
"Remove Effect",
|
||||
[],
|
||||
None,
|
||||
"{index: int (position in the effect list), indices?: [int] (several positions in the same list instead of index, removed in one undo step; every one must exist), item?: appearance item index|null (that fill/stroke's effects; omitted: the active item, else the object's), 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)} → {ids}",
|
||||
"{index: int (position in the effect list), indices?: [int] (several positions in the same list instead of index, removed in one undo step; every one must exist), item?: appearance item index|null (that fill/stroke's effects; omitted: the active item, else the object's), 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; with the art of a graph design in a series given first, rows of the art's stack: docs/mcp.md)} → {ids}",
|
||||
has_doc,
|
||||
remove
|
||||
),
|
||||
@@ -76,7 +78,7 @@ fn effect_specs() -> Vec<CommandSpec> {
|
||||
"Effect Options",
|
||||
[],
|
||||
None,
|
||||
"{index: int, params?: {…} (merged into the current parameters), warpStyle?: string (an existing Warp style suffix, e.g. arc or flag; changes only an applied Warp in place; params are checked against the new style), threeDType?: plane|extrude|revolve|inflate (the 3D and Materials panel's object type: changes only an applied 3D effect in place into Rotate, Extrude & Bevel, Revolve or Inflate; the settings the new effect also takes keep their values, rotation angles wrapped into -180..180 outside Revolve, and the others start at its defaults; params then merge into the new effect's; the type the effect already has, with nothing else given, changes nothing and records no undo step), visible?: bool (any other value but null is refused, when a step plays too), item?: appearance item index|null (as effect.remove), 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)} → {ids}",
|
||||
"{index: int, params?: {…} (merged into the current parameters), warpStyle?: string (an existing Warp style suffix, e.g. arc or flag; changes only an applied Warp in place; params are checked against the new style), threeDType?: plane|extrude|revolve|inflate (the 3D and Materials panel's object type: changes only an applied 3D effect in place into Rotate, Extrude & Bevel, Revolve or Inflate; the settings the new effect also takes keep their values, rotation angles wrapped into -180..180 outside Revolve, and the others start at its defaults; params then merge into the new effect's; the type the effect already has, with nothing else given, changes nothing and records no undo step), visible?: bool (any other value but null is refused, when a step plays too), item?: appearance item index|null (as effect.remove), 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; with the art of a graph design in a series given first, rows of the art's stack: docs/mcp.md)} → {ids}",
|
||||
has_doc,
|
||||
set_params
|
||||
),
|
||||
@@ -85,7 +87,7 @@ fn effect_specs() -> Vec<CommandSpec> {
|
||||
"3D Lighting Preset",
|
||||
[],
|
||||
None,
|
||||
"{index: int, preset: standard|diffuse|topLeft|right, item?: appearance item index|null (omitted: the active item, else the object's), 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)} replace an applied 3D effect's light rig and ambient with an original lighting preset; preserve shading, geometry and view; all targets must have a 3D effect at index other than Extrude & Bevel (Classic), Revolve (Classic) or Rotate (Classic), which have no lighting presets; one undo step → {ids}",
|
||||
"{index: int, preset: standard|diffuse|topLeft|right, item?: appearance item index|null (omitted: the active item, else the object's), 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; with the art of a graph design in a series given first, rows of the art's stack: docs/mcp.md)} replace an applied 3D effect's light rig and ambient with an original lighting preset; preserve shading, geometry and view; all targets must have a 3D effect at index other than Extrude & Bevel (Classic), Revolve (Classic) or Rotate (Classic), which have no lighting presets; one undo step → {ids}",
|
||||
has_doc,
|
||||
lighting_preset
|
||||
),
|
||||
@@ -103,7 +105,7 @@ fn effect_specs() -> Vec<CommandSpec> {
|
||||
"Duplicate Effect",
|
||||
[],
|
||||
None,
|
||||
"{index: int, indices?: [int] (several positions in the same list instead of index; every one must exist), item?: appearance item index|null (as effect.remove), 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)} insert a copy of each effect right after it, in one undo step → {ids}",
|
||||
"{index: int, indices?: [int] (several positions in the same list instead of index; every one must exist), item?: appearance item index|null (as effect.remove), 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; with the art of a graph design in a series given first, rows of the art's stack: docs/mcp.md)} insert a copy of each effect right after it, in one undo step → {ids}",
|
||||
has_doc,
|
||||
duplicate
|
||||
),
|
||||
@@ -112,7 +114,7 @@ fn effect_specs() -> Vec<CommandSpec> {
|
||||
"Move Effect",
|
||||
[],
|
||||
None,
|
||||
"{from: int|[int] (position in the source list; a list moves several effects of that list, which keep their order and land together), to: int (the position of the (first) moved effect in the destination list afterwards), fromItem?: appearance item index|null (the source list: that fill/stroke's effects, null the object's; omitted: the active item, else the object's), toItem?: item index|null (the destination list; default: the source list), copy?: bool (copy instead of move, as Alt-dragging the row), ids?|id?: [..], target?: \"object\"|\"contents\", aligned?: bool (refused when the targets' appearances differ)} reorder an effect or move it between the object and its fills/strokes, as one undo step → {ids, index, item}",
|
||||
"{from: int|[int] (position in the source list; a list moves several effects of that list, which keep their order and land together), to: int (the position of the (first) moved effect in the destination list afterwards), fromItem?: appearance item index|null (the source list: that fill/stroke's effects, null the object's; omitted: the active item, else the object's), toItem?: item index|null (the destination list; default: the source list), copy?: bool (copy instead of move, as Alt-dragging the row), ids?|id?: [..], target?: \"object\"|\"contents\", aligned?: bool (refused when the targets' appearances differ, or differ from that of a graph design's art given first)} reorder an effect or move it between the object and its fills/strokes, as one undo step → {ids, index, item}",
|
||||
has_doc,
|
||||
move_effect
|
||||
),
|
||||
@@ -296,14 +298,20 @@ fn ids_json(ids: &[NodeId]) -> Value {
|
||||
/// effects it edits (`None`: the object's own) and the effect's index there. With `aligned` (see
|
||||
/// [`ItemTarget::per_target`]) `item` and `index` are the first target's, and each other target
|
||||
/// edits its equal item, or its equal object-level effect, each target matched on its own whatever
|
||||
/// the other targets have; in a target without them, `item` and `index` stand for themselves.
|
||||
/// the other targets have; in a target without them, `item` and `index` stand for themselves. When the first object
|
||||
/// given is the art of a graph design in a series ([`design_reference`]), which is left out, they are rows of its stack,
|
||||
/// and a target without them is left out.
|
||||
pub(crate) fn effect_targets(s: &Session, p: &Value, item: ItemTarget, index: usize, cmd: &str) -> Result<Vec<(NodeId, Option<usize>, usize)>> {
|
||||
let roots = edit_targets(s, p)?;
|
||||
let roots = edit_targets(s, p, cmd)?;
|
||||
let of = reference_of(s, p, item, cmd)?;
|
||||
let d = &s.doc()?.doc;
|
||||
let per = item.per_target(s, &roots);
|
||||
let first = roots.first().and_then(|f| d.node(*f));
|
||||
let per = item.per_target_of(s, of, &roots);
|
||||
let first = of.or_else(|| roots.first().copied()).and_then(|f| d.node(f));
|
||||
// Whether `item` names an item in the stack it is a row of (else the edit is object-level there).
|
||||
let anchor = roots.get(item.anchor(s, &roots)).and_then(|a| d.node(*a));
|
||||
let anchor = match of {
|
||||
Some(of) => d.node(of),
|
||||
None => roots.get(item.anchor(s, &roots)).and_then(|a| d.node(*a)),
|
||||
};
|
||||
let anchor_level = anchor.map(|a| item.effects_item(&a.appearance, cmd)).transpose()?.flatten();
|
||||
let aligned = aligned(p, cmd)?;
|
||||
let mut out = Vec::with_capacity(roots.len());
|
||||
@@ -312,7 +320,11 @@ pub(crate) fn effect_targets(s: &Session, p: &Value, item: ItemTarget, index: us
|
||||
let at = item.effects_item(&n.appearance, cmd)?;
|
||||
// An object-level effect this target shares with the first is where it is in this target.
|
||||
let index = match (first.filter(|_| aligned), at, anchor_level) {
|
||||
(Some(f), None, None) => SharedStack::of(&[&*f.appearance, &*n.appearance]).effect_in(index, 1).unwrap_or(index),
|
||||
(Some(f), None, None) => match SharedStack::of(&[&*f.appearance, &*n.appearance]).effect_in(index, 1) {
|
||||
Some(i) => i,
|
||||
None if of.is_some() && index != APPENDED => continue,
|
||||
None => index,
|
||||
},
|
||||
_ => index,
|
||||
};
|
||||
out.push((*id, at, index));
|
||||
@@ -320,6 +332,19 @@ pub(crate) fn effect_targets(s: &Session, p: &Value, item: ItemTarget, index: us
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// The effect index `effect.apply` gives [`effect_targets`] for the effect it appends, which names no effect the targets
|
||||
/// have.
|
||||
const APPENDED: usize = usize::MAX;
|
||||
|
||||
/// The [`design_reference`] of an effect edit aimed at `item`: none for the Appearance panel's active row, which is a
|
||||
/// row of the first selected object's stack.
|
||||
fn reference_of(s: &Session, p: &Value, item: ItemTarget, cmd: &str) -> Result<Option<NodeId>> {
|
||||
if matches!(item, ItemTarget::Item { explicit: false, .. }) {
|
||||
return Ok(None);
|
||||
}
|
||||
design_reference(s, p, cmd)
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn edit_effects(
|
||||
s: &mut Session,
|
||||
@@ -362,6 +387,20 @@ fn edit_effects_at(
|
||||
indices: &[usize],
|
||||
mut f: impl FnMut(&mut Vec<Effect>, &[usize]) -> bool,
|
||||
) -> Result<Vec<NodeId>> {
|
||||
// With a graph design's art given first, the indices are effects of its list: one it doesn't have names no effect,
|
||||
// and the edit is refused as a whole, as it is in a list that lacks one of them.
|
||||
if let Some(of) = reference_of(s, p, item, cmd)?
|
||||
&& let Some(n) = s.doc()?.doc.node(of)
|
||||
{
|
||||
let ap = &n.appearance;
|
||||
let len = match item.effects_item(ap, cmd)? {
|
||||
None => ap.effects.len(),
|
||||
Some(i) => ap.items.get(i).map_or(0, |it| it.effects().len()),
|
||||
};
|
||||
if indices.iter().any(|i| *i >= len) {
|
||||
return Err(EngineError::Other(none.into()));
|
||||
}
|
||||
}
|
||||
// Each target's list and the positions in it, in the order the targets first come up.
|
||||
let mut targets: Vec<(NodeId, Option<usize>, Vec<usize>)> = vec![];
|
||||
for &index in indices {
|
||||
@@ -453,13 +492,18 @@ pub(crate) fn apply(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
return Ok(json!({ "ids": [gid.0], "index": 0, "item": null, "grouped": true }));
|
||||
}
|
||||
let item = item_target(s, p, C)?;
|
||||
// The item as each target has it, read before the effect changes their rows: a row of a graph design's art, which is
|
||||
// left out of the targets, is the row equal to it there, and a target without one is left out.
|
||||
let targets = edit_targets(s, p, C)?;
|
||||
let of = reference_of(s, p, item, C)?;
|
||||
let per: Vec<(NodeId, Option<ItemTarget>)> = targets.iter().copied().zip(item.per_target_of(s, of, &targets)).collect();
|
||||
if effects::is_three_d(id) {
|
||||
let targets = edit_targets(s, p)?;
|
||||
for (target, at) in targets.iter().copied().zip(item.per_target(s, &targets)) {
|
||||
for &(target, at) in &per {
|
||||
let Some(at) = at else { continue };
|
||||
let d = &s.doc()?.doc;
|
||||
let n = d.node(target).ok_or(EngineError::NoNode(target))?;
|
||||
// A new effect goes after the item's (or the object's) others.
|
||||
let on = at.and_then(|at| at.effects_item(&n.appearance, C).ok()).flatten();
|
||||
let on = at.effects_item(&n.appearance, C).ok().flatten();
|
||||
let on = on.and_then(|i| Some((Some(i), n.appearance.items.get(i)?.effects().len())));
|
||||
let on = on.unwrap_or((None, n.appearance.effects.len()));
|
||||
effects::validate_three_d_at_in(Some(d), n, id, &effect.params, on).map_err(|e| bad(C, e))?;
|
||||
@@ -474,14 +518,19 @@ pub(crate) fn apply(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
}
|
||||
}
|
||||
}
|
||||
let ids = edit_effects(s, p, item, C, &label, "Apply Effect: select objects", 0, |fx, _| {
|
||||
let ids = edit_effects(s, p, item, C, &label, "Apply Effect: select objects", APPENDED, |fx, _| {
|
||||
fx.push(effect.clone());
|
||||
true
|
||||
})?;
|
||||
// The item the effect landed on in the object whose row the item is (the first selected object
|
||||
// for the active item, which applies where it fits), and the effect's index in that object.
|
||||
let first = ids.get(item.anchor(s, &ids)).and_then(|id| s.doc().ok()?.doc.node(*id));
|
||||
let landed = first.and_then(|n| item.effects_item(&n.appearance, C).ok().flatten());
|
||||
let k = item.anchor(s, &ids);
|
||||
let first = ids.get(k).and_then(|id| s.doc().ok()?.doc.node(*id));
|
||||
let at = match of {
|
||||
Some(_) => ids.get(k).and_then(|id| per.iter().find(|(t, _)| t == id)).and_then(|(_, at)| *at).unwrap_or(item),
|
||||
None => item,
|
||||
};
|
||||
let landed = first.and_then(|n| at.effects_item(&n.appearance, C).ok().flatten());
|
||||
let index = first.and_then(|n| n.appearance.effects_at(landed)).map_or(0, |fx| fx.len().saturating_sub(1));
|
||||
Ok(json!({ "ids": ids_json(&ids), "index": index, "item": landed }))
|
||||
}
|
||||
@@ -495,7 +544,7 @@ fn loose_for_pathfinder(s: &Session, p: &Value, id: &str) -> Result<Option<Vec<N
|
||||
}
|
||||
let d = &s.doc()?.doc;
|
||||
// The art of a graph design in a series stays where the graph draws it.
|
||||
let roots = super::appearance::without_design_art(d, super::appearance::subject_roots(s)?, "appearance")?;
|
||||
let roots = super::appearance::without_design_art(d, super::appearance::subject_roots(s)?, "effect.apply")?;
|
||||
let grouped = roots.iter().any(|r| d.node(*r).is_some_and(|n| matches!(n.kind, NodeKind::Group { clip: false, .. } | NodeKind::Layer { .. })));
|
||||
Ok((roots.len() >= 2 && !grouped).then_some(roots))
|
||||
}
|
||||
@@ -608,9 +657,9 @@ fn move_effect(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let src = item_target_at(s, p, "fromItem", C)?;
|
||||
let dst = if p.get("toItem").is_some() { item_target_at(s, p, "toItem", C)? } else { src };
|
||||
let copy = switch_or(p, "copy", false, C)?;
|
||||
let roots = edit_targets(s, p)?;
|
||||
let roots = edit_targets(s, p, C)?;
|
||||
// Where an aligned effect would go in each of several different stacks isn't one place.
|
||||
if aligned(p, C)? && super::appearance::appearances_differ(&s.doc()?.doc, &roots) {
|
||||
if aligned(p, C)? && super::appearance::appearances_differ(&s.doc()?.doc, &with_reference(design_reference(s, p, C)?, &roots)) {
|
||||
return Err(bad(C, "effects of objects whose appearances differ can't be moved together"));
|
||||
}
|
||||
// Where the effect landed in each object it moved in, in the order of `ids`.
|
||||
@@ -898,8 +947,11 @@ fn can_expand(s: &Session) -> std::result::Result<(), String> {
|
||||
has_doc(s)?;
|
||||
let ids = appearance_targets(s, &Value::Null).map_err(|e| e.to_string())?;
|
||||
let d = &s.doc().map_err(|e| e.to_string())?.doc;
|
||||
// The art of a graph design in a series, selected or inside a selected graph, is drawn again from the design.
|
||||
let ids = super::appearance::drop_design_art(d, ids);
|
||||
// A dimension selected with the art it measures is left as it is.
|
||||
let kept = super::dimension::with_their_art(d, &ids);
|
||||
let mut kept = super::dimension::with_their_art(d, &ids);
|
||||
kept.extend(super::appearance::design_art_in(d, &ids));
|
||||
if ids.iter().filter_map(|id| d.node(*id)).any(|n| expandable_except(n, &kept)) {
|
||||
Ok(())
|
||||
} else {
|
||||
@@ -1225,12 +1277,22 @@ fn holds_plugin_effect(d: &Document, n: &Node, in_art: bool, seen: &mut std::col
|
||||
}
|
||||
|
||||
fn expand_appearance(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let roots = appearance_targets(s, p)?;
|
||||
let roots = edit_targets(s, p, "effect.expandAppearance")?;
|
||||
// In Safe Mode a plug-in effect draws nothing: expanding it would drop the effect and keep the
|
||||
// geometry it hasn't changed.
|
||||
if vectorcraft_plugins::safe_mode::active() {
|
||||
// A dimension selected with the art it measures is left as it is.
|
||||
let d = &*super::dimension::without_kept(&s.doc()?.doc, &roots);
|
||||
// A dimension selected with the art it measures is left as it is, and so is the art of a graph design in a
|
||||
// series.
|
||||
let mut d = super::dimension::without_kept(&s.doc()?.doc, &roots);
|
||||
let designs = super::appearance::design_art_in(&d, &roots);
|
||||
if !designs.is_empty() {
|
||||
let view = d.to_mut();
|
||||
for id in designs {
|
||||
// Art already gone (inside other art removed) needs nothing more.
|
||||
let _ = view.remove(id);
|
||||
}
|
||||
}
|
||||
let d = &*d;
|
||||
let mut seen = std::collections::HashSet::new();
|
||||
if roots.iter().filter_map(|id| d.node(*id)).any(|n| holds_plugin_effect(d, n, false, &mut seen)) {
|
||||
return Err(EngineError::Other("Expand Appearance: a plug-in effect in the selection can't be expanded in Safe Mode".into()));
|
||||
@@ -1240,6 +1302,10 @@ fn expand_appearance(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let colorized_ok = super::paint::check_colorized(&s.doc()?.doc).is_ok();
|
||||
let ids = s.edit("Expand Appearance", |d, _| {
|
||||
let before = d.clone();
|
||||
// The art of graph designs in the graphs and series among the roots stays as the graph draws it. A group whose
|
||||
// own effect turns it into other art as a whole (a blur, feather or inner glow makes it an image) takes its
|
||||
// art into that art, which draws it, so that art has no place to go back to.
|
||||
let designs = super::appearance::design_art_in(d, &roots);
|
||||
// A dimension selected with the art it measures keeps its own lines and label: it is out
|
||||
// of the document while the art expands.
|
||||
let lifted = super::dimension::lift(d, &roots);
|
||||
@@ -1251,6 +1317,15 @@ fn expand_appearance(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
for id in &roots {
|
||||
expand_node(d, *id, &brushes, &mut out);
|
||||
}
|
||||
for id in &designs {
|
||||
if let (Some(was), Some(slot)) = (before.node(*id), d.node_mut(*id)) {
|
||||
*slot = was.clone();
|
||||
}
|
||||
}
|
||||
if !designs.is_empty() {
|
||||
// A mark that expands into a group stays listed: only what lies in the restored art is left out.
|
||||
out.retain(|id| d.node(*id).is_some() && !d.ancestry(*id).is_some_and(|up| up.iter().any(|a| designs.contains(a))));
|
||||
}
|
||||
super::dimension::put_back(d, lifted);
|
||||
// A dimension whose source was replaced stays where it was, no longer linked.
|
||||
super::dimension::restore_unchanged(&before, d, ended);
|
||||
|
||||
@@ -95,7 +95,7 @@ fn map_graphic(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
effect_targets(s, p, item, index, C)?
|
||||
} else {
|
||||
let mut out = vec![];
|
||||
for id in edit_targets(s, p)? {
|
||||
for id in edit_targets(s, p, C)? {
|
||||
let (item, index) = d.node(id).and_then(first_three_d).ok_or_else(|| bad(C, format!("object {} needs a 3D effect", id.0)))?;
|
||||
out.push((id, item, index));
|
||||
}
|
||||
|
||||
@@ -91,6 +91,7 @@ pub(crate) use mesh3d::{mesh_file_names, mesh_files};
|
||||
pub use naming::{NameParts as FileNameParts, parse_date as parse_file_date, render as render_file_name};
|
||||
pub(crate) use native::preview_png;
|
||||
pub use native::with_compression_pref;
|
||||
pub(crate) use pdfimport::opened_page_document;
|
||||
pub use pdfimport::{LoadOptions, page_document};
|
||||
pub(crate) use save::job_for;
|
||||
pub(crate) use save::save as save_document;
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -8,7 +8,7 @@ use std::sync::Arc;
|
||||
use serde::Deserialize;
|
||||
use serde::de::DeserializeOwned;
|
||||
use serde_json::Value;
|
||||
use vectorcraft_doc::{Artboard, Document, Node, NodeKind, SymbolOverride};
|
||||
use vectorcraft_doc::{Artboard, Document, Node, NodeKind, SYMBOL_SIZES, SymbolOverride};
|
||||
use vectorcraft_geom::Rect;
|
||||
use vectorcraft_render::AntiAlias;
|
||||
use vectorcraft_render::encode::jpeg::{self, JpegOptions};
|
||||
@@ -454,7 +454,12 @@ pub fn export_art_bounds(doc: &Document, format: &str) -> Option<Rect> {
|
||||
/// make the same appearance changes to its art are measured on a copy of the symbol of their own,
|
||||
/// in which each object is judged with those changes ([`as_drawn`]); the objects taken out of
|
||||
/// a dynamic symbol's art leave empty groups in their places, by which instances find the objects
|
||||
/// they change.
|
||||
/// they change. Each copy keeps its symbol's 9-slice guides and natural size. An instance whose art
|
||||
/// goes through 9-slice guides at any depth is measured as its art placed through them
|
||||
/// ([`placed_instances`]), with the objects `left_out` picks hidden after placing: the guides cut
|
||||
/// the box of the whole art. Where an instance stays one, the art of a symbol with 9-slice guides,
|
||||
/// and of the symbols whose instances are in that art at any depth ([`sliced_art`]), is measured
|
||||
/// whole.
|
||||
fn bounds_without(doc: &Document, left_out: &dyn Fn(&Node) -> bool) -> Option<Rect> {
|
||||
// The symbols instances show (the first of each name).
|
||||
let mut names = HashSet::new();
|
||||
@@ -492,12 +497,15 @@ fn bounds_without(doc: &Document, left_out: &dyn Fn(&Node) -> bool) -> Option<Re
|
||||
let Some(s) = d.symbols.get(*i).cloned() else { continue };
|
||||
for (k, c) in sets.get(s.name.as_str()).into_iter().flatten().flatten().enumerate() {
|
||||
copies.push((d.symbols.len(), c.as_slice()));
|
||||
d.symbols.push(vectorcraft_doc::Symbol { name: format!("{}\u{0}{k}", s.name), ..s.clone() });
|
||||
let name = format!("{}\u{0}{k}", s.name);
|
||||
copy_placement(&mut d, &s.name, &name);
|
||||
d.symbols.push(vectorcraft_doc::Symbol { name, ..s.clone() });
|
||||
}
|
||||
}
|
||||
d
|
||||
});
|
||||
let src = remapped_doc.as_ref().unwrap_or(doc);
|
||||
let whole = sliced_art(src);
|
||||
// The symbols to measure with the changes their instances make (`None` for a static symbol),
|
||||
// without the dynamic symbols measured whole.
|
||||
let entries: Vec<(usize, Option<&[&SymbolOverride]>)> = shown
|
||||
@@ -517,11 +525,25 @@ fn bounds_without(doc: &Document, left_out: &dyn Fn(&Node) -> bool) -> Option<Re
|
||||
}
|
||||
let left_out =
|
||||
&|n: &Node| left_out(n) || matches!(&n.kind, NodeKind::SymbolInstance { symbol, .. } if empty.contains(symbol.as_str()) && !has_effects(n));
|
||||
// The instances whose art goes through 9-slice guides, at any depth, are measured as their art
|
||||
// placed by those guides without the objects `left_out` picks, and without a clip group that
|
||||
// holds only such objects.
|
||||
let layers = hidden_nodes(&src.layers, left_out);
|
||||
let placing = holding(src, &whole);
|
||||
let layers = if placing.is_empty() {
|
||||
layers
|
||||
} else {
|
||||
let picked = &|n: &Node| {
|
||||
left_out(n) || matches!(n.kind, NodeKind::Group { clip: true, .. }) && all_left_out(n, left_out, &empty, None, &mut Vec::new())
|
||||
};
|
||||
let mut budget = 0;
|
||||
let from = layers.as_deref().unwrap_or(&src.layers);
|
||||
Some(from.iter().map(|l| placed_instances(src, l, &placing, picked, &mut budget, 0).unwrap_or_else(|| l.clone())).collect())
|
||||
};
|
||||
let symbols: Vec<(usize, Arc<Node>)> = entries
|
||||
.iter()
|
||||
.filter_map(|(i, c)| {
|
||||
let s = src.symbols.get(*i).filter(|s| !empty.contains(s.name.as_str()))?;
|
||||
let s = src.symbols.get(*i).filter(|s| !empty.contains(s.name.as_str()) && !whole.contains(s.name.as_str()))?;
|
||||
without_nodes(&s.art, left_out, *c, &mut Vec::new()).map(|art| (*i, art))
|
||||
})
|
||||
.collect();
|
||||
@@ -540,6 +562,135 @@ fn bounds_without(doc: &Document, left_out: &dyn Fn(&Node) -> bool) -> Option<Re
|
||||
vectorcraft_render::encode::art_bounds(&d)
|
||||
}
|
||||
|
||||
/// Give symbol `to` of `d` the 9-slice guides and the natural size that symbol `from` has.
|
||||
fn copy_placement(d: &mut Document, from: &str, to: &str) {
|
||||
if let Some(guides) = d.symbol_nine_slice(from) {
|
||||
d.set_symbol_nine_slice(to, Some(guides));
|
||||
}
|
||||
if let Some(size) = d.unknown.get(SYMBOL_SIZES).and_then(|m| m.get(from)).cloned()
|
||||
&& let Some(sizes) = d.unknown.get_mut(SYMBOL_SIZES).and_then(Value::as_object_mut)
|
||||
{
|
||||
sizes.insert(to.to_string(), size);
|
||||
}
|
||||
}
|
||||
|
||||
/// The names of the symbols of `doc` with 9-slice guides, and of the symbols whose instances are in
|
||||
/// the art of those at any depth.
|
||||
fn sliced_art(doc: &Document) -> HashSet<&str> {
|
||||
let mut arts: HashMap<&str, &Arc<Node>> = HashMap::new();
|
||||
for s in &doc.symbols {
|
||||
arts.entry(s.name.as_str()).or_insert(&s.art);
|
||||
}
|
||||
let mut todo: Vec<&str> = arts.keys().copied().filter(|name| doc.symbol_nine_slice(name).is_some()).collect();
|
||||
let mut found = HashSet::new();
|
||||
while let Some(name) = todo.pop() {
|
||||
if !found.insert(name) {
|
||||
continue;
|
||||
}
|
||||
if let Some(&art) = arts.get(name) {
|
||||
art.walk(&mut |n| {
|
||||
if let NodeKind::SymbolInstance { symbol, .. } = &n.kind {
|
||||
todo.push(symbol.as_str());
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
found
|
||||
}
|
||||
|
||||
/// The names of the symbols of `doc` in `sliced`, and of the symbols whose art holds an instance of
|
||||
/// one of those at any depth.
|
||||
fn holding<'a>(doc: &'a Document, sliced: &HashSet<&'a str>) -> HashSet<&'a str> {
|
||||
let mut held_by: HashMap<&str, Vec<&str>> = HashMap::new();
|
||||
let mut seen = HashSet::new();
|
||||
for s in doc.symbols.iter().filter(|s| seen.insert(s.name.as_str())) {
|
||||
s.art.walk(&mut |n| {
|
||||
if let NodeKind::SymbolInstance { symbol, .. } = &n.kind {
|
||||
held_by.entry(symbol.as_str()).or_default().push(s.name.as_str());
|
||||
}
|
||||
});
|
||||
}
|
||||
let mut found = HashSet::new();
|
||||
let mut todo: Vec<&str> = sliced.iter().copied().collect();
|
||||
while let Some(name) = todo.pop() {
|
||||
if found.insert(name) {
|
||||
todo.extend(held_by.get(name).into_iter().flatten().copied());
|
||||
}
|
||||
}
|
||||
found
|
||||
}
|
||||
|
||||
/// The most instances deep [`placed_instances`] places symbol art.
|
||||
const MAX_PLACED_DEPTH: usize = 32;
|
||||
|
||||
/// `n` with each instance of the `placing` symbols in it, and in its layers and groups at any depth,
|
||||
/// made a group with the instance's appearance that holds the instance's art (with its changes and
|
||||
/// stain) as `doc` places it, through the symbol's 9-slice guides when it has them, without the
|
||||
/// objects `picked` picks ([`without_nodes`]; hidden when it picks the whole art); `None` when `n`
|
||||
/// holds no such instance, or is hidden. An instance on a perspective grid, one
|
||||
/// [`MAX_PLACED_DEPTH`] instances deep, one in another kind of object (a blend, for example), and
|
||||
/// one whose art would take the objects placed in all (`budget`) past
|
||||
/// [`vectorcraft_doc::MAX_SYMBOL_NODES`] stay instances.
|
||||
/// Their symbols' art is measured whole, as taking objects out of it would change the box the
|
||||
/// guides cut, and the page can come out larger than the plot.
|
||||
fn placed_instances(
|
||||
doc: &Document,
|
||||
n: &Arc<Node>,
|
||||
placing: &HashSet<&str>,
|
||||
picked: &dyn Fn(&Node) -> bool,
|
||||
budget: &mut u64,
|
||||
depth: usize,
|
||||
) -> Option<Arc<Node>> {
|
||||
// Hidden art is not plotted, and placing it would take from the budget of the art that is.
|
||||
if !n.visible {
|
||||
return None;
|
||||
}
|
||||
match &n.kind {
|
||||
NodeKind::SymbolInstance { symbol, xf } => {
|
||||
if !placing.contains(symbol.as_str()) || depth >= MAX_PLACED_DEPTH || n.projection().is_some() {
|
||||
return None;
|
||||
}
|
||||
let sym = doc.symbols.iter().find(|s| s.name == *symbol)?;
|
||||
let art = vectorcraft_doc::symbol_overrides::instance_art(&sym.art, n);
|
||||
let most = doc.symbol_slice_map(symbol, *xf).map_or(art.count_with_masks() as u64, |m| m.most_placed(&art, doc));
|
||||
if budget.saturating_add(most) > vectorcraft_doc::MAX_SYMBOL_NODES {
|
||||
return None;
|
||||
}
|
||||
let art = Arc::new(doc.place_symbol_art(symbol, &art, *xf)?);
|
||||
*budget = budget.saturating_add(art.count_with_masks() as u64);
|
||||
let art = placed_instances(doc, &art, placing, picked, budget, depth + 1).unwrap_or(art);
|
||||
let art = if picked(&art) {
|
||||
let mut hidden = (*art).clone();
|
||||
hidden.visible = false;
|
||||
Arc::new(hidden)
|
||||
} else {
|
||||
without_nodes(&art, picked, None, &mut Vec::new()).unwrap_or(art)
|
||||
};
|
||||
let mut g = (**n).clone();
|
||||
g.kind = NodeKind::Group { children: vec![art], clip: false };
|
||||
Some(Arc::new(g))
|
||||
}
|
||||
NodeKind::Layer { children, .. } | NodeKind::Group { children, .. } => {
|
||||
let mut out: Option<Vec<Arc<Node>>> = None;
|
||||
for (i, c) in children.iter().enumerate() {
|
||||
if let Some(new) = placed_instances(doc, c, placing, picked, budget, depth)
|
||||
&& let Some(slot) = out.get_or_insert_with(|| children.clone()).get_mut(i)
|
||||
{
|
||||
*slot = new;
|
||||
}
|
||||
}
|
||||
out.map(|c| {
|
||||
let mut m = (**n).clone();
|
||||
if let Some(children) = m.children_mut() {
|
||||
*children = c;
|
||||
}
|
||||
Arc::new(m)
|
||||
})
|
||||
}
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// The appearance changes instance `n` makes to its symbol's art, without those that change nothing.
|
||||
fn own_changes(n: &Node) -> Vec<&SymbolOverride> {
|
||||
n.extras().symbol_overrides.iter().filter(|o| !o.is_empty()).collect()
|
||||
@@ -600,9 +751,10 @@ fn all_left_out(n: &Node, left_out: &dyn Fn(&Node) -> bool, empty: &HashSet<&str
|
||||
|
||||
/// Add the sets of appearance changes that the instances in `n` and its subtree make to the art of
|
||||
/// the symbols `sets` names, each set once, and those of the instances in the art of the symbols the
|
||||
/// instances show (`walked`, the symbols already looked in). The instances that `left_out` picks or
|
||||
/// that are in an object it picks are left out, and with `hidden` (in a layer) the hidden ones and
|
||||
/// those in a hidden object. A symbol with more than [`MAX_ART_COPIES`] sets gets `None`.
|
||||
/// instances show (`walked`, the symbols already looked in). With `hidden` (in a layer), the
|
||||
/// instances that are hidden or that `left_out` picks, and those in such an object, are left out.
|
||||
/// In symbol art every instance counts, since an instance of the symbol can change the objects
|
||||
/// that hold it. A symbol with more than [`MAX_ART_COPIES`] sets gets `None`.
|
||||
fn drawn_changes<'a>(
|
||||
doc: &'a Document,
|
||||
n: &'a Node,
|
||||
@@ -611,7 +763,7 @@ fn drawn_changes<'a>(
|
||||
walked: &mut HashSet<&'a str>,
|
||||
sets: &mut HashMap<&'a str, Option<Vec<Vec<&'a SymbolOverride>>>>,
|
||||
) {
|
||||
if hidden && !n.visible || left_out(n) {
|
||||
if hidden && (!n.visible || left_out(n)) {
|
||||
return;
|
||||
}
|
||||
if let NodeKind::SymbolInstance { symbol, .. } = &n.kind {
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -205,7 +205,7 @@ pub fn page_document(bytes: &[u8], page: usize, o: &LoadOptions) -> Result<(Docu
|
||||
}
|
||||
|
||||
/// [`page_document`] of `opened`, the PDF `bytes` already read.
|
||||
fn opened_page_document(bytes: &[u8], opened: &vectorcraft_pdf::Opened, page: usize, o: &LoadOptions) -> Result<(Document, Vec<String>)> {
|
||||
pub(crate) fn opened_page_document(bytes: &[u8], opened: &vectorcraft_pdf::Opened, page: usize, o: &LoadOptions) -> Result<(Document, Vec<String>)> {
|
||||
vectorcraft_pdf::import_opened(bytes, opened, &page_options(page, o)).map(|r| (r.document, r.warnings)).map_err(err)
|
||||
}
|
||||
|
||||
@@ -217,8 +217,11 @@ fn page_options(page: usize, o: &LoadOptions) -> ImportOptions {
|
||||
/// `document.open` with `linkPages`: the pages `o` picks of the PDF (or PDF-compatible `.ai` or
|
||||
/// `.ait`) `name`, read from `path`, as a document holding one placed document per page, linked to
|
||||
/// that page of the file as File › Place with Link places one ([`document::source`]). Each page
|
||||
/// gets an artboard of its `o.crop` box (its art's bounds for `bounding`), in a row from the left
|
||||
/// with the gap a PDF's artboards open with, and a layer named after the page. The document opens
|
||||
/// gets an artboard of its `o.crop` box (its art's bounds for `bounding`, a blank page's crop box;
|
||||
/// a PDF-compatible `.ai` asked for its crop box gets its trim box, as it opens), in a row from the
|
||||
/// left with the gap a PDF's artboards open with, and a layer named after the page. On its trim box,
|
||||
/// a page shows its bleed box round the artboard, and the document's bleed is the pages' bleed (the
|
||||
/// widest on each side); the artboards are then the gap apart plus the bleed on both sides. The document opens
|
||||
/// in CMYK when most of the pages read in CMYK, unless `o.color_mode` names a mode. Refused without a
|
||||
/// path, for any other file, with a password (a linked page is read again without one), with
|
||||
/// `textAs: outlines` (a linked page shows the file's own type) and for a file over
|
||||
@@ -239,12 +242,16 @@ pub(super) fn linked_pages(name: &str, bytes: &[u8], path: Option<&str>, o: &Loa
|
||||
if bytes.len() > document::MAX_FILE {
|
||||
return Err(bad(C, format!("linkPages: `{file}` is over {} MB", document::MAX_FILE >> 20)));
|
||||
}
|
||||
let count = vectorcraft_pdf::open_pdf(bytes, None).map_err(err)?.page_count();
|
||||
// The file is read once: every page is read from it.
|
||||
let opened = vectorcraft_pdf::open_pdf(bytes, None).map_err(err)?;
|
||||
let count = opened.page_count();
|
||||
let picked = match &o.pages {
|
||||
None => (0..count).collect(),
|
||||
Some(range) => parse_range(range, count).map_err(|e| err(format!("pages: {e}")))?,
|
||||
};
|
||||
let bounding = o.crop == CropTo::Bounding;
|
||||
// A PDF-compatible `.ai` asked for its crop box opens on its trim boxes ([`LoadOptions::import_options`]).
|
||||
let crop = if o.crop == CropTo::Crop && opened.has_illustrator_data() { CropTo::Trim } else { o.crop };
|
||||
let mut doc = Document::new(1.0, 1.0);
|
||||
let board = doc.artboards.first().cloned().ok_or_else(|| err("a new document has no artboard"))?;
|
||||
doc.artboards.clear();
|
||||
@@ -253,14 +260,55 @@ pub(super) fn linked_pages(name: &str, bytes: &[u8], path: Option<&str>, o: &Loa
|
||||
let gap = ImportOptions::default().artboard_gap;
|
||||
// Every page's object keeps the same one copy of the file.
|
||||
let shared = Arc::new(bytes.to_vec());
|
||||
let (mut x, mut warnings, mut cmyk_pages) = (0.0, Vec::<String>::new(), 0);
|
||||
for (i, &index) in picked.iter().enumerate() {
|
||||
// The file is hashed once for all its pages.
|
||||
let hash = link.hash.clone().unwrap_or_else(|| vectorcraft_doc::links::hash_bytes(bytes));
|
||||
let (mut warnings, mut cmyk_pages) = (Vec::<String>::new(), 0);
|
||||
// Each page as read, with its bleed (top, bottom, left, right). The pages are laid out once
|
||||
// the document's bleed is known.
|
||||
let mut read_pages = Vec::with_capacity(picked.len());
|
||||
for &index in &picked {
|
||||
let page = index as u32 + 1;
|
||||
let s = document::shared_source(name, &shared, Some(path), page, bounding, document::page_box(o.crop), C)?;
|
||||
let file_once = document::SharedFile { bytes: &shared, hash: &hash, opened: &opened };
|
||||
// On its trim box, a page shows its bleed box, the artboard on the trim box inside it, so
|
||||
// the art in the bleed shows and prints as bleed.
|
||||
let bleed = if crop == CropTo::Trim { opened.page_bleed(index) } else { None };
|
||||
let page_box = if bleed.is_some() { vectorcraft_doc::PageBox::Bleed } else { document::page_box(crop) };
|
||||
let read = |bounding| document::shared_source(name, file_once, Some(path), page, bounding, page_box, C);
|
||||
// A blank page has no art's bounds: it keeps its crop box, as opening it as art does.
|
||||
let (mut s, bounding) = match read(bounding) {
|
||||
Err(e) if bounding && e.to_string().contains("has no art to place") => (read(false)?, false),
|
||||
s => (s?, bounding),
|
||||
};
|
||||
// A page that is only a placeholder (a `.ai` saved without PDF compatibility) shows the
|
||||
// file's editing data, not a page of the file.
|
||||
if s.editing_data {
|
||||
return Err(bad(C, format!("linkPages: `{file}` isn't a PDF or a PDF-compatible .ai file")));
|
||||
}
|
||||
cmyk_pages += usize::from(s.color_mode == ColorMode::Cmyk);
|
||||
// The document's bleed is the widest of the pages' on each side.
|
||||
let sides = bleed.unwrap_or_default();
|
||||
for (side, b) in doc.setup.bleed.iter_mut().zip(sides) {
|
||||
*side = side.max(b.min(vectorcraft_doc::setup::MAX_BLEED));
|
||||
}
|
||||
for w in std::mem::take(&mut s.warnings) {
|
||||
if !warnings.contains(&w) {
|
||||
warnings.push(w);
|
||||
}
|
||||
}
|
||||
read_pages.push((page, s, bounding, sides));
|
||||
}
|
||||
// Each artboard is the gap from the one before plus the bleed on both sides of it: the
|
||||
// document's bleed, which export adds round every artboard, or a page's own bleed box where it
|
||||
// reaches farther. Neither page then shows or exports the other's art.
|
||||
let [_, _, doc_left, doc_right] = doc.setup.bleed;
|
||||
let mut x = 0.0;
|
||||
for (i, (page, s, bounding, [top, bottom, left, right])) in read_pages.into_iter().enumerate() {
|
||||
if i > 0 {
|
||||
x += doc_left.max(left);
|
||||
}
|
||||
let mut node = document::node(&mut doc, &s, LinkInfo { page: Some(page), ..link.clone() }, bounding);
|
||||
if let NodeKind::PlacedDocument(p) = &mut node.kind {
|
||||
p.xf = Affine::translate((x, 0.0));
|
||||
p.xf = Affine::translate((x - left, -top));
|
||||
}
|
||||
node.name = Some(file.clone());
|
||||
let mut layer = Node::layer(doc.alloc_id(), &format!("Page {page}"), LayerColor::Preset((i % 27) as u8));
|
||||
@@ -268,14 +316,9 @@ pub(super) fn linked_pages(name: &str, bytes: &[u8], path: Option<&str>, o: &Loa
|
||||
children.push(Arc::new(node));
|
||||
}
|
||||
doc.layers.push(Arc::new(layer));
|
||||
let rect = Rect::new(x, 0.0, x + s.frame.width(), s.frame.height());
|
||||
let rect = Rect::new(x, 0.0, x + s.frame.width() - left - right, s.frame.height() - top - bottom);
|
||||
doc.artboards.push(Artboard { id: i as u32 + 1, name: format!("Artboard {}", i + 1), rect, ..board.clone() });
|
||||
x = rect.x1 + gap;
|
||||
for w in s.warnings {
|
||||
if !warnings.contains(&w) {
|
||||
warnings.push(w);
|
||||
}
|
||||
}
|
||||
x = rect.x1 + doc_right.max(right) + gap;
|
||||
}
|
||||
// Pages painted mostly in CMYK read as CMYK documents, as opening them as art does: the
|
||||
// document opens in CMYK (with the CMYK default swatches) when most of its pages do.
|
||||
|
||||
@@ -1160,6 +1160,91 @@ fn an_ai_file_saved_without_its_pdf_part_places_from_its_editing_data() {
|
||||
assert!(e.contains("placeholder"), "{e}");
|
||||
}
|
||||
|
||||
/// `document.open` with `linkPages` refuses a `.ai` saved without PDF compatibility, whose page is
|
||||
/// only a placeholder, and opens a PDF-compatible one.
|
||||
#[test]
|
||||
fn link_pages_refuses_an_ai_file_saved_without_its_pdf_part() {
|
||||
let d = std::env::temp_dir().join(format!("vc-ailinkpages-{}", std::process::id()));
|
||||
std::fs::create_dir_all(&d).unwrap();
|
||||
let file = |name: &str, page: &str| {
|
||||
let path = d.join(name);
|
||||
std::fs::write(&path, ai::ai(&compressed(&ai::sample_data()), page)).unwrap();
|
||||
path.to_string_lossy().to_string()
|
||||
};
|
||||
let (plain, compatible) = (file("plain.ai", PLACEHOLDER), file("compatible.ai", "0 g 10 10 40 40 re f"));
|
||||
let mut s = Session::new();
|
||||
let e = s.execute("document.open", &json!({"path": plain, "linkPages": true})).unwrap_err().to_string();
|
||||
assert!(e.contains("isn't a PDF or a PDF-compatible .ai file"), "{e}");
|
||||
assert!(s.documents().is_empty(), "nothing opened");
|
||||
s.execute("document.open", &json!({"path": compatible, "linkPages": true})).unwrap();
|
||||
assert!(s.doc().unwrap().doc.has_placed());
|
||||
let _ = std::fs::remove_dir_all(&d);
|
||||
}
|
||||
|
||||
/// `document.open` with `linkPages` opens a PDF-compatible `.ai` asked for its crop box on its trim
|
||||
/// box, as it opens as art, showing its bleed box round the artboard, which is the document's bleed.
|
||||
#[test]
|
||||
fn link_pages_open_an_ai_file_on_its_trim_box_with_its_bleed() {
|
||||
let d = std::env::temp_dir().join(format!("vc-ailinkpagestrim-{}", std::process::id()));
|
||||
std::fs::create_dir_all(&d).unwrap();
|
||||
let path = d.join("trimmed.ai");
|
||||
let boxes = "/TrimBox [10 10 190 90] /BleedBox [4 2 196 98]";
|
||||
std::fs::write(&path, ai::ai_with(&compressed(&ai::sample_data()), "0 g 10 10 40 40 re f", boxes)).unwrap();
|
||||
let path = path.to_string_lossy().to_string();
|
||||
let mut s = Session::new();
|
||||
s.execute("document.open", &json!({"path": path, "linkPages": true})).unwrap();
|
||||
let doc = &s.doc().unwrap().doc;
|
||||
let board = doc.artboards[0].rect;
|
||||
assert_eq!((board.width(), board.height()), (180.0, 80.0), "the trim box");
|
||||
assert_eq!(doc.setup.bleed, [8.0, 8.0, 6.0, 6.0], "top, bottom, left, right");
|
||||
// The page shows its bleed box, so the art in the bleed is kept.
|
||||
let placed = doc.layers[0].children().unwrap()[0].clone();
|
||||
let NodeKind::PlacedDocument(p) = &placed.kind else { panic!("{:?}", placed.kind) };
|
||||
assert_eq!((p.width, p.height, p.page_box), (192.0, 96.0, vectorcraft_doc::PageBox::Bleed));
|
||||
assert_eq!(p.xf.translation(), vectorcraft_geom::Vec2::new(-6.0, -8.0), "the trim box on the artboard");
|
||||
let _ = std::fs::remove_dir_all(&d);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn link_pages_on_their_trim_boxes_keep_the_bleed_between_the_artboards() {
|
||||
// Two 100 pt trim boxes: the first with 60 pt of bleed on each side, the second with 80 pt on
|
||||
// its left, more than the largest document bleed (72 pt).
|
||||
let entries = format!("/PieceInfo << /Illustrator << /Private << /AIPrivateData1 {} 0 R /NumBlock 1 >> >> >>", first_extra(2));
|
||||
let first = PdfPage {
|
||||
bleed: Some([0.0, 0.0, 220.0, 220.0]),
|
||||
trim: Some([60.0, 60.0, 160.0, 160.0]),
|
||||
entries,
|
||||
..PdfPage::new(220.0, 220.0, "0 g 0 0 220 220 re f")
|
||||
};
|
||||
let second = PdfPage {
|
||||
bleed: Some([0.0, 0.0, 240.0, 220.0]),
|
||||
trim: Some([80.0, 60.0, 180.0, 160.0]),
|
||||
..PdfPage::new(240.0, 220.0, "0 g 0 0 240 220 re f")
|
||||
};
|
||||
let d = std::env::temp_dir().join(format!("vc-ailinkpagesgap-{}", std::process::id()));
|
||||
std::fs::create_dir_all(&d).unwrap();
|
||||
let path = d.join("pages.ai");
|
||||
std::fs::write(&path, pdf_with(&[first, second], &["<< /Length 4 >>\nstream\ndata\nendstream"], None)).unwrap();
|
||||
let path = path.to_string_lossy().to_string();
|
||||
let mut s = Session::new();
|
||||
s.execute("document.open", &json!({"path": path, "linkPages": true})).unwrap();
|
||||
let doc = &s.doc().unwrap().doc;
|
||||
assert_eq!(doc.setup.bleed, [60.0, 60.0, 72.0, 60.0], "top, bottom, left, right");
|
||||
// The first page's bleed ends at 160; the second's starts 36 pt later, 80 pt left of its trim box.
|
||||
let boards: Vec<(f64, f64)> = doc.artboards.iter().map(|a| (a.rect.x0, a.rect.x1)).collect();
|
||||
assert_eq!(boards, [(0.0, 100.0), (276.0, 376.0)]);
|
||||
let shown: Vec<f64> = doc
|
||||
.layers
|
||||
.iter()
|
||||
.map(|l| match &l.children().unwrap()[0].kind {
|
||||
NodeKind::PlacedDocument(p) => p.xf.translation().x,
|
||||
k => panic!("{k:?}"),
|
||||
})
|
||||
.collect();
|
||||
assert_eq!(shown, [-60.0, 196.0], "each page's bleed box round its artboard");
|
||||
let _ = std::fs::remove_dir_all(&d);
|
||||
}
|
||||
|
||||
/// A page of text alone, as text-only art has in its PDF part: no path is painted.
|
||||
const TEXT_ART: &str = "BT /F1 36 Tf 10 40 Td (Text only) Tj ET";
|
||||
|
||||
|
||||
@@ -338,6 +338,152 @@ fn a_plot_of_the_arts_bounds_leaves_out_images_in_dynamic_symbol_art() {
|
||||
assert!(e.contains("no visible art"), "{e}");
|
||||
}
|
||||
|
||||
/// A rectangle filled black, or one that paints nothing.
|
||||
fn plot_rect(d: &mut vectorcraft_doc::Document, r: vectorcraft_geom::Rect, painted: bool) -> std::sync::Arc<vectorcraft_doc::Node> {
|
||||
use vectorcraft_color::{Color, Paint};
|
||||
let fill = if painted { Paint::solid(Color::BLACK) } else { Paint::None };
|
||||
let appearance = vectorcraft_doc::Appearance::basic(fill, Paint::None, 0.0);
|
||||
std::sync::Arc::new(vectorcraft_doc::Node::path(d.alloc_id(), vectorcraft_geom::shapes::rectangle(r), appearance))
|
||||
}
|
||||
|
||||
/// An instance of symbol `symbol` placed by `xf`.
|
||||
fn plot_instance(d: &mut vectorcraft_doc::Document, symbol: &str, xf: vectorcraft_geom::Affine) -> vectorcraft_doc::Node {
|
||||
vectorcraft_doc::Node::new(d.alloc_id(), NodeKind::SymbolInstance { symbol: symbol.into(), xf })
|
||||
}
|
||||
|
||||
/// Symbol `name` holding `art` in a group.
|
||||
fn plot_symbol(d: &mut vectorcraft_doc::Document, name: &str, art: Vec<std::sync::Arc<vectorcraft_doc::Node>>, dynamic: bool) {
|
||||
let group = vectorcraft_doc::Node::group(d.alloc_id(), art);
|
||||
d.symbols.push(vectorcraft_doc::Symbol { dynamic, name: name.into(), art: std::sync::Arc::new(group) });
|
||||
}
|
||||
|
||||
/// Puts `n` on the first layer of `d`.
|
||||
fn plot_on_layer(d: &mut vectorcraft_doc::Document, n: vectorcraft_doc::Node) {
|
||||
std::sync::Arc::make_mut(&mut d.layers[0]).children_mut().unwrap().push(std::sync::Arc::new(n));
|
||||
}
|
||||
|
||||
/// The art of a symbol with 9-slice guides, static or dynamic, is placed whole and measured
|
||||
/// without what the plot leaves out: a frame that paints nothing sets where the guides place the
|
||||
/// painted art inside it, and is not measured itself.
|
||||
#[test]
|
||||
fn a_plot_of_the_arts_bounds_places_9_slice_symbol_art_by_all_of_it() {
|
||||
use vectorcraft_geom::{Affine, Rect};
|
||||
// Static and dynamic, and with the instance's fill staining the art.
|
||||
for (dynamic, stained) in [(false, false), (true, false), (false, true)] {
|
||||
let mut d = vectorcraft_doc::Document::new(400.0, 400.0);
|
||||
let frame = plot_rect(&mut d, Rect::new(0.0, 0.0, 100.0, 100.0), false);
|
||||
let square = plot_rect(&mut d, Rect::new(40.0, 40.0, 60.0, 60.0), true);
|
||||
plot_symbol(&mut d, "Panel", vec![frame, square], dynamic);
|
||||
d.set_symbol_nine_slice("Panel", Some(vectorcraft_doc::SymbolNineSlice::default()));
|
||||
let mut wide = plot_instance(&mut d, "Panel", Affine::scale_non_uniform(2.0, 1.0));
|
||||
if stained {
|
||||
let red = vectorcraft_color::Paint::solid(vectorcraft_color::Color::rgb(1.0, 0.0, 0.0));
|
||||
wide.appearance = vectorcraft_doc::Appearance::basic(red, vectorcraft_color::Paint::None, 0.0).into();
|
||||
}
|
||||
plot_on_layer(&mut d, wide);
|
||||
// Scaled 2 times across, the middle column maps x to 3x - 50: the square draws from x 70
|
||||
// to 130.
|
||||
let b = super::export_art_bounds(&d, "hpgl").unwrap();
|
||||
let exact = [(b.x0, 70.0), (b.y0, 40.0), (b.x1, 130.0), (b.y1, 60.0)].iter().all(|(a, e)| (a - e).abs() < 1e-6);
|
||||
assert!(exact, "dynamic: {dynamic}, stained: {stained}, {b:?}");
|
||||
}
|
||||
}
|
||||
|
||||
/// In art placed through 9-slice guides, a group with a fill of its own keeps the path it paints,
|
||||
/// which paints nothing by itself.
|
||||
#[test]
|
||||
fn a_plot_of_the_arts_bounds_keeps_what_a_group_paints_in_9_slice_symbol_art() {
|
||||
use vectorcraft_geom::{Affine, Rect};
|
||||
let mut d = vectorcraft_doc::Document::new(400.0, 400.0);
|
||||
let frame = plot_rect(&mut d, Rect::new(0.0, 0.0, 100.0, 100.0), false);
|
||||
let bare = plot_rect(&mut d, Rect::new(40.0, 40.0, 60.0, 60.0), false);
|
||||
let mut group = vectorcraft_doc::Node::group(d.alloc_id(), vec![bare]);
|
||||
let black = vectorcraft_color::Paint::solid(vectorcraft_color::Color::BLACK);
|
||||
group.appearance = vectorcraft_doc::Appearance::basic(black, vectorcraft_color::Paint::None, 0.0).into();
|
||||
plot_symbol(&mut d, "Panel", vec![frame, std::sync::Arc::new(group)], false);
|
||||
d.set_symbol_nine_slice("Panel", Some(vectorcraft_doc::SymbolNineSlice::default()));
|
||||
let wide = plot_instance(&mut d, "Panel", Affine::scale_non_uniform(2.0, 1.0));
|
||||
plot_on_layer(&mut d, wide);
|
||||
let b = super::export_art_bounds(&d, "hpgl").expect("the group's path is plotted");
|
||||
let exact = [(b.x0, 70.0), (b.y0, 40.0), (b.x1, 130.0), (b.y1, 60.0)].iter().all(|(a, e)| (a - e).abs() < 1e-6);
|
||||
assert!(exact, "{b:?}");
|
||||
}
|
||||
|
||||
/// A symbol without guides in the art of a symbol with 9-slice guides is measured without what the
|
||||
/// plot leaves out, in the placed art and in an instance of its own.
|
||||
#[test]
|
||||
fn a_plot_of_the_arts_bounds_leaves_out_unplotted_art_of_symbols_in_9_slice_art() {
|
||||
use vectorcraft_geom::{Affine, Rect};
|
||||
let mut d = vectorcraft_doc::Document::new(400.0, 400.0);
|
||||
let far = plot_rect(&mut d, Rect::new(45.0, -500.0, 55.0, -480.0), false);
|
||||
let dot = plot_rect(&mut d, Rect::new(45.0, 45.0, 55.0, 55.0), true);
|
||||
plot_symbol(&mut d, "Dot", vec![far, dot], false);
|
||||
let frame = plot_rect(&mut d, Rect::new(0.0, 0.0, 100.0, 100.0), false);
|
||||
let inner = std::sync::Arc::new(plot_instance(&mut d, "Dot", Affine::IDENTITY));
|
||||
plot_symbol(&mut d, "Panel", vec![frame, inner], false);
|
||||
d.set_symbol_nine_slice("Panel", Some(vectorcraft_doc::SymbolNineSlice::default()));
|
||||
let wide = plot_instance(&mut d, "Panel", Affine::scale_non_uniform(2.0, 1.0));
|
||||
plot_on_layer(&mut d, wide);
|
||||
let near = |b: Rect, e: [f64; 4]| [b.x0, b.y0, b.x1, b.y1].iter().zip(e).all(|(a, e)| (a - e).abs() < 1e-6);
|
||||
// The dot's box is in the middle column, which maps x to 3x - 50.
|
||||
let b = super::export_art_bounds(&d, "hpgl").unwrap();
|
||||
assert!(near(b, [85.0, 45.0, 115.0, 55.0]), "{b:?}");
|
||||
let own = plot_instance(&mut d, "Dot", Affine::translate((200.0, 0.0)));
|
||||
plot_on_layer(&mut d, own);
|
||||
let b = super::export_art_bounds(&d, "hpgl").unwrap();
|
||||
assert!(near(b, [85.0, 45.0, 255.0, 55.0]), "{b:?}");
|
||||
}
|
||||
|
||||
/// The copy of a dynamic symbol that an instance's changes are measured on keeps the symbol's
|
||||
/// 9-slice guides.
|
||||
#[test]
|
||||
fn a_plot_of_the_arts_bounds_measures_changed_9_slice_instances_by_their_guides() {
|
||||
use vectorcraft_geom::{Affine, Rect};
|
||||
let mut d = vectorcraft_doc::Document::new(400.0, 400.0);
|
||||
let bar = plot_rect(&mut d, Rect::new(10.0, 40.0, 70.0, 60.0), true);
|
||||
plot_symbol(&mut d, "Bar", vec![bar], false);
|
||||
let frame = plot_rect(&mut d, Rect::new(0.0, 0.0, 100.0, 100.0), false);
|
||||
let inner = std::sync::Arc::new(plot_instance(&mut d, "Bar", Affine::IDENTITY));
|
||||
plot_symbol(&mut d, "Panel", vec![frame, inner], true);
|
||||
d.set_symbol_nine_slice("Panel", Some(vectorcraft_doc::SymbolNineSlice::default()));
|
||||
let mut wide = plot_instance(&mut d, "Panel", Affine::scale_non_uniform(2.0, 1.0));
|
||||
wide.extras_mut().symbol_overrides =
|
||||
vec![vectorcraft_doc::SymbolOverride { path: vec![0], fill: Some(vectorcraft_color::Paint::None), ..Default::default() }];
|
||||
plot_on_layer(&mut d, wide);
|
||||
// The bar crosses the left guide and is placed by the middle column's map, x to 3x - 50: it
|
||||
// draws from x -20 to 160.
|
||||
let b = super::export_art_bounds(&d, "hpgl").unwrap();
|
||||
let exact = |b: Rect| [(b.x0, -20.0), (b.y0, 40.0), (b.x1, 160.0), (b.y1, 60.0)].iter().all(|(a, e)| (a - e).abs() < 1e-6);
|
||||
assert!(exact(b), "{b:?}");
|
||||
// The same instance in the art of a symbol without guides.
|
||||
let wide = std::sync::Arc::make_mut(&mut d.layers[0]).children_mut().unwrap().pop().unwrap();
|
||||
plot_symbol(&mut d, "Holder", vec![wide], false);
|
||||
let holder = plot_instance(&mut d, "Holder", Affine::IDENTITY);
|
||||
plot_on_layer(&mut d, holder);
|
||||
let b = super::export_art_bounds(&d, "hpgl").unwrap();
|
||||
assert!(exact(b), "{b:?}");
|
||||
}
|
||||
|
||||
/// An instance in a dynamic symbol's art, in a group at 0% opacity that an instance of the symbol
|
||||
/// brings back to full opacity, is measured with the paint it gives its own symbol's art.
|
||||
#[test]
|
||||
fn a_plot_of_the_arts_bounds_measures_instances_that_an_instance_brings_back() {
|
||||
use vectorcraft_geom::{Affine, Rect};
|
||||
let mut d = vectorcraft_doc::Document::new(400.0, 400.0);
|
||||
let bare = plot_rect(&mut d, Rect::new(300.0, 300.0, 320.0, 320.0), false);
|
||||
plot_symbol(&mut d, "Inner", vec![bare], true);
|
||||
let mut inner = plot_instance(&mut d, "Inner", Affine::IDENTITY);
|
||||
let black = vectorcraft_color::Paint::solid(vectorcraft_color::Color::BLACK);
|
||||
inner.extras_mut().symbol_overrides = vec![vectorcraft_doc::SymbolOverride { path: vec![0], fill: Some(black), ..Default::default() }];
|
||||
plot_symbol(&mut d, "Outer", vec![std::sync::Arc::new(inner)], true);
|
||||
std::sync::Arc::make_mut(&mut d.symbols[1].art).opacity = 0.0;
|
||||
let mut outer = plot_instance(&mut d, "Outer", Affine::IDENTITY);
|
||||
outer.extras_mut().symbol_overrides = vec![vectorcraft_doc::SymbolOverride { path: vec![], opacity: Some(1.0), ..Default::default() }];
|
||||
plot_on_layer(&mut d, outer);
|
||||
let b = super::export_art_bounds(&d, "hpgl").expect("the painted rectangle is plotted");
|
||||
assert!((b.x0 - 300.0).abs() < 1e-6 && (b.x1 - 320.0).abs() < 1e-6, "{b:?}");
|
||||
}
|
||||
|
||||
/// A placed image with a Vector Halftone effect is measured when the effect makes dots, and left
|
||||
/// out when it would take more cells than the effect makes.
|
||||
#[test]
|
||||
|
||||
@@ -182,7 +182,8 @@ fn edit<R>(s: &mut Session, p: &Value, cmd: &str, mut f: impl FnMut(&mut Freefor
|
||||
let item = item_target(s, p, cmd)?;
|
||||
let stroke = edits_stroke(s, p, item, !s.fill_active, cmd)?;
|
||||
let item = item.of_kind(s, !stroke);
|
||||
let ids = item.targets(s, p)?;
|
||||
// The art of a graph design in a series keeps the design's gradient.
|
||||
let ids = super::appearance::without_design_art(&s.doc()?.doc, item.targets(s, p)?, cmd)?;
|
||||
if ids.is_empty() {
|
||||
return Err(bad(cmd, "select an object with a freeform gradient"));
|
||||
}
|
||||
|
||||
@@ -531,6 +531,10 @@ fn edit_gradient(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
check_numbers(p, &["angle"], C)?;
|
||||
let item = item.of_kind(s, !stroke);
|
||||
let ids = item.targets(s, p)?;
|
||||
// Objects were given or selected, even when all of them are graph design art left out below: the stroke mode is then
|
||||
// not one for new art.
|
||||
let selected = !ids.is_empty();
|
||||
let ids = super::appearance::drop_design_art(&s.doc()?.doc, ids);
|
||||
// How the gradient lies on a stroke (the panel's Stroke buttons).
|
||||
let mode = match str_param(p, "strokeMode") {
|
||||
None => None,
|
||||
@@ -605,7 +609,7 @@ fn edit_gradient(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
Ok(())
|
||||
})?;
|
||||
if let Some(m) = mode
|
||||
&& ids.is_empty()
|
||||
&& !selected
|
||||
&& p.get("ids").is_none()
|
||||
&& let Some(st) = s.new_art_stroke_mut()
|
||||
{
|
||||
@@ -760,6 +764,7 @@ fn set_gradient_geom(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
if targets.is_empty() {
|
||||
return Err(EngineError::Other("nothing selected".into()));
|
||||
}
|
||||
let targets = super::appearance::drop_design_art(&s.doc()?.doc, targets);
|
||||
let model = super::paint::new_color_model(s, &Value::Null);
|
||||
edit_items(s, &targets, item, C, "Gradient", !stroke, |n, index| {
|
||||
if index.is_none()
|
||||
|
||||
@@ -416,9 +416,186 @@ fn refused_on_design_art(s: &mut Session, cmd: &str, p: serde_json::Value) {
|
||||
let steps = s.doc().unwrap().history.undo.len();
|
||||
let e = s.execute(cmd, &p).unwrap_err().to_string();
|
||||
assert!(e.contains("drawn again from the design"), "{cmd}: {e}");
|
||||
// `appearance.addEffect` is an alias of `effect.apply`, whose errors name `effect.apply`.
|
||||
let named = if cmd == "appearance.addEffect" { "effect.apply" } else { cmd };
|
||||
assert!(e.contains(&format!("`{named}`")), "the error names the command that ran: {e}");
|
||||
assert_eq!(s.doc().unwrap().history.undo.len(), steps, "{cmd}");
|
||||
}
|
||||
|
||||
/// The look of object `id`: its appearance, opacity, blend mode, isolation, knockout and overprint.
|
||||
fn look(s: &Session, id: NodeId) -> String {
|
||||
format!("{:?}", s.doc().unwrap().doc.node(id).unwrap())
|
||||
}
|
||||
|
||||
/// An edit of a graph design's art in a series lasts only until the graph is drawn again: the graph draws the art again
|
||||
/// from the design.
|
||||
#[test]
|
||||
fn an_edit_of_a_graph_designs_art_is_lost_when_the_graph_is_drawn_again() {
|
||||
let (mut s, id, _, art, _) = design_art();
|
||||
let path = |s: &Session| {
|
||||
let n = s.doc().unwrap().doc.node(id).unwrap().clone();
|
||||
let mut out = None;
|
||||
series(&n, 0).children().unwrap()[0].walk(&mut |m| {
|
||||
if out.is_none() && matches!(m.kind, NodeKind::Path { .. }) {
|
||||
out = Some(m.clone());
|
||||
}
|
||||
});
|
||||
out.unwrap()
|
||||
};
|
||||
assert_eq!(path(&s).id, art);
|
||||
s.execute("object.setProps", &json!({"id": art.0, "opacity": 50})).unwrap();
|
||||
assert!((path(&s).opacity - 0.5).abs() < 1e-6);
|
||||
s.execute("select.set", &json!({"ids": [id.0]})).unwrap();
|
||||
s.execute("graph.setData", &json!({"csv": ",2024,2025\nQ1,3,2\nQ2,5,8\nQ3,-1,6"})).unwrap();
|
||||
assert!((path(&s).opacity - 1.0).abs() < 1e-6);
|
||||
}
|
||||
|
||||
/// The paint, stroke, gradient and overprint commands leave the art of a graph design in a series as the design draws
|
||||
/// it, and edit the other objects they are given.
|
||||
#[test]
|
||||
fn paint_commands_leave_a_graph_designs_art_out() {
|
||||
let (mut s, _, copy, art, mark) = design_art();
|
||||
let fill = top_row(&s, art, true);
|
||||
let commands = [
|
||||
("stroke.set", json!({"weight": 5})),
|
||||
("stroke.weightUp", json!({})),
|
||||
("stroke.setAdvanced", json!({"arrowScale": [200, 200]})),
|
||||
("paint.editGradient", json!({"kind": "linear"})),
|
||||
("paint.setGradientGeom", json!({"start": [0, 0], "end": [50, 50]})),
|
||||
("object.setOverprint", json!({"fill": true})),
|
||||
("paint.swap", json!({})),
|
||||
("paint.default", json!({})),
|
||||
("paint.invert", json!({"item": fill, "stroke": false})),
|
||||
];
|
||||
let (copy_was, art_was) = (look(&s, copy), look(&s, art));
|
||||
for (cmd, p) in &commands {
|
||||
for ids in [json!([copy.0]), json!([art.0])] {
|
||||
let steps = s.doc().unwrap().history.undo.len();
|
||||
let mut q = p.clone();
|
||||
q["ids"] = ids;
|
||||
let _ = s.execute(cmd, &q);
|
||||
assert_eq!((look(&s, copy), look(&s, art)), (copy_was.clone(), art_was.clone()), "{cmd}");
|
||||
assert_eq!(s.doc().unwrap().history.undo.len(), steps, "{cmd}");
|
||||
}
|
||||
let mark_was = look(&s, mark);
|
||||
let mut q = p.clone();
|
||||
q["ids"] = json!([art.0, mark.0]);
|
||||
s.execute(cmd, &q).unwrap();
|
||||
assert_eq!(look(&s, art), art_was, "{cmd}");
|
||||
assert_ne!(look(&s, mark), mark_was, "{cmd}");
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
}
|
||||
// Selected, without ids.
|
||||
s.execute("select.set", &json!({"ids": [art.0]})).unwrap();
|
||||
s.execute("stroke.set", &json!({"weight": 5})).unwrap();
|
||||
assert_eq!(look(&s, art), art_was);
|
||||
// A freeform gradient point is refused on the art alone.
|
||||
refused_on_design_art(&mut s, "paint.freeform.addPoint", json!({"ids": [art.0], "at": [0, 0]}));
|
||||
}
|
||||
|
||||
/// `attributes.info` reads the overprint of a graph design's art in a series, which the overprint edits leave out.
|
||||
#[test]
|
||||
fn attributes_info_reads_a_graph_designs_art() {
|
||||
let (mut s, _, _, art, _) = design_art();
|
||||
let info = s.execute("attributes.info", &json!({"ids": [art.0]})).unwrap();
|
||||
assert_eq!(info["ids"], json!([art.0]));
|
||||
assert_eq!(info["overprintFill"], json!(false), "{info}");
|
||||
assert_eq!(info["overprintStroke"], json!(false), "{info}");
|
||||
}
|
||||
|
||||
/// With only the art of a graph design selected, `stroke.setAdvanced` and the stroke mode of `paint.editGradient` act
|
||||
/// as edits of a selection, not of the stroke the next object drawn takes.
|
||||
#[test]
|
||||
fn stroke_edits_of_selected_design_art_leave_the_new_art_stroke() {
|
||||
let (mut s, _, _, art, _) = design_art();
|
||||
s.execute("select.set", &json!({"ids": [art.0]})).unwrap();
|
||||
let was = format!("{:?}", s.new_art_stroke());
|
||||
s.execute("stroke.setAdvanced", &json!({"arrowScale": [200, 200], "swapArrows": true})).unwrap();
|
||||
assert_eq!(format!("{:?}", s.new_art_stroke()), was, "stroke.setAdvanced");
|
||||
let mode = s.new_art_stroke().gradient_mode;
|
||||
s.execute("paint.editGradient", &json!({"kind": "linear", "stroke": true, "strokeMode": "along"})).unwrap();
|
||||
assert_eq!(s.new_art_stroke().gradient_mode, mode, "paint.editGradient");
|
||||
}
|
||||
|
||||
/// `transparency.set` without `item` refuses the art of a graph design in a series, and given it with other objects
|
||||
/// sets only theirs.
|
||||
#[test]
|
||||
fn object_transparency_leaves_a_graph_designs_art_out() {
|
||||
let (mut s, _, copy, art, mark) = design_art();
|
||||
refused_on_design_art(&mut s, "transparency.set", json!({"ids": [art.0], "opacity": 40}));
|
||||
refused_on_design_art(&mut s, "transparency.set", json!({"ids": [copy.0], "isolate": true}));
|
||||
s.execute("transparency.set", &json!({"ids": [art.0, mark.0], "opacity": 40, "blend": "multiply"})).unwrap();
|
||||
let d = &s.doc().unwrap().doc;
|
||||
assert!((d.node(art).unwrap().opacity - 1.0).abs() < 1e-6);
|
||||
assert!((d.node(mark).unwrap().opacity - 0.4).abs() < 1e-6);
|
||||
}
|
||||
|
||||
/// With the art of a graph design selected first, the Appearance panel's active row (a row of that art) stands for no
|
||||
/// row of the other objects selected: an effect goes on each of them as a whole.
|
||||
#[test]
|
||||
fn an_active_row_of_a_graph_designs_art_is_not_applied_to_other_objects() {
|
||||
let (mut s, _, _, art, mark) = design_art();
|
||||
let stroke = top_row(&s, art, false);
|
||||
s.execute("select.set", &json!({"ids": [art.0, mark.0]})).unwrap();
|
||||
assert_eq!(s.doc().unwrap().selection.subjects().first(), Some(&art));
|
||||
s.execute("appearance.setActiveItem", &json!({"index": stroke})).unwrap();
|
||||
s.execute("effect.apply", &json!({"effect": "stylize.dropShadow"})).unwrap();
|
||||
let n = s.doc().unwrap().doc.node(mark).unwrap().clone();
|
||||
assert_eq!(n.appearance.effects.len(), 1);
|
||||
assert!(n.appearance.items.iter().all(|it| it.effects().is_empty()));
|
||||
assert!(s.doc().unwrap().doc.node(art).unwrap().appearance.effects.is_empty());
|
||||
}
|
||||
|
||||
/// Expand Appearance leaves the art of a graph design in a series as the design draws it.
|
||||
#[test]
|
||||
fn expand_appearance_leaves_a_graph_designs_art() {
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", &json!({"width": 800, "height": 800})).unwrap();
|
||||
let id = graph(&mut s, "column");
|
||||
let r = s.execute("shape.rectangle", &json!({"x": 600, "y": 600, "width": 10, "height": 10})).unwrap()["id"].clone();
|
||||
s.execute("effect.apply", &json!({"effect": "stylize.dropShadow", "ids": [r]})).unwrap();
|
||||
s.execute("select.set", &json!({"ids": [r]})).unwrap();
|
||||
s.execute("graph.design", &json!({"new": "Shadow"})).unwrap();
|
||||
s.execute("graph.column", &json!({"id": id.0, "seriesIndexes": [0], "design": "Shadow"})).unwrap();
|
||||
let n = s.doc().unwrap().doc.node(id).unwrap().clone();
|
||||
let mut art = vec![];
|
||||
series(&n, 0).children().unwrap()[0].walk(&mut |m| {
|
||||
if !m.appearance.effects.is_empty() {
|
||||
art.push(m.id);
|
||||
}
|
||||
});
|
||||
let art = art[0];
|
||||
let other = s.execute("shape.rectangle", &json!({"x": 700, "y": 700, "width": 10, "height": 10})).unwrap()["id"].as_u64().unwrap();
|
||||
s.execute("effect.apply", &json!({"effect": "stylize.dropShadow", "ids": [other]})).unwrap();
|
||||
let art_was = look(&s, art);
|
||||
// Given alone, by id or selected, it leaves Expand Appearance disabled.
|
||||
for p in [json!({"ids": [art.0]}), json!({})] {
|
||||
s.execute("select.set", &json!({"ids": [art.0]})).unwrap();
|
||||
assert!(s.execute("effect.expandAppearance", &p).unwrap_err().to_string().contains("not available"));
|
||||
assert_eq!(look(&s, art), art_was);
|
||||
}
|
||||
// Inside a selected graph or series group, the art stays as the design draws it: with nothing else to expand there,
|
||||
// Expand Appearance is disabled, and with a mark's effect, the mark expands alone.
|
||||
let series_group = series(&n, 0).id;
|
||||
for root in [id, series_group] {
|
||||
s.execute("select.set", &json!({"ids": [root.0]})).unwrap();
|
||||
assert!(s.execute("effect.expandAppearance", &json!({})).unwrap_err().to_string().contains("not available"));
|
||||
assert_eq!(look(&s, art), art_was);
|
||||
}
|
||||
let mark = series(&n, 1).children().unwrap()[0].id;
|
||||
s.execute("effect.apply", &json!({"effect": "stylize.dropShadow", "ids": [mark.0]})).unwrap();
|
||||
s.execute("select.set", &json!({"ids": [id.0]})).unwrap();
|
||||
let expanded = s.execute("effect.expandAppearance", &json!({})).unwrap();
|
||||
assert_eq!(expanded["ids"], json!([mark.0]));
|
||||
assert_eq!(look(&s, art), art_was);
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
// Selected with another object, the art is left as it is and the other object expands.
|
||||
s.execute("select.set", &json!({"ids": [art.0, other]})).unwrap();
|
||||
s.execute("effect.expandAppearance", &json!({})).unwrap();
|
||||
assert_eq!(look(&s, art), art_was);
|
||||
assert!(s.doc().unwrap().doc.node(NodeId(other)).is_none_or(|m| m.appearance.effects.is_empty()));
|
||||
}
|
||||
|
||||
/// The commands that give rows, effects or graphic styles refuse the art of a graph design inside a series, given by
|
||||
/// id (the design's copy or a path inside it) or selected: the graph draws that art again from the design.
|
||||
#[test]
|
||||
@@ -445,6 +622,7 @@ fn appearance_commands_refuse_a_graph_designs_art_in_a_series() {
|
||||
refused_on_design_art(&mut s, "effect.remove", json!({"ids": ids, "index": 0}));
|
||||
refused_on_design_art(&mut s, "threeD.mapGraphic", json!({"ids": ids, "symbol": "Mark"}));
|
||||
refused_on_design_art(&mut s, "graphicStyle.apply", json!({"name": "Blue", "ids": ids}));
|
||||
refused_on_design_art(&mut s, "graphicStyle.breakLink", json!({"ids": ids}));
|
||||
}
|
||||
for cmd in ["appearance.removeItem", "appearance.duplicateItem"] {
|
||||
refused_on_design_art(&mut s, cmd, json!({"ids": [art.0], "index": fill}));
|
||||
@@ -499,6 +677,269 @@ fn appearance_commands_edit_the_objects_given_with_a_graph_designs_art() {
|
||||
assert_eq!(rows(&s, mark).items.len(), mark_was.items.len() + 1);
|
||||
}
|
||||
|
||||
/// `design_art()` with the design's art painted with a red and a blue fill and series 1's first mark with a blue fill and
|
||||
/// a black stroke, so that row 1 of the art (its blue fill) is row 0 of the mark: the graph, the art and the mark.
|
||||
fn aligned_design_art() -> (Session, NodeId, NodeId, NodeId) {
|
||||
let (mut s, id, _, art, mark) = design_art();
|
||||
let (red, blue) = (Paint::solid(vectorcraft_color::Color::rgb(1.0, 0.0, 0.0)), Paint::solid(vectorcraft_color::Color::rgb(0.0, 0.0, 1.0)));
|
||||
let mark_look = vectorcraft_doc::Appearance::basic(blue, Paint::solid(vectorcraft_color::Color::BLACK), 1.0);
|
||||
let mut art_look = mark_look.clone();
|
||||
art_look.items = vec![art_look.items[0].clone(), art_look.items[0].clone()];
|
||||
*art_look.items[0].paint_mut() = red;
|
||||
s.edit("Looks", |d, _| {
|
||||
d.node_mut(art).unwrap().appearance = vectorcraft_doc::Shared::new(art_look.clone());
|
||||
d.node_mut(mark).unwrap().appearance = vectorcraft_doc::Shared::new(mark_look.clone());
|
||||
Ok(())
|
||||
})
|
||||
.unwrap();
|
||||
(s, id, art, mark)
|
||||
}
|
||||
|
||||
/// Aligned rows given with the art of a graph design in a series first are rows of that art's stack, which the commands
|
||||
/// leave as it is: each other object edits the row equal to the given one, never the row at the same index.
|
||||
#[test]
|
||||
fn aligned_rows_of_a_graph_designs_art_edit_the_equal_rows_of_the_other_objects() {
|
||||
let rows = |s: &Session, id: NodeId| (*s.doc().unwrap().doc.node(id).unwrap().appearance).clone();
|
||||
let green = Paint::solid(vectorcraft_color::Color::rgb(0.0, 1.0, 0.0));
|
||||
let (mut s, _, art, mark) = aligned_design_art();
|
||||
let (art_was, mark_was) = (rows(&s, art), rows(&s, mark));
|
||||
s.execute("appearance.removeItem", &json!({"ids": [art.0, mark.0], "index": 1, "aligned": true})).unwrap();
|
||||
assert_eq!(rows(&s, art), art_was);
|
||||
assert_eq!(rows(&s, mark).items, mark_was.items[1..], "the mark loses its blue fill and keeps its stroke");
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
|
||||
s.execute("appearance.setItem", &json!({"ids": [art.0, mark.0], "index": 1, "aligned": true, "color": "#00ff00"})).unwrap();
|
||||
assert_eq!(rows(&s, art), art_was);
|
||||
assert_eq!((rows(&s, mark).items[0].paint(), &rows(&s, mark).items[1]), (&green, &mark_was.items[1]));
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
|
||||
s.execute("appearance.duplicateItem", &json!({"ids": [art.0, mark.0], "index": 1, "aligned": true})).unwrap();
|
||||
assert_eq!(rows(&s, art), art_was);
|
||||
let fills: Vec<bool> = rows(&s, mark).items.iter().map(|it| it.is_fill()).collect();
|
||||
assert_eq!(fills, [true, true, false], "the mark's blue fill is copied, not its stroke");
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
|
||||
s.execute("transparency.set", &json!({"ids": [art.0, mark.0], "item": 1, "aligned": true, "opacity": 40})).unwrap();
|
||||
assert_eq!(rows(&s, art), art_was);
|
||||
assert_eq!((rows(&s, mark).items[0].opacity(), rows(&s, mark).items[1].opacity()), (0.4, 1.0));
|
||||
|
||||
// A row of the art past the end of the mark's stack.
|
||||
let (mut s, _, art, mark) = aligned_design_art();
|
||||
s.edit("Rows", |d, _| {
|
||||
let ap = &mut d.node_mut(art).unwrap().appearance;
|
||||
let red = ap.items[0].clone();
|
||||
ap.items.insert(0, red);
|
||||
Ok(())
|
||||
})
|
||||
.unwrap();
|
||||
s.execute("transparency.set", &json!({"ids": [art.0, mark.0], "item": 2, "aligned": true, "opacity": 40})).unwrap();
|
||||
assert_eq!((rows(&s, mark).items[0].opacity(), rows(&s, mark).items[1].opacity()), (0.4, 1.0));
|
||||
|
||||
// A row of the art (its red fill) that the mark has no equal of: transparency.set is an error, and appearance.setItem
|
||||
// and appearance.removeItem leave the document as it is.
|
||||
let (art_was, mark_was) = (rows(&s, art), rows(&s, mark));
|
||||
let steps = s.doc().unwrap().history.undo.len();
|
||||
let e = s.execute("transparency.set", &json!({"ids": [art.0, mark.0], "item": 0, "aligned": true, "opacity": 40})).unwrap_err();
|
||||
assert!(e.to_string().contains("no appearance item 0"), "{e}");
|
||||
s.execute("appearance.setItem", &json!({"ids": [art.0, mark.0], "index": 0, "aligned": true, "color": "#00ff00"})).unwrap();
|
||||
s.execute("appearance.removeItem", &json!({"ids": [art.0, mark.0], "index": 0, "aligned": true})).unwrap();
|
||||
assert_eq!((rows(&s, art), rows(&s, mark)), (art_was, mark_was));
|
||||
assert_eq!(s.doc().unwrap().history.undo.len(), steps, "no undo step is recorded");
|
||||
}
|
||||
|
||||
/// Aligned effects given with the art of a graph design in a series first go on, and come off, the rows equal to the art's
|
||||
/// row in the other objects; an object without an equal row is left as it is.
|
||||
#[test]
|
||||
fn aligned_effects_of_a_graph_designs_art_edit_the_equal_rows_of_the_other_objects() {
|
||||
let rows = |s: &Session, id: NodeId| (*s.doc().unwrap().doc.node(id).unwrap().appearance).clone();
|
||||
let effects = |s: &Session, id: NodeId| rows(s, id).items.iter().map(|it| it.effects().len()).collect::<Vec<_>>();
|
||||
let (mut s, id, art, mark) = aligned_design_art();
|
||||
// A second mark of series 1 with a green fill, which no row of the art equals, and the same black stroke.
|
||||
let n = s.doc().unwrap().doc.node(id).unwrap().clone();
|
||||
let other = series(&n, 1).children().unwrap()[1].id;
|
||||
let mut other_look = rows(&s, mark);
|
||||
*other_look.items[0].paint_mut() = Paint::solid(vectorcraft_color::Color::rgb(0.0, 1.0, 0.0));
|
||||
s.edit("Look", |d, _| {
|
||||
d.node_mut(other).unwrap().appearance = vectorcraft_doc::Shared::new(other_look.clone());
|
||||
Ok(())
|
||||
})
|
||||
.unwrap();
|
||||
let (art_was, other_was) = (rows(&s, art), rows(&s, other));
|
||||
let r = s.execute("effect.apply", &json!({"effect": "stylize.dropShadow", "ids": [art.0, mark.0, other.0], "item": 1, "aligned": true})).unwrap();
|
||||
assert_eq!(rows(&s, art), art_was);
|
||||
assert_eq!(effects(&s, mark), [1, 0], "the shadow goes on the mark's blue fill");
|
||||
assert_eq!(rows(&s, other), other_was, "the other mark has no blue fill");
|
||||
assert_eq!((&r["ids"], &r["item"], &r["index"]), (&json!([mark.0]), &json!(0), &json!(0)), "the reply names the mark's row");
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
// A 3D effect is checked on the rows it goes on only.
|
||||
s.execute("effect.apply", &json!({"effect": "threeD.extrude", "ids": [art.0, mark.0, other.0], "item": 1, "aligned": true})).unwrap();
|
||||
assert_eq!((rows(&s, art), effects(&s, mark), rows(&s, other)), (art_was.clone(), vec![1, 0], other_was.clone()));
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
s.execute("appearance.addEffect", &json!({"effect": "stylize.dropShadow", "ids": [art.0, mark.0, other.0], "item": 1, "aligned": true})).unwrap();
|
||||
assert_eq!((rows(&s, art), effects(&s, mark), rows(&s, other)), (art_was, vec![1, 0], other_was));
|
||||
|
||||
// The art's blue fill with the same shadow as the mark's: removing it from the art's row takes it off the mark's.
|
||||
s.edit("Shadow", |d, _| {
|
||||
let row = d.node(mark).unwrap().appearance.items[0].clone();
|
||||
d.node_mut(art).unwrap().appearance.items[1] = row;
|
||||
Ok(())
|
||||
})
|
||||
.unwrap();
|
||||
let art_was = rows(&s, art);
|
||||
// The effect dialog reads the shadow from the art's row and passes the art on first.
|
||||
let (target, fx) = s.appearance_effect_target(&json!({"ids": [art.0, mark.0], "item": 1, "aligned": true})).unwrap();
|
||||
assert_eq!((fx.len(), &target["ids"], &target["item"]), (1, &json!([art.0, mark.0]), &json!(1)));
|
||||
// Edits of the art's row effect change the mark's equal row.
|
||||
s.execute("effect.setParams", &json!({"ids": [art.0, mark.0], "item": 1, "index": 0, "visible": false, "aligned": true})).unwrap();
|
||||
assert_eq!(rows(&s, art), art_was);
|
||||
assert!(!rows(&s, mark).items[0].effects()[0].visible, "the mark's shadow is hidden");
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
s.execute("effect.duplicate", &json!({"ids": [art.0, mark.0], "item": 1, "index": 0, "aligned": true})).unwrap();
|
||||
assert_eq!((rows(&s, art), effects(&s, mark)), (art_was.clone(), vec![2, 0]));
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
s.execute("effect.remove", &json!({"ids": [art.0, mark.0], "item": 1, "index": 0, "aligned": true})).unwrap();
|
||||
assert_eq!(rows(&s, art), art_was);
|
||||
assert_eq!(effects(&s, mark), [0, 0]);
|
||||
|
||||
// An object-level effect of the art that the mark does not have: a new object-level effect still goes on the mark.
|
||||
s.edit("Effect", |d, _| {
|
||||
let shadow = d.node(art).unwrap().appearance.items[1].effects()[0].clone();
|
||||
d.node_mut(art).unwrap().appearance.effects.push(shadow);
|
||||
Ok(())
|
||||
})
|
||||
.unwrap();
|
||||
let art_was = rows(&s, art);
|
||||
s.execute("effect.apply", &json!({"effect": "distort.roughen", "ids": [art.0, mark.0], "item": null, "aligned": true})).unwrap();
|
||||
assert_eq!(rows(&s, art), art_was);
|
||||
assert_eq!(rows(&s, mark).effects.len(), 1);
|
||||
// The art's object-level shadow is not the mark's roughen: the effect dialog reads the art's, and removing it leaves
|
||||
// the mark as it is.
|
||||
let (_, fx) = s.appearance_effect_target(&json!({"ids": [art.0, mark.0], "item": null, "aligned": true})).unwrap();
|
||||
assert_eq!(fx.iter().map(|e| e.id.to_string()).collect::<Vec<_>>(), ["stylize.dropShadow"]);
|
||||
let mark_was = rows(&s, mark);
|
||||
assert!(s.execute("effect.remove", &json!({"ids": [art.0, mark.0], "item": null, "index": 0, "aligned": true})).is_err());
|
||||
assert_eq!((rows(&s, art), rows(&s, mark)), (art_was.clone(), mark_was));
|
||||
// An object-level effect index past the end of the art's list names no effect of the mark either.
|
||||
s.execute("effect.apply", &json!({"effect": "stylize.dropShadow", "ids": [mark.0], "item": null})).unwrap();
|
||||
let mark_was = rows(&s, mark);
|
||||
assert!(s.execute("effect.remove", &json!({"ids": [art.0, mark.0], "item": null, "index": 1, "aligned": true})).is_err());
|
||||
assert_eq!((rows(&s, art), rows(&s, mark)), (art_was.clone(), mark_was));
|
||||
// The mark with the art's object-level shadow after its roughen: removing the art's effect 0 takes off the mark's 1.
|
||||
s.edit("Shadow", |d, _| {
|
||||
let shadow = d.node(art).unwrap().appearance.effects[0].clone();
|
||||
d.node_mut(mark).unwrap().appearance.effects[1] = shadow;
|
||||
Ok(())
|
||||
})
|
||||
.unwrap();
|
||||
s.execute("effect.remove", &json!({"ids": [art.0, mark.0], "item": null, "index": 0, "aligned": true})).unwrap();
|
||||
assert_eq!(rows(&s, art), art_was);
|
||||
assert_eq!(rows(&s, mark).effects.iter().map(|e| e.id.to_string()).collect::<Vec<_>>(), ["distort.roughen"]);
|
||||
}
|
||||
|
||||
/// Aligned rows of a graph design's art and of another object whose stack differs are not one place in both stacks, so
|
||||
/// they are not reordered, and `to` is refused for their copies; with the same stacks, the copies go to `to`.
|
||||
#[test]
|
||||
fn aligned_rows_of_a_graph_designs_art_and_a_different_stack_are_not_moved() {
|
||||
let rows = |s: &Session, id: NodeId| (*s.doc().unwrap().doc.node(id).unwrap().appearance).clone();
|
||||
let (mut s, _, art, mark) = aligned_design_art();
|
||||
let mark_was = rows(&s, mark);
|
||||
assert!(s.execute("appearance.moveItem", &json!({"ids": [art.0, mark.0], "from": 1, "to": 0, "aligned": true})).is_err());
|
||||
assert_eq!(rows(&s, mark), mark_was);
|
||||
assert!(s.execute("appearance.duplicateItem", &json!({"ids": [art.0, mark.0], "index": 1, "to": 0, "aligned": true})).is_err());
|
||||
assert_eq!(rows(&s, mark), mark_was);
|
||||
s.execute("effect.apply", &json!({"effect": "stylize.dropShadow", "ids": [mark.0], "item": null})).unwrap();
|
||||
s.execute("effect.apply", &json!({"effect": "distort.roughen", "ids": [mark.0], "item": null})).unwrap();
|
||||
let mark_was = rows(&s, mark);
|
||||
assert!(s.execute("effect.move", &json!({"ids": [art.0, mark.0], "from": 1, "to": 0, "aligned": true})).is_err());
|
||||
assert_eq!(rows(&s, mark), mark_was);
|
||||
|
||||
// The mark with the art's stack.
|
||||
let (mut s, _, art, mark) = aligned_design_art();
|
||||
let art_was = rows(&s, art);
|
||||
s.edit("Look", |d, _| {
|
||||
d.node_mut(mark).unwrap().appearance = vectorcraft_doc::Shared::new(art_was.clone());
|
||||
Ok(())
|
||||
})
|
||||
.unwrap();
|
||||
assert!(s.execute("appearance.duplicateItem", &json!({"ids": [art.0, mark.0], "indices": [1, 9], "to": 0, "aligned": true})).is_err());
|
||||
assert_eq!(rows(&s, mark), art_was, "a row that does not exist is refused");
|
||||
s.execute("appearance.duplicateItem", &json!({"ids": [art.0, mark.0], "index": 1, "to": 0, "aligned": true})).unwrap();
|
||||
assert_eq!(rows(&s, art), art_was);
|
||||
let paints: Vec<_> = rows(&s, mark).items.iter().map(|it| it.paint().clone()).collect();
|
||||
assert_eq!(paints, [art_was.items[1].paint().clone(), art_was.items[0].paint().clone(), art_was.items[1].paint().clone()]);
|
||||
}
|
||||
|
||||
/// The Contents row of a graph design's copy and of a group whose stack differs is not moved with `aligned: true`, as
|
||||
/// the rows are not.
|
||||
#[test]
|
||||
fn aligned_contents_row_of_a_graph_designs_copy_and_a_different_stack_is_not_moved() {
|
||||
let rows = |s: &Session, id: NodeId| (*s.doc().unwrap().doc.node(id).unwrap().appearance).clone();
|
||||
let (mut s, _, copy, _, _) = design_art();
|
||||
s.execute("shape.rectangle", &json!({"x": 700, "y": 700, "width": 10, "height": 10})).unwrap();
|
||||
let g = NodeId(s.execute("object.group", &json!({})).unwrap()["id"].as_u64().unwrap());
|
||||
s.execute("appearance.addFill", &json!({"ids": [g.0]})).unwrap();
|
||||
s.execute("appearance.addFill", &json!({"ids": [g.0]})).unwrap();
|
||||
assert_ne!(rows(&s, copy), rows(&s, g));
|
||||
let (copy_was, g_was) = (rows(&s, copy), rows(&s, g));
|
||||
let steps = s.doc().unwrap().history.undo.len();
|
||||
let e = s.execute("appearance.moveItem", &json!({"ids": [copy.0, g.0], "from": "contents", "to": 1, "aligned": true})).unwrap_err();
|
||||
assert!(e.to_string().contains("appearances differ"), "{e}");
|
||||
assert_eq!((rows(&s, copy), rows(&s, g)), (copy_was, g_was));
|
||||
assert_eq!(s.doc().unwrap().history.undo.len(), steps);
|
||||
}
|
||||
|
||||
/// Invert with an `item` remembers the paint of an object it recolored, not that of a graph design's art given first,
|
||||
/// which it leaves out.
|
||||
#[test]
|
||||
fn invert_of_an_item_remembers_the_recolored_paint_not_the_design_arts() {
|
||||
let (mut s, _, art, mark) = aligned_design_art();
|
||||
s.execute("paint.invert", &json!({"ids": [art.0, mark.0], "item": 0, "stroke": false})).unwrap();
|
||||
let Paint::Solid { color, .. } = s.doc().unwrap().doc.node(mark).unwrap().appearance.items[0].paint().clone() else {
|
||||
panic!("the mark's fill stays a solid color");
|
||||
};
|
||||
assert_ne!(color, vectorcraft_color::Color::rgb(0.0, 0.0, 1.0), "the mark's blue fill is inverted");
|
||||
assert_eq!(s.last_solid, color, "the mark's inverted fill is remembered, not the art's red one");
|
||||
}
|
||||
|
||||
/// With a graph design's art given first and `aligned: true`, `effect.remove` and `effect.duplicate` with `indices` are an
|
||||
/// error and change nothing when one of the indices names no effect of the art.
|
||||
#[test]
|
||||
fn aligned_effect_indices_past_the_end_of_a_graph_designs_art_are_refused() {
|
||||
let rows = |s: &Session, id: NodeId| (*s.doc().unwrap().doc.node(id).unwrap().appearance).clone();
|
||||
let (mut s, _, art, mark) = aligned_design_art();
|
||||
s.execute("effect.apply", &json!({"effect": "stylize.dropShadow", "ids": [mark.0], "item": null})).unwrap();
|
||||
s.edit("Shadow", |d, _| {
|
||||
let shadow = d.node(mark).unwrap().appearance.effects[0].clone();
|
||||
d.node_mut(art).unwrap().appearance.effects.push(shadow);
|
||||
Ok(())
|
||||
})
|
||||
.unwrap();
|
||||
let (art_was, mark_was) = (rows(&s, art), rows(&s, mark));
|
||||
let steps = s.doc().unwrap().history.undo.len();
|
||||
for cmd in ["effect.remove", "effect.duplicate"] {
|
||||
let e = s.execute(cmd, &json!({"ids": [art.0, mark.0], "item": null, "indices": [0, 9], "aligned": true})).unwrap_err();
|
||||
assert!(e.to_string().contains("no effects at indices 0, 9"), "{cmd}: {e}");
|
||||
assert_eq!((rows(&s, art), rows(&s, mark)), (art_was.clone(), mark_was.clone()), "{cmd}");
|
||||
assert_eq!(s.doc().unwrap().history.undo.len(), steps, "{cmd}");
|
||||
}
|
||||
// With index 0 alone, which the art has, the mark's equal effect is removed.
|
||||
s.execute("effect.remove", &json!({"ids": [art.0, mark.0], "item": null, "indices": [0], "aligned": true})).unwrap();
|
||||
assert!(rows(&s, mark).effects.is_empty());
|
||||
}
|
||||
|
||||
/// A blur on a series group makes the whole group an image, which draws the design's art with the series' other art:
|
||||
/// Expand Appearance does not put the art back beside the image.
|
||||
#[test]
|
||||
fn expanding_a_blurred_series_group_takes_a_graph_designs_art_into_the_image() {
|
||||
let (mut s, id, copy, _, _) = design_art();
|
||||
let series_group = series(s.doc().unwrap().doc.node(id).unwrap(), 0).id;
|
||||
s.execute("effect.apply", &json!({"effect": "blur.gaussian", "ids": [series_group.0]})).unwrap();
|
||||
s.execute("effect.expandAppearance", &json!({"ids": [series_group.0]})).unwrap();
|
||||
let d = &s.doc().unwrap().doc;
|
||||
assert!(matches!(d.node(series_group).unwrap().kind, NodeKind::Image(_)));
|
||||
assert!(d.node(copy).is_none());
|
||||
}
|
||||
|
||||
/// The paint commands leave the art of a graph design in a series out, a copy of the design given by id with an item
|
||||
/// included.
|
||||
#[test]
|
||||
|
||||
@@ -158,6 +158,9 @@ fn apply_transform_moving_guides(
|
||||
}
|
||||
None => ids.clone(),
|
||||
};
|
||||
// Pattern editing with Move Tile with Art: the tile art's bounds before a move.
|
||||
let moves_only = c[0] == 1.0 && c[1] == 0.0 && c[2] == 0.0 && c[3] == 1.0;
|
||||
let tile_art = if objects && !copy && moves_only { crate::cmd::patterncmds::tile_art_bounds(d) } else { None };
|
||||
// Transforms don't move nodes in the tree, so reuse the paths found for the no-copy
|
||||
// case; copies and a settled typing session need paths from the edited document.
|
||||
let paths = if reindex { d.index_paths(targets.iter().copied()) } else { paths };
|
||||
@@ -172,6 +175,10 @@ fn apply_transform_moving_guides(
|
||||
for id in &targets {
|
||||
super::pixelgrid::keep_compound_aligned(d, *id, sc);
|
||||
}
|
||||
// Moving all of the tile art moves the tile as far.
|
||||
if let Some(before) = tile_art {
|
||||
crate::cmd::patterncmds::move_tile_with_art(d, &targets, before);
|
||||
}
|
||||
}
|
||||
if with_guides && (delta.x != 0.0 || delta.y != 0.0) {
|
||||
for i in sel.guides.clone() {
|
||||
|
||||
@@ -156,10 +156,15 @@ pub(super) fn edit_counted(s: &mut Session, label: &str, f: impl FnOnce(&mut Doc
|
||||
pub(super) type OverprintJob = ((bool, ItemTarget, Vec<NodeId>), bool);
|
||||
|
||||
/// The overprint edits `p` asks for under `keys` (the fill's and the stroke's), aimed as
|
||||
/// `object.setOverprint` aims.
|
||||
/// `object.setOverprint` aims, leaving out the art of graph designs drawn in graph series.
|
||||
pub(super) fn overprint_jobs(s: &Session, p: &Value, cmd: &str, keys: [&str; 2]) -> Result<Vec<OverprintJob>> {
|
||||
let kinds = kinds(p, cmd, keys)?;
|
||||
Ok(aims(s, p, cmd, kinds.iter().map(|k| k.0))?.into_iter().zip(kinds.iter().map(|k| k.1)).collect())
|
||||
let d = &s.doc()?.doc;
|
||||
Ok(aims(s, p, cmd, kinds.iter().map(|k| k.0))?
|
||||
.into_iter()
|
||||
.map(|(fill, it, ids)| (fill, it, super::appearance::drop_design_art(d, ids)))
|
||||
.zip(kinds.iter().map(|k| k.1))
|
||||
.collect())
|
||||
}
|
||||
|
||||
/// Apply `jobs` to `d`, adding the objects changed to `changed`.
|
||||
|
||||
@@ -81,7 +81,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"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; when every target is a gradient mesh with points picked by the Mesh tool, Direct Selection or the Lasso, those points take the opacity themselves). 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, and on a series group without an item, the group's own opacity, blend mode, isolate, knockout and knockoutShape (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",
|
||||
"{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; with the art of a graph design in a series given first, rows of the art's stack: docs/mcp.md)} 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; when every target is a gradient mesh with points picked by the Mesh tool, Direct Selection or the Lasso, those points take the opacity themselves). 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, and on a series group without an item, the group's own opacity, blend mode, isolate, knockout and knockoutShape (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
|
||||
),
|
||||
@@ -966,7 +966,7 @@ pub(super) fn restore_unpainted_stroke(ap: &mut Appearance, index: Option<usize>
|
||||
}
|
||||
|
||||
fn swap(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let ids = paint_targets(s, p)?;
|
||||
let ids = super::appearance::drop_design_art(&s.doc()?.doc, paint_targets(s, p)?);
|
||||
if !ids.is_empty() {
|
||||
// When every object comes out as it was, the document and its undo history stay as they are.
|
||||
let before = s.doc()?.doc.clone();
|
||||
@@ -1001,7 +1001,7 @@ fn swap(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
}
|
||||
|
||||
fn default_paint(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let ids = paint_targets(s, p)?;
|
||||
let ids = super::appearance::drop_design_art(&s.doc()?.doc, paint_targets(s, p)?);
|
||||
if !ids.is_empty() {
|
||||
// When every object comes out as it was, the document and its undo history stay as they are.
|
||||
let before = s.doc()?.doc.clone();
|
||||
@@ -1070,9 +1070,9 @@ fn proxy_recolor(s: &mut Session, p: &Value, cmd: &str, label: &str, f: &dyn Fn(
|
||||
return Ok(json!({ "changed": changed as usize }));
|
||||
}
|
||||
let item = item_target(s, p, cmd)?.of_kind(s, !stroke);
|
||||
let (r, shown_item) = match item {
|
||||
let (r, shown_item, edited) = match item {
|
||||
ItemTarget::Item { index, .. } => {
|
||||
let ids = item.targets(s, p)?;
|
||||
let ids = super::appearance::drop_design_art(&s.doc()?.doc, item.targets(s, p)?);
|
||||
let mut changed = 0;
|
||||
edit_items(s, &ids, item, cmd, label, !stroke, |n, i| {
|
||||
let paint =
|
||||
@@ -1080,15 +1080,19 @@ fn proxy_recolor(s: &mut Session, p: &Value, cmd: &str, label: &str, f: &dyn Fn(
|
||||
changed += paint.is_some_and(|paint| super::colorcmds::map_paint(paint, f)) as usize;
|
||||
Ok(())
|
||||
})?;
|
||||
(json!({ "changed": changed }), Some(index))
|
||||
(json!({ "changed": changed }), Some(index), Some(ids))
|
||||
}
|
||||
// The proxy recolors its paints, including a colorized image's fill, without changing image pixels or pattern tiles.
|
||||
ItemTarget::Top => {
|
||||
let q = json!({"fill": !stroke, "stroke": stroke, "includeImages": false, "includePatterns": false, "ids": ids.iter().map(|i| i.0).collect::<Vec<_>>()});
|
||||
(super::colorcmds::recolor(s, &q, cmd, label, f)?, None)
|
||||
(super::colorcmds::recolor(s, &q, cmd, label, f)?, None, None)
|
||||
}
|
||||
};
|
||||
let shown_ids = if shown_item.is_some() { ids } else { leaf_targets(s, &ids)? };
|
||||
// The paint remembered is one the edit recolored: a graph design's art that the item edit leaves out gives none.
|
||||
let shown_ids = match edited {
|
||||
Some(ids) => ids,
|
||||
None => leaf_targets(s, &ids)?,
|
||||
};
|
||||
if let Some(shown) = shown_ids.first().and_then(|id| s.doc().ok()?.doc.node(*id).map(|n| proxy_paint(n, stroke, shown_item))) {
|
||||
s.remember_paint(&shown);
|
||||
}
|
||||
@@ -1142,6 +1146,9 @@ fn transparency(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
if ids.is_empty() {
|
||||
return Err(bad(C, "select objects or give ids"));
|
||||
}
|
||||
// The object-level opacity, blend mode, isolation and knockout of a graph design's art in a series are drawn again
|
||||
// from the design too.
|
||||
let ids = super::appearance::without_design_art(&s.doc()?.doc, ids, C)?;
|
||||
// While an opacity mask is edited the selection is its art, so the Appearance panel's active
|
||||
// item (a row of that art) doesn't stand for the masked object.
|
||||
let editing_mask = p.get("ids").is_none() && p.get("id").is_none() && s.doc()?.doc.mask_edit.is_some();
|
||||
@@ -1168,13 +1175,22 @@ fn transparency(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
return s.execute("object.setProps", &Value::Object(q));
|
||||
};
|
||||
// Opacity and blend belong to the targeted fill/stroke; isolate/knockout/knockoutShape stay object-level.
|
||||
let item_ids = if editing_mask { ids } else { row_targets(s, p)? };
|
||||
let item_ids = if editing_mask { ids } else { row_targets(s, p, C)? };
|
||||
// The art of a graph design whose stack an explicit aligned item is a row of goes first: appearance.setItem leaves
|
||||
// it as it is and matches the other objects' rows against it.
|
||||
let of = if editing_mask || !explicit { None } else { super::appearance::design_reference(s, p, C)? };
|
||||
if explicit {
|
||||
let d = &s.doc()?.doc;
|
||||
if !item_ids.iter().any(|id| d.node(*id).is_some_and(|n| index < n.appearance.items.len())) {
|
||||
// With such art, an object has the item when it has a row equal to the art's.
|
||||
let found = match of {
|
||||
Some(_) => super::appearance::rows_per_target(d, of, &item_ids, &[index], true).iter().any(|r| !r.is_empty()),
|
||||
None => item_ids.iter().any(|id| d.node(*id).is_some_and(|n| index < n.appearance.items.len())),
|
||||
};
|
||||
if !found {
|
||||
return Err(bad(C, format!("no appearance item {index}")));
|
||||
}
|
||||
}
|
||||
let item_ids = super::appearance::with_reference(of, &item_ids);
|
||||
let (opacity, blend) = (q.remove("opacity"), q.remove("blend"));
|
||||
if let Some(b) = &blend
|
||||
&& b.as_str().and_then(BlendMode::parse).is_none()
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -78,7 +78,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Pattern Options",
|
||||
["Window", "Pattern Options"],
|
||||
None,
|
||||
"{name? (default: the pattern being edited), newName? (trimmed; blank: the name is kept), tileType?, brickOffset?, width?, height?, hSpacing?, vSpacing?, sizeTileToArt?: bool, overlap?: {h: left|right, v: top|bottom}, copies?: 3|5|7|9, dimCopies?: %, showTileEdge?: bool, showSwatchBounds?: bool (outline the part of the tiling the swatch repeats, dashed)}",
|
||||
"{name? (default: the pattern being edited), newName? (trimmed; blank: the name is kept), tileType?, brickOffset?, width?, height?, hSpacing?, vSpacing?, sizeTileToArt?: bool, moveTileWithArt?: bool (in pattern editing mode, a move of all of the tile art moves the tile with it; off for a new pattern), overlap?: {h: left|right, v: top|bottom}, copies?: 3|5|7|9, dimCopies?: %, showTileEdge?: bool, showSwatchBounds?: bool (outline the part of the tiling the swatch repeats, dashed)}",
|
||||
has_doc,
|
||||
pattern_options
|
||||
),
|
||||
@@ -530,7 +530,7 @@ fn pattern_options(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let cur = s.doc()?.doc.pattern(&name).cloned().ok_or_else(|| bad(C, format!("no pattern `{name}`")))?;
|
||||
let tt = tile_type_param(p, C, cur.tile_type)?;
|
||||
check_numbers(p, &["width", "height", "hSpacing", "vSpacing", "copies", "dimCopies"], C)?;
|
||||
check_switches(p, &["sizeTileToArt", "showTileEdge", "showSwatchBounds"], C)?;
|
||||
check_switches(p, &["sizeTileToArt", "moveTileWithArt", "showTileEdge", "showSwatchBounds"], C)?;
|
||||
let num = |k: &str| p.get(k).and_then(Value::as_f64);
|
||||
for k in ["width", "height"] {
|
||||
if num(k).is_some_and(|v| !(v.is_finite() && v > 0.0)) {
|
||||
@@ -560,11 +560,11 @@ fn pattern_options(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
};
|
||||
let edit_layer = editing(s).filter(|e| e.pattern == name).map(|e| e.layer);
|
||||
let out_name = new_name.clone().unwrap_or(name.clone());
|
||||
s.edit("Pattern Options", |d, _| {
|
||||
// While editing, the tile art is the edit layer's.
|
||||
let live_art: Option<Vec<Arc<Node>>> =
|
||||
edit_layer.and_then(|l| d.node(l)).and_then(|l| l.children()).map(|ch| ch.iter().filter(|c| !c.is_ruler_guide()).cloned().collect());
|
||||
let def = d.pattern_mut(&name).ok_or_else(|| EngineError::Other("pattern missing".into()))?;
|
||||
// While editing, the tile art is the edit layer's.
|
||||
let live_art = |d: &Document| -> Option<Vec<Arc<Node>>> {
|
||||
edit_layer.and_then(|l| d.node(l)).and_then(|l| l.children()).map(|ch| ch.iter().filter(|c| !c.is_ruler_guide()).cloned().collect())
|
||||
};
|
||||
let apply = |def: &mut PatternDef, live_art: Option<Vec<Arc<Node>>>| {
|
||||
def.tile_type = tt;
|
||||
def.overlap = overlap;
|
||||
if let Some(v) = num("hSpacing") {
|
||||
@@ -576,6 +576,9 @@ fn pattern_options(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
if let Some(v) = p.get("sizeTileToArt").and_then(Value::as_bool) {
|
||||
def.size_tile_to_art = v;
|
||||
}
|
||||
if let Some(v) = p.get("moveTileWithArt").and_then(Value::as_bool) {
|
||||
def.move_tile_with_art = v;
|
||||
}
|
||||
if let Some(v) = num("copies") {
|
||||
def.copies = (v as u32).clamp(1, 15) | 1;
|
||||
}
|
||||
@@ -590,13 +593,24 @@ fn pattern_options(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
}
|
||||
if def.size_tile_to_art {
|
||||
let saved = std::mem::take(&mut def.art);
|
||||
def.art = live_art.clone().unwrap_or_else(|| saved.clone());
|
||||
def.art = live_art.unwrap_or_else(|| saved.clone());
|
||||
def.fit_tile_to_art();
|
||||
def.art = saved;
|
||||
} else if num("width").is_some() || num("height").is_some() {
|
||||
let c = def.tile.center();
|
||||
def.tile = Rect::from_center_size(c, (num("width").unwrap_or(def.tile.width()), num("height").unwrap_or(def.tile.height())));
|
||||
}
|
||||
};
|
||||
// Options that change nothing leave the document and its undo history as they are.
|
||||
let mut next = cur.clone();
|
||||
apply(&mut next, live_art(&s.doc()?.doc));
|
||||
if next == cur && new_name.is_none() {
|
||||
return Ok(json!({ "name": out_name }));
|
||||
}
|
||||
s.edit("Pattern Options", |d, _| {
|
||||
let live = live_art(d);
|
||||
let def = d.pattern_mut(&name).ok_or_else(|| EngineError::Other("pattern missing".into()))?;
|
||||
apply(def, live);
|
||||
if let Some(nn) = &new_name {
|
||||
def.name = nn.clone();
|
||||
rename_pattern_uses(d, &name, nn);
|
||||
@@ -609,6 +623,47 @@ fn pattern_options(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
Ok(json!({ "name": out_name }))
|
||||
}
|
||||
|
||||
/// The tile art's objects (the editing layer's, ruler guides aside) while `d` edits a pattern that
|
||||
/// has Move Tile with Art on, with the pattern's name.
|
||||
fn moving_tile_art(d: &Document) -> Option<(String, Vec<Arc<Node>>)> {
|
||||
let pe = d.pattern_edit.as_ref()?;
|
||||
if !d.pattern(&pe.pattern).is_some_and(|p| p.move_tile_with_art) {
|
||||
return None;
|
||||
}
|
||||
let art = d.node(pe.layer)?.children()?.iter().filter(|c| !c.is_ruler_guide()).cloned().collect();
|
||||
Some((pe.pattern.clone(), art))
|
||||
}
|
||||
|
||||
/// Before a move: the bounds of the tile art for [`move_tile_with_art`], `None` unless `d` edits a
|
||||
/// pattern with Move Tile with Art on.
|
||||
pub(crate) fn tile_art_bounds(d: &Document) -> Option<Rect> {
|
||||
moving_tile_art(d).and_then(|(_, art)| vectorcraft_doc::live::nodes_bounds(&art))
|
||||
}
|
||||
|
||||
/// Move Tile with Art: after a move of `moved` that takes every object of the tile art (a group
|
||||
/// counting when all of its objects moved), the tile moves by as much as the art did, measured from
|
||||
/// its bounds `before` the move (objects aligned to the pixel grid snap back to it). A move of only
|
||||
/// some of the art leaves the tile where it is.
|
||||
pub(crate) fn move_tile_with_art(d: &mut Document, moved: &[NodeId], before: Rect) {
|
||||
let Some((name, art)) = moving_tile_art(d) else { return };
|
||||
let moved: std::collections::HashSet<NodeId> = moved.iter().copied().collect();
|
||||
// An object moves whole when it moved, or when every object in it did (a group whose members
|
||||
// were selected and moved one by one).
|
||||
fn whole(n: &Node, moved: &std::collections::HashSet<NodeId>) -> bool {
|
||||
moved.contains(&n.id) || n.children().is_some_and(|ch| !ch.is_empty() && ch.iter().all(|c| whole(c, moved)))
|
||||
}
|
||||
if art.is_empty() || !art.iter().all(|c| whole(c, &moved)) {
|
||||
return;
|
||||
}
|
||||
let Some(after) = vectorcraft_doc::live::nodes_bounds(&art) else { return };
|
||||
let by = after.origin() - before.origin();
|
||||
if (by.x != 0.0 || by.y != 0.0)
|
||||
&& let Some(def) = d.pattern_mut(&name)
|
||||
{
|
||||
def.tile = def.tile + by;
|
||||
}
|
||||
}
|
||||
|
||||
/// Side `side` of the `overlap` object `o` gives `cmd`: true for the second of `names`, false for the
|
||||
/// first, `current` when `o` leaves it out or gives null. Any other value is an error.
|
||||
fn overlap_side(o: &Value, side: &str, names: [&str; 2], current: bool, cmd: &str) -> Result<bool> {
|
||||
@@ -680,6 +735,7 @@ fn pattern_list(s: &mut Session, _: &Value) -> Result<Value> {
|
||||
"hSpacing": p.h_spacing,
|
||||
"vSpacing": p.v_spacing,
|
||||
"sizeTileToArt": p.size_tile_to_art,
|
||||
"moveTileWithArt": p.move_tile_with_art,
|
||||
"overlap": {"h": if p.overlap.right_in_front { "right" } else { "left" }, "v": if p.overlap.bottom_in_front { "bottom" } else { "top" }},
|
||||
"copies": p.copies,
|
||||
"dimCopies": p.dim_copies,
|
||||
|
||||
@@ -257,7 +257,7 @@ fn load(p: &Value, cmd: &str, board: Option<Rect>, link_pdf: bool) -> Result<Loa
|
||||
let (natural, warnings) = (s.frame, s.warnings.clone());
|
||||
return Ok(Loaded { name, format, art: Art::Document(s, link.clone(), !crop), natural, warnings, link: Some(link), board: None });
|
||||
}
|
||||
let a = pdf_art(src.name, &src.bytes, src.path, page, opts, !crop, cmd)?;
|
||||
let a = pdf_art(src.name, (&src.bytes, None), src.path, page, opts, !crop, cmd)?;
|
||||
let art = Art::Vector { src: Box::new(a.doc), nodes: a.nodes, clip: a.clip };
|
||||
return Ok(Loaded { name, format, art, natural: a.frame, warnings: a.warnings, link: None, board: None });
|
||||
}
|
||||
@@ -308,10 +308,12 @@ fn load(p: &Value, cmd: &str, board: Option<Rect>, link_pdf: bool) -> Result<Loa
|
||||
/// art; read with `opts.password`. Only the page placed is read. A .ai saved without PDF
|
||||
/// compatibility is read from its editing data, as it opens, and placed as an EPS is: the art on
|
||||
/// artboard `page`, its linked images found from its folder. So is page 1 of a .ai or PDF whose
|
||||
/// PDF part can't be read.
|
||||
/// PDF part can't be read. `opened`, when given, is the PDF `bytes` already read
|
||||
/// ([`vectorcraft_pdf::open_pdf`] with `opts`' password): its pages are read without reading the
|
||||
/// file again.
|
||||
pub(crate) fn pdf_art(
|
||||
src: &str,
|
||||
bytes: &[u8],
|
||||
(bytes, opened): (&[u8], Option<&vectorcraft_pdf::Opened>),
|
||||
path: Option<&str>,
|
||||
page: usize,
|
||||
opts: fileio::LoadOptions,
|
||||
@@ -321,11 +323,15 @@ pub(crate) fn pdf_art(
|
||||
let name = fileio::file_name(src);
|
||||
let index = page.checked_sub(1).ok_or_else(|| bad(cmd, "page must be a whole number from 1"))?;
|
||||
let o = fileio::LoadOptions { crop: if bounding { CropTo::Crop } else { opts.crop }, ..opts };
|
||||
let mut read = fileio::page_document(bytes, index, &o);
|
||||
let page_document = |i: usize| match opened {
|
||||
Some(opened) => fileio::opened_page_document(bytes, opened, i, &o),
|
||||
None => fileio::page_document(bytes, i, &o),
|
||||
};
|
||||
let mut read = page_document(index);
|
||||
// Such a file can have more artboards than placeholder pages.
|
||||
if page > 1
|
||||
&& read.is_err()
|
||||
&& let Err(e) = fileio::page_document(bytes, 0, &o)
|
||||
&& let Err(e) = page_document(0)
|
||||
&& fileio::is_placeholder(&e)
|
||||
{
|
||||
read = Err(e);
|
||||
@@ -351,7 +357,7 @@ pub(crate) fn pdf_art(
|
||||
let clip = (!bounding).then_some(board);
|
||||
let frame = clip.or_else(|| shown_bounds(&symbols, &nodes));
|
||||
let frame = frame.filter(|r| r.width() > 0.0 || r.height() > 0.0).ok_or_else(|| bad(cmd, format!("`{name}` has no art to place")))?;
|
||||
Ok(document::DocumentArt { doc, nodes, clip, frame, warnings })
|
||||
Ok(document::DocumentArt { doc, nodes, clip, frame, warnings, editing_data: alone })
|
||||
}
|
||||
|
||||
/// The bounds of what `nodes` draw, as an export measures art
|
||||
|
||||
@@ -100,6 +100,9 @@ pub(crate) struct DocumentArt {
|
||||
/// What is shown: the artboard, or the art's bounds.
|
||||
pub frame: Rect,
|
||||
pub warnings: Vec<String>,
|
||||
/// Whether a PDF or `.ai` page was read from the file's editing data (its page only a
|
||||
/// placeholder, or its PDF part unreadable) instead of from the page.
|
||||
pub editing_data: bool,
|
||||
}
|
||||
|
||||
/// Artboard `page` (1-based) of the VectorCraft document `name` (`bytes`, read from `path` when
|
||||
@@ -114,6 +117,21 @@ pub(crate) fn document_art(
|
||||
bounding: bool,
|
||||
page_box: PageBox,
|
||||
cmd: &str,
|
||||
) -> Result<DocumentArt> {
|
||||
opened_document_art(name, (bytes, None), path, page, bounding, page_box, cmd)
|
||||
}
|
||||
|
||||
/// [`document_art`] of `bytes` and, for a PDF already read, `opened`: the PDF `bytes` as
|
||||
/// [`vectorcraft_pdf::open_pdf`] read it without a password, so its page is read without reading
|
||||
/// the file again.
|
||||
fn opened_document_art(
|
||||
name: &str,
|
||||
(bytes, opened): (&[u8], Option<&vectorcraft_pdf::Opened>),
|
||||
path: Option<&str>,
|
||||
page: u32,
|
||||
bounding: bool,
|
||||
page_box: PageBox,
|
||||
cmd: &str,
|
||||
) -> Result<DocumentArt> {
|
||||
let file = fileio::file_name(name);
|
||||
let pdf = is_pdf(name, bytes);
|
||||
@@ -125,7 +143,7 @@ pub(crate) fn document_art(
|
||||
}
|
||||
if pdf {
|
||||
let o = fileio::LoadOptions { crop: crop_to(page_box), ..Default::default() };
|
||||
return super::pdf_art(name, bytes, path, page as usize, o, bounding, cmd);
|
||||
return super::pdf_art(name, (bytes, opened), path, page as usize, o, bounding, cmd);
|
||||
}
|
||||
let mut l = fileio::load(name, bytes)?;
|
||||
// Its linked images show their files; its placed documents stay as they are, unless their
|
||||
@@ -145,7 +163,7 @@ pub(crate) fn document_art(
|
||||
let nodes = super::on_artboard(&symbols, &layers, board);
|
||||
let frame = if bounding { super::shown_bounds(&symbols, &nodes) } else { Some(board) };
|
||||
let frame = frame.filter(|r| r.width() > 0.0 || r.height() > 0.0).ok_or_else(|| bad(cmd, format!("`{file}` has no art to place")))?;
|
||||
Ok(DocumentArt { doc: l.doc, nodes, clip: (!bounding).then_some(board), frame, warnings: l.warnings })
|
||||
Ok(DocumentArt { doc: l.doc, nodes, clip: (!bounding).then_some(board), frame, warnings: l.warnings, editing_data: false })
|
||||
}
|
||||
|
||||
/// The most placed documents read inside one another while their links are resolved.
|
||||
@@ -223,6 +241,8 @@ pub(crate) struct Source {
|
||||
pub warnings: Vec<String>,
|
||||
/// The colour mode the file's art was read in (a PDF page painted mostly in CMYK reads in CMYK).
|
||||
pub color_mode: ColorMode,
|
||||
/// [`DocumentArt::editing_data`].
|
||||
pub editing_data: bool,
|
||||
}
|
||||
|
||||
/// The VectorCraft document `name` (`bytes`, from `path` when there is one) read for showing
|
||||
@@ -232,33 +252,48 @@ pub(crate) fn source(name: &str, bytes: &[u8], path: Option<&str>, page: u32, bo
|
||||
read_source(name, bytes, None, path, (page, bounding, page_box), cmd)
|
||||
}
|
||||
|
||||
/// [`source`] of the file whose bytes `bytes` holds, kept by the blob without a copy of its own:
|
||||
/// several pages of one file read this way share one copy of it.
|
||||
/// A PDF (or PDF-compatible `.ai`) whose pages are read for placing linked, read once for them all.
|
||||
#[derive(Clone, Copy)]
|
||||
pub(crate) struct SharedFile<'a> {
|
||||
/// The file's bytes, which every page's blob keeps without a copy of its own.
|
||||
pub bytes: &'a Arc<Vec<u8>>,
|
||||
/// The bytes' hash ([`hash_bytes`]).
|
||||
pub hash: &'a str,
|
||||
/// The bytes as [`vectorcraft_pdf::open_pdf`] read them, without a password.
|
||||
pub opened: &'a vectorcraft_pdf::Opened,
|
||||
}
|
||||
|
||||
/// [`source`] of page `page` of `file`: several pages of one file read this way share one copy of
|
||||
/// it, and the file is hashed and read once for them all.
|
||||
pub(crate) fn shared_source(
|
||||
name: &str,
|
||||
bytes: &Arc<Vec<u8>>,
|
||||
file: SharedFile<'_>,
|
||||
path: Option<&str>,
|
||||
page: u32,
|
||||
bounding: bool,
|
||||
page_box: PageBox,
|
||||
cmd: &str,
|
||||
) -> Result<Source> {
|
||||
read_source(name, bytes, Some(bytes), path, (page, bounding, page_box), cmd)
|
||||
read_source(name, file.bytes, Some(file), path, (page, bounding, page_box), cmd)
|
||||
}
|
||||
|
||||
/// [`source`], the blob keeping `shared` when given (else a copy of `bytes`); `shown` is the page
|
||||
/// (or artboard), whether the art's bounds are shown, and the page's box.
|
||||
/// [`source`], the blob keeping `shared`'s bytes when given (else a copy of `bytes`), its hash
|
||||
/// used for the key (else `bytes` hashed) and its pages read from what was read of it; `shown` is
|
||||
/// the page (or artboard), whether the art's bounds are shown, and the page's box.
|
||||
fn read_source(
|
||||
name: &str,
|
||||
bytes: &[u8],
|
||||
shared: Option<&Arc<Vec<u8>>>,
|
||||
shared: Option<SharedFile<'_>>,
|
||||
path: Option<&str>,
|
||||
shown: (u32, bool, PageBox),
|
||||
cmd: &str,
|
||||
) -> Result<Source> {
|
||||
let (page, bounding, page_box) = shown;
|
||||
let hash = shared.map_or_else(|| hash_bytes(bytes), |f| f.hash.to_string());
|
||||
let opened = shared.map(|f| f.opened);
|
||||
let shared = shared.map(|f| f.bytes);
|
||||
// The same document placed the same way is read once (placing it again is free).
|
||||
let key = key_for(&hash_bytes(bytes), page, bounding, page_box);
|
||||
let key = key_for(&hash, page, bounding, page_box);
|
||||
// Resolving its links depends on where the file is and on what the caller may read, so a hit
|
||||
// needs the same path, and nothing is shared with or from a confined (automation) caller.
|
||||
let confined = crate::file_access::current().is_some();
|
||||
@@ -269,7 +304,7 @@ fn read_source(
|
||||
}
|
||||
return Ok(s);
|
||||
}
|
||||
let a = document_art(name, bytes, path, page, bounding, page_box, cmd)?;
|
||||
let a = opened_document_art(name, (bytes, opened), path, page, bounding, page_box, cmd)?;
|
||||
let bytes = shared.cloned().unwrap_or_else(|| Arc::new(bytes.to_vec()));
|
||||
let mut blob = ImageBlob { mime: mime(name, &bytes).into(), bytes, proxy: None };
|
||||
let shown = PlacedDocument {
|
||||
@@ -284,10 +319,10 @@ fn read_source(
|
||||
};
|
||||
let art = group(a.nodes, a.clip);
|
||||
blob.proxy = preview(&a.doc, &art, a.frame).map(Arc::new);
|
||||
let color_mode = a.doc.color_mode;
|
||||
let (color_mode, editing_data) = (a.doc.color_mode, a.editing_data);
|
||||
// Drawing it needn't read it again.
|
||||
vectorcraft_doc::placed_document::prime(&shown, blob.bytes.clone(), (a.doc, art, a.frame));
|
||||
let s = Source { blob, key, page_box, frame: a.frame, warnings: a.warnings, color_mode };
|
||||
let s = Source { blob, key, page_box, frame: a.frame, warnings: a.warnings, color_mode, editing_data };
|
||||
if !confined {
|
||||
let mut m = lock_memo();
|
||||
if m.len() >= MEMO_SIZE {
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -234,6 +234,8 @@ fn stroke_set(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let item = item_target(s, p, C)?.of_kind(s, false);
|
||||
let mut ids = if item == ItemTarget::Top { own_stroke_or_leaves(s, p)? } else { item.targets(s, p)? };
|
||||
let new_art = no_targets(&ids, p);
|
||||
// The art of a graph design in a series keeps the design's stroke.
|
||||
ids = super::appearance::drop_design_art(&s.doc()?.doc, ids);
|
||||
if item == ItemTarget::Top {
|
||||
// Images and symbol instances take no stroke from the panel.
|
||||
let d = &s.doc()?.doc;
|
||||
@@ -368,6 +370,7 @@ fn stroke_step(s: &mut Session, p: &Value, up: bool) -> Result<Value> {
|
||||
}
|
||||
return Ok(json!({"objects": 0}));
|
||||
}
|
||||
let ids = super::appearance::drop_design_art(&s.doc()?.doc, ids);
|
||||
let label = if up { "Stroke Weight Up" } else { "Stroke Weight Down" };
|
||||
edit_items(s, &ids, item, c, label, false, |n, index| {
|
||||
if index.is_none()
|
||||
@@ -510,6 +513,9 @@ fn stroke_advanced(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
}
|
||||
let item = item_target(s, p, C)?.of_kind(s, false);
|
||||
let ids = item.targets(s, p)?;
|
||||
let new_art = no_targets(&ids, p);
|
||||
// The art of a graph design in a series keeps the design's stroke.
|
||||
let ids = super::appearance::drop_design_art(&s.doc()?.doc, ids);
|
||||
let arrows_and_profile = |st: &mut StrokeLayer| {
|
||||
if let Some(sc) = scale {
|
||||
st.arrow_scale = sc;
|
||||
@@ -523,7 +529,7 @@ fn stroke_advanced(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
}
|
||||
};
|
||||
// As stroke.set: with nothing selected, for the next object drawn.
|
||||
if no_targets(&ids, p)
|
||||
if new_art
|
||||
&& (scale.is_some() || swap || flip.is_some())
|
||||
&& let Some(st) = s.new_art_stroke_mut()
|
||||
{
|
||||
|
||||
@@ -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); 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}",
|
||||
"{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, and the art of a graph design in a series, which is left out: 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
|
||||
),
|
||||
@@ -332,7 +332,7 @@ fn style_apply(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let i = style_index(&s.doc()?.doc, name)?;
|
||||
let add = bool_or(p, "add", false);
|
||||
let chars = s.prefs.override_char_color;
|
||||
let own = super::appearance::edit_targets(s, p)?;
|
||||
let own = super::appearance::edit_targets(s, p, "graphicStyle.apply")?;
|
||||
let ids = super::appearance::rowless_series_marks(&s.doc()?.doc, &own);
|
||||
// The series groups their paths stand for: a style that replaces the appearance clears what they hold themselves,
|
||||
// and a style added on top unlinks them.
|
||||
@@ -521,7 +521,7 @@ fn style_redefine(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
}
|
||||
|
||||
fn style_break_link(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let ids = super::appearance::whole_targets(s, p)?;
|
||||
let ids = super::appearance::whole_targets(s, p, "graphicStyle.breakLink")?;
|
||||
s.edit("Break Link to Graphic Style", |d, _| {
|
||||
set_link(d, &ids, None);
|
||||
Ok(())
|
||||
|
||||
@@ -167,7 +167,7 @@ fn add(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
}
|
||||
if apply {
|
||||
// Objects that can't take a style are refused before any style is added.
|
||||
super::appearance::edit_targets(s, &if ids_given { json!({"ids": p["ids"]}) } else { json!({}) })?;
|
||||
super::appearance::edit_targets(s, &if ids_given { json!({"ids": p["ids"]}) } else { json!({}) }, C)?;
|
||||
}
|
||||
let mut names = names_param(p, C)?;
|
||||
names.extend(text_param(p, "name", C)?.map(str::to_string));
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -2008,6 +2008,38 @@ fn glyph_form_is_one_character_attribute_saved_only_when_set() {
|
||||
assert!(runs(&s).iter().all(|r| r.style.glyph_form == GlyphForm::Default));
|
||||
}
|
||||
|
||||
/// A glyph choice (`text.setGlyphAlternate`) would keep drawing its own glyph over a glyph form,
|
||||
/// so setting a form other than "default" takes it away, in `text.setRangeStyle` and in
|
||||
/// `text.setFormat`; "default" (Revert To Default Forms) and calls without `glyphForm` keep it.
|
||||
#[test]
|
||||
fn a_glyph_form_takes_away_the_characters_glyph_choice() {
|
||||
use vectorcraft_doc::{GlyphChoice, GlyphForm};
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", &json!({"width": 400, "height": 400})).unwrap();
|
||||
let id = s.execute("text.create", &json!({"x": 10, "y": 50, "text": "亜唖"})).unwrap()["id"].as_u64().unwrap();
|
||||
let choice = GlyphChoice { ch: '唖', gid: 42, font: "Test-Regular".into(), version: "1".into() };
|
||||
if let Some(NodeKind::Text(t)) = std::sync::Arc::make_mut(&mut s.doc_mut().unwrap().doc).node_mut(NodeId(id)).map(|n| &mut n.kind) {
|
||||
vectorcraft_text::edit::style_range(&mut t.runs, 3, 6, |st| st.glyph_alternate = Some(choice.clone()));
|
||||
}
|
||||
let state = |s: &Session| match &s.doc().unwrap().doc.node(NodeId(id)).unwrap().kind {
|
||||
NodeKind::Text(t) => t.runs.iter().map(|r| (r.text.clone(), r.style.glyph_form, r.style.glyph_alternate.is_some())).collect::<Vec<_>>(),
|
||||
_ => panic!("text"),
|
||||
};
|
||||
s.execute("text.setRangeStyle", &json!({"id": id, "size": 30})).unwrap();
|
||||
assert_eq!(state(&s), [("亜".into(), GlyphForm::Default, false), ("唖".into(), GlyphForm::Default, true)], "no glyphForm: kept");
|
||||
s.execute("text.setRangeStyle", &json!({"id": id, "glyphForm": "default"})).unwrap();
|
||||
assert_eq!(state(&s)[1], ("唖".into(), GlyphForm::Default, true), "default: kept");
|
||||
s.execute("text.setRangeStyle", &json!({"id": id, "start": 3, "end": 6, "glyphForm": "jis78"})).unwrap();
|
||||
assert_eq!(state(&s), [("亜".into(), GlyphForm::Default, false), ("唖".into(), GlyphForm::Jis78, false)]);
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
assert_eq!(state(&s)[1], ("唖".into(), GlyphForm::Default, true), "one undo step brings both back");
|
||||
s.execute("select.set", &json!({"ids": [id]})).unwrap();
|
||||
s.execute("text.setFormat", &json!({"glyphForm": "default"})).unwrap();
|
||||
assert!(state(&s).iter().any(|r| r.2), "default: kept");
|
||||
s.execute("text.setFormat", &json!({"glyphForm": "traditional"})).unwrap();
|
||||
assert!(state(&s).iter().all(|r| r.1 == GlyphForm::Traditional && !r.2), "{:?}", state(&s));
|
||||
}
|
||||
|
||||
/// No Break is a character attribute, saved only when on.
|
||||
#[test]
|
||||
fn no_break_is_a_character_attribute_saved_only_when_on() {
|
||||
|
||||
@@ -57,7 +57,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Type on a Path Options…",
|
||||
["Type", "Type on a Path"],
|
||||
None,
|
||||
"{ids?|id?, start?: 0..1, end?: 0..1|null, flip?: bool, effect?: rainbow|skew|3dRibbon|stairStep|gravity, alignToPath?: ascender|descender|center|baseline, spacing?: pt} set the selected type on a path's options: start and end place its start and end brackets as fractions of the path's length (end null: the end of the path, or once round a closed path), and flip then turns the type to the other side of its path (the path runs the other way, the brackets swap ends); alignToPath runs each character's em box top (ascender), font bounding box bottom (descender), half the height of its H's top above the baseline (center) or baseline along the path, by the character's own font, size and vertical scale (vertical type: half an em either way, or none for center); none given: query → the first object's {start, end, flip: false, effect, alignToPath, spacing}",
|
||||
"{ids?|id?, start?: -1000000..1 (below 0 only on an open path), end?: 0..1|null, flip?: bool, effect?: rainbow|skew|3dRibbon|stairStep|gravity, alignToPath?: ascender|descender|center|baseline, spacing?: pt} set the selected type on a path's options: start and end place its start and end brackets as fractions of the path's length (end null: the end of the path, or once round a closed path); on an open path a start below 0 starts the text that far before the path's start, and the characters whose middle falls before it are not drawn (on a closed path it is the path's start), and flip then turns the type to the other side of its path (the path runs the other way, the brackets swap ends); alignToPath runs each character's em box top (ascender), font bounding box bottom (descender), half the height of its H's top above the baseline (center) or baseline along the path, by the character's own font, size and vertical scale (vertical type: half an em either way, or none for center); none given: query → the first object's {start, end, flip: false, effect, alignToPath, spacing}",
|
||||
has_selection,
|
||||
path_options
|
||||
),
|
||||
@@ -1018,7 +1018,18 @@ fn path_options(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
Some(v) => v.as_f64().filter(|x| x.is_finite()).map(|x| Some(x.clamp(0.0, 1.0))).ok_or_else(|| bad(C, format!("`{k}` is a number 0..1"))),
|
||||
}
|
||||
};
|
||||
let start = fraction("start")?;
|
||||
// On an open path the text may start before the path's start (below 0, see
|
||||
// `TextKind::path_text_start`); on a closed path a start below 0 is the path's start.
|
||||
let start = match p.get("start") {
|
||||
None | Some(Value::Null) => None,
|
||||
Some(v) => Some(
|
||||
v.as_f64()
|
||||
.filter(|x| x.is_finite())
|
||||
.map(|x| x.clamp(-vectorcraft_doc::PATH_LEAD_MAX, 1.0))
|
||||
.ok_or_else(|| bad(C, "`start` is a number up to 1 (below 0: before an open path's start)"))?,
|
||||
),
|
||||
};
|
||||
let start_on = |path: &vectorcraft_geom::PathData, v: f64| if path.is_closed() { v.max(0.0) } else { v };
|
||||
// `end: null` puts the end bracket back at the end of the path.
|
||||
let end = p.get("end").map(|_| fraction("end")).transpose()?;
|
||||
let flip = switch_or(p, "flip", false, C)?;
|
||||
@@ -1037,8 +1048,8 @@ fn path_options(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
// Without a flip, when every object already has every value given, the document and its undo
|
||||
// history stay as they are.
|
||||
let has_values = |t: &TextObject| match &t.kind {
|
||||
TextKind::OnPath { start: st, end: en, .. } => {
|
||||
start.is_none_or(|v| v == *st)
|
||||
TextKind::OnPath { path, start: st, end: en } => {
|
||||
start.is_none_or(|v| start_on(path, v) == *st)
|
||||
&& end.is_none_or(|v| v == *en)
|
||||
&& effect.is_none_or(|e| t.path_effect == e)
|
||||
&& align.is_none_or(|a| t.path_align == a)
|
||||
@@ -1053,9 +1064,9 @@ fn path_options(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
s.edit("Type on a Path Options", |d, _| {
|
||||
for id in &ids {
|
||||
let Some(NodeKind::Text(t)) = d.node_mut(*id).map(|n| &mut n.kind) else { continue };
|
||||
let TextKind::OnPath { start: st, end: en, .. } = &mut t.kind else { continue };
|
||||
let TextKind::OnPath { path, start: st, end: en } = &mut t.kind else { continue };
|
||||
if let Some(v) = start {
|
||||
*st = v;
|
||||
*st = start_on(path, v);
|
||||
}
|
||||
if let Some(v) = end {
|
||||
*en = v;
|
||||
|
||||
@@ -399,6 +399,12 @@ impl ScriptScope {
|
||||
Self { roots: std::fs::canonicalize(dir).ok().into_iter().collect() }
|
||||
}
|
||||
|
||||
/// The scope of a script run from a file in the folder whose real path, with its links already
|
||||
/// followed, is `real`: that folder, which is not looked up again.
|
||||
pub(crate) fn of_real_folder(real: PathBuf) -> Self {
|
||||
Self { roots: vec![real] }
|
||||
}
|
||||
|
||||
/// Is `path` refused by its name alone: a `..` component, a NUL, a Windows device name or
|
||||
/// stream, or a computer on the network?
|
||||
fn refused_by_name(path: &Path) -> bool {
|
||||
|
||||
@@ -295,12 +295,14 @@ fn display(path: &Path) -> Arc<str> {
|
||||
|
||||
impl Files {
|
||||
/// The files of a run of the script at `path` (`None` for a script given as text), with the
|
||||
/// `JSINCLUDE` folders `jsinclude`.
|
||||
pub(crate) fn new(path: Option<&str>, jsinclude: Vec<PathBuf>) -> Self {
|
||||
let real = path.and_then(|p| std::fs::canonicalize(p).ok());
|
||||
/// `JSINCLUDE` folders `jsinclude`. `checked` is the script's real path when the read of its text
|
||||
/// found it, which the run's folder then comes from; without it, `path` is looked up.
|
||||
pub(crate) fn new(path: Option<&str>, checked: Option<PathBuf>, jsinclude: Vec<PathBuf>) -> Self {
|
||||
let scope_of_checked = checked.as_deref().and_then(Path::parent).map(|d| ScriptScope::of_real_folder(d.to_path_buf()));
|
||||
let real = checked.or_else(|| path.and_then(|p| std::fs::canonicalize(p).ok()));
|
||||
let dir = real.as_deref().and_then(folder_of);
|
||||
Self {
|
||||
scope: dir.as_deref().map(ScriptScope::of_folder).unwrap_or_default(),
|
||||
scope: scope_of_checked.or_else(|| dir.as_deref().map(ScriptScope::of_folder)).unwrap_or_default(),
|
||||
jsinclude,
|
||||
include_path: Vec::new(),
|
||||
files: 0,
|
||||
|
||||
@@ -163,6 +163,9 @@ pub(crate) struct Request {
|
||||
pub name_given: bool,
|
||||
/// The file the script was read from, which its included files are read beside.
|
||||
pub file: Option<String>,
|
||||
/// The real path of `file` as the read of `source` found it, which the included files are then
|
||||
/// read beside instead of looking `file` up again.
|
||||
pub checked_real: Option<std::path::PathBuf>,
|
||||
pub source: String,
|
||||
pub args: Map<String, Value>,
|
||||
pub fuel: u64,
|
||||
@@ -1814,7 +1817,7 @@ impl Session {
|
||||
self.script_high_water.retain(|uid, _| open.contains(uid));
|
||||
self.script_stop.clear();
|
||||
self.script_report = None;
|
||||
let mut files = include::Files::new(request.file.as_deref(), include::Files::jsinclude_env());
|
||||
let mut files = include::Files::new(request.file.as_deref(), request.checked_real, include::Files::jsinclude_env());
|
||||
let expanded = files.expand_script(&request.source);
|
||||
let name = match &expanded {
|
||||
Ok(include::Expanded { name: Some(name), .. }) if !request.name_given => name.clone(),
|
||||
|
||||
@@ -115,7 +115,8 @@ fn the_builtin_libraries_are_original_brushes_generated_in_code() {
|
||||
"scatter-shapes",
|
||||
"game-trails",
|
||||
"energy-beams",
|
||||
"dry-media"
|
||||
"dry-media",
|
||||
"tech-circuit"
|
||||
]
|
||||
);
|
||||
assert!(libs.iter().all(|l| l["category"] == "builtIn"));
|
||||
@@ -217,6 +218,87 @@ fn black_stroke() -> vectorcraft_doc::StrokeLayer {
|
||||
vectorcraft_doc::StrokeLayer::new(vectorcraft_color::Paint::solid(vectorcraft_color::Color::BLACK), 1.0)
|
||||
}
|
||||
|
||||
/// Original circuit art and repeat tiles survive adding, applying and saving a library.
|
||||
#[test]
|
||||
fn the_tech_circuit_library_constructs_adds_and_applies_each_brush() {
|
||||
let mut s = session();
|
||||
let listed = run(&mut s, "brush.libraries", json!({}));
|
||||
let entry = listed["libraries"].as_array().unwrap().iter().find(|l| l["id"] == "tech-circuit").unwrap();
|
||||
assert_eq!((entry["name"].as_str(), entry["category"].as_str(), entry["count"].as_u64()), (Some("Tech Circuit"), Some("builtIn"), Some(3)));
|
||||
let (_, lib) = cmd::brushlib::library(&s, "Tech Circuit").unwrap();
|
||||
let expected = [("Circuit Trace", "art"), ("Bus Lines", "pattern"), ("Terminal Nodes", "pattern")];
|
||||
assert_eq!(lib.brushes.iter().map(|b| (b.name.as_str(), b.kind.type_id())).collect::<Vec<_>>(), expected);
|
||||
let path = run(&mut s, "shape.line", json!({"x1": 10, "y1": 20, "x2": 210, "y2": 20}))["id"].as_u64().unwrap();
|
||||
let curve = {
|
||||
let mut p = line_to(40.0);
|
||||
p.curve_to((60.0, -40.0), (140.0, 40.0), (200.0, 0.0));
|
||||
p
|
||||
};
|
||||
let corner = vectorcraft_geom::shapes::rectangle(vectorcraft_geom::Rect::new(0.0, 0.0, 80.0, 60.0)).to_bezpath();
|
||||
for b in &lib.brushes {
|
||||
let mut sanitized = b.clone();
|
||||
sanitized.sanitize();
|
||||
assert_eq!(&sanitized, b, "{} changes on load", b.name);
|
||||
for tile in b.art() {
|
||||
let pts = anchors(&[tile]);
|
||||
assert!(!pts.is_empty() && pts.len() <= 100, "{}", b.name);
|
||||
assert!(pts.iter().all(|p| p.x.is_finite() && p.y.is_finite()));
|
||||
tile.walk(&mut |n| {
|
||||
if let Some(p) = n.path_data() {
|
||||
assert!(p.subpaths.iter().all(|s| s.closed));
|
||||
}
|
||||
});
|
||||
}
|
||||
if let BrushKind::Pattern(p) = &b.kind {
|
||||
assert_eq!(p.colorization, vectorcraft_brush::Colorization::Tints);
|
||||
assert_eq!(p.spacing, 0.0, "conductors join adjacent tiles");
|
||||
if b.name == "Bus Lines" {
|
||||
let lanes = p.side.children().unwrap();
|
||||
assert_eq!(lanes.len(), 3);
|
||||
let boxes: Vec<_> = lanes.iter().map(|n| n.path_data().unwrap().bounds().unwrap()).collect();
|
||||
for r in &boxes {
|
||||
assert_eq!((r.x0, r.x1), (0.0, 24.0));
|
||||
}
|
||||
assert!(boxes.windows(2).all(|r| r[0].y1 < r[1].y0), "lanes stay separate");
|
||||
}
|
||||
if b.name == "Terminal Nodes" {
|
||||
let mut rings = 0;
|
||||
p.side.walk(&mut |n| {
|
||||
if matches!(n.kind, NodeKind::Compound { rule: vectorcraft_geom::FillRule::EvenOdd, .. }) {
|
||||
rings += 1;
|
||||
}
|
||||
});
|
||||
assert_eq!(rings, 1, "a hollow circular test point per tile");
|
||||
}
|
||||
}
|
||||
for p in [line_to(200.0), line_to(2.0), curve.clone(), corner.clone()] {
|
||||
let pieces = vectorcraft_brush::stroke_pieces(&lib.doc, b, &p, &black_stroke());
|
||||
let pts = anchors(&pieces.iter().collect::<Vec<_>>());
|
||||
assert!(!pts.is_empty() && pts.iter().all(|p| p.x.is_finite() && p.y.is_finite()), "{}", b.name);
|
||||
}
|
||||
let before = undo_len(&s);
|
||||
let r = run(&mut s, "brush.addFromLibrary", json!({"library": "tech-circuit", "name": b.name, "apply": true, "ids": [path]}));
|
||||
assert_eq!(strs(&r["added"]), [b.name.as_str()]);
|
||||
assert_eq!(r["applied"], json!([path]));
|
||||
assert_eq!(undo_len(&s), before + 1);
|
||||
assert_eq!(brush(&s, &b.name), *b);
|
||||
assert_eq!(doc(&s).node(NodeId(path)).unwrap().appearance.stroke().unwrap().brush.as_deref(), Some(b.name.as_str()));
|
||||
run(&mut s, "edit.undo", json!({}));
|
||||
assert!(vectorcraft_brush::find(doc(&s), &b.name).is_none());
|
||||
run(&mut s, "edit.redo", json!({}));
|
||||
let before = undo_len(&s);
|
||||
let again = run(&mut s, "brush.addFromLibrary", json!({"library": "tech-circuit", "name": b.name}));
|
||||
assert!(strs(&again["added"]).is_empty());
|
||||
assert_eq!(strs(&again["existing"]), [b.name.as_str()]);
|
||||
assert_eq!(undo_len(&s), before);
|
||||
}
|
||||
let names: Vec<&str> = expected.iter().map(|(name, _)| *name).collect();
|
||||
let saved = run(&mut s, "brush.saveLibrary", json!({"names": names}));
|
||||
let bytes = vectorcraft_format::base64_decode(saved["dataBase64"].as_str().unwrap()).unwrap();
|
||||
let reopened = vectorcraft_format::load(&bytes).unwrap();
|
||||
assert_eq!(vectorcraft_brush::library(&reopened), lib.brushes);
|
||||
}
|
||||
|
||||
/// Ink and Wash: a tapered ink art brush, a brush pen whose size follows the pen pressure and two
|
||||
/// translucent washes, with names of their own and a few paths of art each.
|
||||
#[test]
|
||||
|
||||
@@ -612,6 +612,100 @@ fn eraser_size_follows_the_pen_pressure() {
|
||||
assert!(err.to_string().contains("sizeMode"), "{err}");
|
||||
}
|
||||
|
||||
/// Eraser Tool Options' Angle, Roundness and Size variation menus: a value on Rotation, Bearing,
|
||||
/// Tilt or Pressure takes what the pen read along the drag, Random stays within its Variation, a
|
||||
/// drag without the reading erases at the value itself, an open path is cut where the varied tip
|
||||
/// covers it, and an unknown mode or a variation out of its range is refused with nothing erased.
|
||||
#[test]
|
||||
fn the_eraser_tip_varies_with_its_modes() {
|
||||
// Which of `probes` a level drag through the middle of a 400 by 200 rectangle erases, each of
|
||||
// its points `[x, 100]` followed by `pen` ([pressure, tiltX, tiltY, rotation]).
|
||||
let erased = |tip: serde_json::Value, pen: serde_json::Value, probes: &[(f64, f64)]| -> Vec<bool> {
|
||||
let mut s = session();
|
||||
rect(&mut s, 0.0, 0.0, 400.0, 200.0);
|
||||
let points: Vec<_> = (0..=20)
|
||||
.map(|i| {
|
||||
let mut p = vec![json!(100.0 + f64::from(i) * 10.0), json!(100.0)];
|
||||
p.extend(pen.as_array().cloned().unwrap_or_default());
|
||||
serde_json::Value::Array(p)
|
||||
})
|
||||
.collect();
|
||||
s.execute("path.eraseRegion", &with(&json!({"points": points, "size": 20}), tip.clone())).unwrap();
|
||||
let filled = |x: f64, y: f64| {
|
||||
paths(&s).iter().any(|(_, pd)| vectorcraft_geom::hit::fill_contains(&pd.to_bezpath(), FillRule::NonZero, Point::new(x, y)))
|
||||
};
|
||||
assert!(filled(200.0, 150.0), "{tip} {pen}: the rest of the rectangle stays");
|
||||
probes.iter().map(|&(x, y)| !filled(x, y)).collect()
|
||||
};
|
||||
let none = json!(null);
|
||||
// A 20 by 2 pt tip lying level erases a band 2 pt tall; standing upright it erases one 20 pt tall.
|
||||
let level = json!({"roundness": 10});
|
||||
let probes = [(200.0, 100.5), (200.0, 108.0)];
|
||||
assert_eq!(erased(level.clone(), json!([]), &probes), [true, false], "Fixed");
|
||||
let on_rotation = with(&level, json!({"angleMode": "rotation", "angleVariation": 90}));
|
||||
// The barrel turned half way round reads the most, which turns the tip upright.
|
||||
assert_eq!(erased(on_rotation.clone(), json!([none, none, none, 180]), &probes), [true, true], "Rotation");
|
||||
assert_eq!(erased(on_rotation.clone(), json!([]), &probes), [true, false], "Rotation without a reading");
|
||||
// Leaning to the left (a bearing of 180°) reads the most too.
|
||||
let on_bearing = with(&level, json!({"angleMode": "bearing", "angleVariation": 90}));
|
||||
assert_eq!(erased(on_bearing.clone(), json!([none, -45, 0, none]), &probes), [true, true], "Bearing");
|
||||
assert_eq!(erased(on_bearing, json!([none, 45, 0, none]), &probes), [true, false], "Bearing at 0°");
|
||||
// A round tip: upright the pen gives 28 pt, nearly flat about 12 pt.
|
||||
let on_tilt = json!({"sizeMode": "tilt", "sizeVariation": 8});
|
||||
let wide = [(200.0, 105.0), (200.0, 113.0)];
|
||||
assert_eq!(erased(on_tilt.clone(), json!([none, 0, 0, none]), &wide), [true, true], "Tilt upright");
|
||||
assert_eq!(erased(on_tilt, json!([none, 89, 0, none]), &wide), [true, false], "Tilt flat");
|
||||
// Roundness from 10 % less 90 (kept to 1 %) at the lightest pressure to 100 % at the heaviest.
|
||||
let on_pressure = with(&level, json!({"roundnessMode": "pressure", "roundnessVariation": 90}));
|
||||
assert_eq!(erased(on_pressure.clone(), json!([1.0]), &probes), [true, true], "Pressure at the heaviest");
|
||||
// At the lightest the tip is 0.2 pt across, narrower than the 2 pt band Fixed erases.
|
||||
assert_eq!(erased(on_pressure, json!([0.0]), &[(200.0, 100.0), (200.0, 100.5)]), [true, false], "Pressure at the lightest");
|
||||
// Random: one size for the drag, from 16 to 24 pt.
|
||||
let random = json!({"sizeMode": "random", "sizeVariation": 4});
|
||||
assert_eq!(erased(random, json!([]), &[(200.0, 107.5), (200.0, 112.5)]), [true, false], "Random");
|
||||
// A press on a line with the tip turned upright by its rotation cuts 5 pt of it, not 20.
|
||||
let left_of_line = |pen: serde_json::Value| {
|
||||
let mut s = session();
|
||||
line(&mut s, 300.0, 150.0, 500.0, 150.0);
|
||||
let mut point = vec![json!(400), json!(150)];
|
||||
point.extend(pen.as_array().cloned().unwrap_or_default());
|
||||
let tip = json!({"points": [point], "size": 20, "roundness": 25, "angleMode": "rotation", "angleVariation": 90});
|
||||
s.execute("path.eraseRegion", &tip).unwrap();
|
||||
paths(&s).iter().map(|p| p.1.length()).sum::<f64>()
|
||||
};
|
||||
let upright = left_of_line(json!([none, none, none, 180]));
|
||||
assert!((upright - 195.0).abs() < 0.3, "the upright tip left {upright} pt of the line");
|
||||
let level_tip = left_of_line(json!([]));
|
||||
assert!((level_tip - 180.0).abs() < 0.3, "the level tip left {level_tip} pt of the line");
|
||||
// A 1500 pt tip on Tilt erases 1500 pt across with a mouse, as it does on Fixed.
|
||||
let mut s = session();
|
||||
rect(&mut s, 0.0, 0.0, 4000.0, 4000.0);
|
||||
let big = json!({"points": [[1000, 2000], [3000, 2000]], "size": 1500, "sizeMode": "tilt", "sizeVariation": 10});
|
||||
s.execute("path.eraseRegion", &big).unwrap();
|
||||
let filled =
|
||||
|x: f64, y: f64| paths(&s).iter().any(|(_, pd)| vectorcraft_geom::hit::fill_contains(&pd.to_bezpath(), FillRule::NonZero, Point::new(x, y)));
|
||||
assert_eq!((filled(2000.0, 2740.0), filled(2000.0, 2760.0)), (false, true), "a 1500 pt tip");
|
||||
// A Size Variation kept while Size is Fixed leaves the size alone.
|
||||
let kept = with(&on_rotation, json!({"sizeMode": "fixed", "sizeVariation": 1000000}));
|
||||
assert_eq!(erased(kept, json!([]), &probes), [true, false], "Size on Fixed with a Variation");
|
||||
let mut s = session();
|
||||
let id = rect(&mut s, 0.0, 0.0, 100.0, 100.0);
|
||||
s.execute("select.set", &json!({"ids": [id.0]})).unwrap();
|
||||
let stroke = json!({"points": [[-10, 50], [110, 50]]});
|
||||
let modes = "\"fixed\", \"random\", \"pressure\", \"tilt\", \"bearing\" or \"rotation\"";
|
||||
for (extra, want) in [
|
||||
(json!({"angleMode": "wheel"}), format!("angleMode is one of {modes}, not `wheel`")),
|
||||
(json!({"roundnessMode": 3}), format!("roundnessMode is one of {modes}, not 3")),
|
||||
(json!({"sizeMode": "stylusWheel"}), format!("sizeMode is one of {modes}, not `stylusWheel`")),
|
||||
(json!({"angleVariation": 181}), "angleVariation must be at most 180".to_string()),
|
||||
(json!({"roundnessVariation": 101}), "roundnessVariation must be at most 100".to_string()),
|
||||
(json!({"roundnessVariation": -1}), "roundnessVariation must be 0 or more".to_string()),
|
||||
] {
|
||||
refuses_with(&mut s, "path.eraseRegion", &with(&stroke, extra), &want);
|
||||
}
|
||||
refuses_a_number_given_as_text(&mut s, "path.eraseRegion", &stroke, "angleVariation");
|
||||
}
|
||||
|
||||
/// A Blob Brush stroke merges with the topmost matching path it touches and with the touched
|
||||
/// matching paths next to it in stacking order in the same group or layer; matching paths in other
|
||||
/// layers or groups, or below an object of another kind, are left alone.
|
||||
|
||||
@@ -127,6 +127,32 @@ fn options_keep_the_brackets() {
|
||||
}
|
||||
}
|
||||
|
||||
/// `start` below 0 starts type on an open path before the path's start, one undo step; on a closed
|
||||
/// path it is the path's start, and a start that is not a number is an error.
|
||||
#[test]
|
||||
fn options_start_type_before_an_open_path() {
|
||||
let mut s = session();
|
||||
let id = on_line(&mut s);
|
||||
let n = undo_len(&s);
|
||||
let r = s.execute("type.pathOptions", &json!({"start": -0.25})).unwrap();
|
||||
assert_eq!(brackets(&s, id), (-0.25, None));
|
||||
assert!(near(r["start"].as_f64().unwrap(), -0.25), "{r}");
|
||||
assert_eq!(undo_len(&s), n + 1);
|
||||
// The same start again records nothing.
|
||||
s.execute("type.pathOptions", &json!({"start": -0.25})).unwrap();
|
||||
assert_eq!(undo_len(&s), n + 1);
|
||||
// 100 pt before a 400 pt line: the 12 pt word is drawn nowhere.
|
||||
let laid = vectorcraft_text::layout(vectorcraft_text::FontDb::global(), &text(&s, id));
|
||||
assert!(!laid.glyphs.is_empty() && laid.glyphs.iter().all(|g| g.outline.elements().is_empty()));
|
||||
assert!(s.execute("type.pathOptions", &json!({"start": "-0.5"})).is_err());
|
||||
// A flip keeps the stretch from the path's start: the type then starts at the start bracket.
|
||||
s.execute("type.pathOptions", &json!({"flip": true})).unwrap();
|
||||
assert_eq!(brackets(&s, id), (0.0, None));
|
||||
let ring = path_type(&mut s, "M100 100 L300 100 L300 300 L100 300 Z", [200.0, 90.0], "Ring");
|
||||
s.execute("type.pathOptions", &json!({"ids": [ring.0], "start": -0.25})).unwrap();
|
||||
assert_eq!(brackets(&s, ring).0, 0.0);
|
||||
}
|
||||
|
||||
/// Type on a Path Options given the values the type has (OK in the dialog with nothing changed, the
|
||||
/// effect it has from the Type on a Path menu, the end bracket at the end of the path, or the
|
||||
/// query's own reply) records no undo step: the step to redo stays and the document stays
|
||||
|
||||
@@ -492,6 +492,111 @@ fn options_set_tile_type_spacing_and_overlap() {
|
||||
assert!(s.execute("pattern.options", &json!({"name": "Dots", "width": -1})).is_err());
|
||||
}
|
||||
|
||||
/// Move Tile with Art: in pattern editing mode, a move of all of the tile art moves the tile by the
|
||||
/// same offset, through `object.move`, `object.nudge` and `object.transform`. With the option off
|
||||
/// (a new pattern's default), a move of part of the art, a rotation or a copy, the tile stays.
|
||||
/// Undo puts the tile back, and Done keeps the moved tile.
|
||||
#[test]
|
||||
fn move_tile_with_art_moves_the_tile_when_all_of_the_art_moves() {
|
||||
let mut s = session();
|
||||
let a = rect(&mut s, 0.0, 0.0, 10.0, 10.0);
|
||||
let b = rect(&mut s, 12.0, 0.0, 6.0, 6.0);
|
||||
sel(&mut s, &[a, b]);
|
||||
s.execute("object.pattern.make", &json!({"name": "P"})).unwrap();
|
||||
let tile = |s: &Session| doc(s).pattern("P").unwrap().tile;
|
||||
let start = tile(&s);
|
||||
let art: Vec<NodeId> = s.doc().unwrap().selection.objects.to_vec();
|
||||
assert_eq!(art.len(), 2);
|
||||
assert!(!doc(&s).pattern("P").unwrap().move_tile_with_art, "off for a new pattern");
|
||||
s.execute("object.move", &json!({"dx": 5, "dy": 0})).unwrap();
|
||||
assert_eq!(tile(&s), start, "with the option off the tile stays");
|
||||
s.execute("object.move", &json!({"dx": -5, "dy": 0})).unwrap();
|
||||
|
||||
s.execute("pattern.options", &json!({"moveTileWithArt": true})).unwrap();
|
||||
assert_eq!(s.execute("pattern.list", &json!({})).unwrap()["patterns"][0]["moveTileWithArt"], true);
|
||||
s.execute("object.move", &json!({"dx": 3, "dy": 4})).unwrap();
|
||||
assert_eq!(tile(&s), start + vectorcraft_geom::Vec2::new(3.0, 4.0));
|
||||
s.execute("object.transform", &json!({"matrix": [1, 0, 0, 1, -1, 2]})).unwrap();
|
||||
assert_eq!(tile(&s), start + vectorcraft_geom::Vec2::new(2.0, 6.0));
|
||||
let k = s.prefs.keyboard_increment;
|
||||
s.execute("object.nudge", &json!({"dx": 1, "dy": 0})).unwrap();
|
||||
assert_eq!(tile(&s), start + vectorcraft_geom::Vec2::new(2.0 + k, 6.0));
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
assert_eq!(tile(&s), start + vectorcraft_geom::Vec2::new(3.0, 4.0), "undo puts the tile back");
|
||||
let moved = tile(&s);
|
||||
|
||||
sel(&mut s, &art[..1]);
|
||||
s.execute("object.move", &json!({"dx": 7, "dy": 0})).unwrap();
|
||||
assert_eq!(tile(&s), moved, "a move of part of the art leaves the tile");
|
||||
sel(&mut s, &art);
|
||||
s.execute("object.rotate", &json!({"angle": 90})).unwrap();
|
||||
assert_eq!(tile(&s), moved, "a rotation leaves the tile");
|
||||
s.execute("object.move", &json!({"dx": 1, "dy": 1, "copy": true})).unwrap();
|
||||
assert_eq!(tile(&s), moved, "a copy leaves the tile");
|
||||
|
||||
s.execute("object.pattern.done", &json!({})).unwrap();
|
||||
assert_eq!(tile(&s), moved, "Done keeps the moved tile");
|
||||
assert!(doc(&s).pattern("P").unwrap().move_tile_with_art);
|
||||
}
|
||||
|
||||
/// Move Tile with Art counts a group in the tile art as moved when each of its members moved, one
|
||||
/// by one selected.
|
||||
#[test]
|
||||
fn move_tile_with_art_counts_a_group_whose_members_all_moved() {
|
||||
let mut s = session();
|
||||
let a = rect(&mut s, 0.0, 0.0, 10.0, 10.0);
|
||||
let b = rect(&mut s, 12.0, 0.0, 6.0, 6.0);
|
||||
sel(&mut s, &[a, b]);
|
||||
s.execute("object.group", &json!({})).unwrap();
|
||||
s.execute("object.pattern.make", &json!({"name": "G"})).unwrap();
|
||||
s.execute("pattern.options", &json!({"moveTileWithArt": true})).unwrap();
|
||||
let start = doc(&s).pattern("G").unwrap().tile;
|
||||
let layer = doc(&s).pattern_edit.as_ref().unwrap().layer;
|
||||
let group = doc(&s).node(layer).unwrap().children().unwrap()[0].clone();
|
||||
let members: Vec<NodeId> = group.children().unwrap().iter().map(|c| c.id).collect();
|
||||
assert_eq!(members.len(), 2);
|
||||
sel(&mut s, &members[..1]);
|
||||
s.execute("object.move", &json!({"dx": 4, "dy": 0})).unwrap();
|
||||
assert_eq!(doc(&s).pattern("G").unwrap().tile, start, "one member of two");
|
||||
sel(&mut s, &members);
|
||||
s.execute("object.move", &json!({"dx": 0, "dy": 5})).unwrap();
|
||||
assert_eq!(doc(&s).pattern("G").unwrap().tile, start + vectorcraft_geom::Vec2::new(0.0, 5.0), "every member");
|
||||
}
|
||||
|
||||
/// Move Tile with Art moves the tile as far as the art moved: when the art is aligned to the pixel
|
||||
/// grid and a move snaps it to the grid, the tile moves by the snapped distance.
|
||||
#[test]
|
||||
fn move_tile_with_art_follows_art_that_snaps_to_the_pixel_grid() {
|
||||
let mut s = session();
|
||||
let a = rect(&mut s, 0.0, 0.0, 10.0, 10.0);
|
||||
sel(&mut s, &[a]);
|
||||
s.execute("object.pattern.make", &json!({"name": "P"})).unwrap();
|
||||
s.execute("pattern.options", &json!({"moveTileWithArt": true})).unwrap();
|
||||
let art = s.doc().unwrap().selection.objects[0];
|
||||
std::sync::Arc::make_mut(&mut s.doc_mut().unwrap().doc).node_mut(art).unwrap().extras_mut().pixel_aligned = true;
|
||||
let start = doc(&s).pattern("P").unwrap().tile;
|
||||
let origin = |s: &Session| doc(s).node(art).unwrap().geometric_bounds().unwrap().origin();
|
||||
let before = origin(&s);
|
||||
s.execute("object.move", &json!({"dx": 0.3, "dy": 0})).unwrap();
|
||||
let moved = origin(&s) - before;
|
||||
assert!((moved.x - 0.3).abs() > 1e-6, "the art snapped to the grid: {moved:?}");
|
||||
assert_eq!(doc(&s).pattern("P").unwrap().tile, start + moved, "the tile moved as far");
|
||||
}
|
||||
|
||||
/// Pattern Options that change nothing record no undo step.
|
||||
#[test]
|
||||
fn options_that_change_nothing_record_no_undo_step() {
|
||||
let mut s = session();
|
||||
make_dots(&mut s);
|
||||
let steps = |s: &Session| s.doc().unwrap().history.undo.len();
|
||||
let before = steps(&s);
|
||||
s.execute("pattern.options", &json!({"name": "Dots", "moveTileWithArt": false, "copies": 5})).unwrap();
|
||||
assert_eq!(steps(&s), before);
|
||||
s.execute("pattern.options", &json!({"name": "Dots", "moveTileWithArt": true})).unwrap();
|
||||
assert_eq!(steps(&s), before + 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn options_rename_updates_swatch_and_paints() {
|
||||
let mut s = session();
|
||||
@@ -1625,6 +1730,7 @@ fn pattern_commands_refuse_a_param_of_the_wrong_type() {
|
||||
("pattern.options", options(json!({"copies": "5"})), number("copies", json!("5"))),
|
||||
("pattern.options", options(json!({"dimCopies": "50"})), number("dimCopies", json!("50"))),
|
||||
("pattern.options", options(json!({"sizeTileToArt": "true"})), switch("sizeTileToArt")),
|
||||
("pattern.options", options(json!({"moveTileWithArt": "yes"})), switch("moveTileWithArt")),
|
||||
("pattern.options", options(json!({"showTileEdge": 1})), switch("showTileEdge")),
|
||||
("pattern.options", options(json!({"showSwatchBounds": "no"})), switch("showSwatchBounds")),
|
||||
("pattern.options", options(json!({"overlap": "right"})), "`overlap` must be an object, not \"right\"".into()),
|
||||
|
||||
@@ -666,6 +666,26 @@ fn linked_pages_share_one_copy_of_the_file_and_open_in_the_pages_color_mode() {
|
||||
assert_eq!(s.doc().unwrap().doc.color_mode, vectorcraft_doc::ColorMode::Rgb, "colorMode wins");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn linked_pages_read_the_file_once_and_a_blank_page_keeps_its_crop_box() {
|
||||
use vectorcraft_testkit::pdf::{PdfPage, pdf};
|
||||
let dir = Folder::new("open-pdf-link-pages-blank");
|
||||
let src = dir.file("blank.pdf");
|
||||
let art = || PdfPage::new(100.0, 50.0, "1 0 0 rg 10 10 20 20 re f");
|
||||
write(&src, &pdf(&[art(), PdfPage::new(80.0, 40.0, ""), art()], None));
|
||||
let mut s = Session::new();
|
||||
s.execute("document.open", &json!({"path": src, "linkPages": true, "cropTo": "bounding"})).unwrap();
|
||||
let d = &s.doc().unwrap().doc;
|
||||
let boards: Vec<Rect> = d.artboards.iter().map(|a| a.rect).collect();
|
||||
assert_eq!(boards.len(), 3, "{boards:?}");
|
||||
assert_eq!((boards[0].width(), boards[0].height()), (20.0, 20.0), "the art's bounds");
|
||||
assert_eq!((boards[1].width(), boards[1].height()), (80.0, 40.0), "the blank page's crop box");
|
||||
// The pages are read from the one copy every page's blob keeps: a page that read the file
|
||||
// again would count here.
|
||||
let blob = d.images.values().next().map(|b| b.bytes.clone()).unwrap();
|
||||
assert_eq!(vectorcraft_pdf::reads_of(&blob), 0, "the pages are read from the file read once");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn link_pages_is_refused_without_a_path_for_other_files_and_with_a_password_or_outlines() {
|
||||
let dir = Folder::new("open-pdf-link-pages-refused");
|
||||
|
||||
@@ -300,6 +300,71 @@ fn an_installed_script_is_read_as_script_run_reads_a_file() {
|
||||
let _ = std::fs::remove_dir_all(dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_installed_run_runs_the_text_its_own_read_checked() {
|
||||
let dir = folder("installed-run");
|
||||
let root = dir.join("Scripts");
|
||||
let script = touch(&root, "draw.jsx");
|
||||
std::fs::write(&script, "#include \"lib.jsxinc\"\nvc.log(x);").unwrap();
|
||||
std::fs::write(root.join("lib.jsxinc"), "var x = 1;").unwrap();
|
||||
// A folder in the Scripts folder that is a link: script.run by path alone follows it.
|
||||
let elsewhere = dir.join("elsewhere");
|
||||
std::fs::create_dir_all(&elsewhere).unwrap();
|
||||
std::fs::write(elsewhere.join("other.jsx"), "vc.log(2);").unwrap();
|
||||
link_folder(&elsewhere, &root.join("linked"));
|
||||
let linked = root.join("linked").join("other.jsx").to_string_lossy().into_owned();
|
||||
let mut s = Session::new();
|
||||
s.attach_scripts_dir(root.clone());
|
||||
let path = script.to_string_lossy().into_owned();
|
||||
let r = s.execute("script.run", &json!({"path": path, "installed": true})).unwrap();
|
||||
assert_eq!(r["log"], json!([1]));
|
||||
assert_eq!(s.execute("script.run", &json!({"path": linked})).unwrap()["log"], json!([2]));
|
||||
// With `installed`, the run's own read is the check: a script behind a link is not run.
|
||||
let e = s.execute("script.run", &json!({"path": linked, "installed": true})).unwrap_err();
|
||||
assert_eq!(e.to_string(), cmd::scripts::NOT_LISTED);
|
||||
let outside = touch(&dir, "outside.js");
|
||||
let e = s.execute("script.run", &json!({"path": outside.to_string_lossy(), "installed": true})).unwrap_err();
|
||||
assert_eq!(e.to_string(), cmd::scripts::NOT_LISTED);
|
||||
// `installed` is true or false, and takes a path.
|
||||
assert!(
|
||||
s.execute("script.run", &json!({"path": path, "installed": "yes"})).unwrap_err().to_string().contains("`installed` must be true or false")
|
||||
);
|
||||
assert!(s.execute("script.run", &json!({"source": "1", "installed": true})).unwrap_err().to_string().contains("`installed` takes a `path`"));
|
||||
assert_eq!(s.execute("script.run", &json!({"source": "vc.log(3);", "installed": false})).unwrap()["log"], json!([3]));
|
||||
let _ = std::fs::remove_dir_all(dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_installed_run_reads_its_includes_in_the_folder_its_read_found() {
|
||||
let dir = folder("installed-scope");
|
||||
let checked = dir.join("Scripts").join("tools");
|
||||
let swapped = dir.join("swapped");
|
||||
for (folder, x) in [(&checked, 1), (&swapped, 2)] {
|
||||
std::fs::create_dir_all(folder).unwrap();
|
||||
std::fs::write(folder.join("draw.jsx"), "").unwrap();
|
||||
std::fs::write(folder.join("lib.jsxinc"), format!("var x = {x};")).unwrap();
|
||||
}
|
||||
// The path now leads through a link to another folder, as after a link was put in place of the
|
||||
// folder once the script's text was read.
|
||||
let link = dir.join("link");
|
||||
link_folder(&swapped, &link);
|
||||
let mut s = Session::new();
|
||||
let request = script::Request {
|
||||
name: "draw.jsx".into(),
|
||||
name_given: false,
|
||||
file: Some(link.join("draw.jsx").to_string_lossy().into_owned()),
|
||||
checked_real: Some(std::fs::canonicalize(checked.join("draw.jsx")).unwrap()),
|
||||
source: "#include \"lib.jsxinc\"\nvc.log(x);".into(),
|
||||
args: Default::default(),
|
||||
fuel: script::DEFAULT_FUEL,
|
||||
locale: "en_US".into(),
|
||||
answers: Vec::new(),
|
||||
};
|
||||
s.script_start(request).unwrap();
|
||||
assert_eq!(s.script_run_to_end().unwrap()["log"], json!([1]));
|
||||
let _ = std::fs::remove_dir_all(dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_automation_read_root_keeps_an_agent_out_of_the_scripts_folder() {
|
||||
let dir = folder("roots");
|
||||
|
||||
@@ -724,6 +724,44 @@ fn touch_type_moves_one_character_and_leaves_the_others_where_they_are() {
|
||||
assert!(s.execute("text.touchType", &json!({"id": id.0, "start": (1u64 << 32) + 1, "dy": 1})).is_err(), "no character there");
|
||||
}
|
||||
|
||||
/// Touch Type does not change a character of type on an open path that is not drawn because it
|
||||
/// falls before the path's start; a drawn character of the same type still changes.
|
||||
#[test]
|
||||
fn touch_type_refuses_a_character_before_an_open_paths_start() {
|
||||
let mut s = session();
|
||||
let pid = s.execute("path.create", &json!({"d": "M100 300 L500 300"})).unwrap()["id"].as_u64().unwrap();
|
||||
let r = s.execute("text.createInPath", &json!({"path": pid, "mode": "onPath", "text": "Hidden", "size": 12, "at": [100, 300]})).unwrap();
|
||||
let id = NodeId(r["id"].as_u64().unwrap());
|
||||
s.execute("select.set", &json!({"ids": [id.0]})).unwrap();
|
||||
s.execute("type.pathOptions", &json!({"start": -0.02})).unwrap();
|
||||
let steps = undo_labels(&s).len();
|
||||
assert!(s.execute("text.touchType", &json!({"id": id.0, "start": 0, "dy": 2})).is_err(), "the H is not drawn");
|
||||
assert_eq!(undo_labels(&s).len(), steps);
|
||||
s.execute("text.touchType", &json!({"id": id.0, "start": 5, "dy": 2})).unwrap();
|
||||
// An All Caps ß is two glyphs: with the first before the path's start and the second drawn, it
|
||||
// still changes.
|
||||
let pid = s.execute("path.create", &json!({"d": "M100 400 L500 400"})).unwrap()["id"].as_u64().unwrap();
|
||||
let r = s.execute("text.createInPath", &json!({"path": pid, "mode": "onPath", "text": "ße", "size": 40, "at": [100, 400]})).unwrap();
|
||||
let id = NodeId(r["id"].as_u64().unwrap());
|
||||
s.execute("text.setRangeStyle", &json!({"id": id.0, "start": 0, "end": 4, "allCaps": true})).unwrap();
|
||||
s.execute("select.set", &json!({"ids": [id.0]})).unwrap();
|
||||
let adv = vectorcraft_text::layout(vectorcraft_text::FontDb::global(), &obj(&s, id)).glyphs[0].advance;
|
||||
s.execute("type.pathOptions", &json!({"start": -1.2 * adv / 400.0})).unwrap();
|
||||
let lay = vectorcraft_text::layout(vectorcraft_text::FontDb::global(), &obj(&s, id));
|
||||
assert!(lay.glyphs[0].byte == 0 && lay.glyphs[1].byte == 0 && lay.glyphs[0].off_path && !lay.glyphs[1].off_path);
|
||||
let drawn = |s: &Session| {
|
||||
let t = obj(s, id);
|
||||
let lay = vectorcraft_text::layout(vectorcraft_text::FontDb::global(), &t);
|
||||
let g = lay.glyphs.iter().rfind(|g| g.byte == 0 && !g.off_path).unwrap();
|
||||
t.xf * (g.xf * Point::ZERO)
|
||||
};
|
||||
let before = drawn(&s);
|
||||
s.execute("text.touchType", &json!({"id": id.0, "start": 0, "dy": 2})).unwrap();
|
||||
// The drawn glyph rises 2 points.
|
||||
let after = drawn(&s);
|
||||
assert!((after.x - before.x).abs() < 1e-6 && (after.y - (before.y - 2.0)).abs() < 1e-6, "{before:?} -> {after:?}");
|
||||
}
|
||||
|
||||
/// Touch Type on the first character of a list item moves that character and leaves the bullet
|
||||
/// where it is. The bullet stands for no character, so the first character still moves only up
|
||||
/// and down.
|
||||
|
||||
@@ -5128,6 +5128,16 @@ fn a_first_baseline_minimum_and_a_path_end_the_reader_cant_read_are_reported() {
|
||||
};
|
||||
assert_eq!(with(on_path(Some(0.999))).0, [writer::TYPE_ATTRIBUTES]);
|
||||
assert!(with(on_path(None)).0.is_empty());
|
||||
// Type that starts before its open path comes back starting at the path's start.
|
||||
let mut before = on_path(None);
|
||||
if let TextKind::OnPath { start, .. } = &mut before.kind {
|
||||
*start = -0.25;
|
||||
}
|
||||
let (warnings, back) = with(before);
|
||||
assert_eq!(warnings, [writer::TYPE_ATTRIBUTES]);
|
||||
let NodeKind::Text(t) = &back.layers[0].children().unwrap()[0].kind else { panic!("type") };
|
||||
let TextKind::OnPath { start, .. } = t.kind else { panic!("type on a path") };
|
||||
assert!(start.abs() < 1e-3, "{start}");
|
||||
let (warnings, back) = with(on_path(Some(0.5)));
|
||||
assert!(warnings.is_empty(), "{warnings:?}");
|
||||
let NodeKind::Text(t) = &back.layers[0].children().unwrap()[0].kind else { panic!("type") };
|
||||
|
||||
@@ -440,7 +440,13 @@ pub(in crate::ai_editing) fn loses(t: &TextObject) -> bool {
|
||||
// The text document holds the Ascent first baseline by leaving it out, without a minimum.
|
||||
|| (t.area.first_baseline == FirstBaseline::Ascent && t.area.first_baseline_min != 0.0)))
|
||||
|| (matches!(t.kind, TextKind::OnPath { .. })
|
||||
&& (t.path_effect != PathEffect::default() || t.path_align != PathAlign::default() || t.path_spacing != 0.0 || end_read_as_none(t)))
|
||||
&& (t.path_effect != PathEffect::default()
|
||||
|| t.path_align != PathAlign::default()
|
||||
|| t.path_spacing != 0.0
|
||||
|| end_read_as_none(t)
|
||||
// The writer writes type that starts before its open path starting at the
|
||||
// path's start.
|
||||
|| t.kind.starts_before_path()))
|
||||
}
|
||||
|
||||
/// Key 35 of a run in style `st`, as the reader's `Texts::style` reads it: 3 for Tate-chu-yoko, 1
|
||||
|
||||
@@ -2,8 +2,9 @@
|
||||
//!
|
||||
//! The editing copy of a file keeps the characters of its text objects, with their fonts, sizes,
|
||||
//! colours and where they sit, in one text document of its own: between `%AI11_BeginTextDocument`
|
||||
//! and `%AI11_EndTextDocument`, ASCII85 of nested dictionaries with numbers for keys. A text object
|
||||
//! in a layer is only a stub naming its story in it (`/StoryIndex`).
|
||||
//! and `%AI11_EndTextDocument`, ASCII85 of nested dictionaries with numbers for keys (names in files
|
||||
//! saved for version 11, and for keys newer than the version a file is saved for: see [`names`]).
|
||||
//! A text object in a layer is only a stub naming its story in it (`/StoryIndex`).
|
||||
//!
|
||||
//! The format isn't documented. Most of what is read here was worked out from the files of the
|
||||
//! project's users, used locally and never committed, by comparing it with the type their pages
|
||||
@@ -32,6 +33,8 @@ use vectorcraft_doc::{
|
||||
};
|
||||
use vectorcraft_geom::{Affine, PathData, Point, Rect};
|
||||
|
||||
mod names;
|
||||
|
||||
/// Longest story read (bytes).
|
||||
pub(crate) const MAX_STORY: usize = 1 << 20;
|
||||
/// Biggest text document read (bytes, decoded).
|
||||
@@ -631,7 +634,7 @@ impl Texts {
|
||||
if bytes.len() > MAX_BYTES {
|
||||
return None;
|
||||
}
|
||||
let doc = read(&bytes)?;
|
||||
let doc = names::numbered(read(&bytes)?);
|
||||
let fonts = doc
|
||||
.get("0")?
|
||||
.get("1")?
|
||||
|
||||
@@ -0,0 +1,355 @@
|
||||
//! The keys of a text document written by name.
|
||||
//!
|
||||
//! Files saved for version 11 write every key of the text document by its name
|
||||
//! (`/DocumentResources << /FontSet << /Resources [ ...`) and a typed node's kind under
|
||||
//! `/StreamTag`, with the long names of the layout's strikes (`/FrameStrike`, `/LineStrike`). Later
|
||||
//! versions write each key as its number (`/0 << /1 << /0 [ ...`, the kind under `/99`, `/F`,
|
||||
//! `/L`), except the keys that are newer than the version the file is saved for, which they still
|
||||
//! write by name (`/ParagraphDirection`, `/VersionString`). A key's number depends on the
|
||||
//! dictionary it is in (`Leading` is 5 in a character style and 11 in a line), so the names are
|
||||
//! read with the dictionary each one sits in, from the root down.
|
||||
//!
|
||||
//! A public reader of the format lists the names and numbers of the document's resources and
|
||||
//! objects, the character and paragraph features, a story's model and view, a frame's data, the
|
||||
//! line and glyph strikes, a font record's name, type and axes, and a color's type and values:
|
||||
//! <https://github.com/Zignixx/firepixel/blob/60e3d5517df3106674b3208eb71e65471b4daec4/crates/fp-fwpng/src/ate_keys.rs>.
|
||||
//! The other names here (the sets' lists, a resource and its kind, a font record's version, the
|
||||
//! styles' name, parent and features, a Kinsoku set's name and data, a frame's point, curve, first
|
||||
//! baseline and path options, the runs and their kerning, a strike's kind and origin, the tab stops
|
||||
//! and the paints) are VectorCraft's own reading of the format, matched to the numbers the reader
|
||||
//! already uses.
|
||||
//!
|
||||
//! Only the keys the text document's reader looks at, and the dictionaries on the way to them, are
|
||||
//! given numbers; any other key keeps its name and is skipped as before. A key that the dictionary
|
||||
//! also has as a number keeps the number's value.
|
||||
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
use super::Val;
|
||||
|
||||
/// The kind of a dictionary of the text document, which gives its keys' numbers.
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
enum Kind {
|
||||
Root,
|
||||
Resources,
|
||||
Objects,
|
||||
FontSet,
|
||||
FontEntry,
|
||||
Font,
|
||||
FontRecord,
|
||||
KinsokuSet,
|
||||
KinsokuEntry,
|
||||
Kinsoku,
|
||||
CharSheetSet,
|
||||
CharSheetEntry,
|
||||
CharSheet,
|
||||
ParaSheetSet,
|
||||
ParaSheetEntry,
|
||||
ParaSheet,
|
||||
FrameSet,
|
||||
FrameEntry,
|
||||
Frame,
|
||||
Bezier,
|
||||
FrameData,
|
||||
FirstBaseline,
|
||||
PathOptions,
|
||||
TextObject,
|
||||
Model,
|
||||
ParaRuns,
|
||||
ParaRun,
|
||||
ParaRunData,
|
||||
CharRuns,
|
||||
CharRun,
|
||||
CharRunData,
|
||||
KernRuns,
|
||||
KernRun,
|
||||
KernRunData,
|
||||
View,
|
||||
FrameRef,
|
||||
Strike,
|
||||
Transform,
|
||||
CharFeatures,
|
||||
ParaFeatures,
|
||||
TabStops,
|
||||
TabStop,
|
||||
Paint,
|
||||
Color,
|
||||
/// A dictionary none of whose keys are read: its keys stay as they are.
|
||||
Other,
|
||||
}
|
||||
|
||||
/// A key of a dictionary: its name, its number, and the kind of the dictionaries its value holds
|
||||
/// (the value itself, or the elements of a list).
|
||||
type Key = (&'static str, &'static str, Kind);
|
||||
|
||||
/// The keys of a set of resources (`FontSet`, `StyleSheetSet`, ...), whose list holds `entry`.
|
||||
const fn set(entry: Kind) -> [Key; 2] {
|
||||
[("Resources", "0", entry), ("DisplayList", "1", Kind::Other)]
|
||||
}
|
||||
|
||||
const FONT_SET: [Key; 2] = set(Kind::FontEntry);
|
||||
const KINSOKU_SET: [Key; 2] = set(Kind::KinsokuEntry);
|
||||
const CHAR_SHEET_SET: [Key; 2] = set(Kind::CharSheetEntry);
|
||||
const PARA_SHEET_SET: [Key; 2] = set(Kind::ParaSheetEntry);
|
||||
const FRAME_SET: [Key; 2] = set(Kind::FrameEntry);
|
||||
|
||||
/// The character features the reader looks at.
|
||||
const CHAR_FEATURES: [Key; 20] = [
|
||||
("Font", "0", Kind::Other),
|
||||
("FontSize", "1", Kind::Other),
|
||||
("AutoLeading", "4", Kind::Other),
|
||||
("Leading", "5", Kind::Other),
|
||||
("HorizontalScale", "6", Kind::Other),
|
||||
("VerticalScale", "7", Kind::Other),
|
||||
("Tracking", "8", Kind::Other),
|
||||
("BaselineShift", "9", Kind::Other),
|
||||
("FontCaps", "12", Kind::Other),
|
||||
("BaselineDirection", "35", Kind::Other),
|
||||
("LeftAki", "49", Kind::Other),
|
||||
("RightAki", "50", Kind::Other),
|
||||
("FillColor", "53", Kind::Paint),
|
||||
("StrokeColor", "54", Kind::Paint),
|
||||
("FillFlag", "56", Kind::Other),
|
||||
("StrokeFlag", "57", Kind::Other),
|
||||
("LineCap", "61", Kind::Other),
|
||||
("LineJoin", "62", Kind::Other),
|
||||
("LineWidth", "63", Kind::Other),
|
||||
("MiterLimit", "64", Kind::Other),
|
||||
];
|
||||
|
||||
/// The paragraph features the reader looks at.
|
||||
const PARA_FEATURES: [Key; 22] = [
|
||||
("Justification", "0", Kind::Other),
|
||||
("FirstLineIndent", "1", Kind::Other),
|
||||
("StartIndent", "2", Kind::Other),
|
||||
("EndIndent", "3", Kind::Other),
|
||||
("SpaceBefore", "4", Kind::Other),
|
||||
("SpaceAfter", "5", Kind::Other),
|
||||
("AutoLeading", "7", Kind::Other),
|
||||
("AutoHyphenate", "9", Kind::Other),
|
||||
("HyphenatedWordSize", "10", Kind::Other),
|
||||
("PreHyphen", "11", Kind::Other),
|
||||
("PostHyphen", "12", Kind::Other),
|
||||
("ConsecutiveHyphens", "13", Kind::Other),
|
||||
("Zone", "14", Kind::Other),
|
||||
("HyphenateCapitalized", "15", Kind::Other),
|
||||
("KeepTogether", "23", Kind::Other),
|
||||
("BurasagariType", "24", Kind::Other),
|
||||
("KinsokuOrder", "25", Kind::Other),
|
||||
("Kinsoku", "27", Kind::Other),
|
||||
("TabStops", "30", Kind::TabStops),
|
||||
("DefaultTabWidth", "31", Kind::Other),
|
||||
("DefaultStyle", "32", Kind::Other),
|
||||
("ParagraphDirection", "33", Kind::Other),
|
||||
];
|
||||
|
||||
/// The keys of a strike of the layout. A glyph strike has no children; its own keys start at 5. Its
|
||||
/// glyph data (key 21) has no name here: the public list above does not name it, and the files
|
||||
/// checked do not write it by name.
|
||||
const STRIKE: [Key; 5] = [
|
||||
("StreamTag", "99", Kind::Other),
|
||||
("Transform", "0", Kind::Transform),
|
||||
("Bounds", "1", Kind::Other),
|
||||
("ChildProcession", "5", Kind::Other),
|
||||
("Children", "6", Kind::Strike),
|
||||
];
|
||||
const GLYPH_STRIKE: [Key; 3] = [("StreamTag", "99", Kind::Other), ("Transform", "0", Kind::Transform), ("Bounds", "1", Kind::Other)];
|
||||
|
||||
/// The long names of the strikes' kinds, with the short ones later versions write.
|
||||
const STRIKE_KINDS: [(&str, &str); 6] =
|
||||
[("PathSelectGroupCharacter", "PC"), ("FrameStrike", "F"), ("RowColStrike", "R"), ("LineStrike", "L"), ("Segment", "S"), ("GlyphStrike", "G")];
|
||||
|
||||
impl Kind {
|
||||
/// The keys of a dictionary of this kind; `tag` is the kind of typed node it is, for a strike.
|
||||
fn keys(self, tag: Option<&str>) -> &'static [Key] {
|
||||
use Kind as K;
|
||||
match self {
|
||||
K::Root => &[("DocumentResources", "0", K::Resources), ("DocumentObjects", "1", K::Objects)],
|
||||
K::Resources => &[
|
||||
("FontSet", "1", K::FontSet),
|
||||
("KinsokuSet", "4", K::KinsokuSet),
|
||||
("StyleSheetSet", "5", K::CharSheetSet),
|
||||
("ParagraphSheetSet", "6", K::ParaSheetSet),
|
||||
("TextFrameSet", "8", K::FrameSet),
|
||||
],
|
||||
K::Objects => &[
|
||||
("DocumentSettings", "0", K::Other),
|
||||
("TextObjects", "1", K::TextObject),
|
||||
("OriginalNormalStyleFeatures", "2", K::CharFeatures),
|
||||
("OriginalNormalParagraphFeatures", "3", K::ParaFeatures),
|
||||
],
|
||||
K::FontSet => &FONT_SET,
|
||||
K::FontEntry => &[("Resource", "0", K::Font)],
|
||||
K::Font => &[("StreamTag", "99", K::Other), ("Identifier", "0", K::FontRecord)],
|
||||
K::FontRecord => &[("Name", "0", K::Other), ("Type", "2", K::Other), ("MMAxis", "4", K::Other), ("VersionString", "5", K::Other)],
|
||||
K::KinsokuSet => &KINSOKU_SET,
|
||||
K::KinsokuEntry => &[("Resource", "0", K::Kinsoku)],
|
||||
K::Kinsoku => &[("Name", "0", K::Other), ("Data", "5", K::Other)],
|
||||
K::CharSheetSet => &CHAR_SHEET_SET,
|
||||
K::CharSheetEntry => &[("Resource", "0", K::CharSheet)],
|
||||
K::CharSheet => &[("Name", "0", K::Other), ("Parent", "5", K::Other), ("Features", "6", K::CharFeatures)],
|
||||
K::ParaSheetSet => &PARA_SHEET_SET,
|
||||
K::ParaSheetEntry => &[("Resource", "0", K::ParaSheet)],
|
||||
K::ParaSheet => &[("Name", "0", K::Other), ("Features", "5", K::ParaFeatures), ("Parent", "6", K::Other)],
|
||||
K::FrameSet => &FRAME_SET,
|
||||
K::FrameEntry => &[("Resource", "0", K::Frame)],
|
||||
K::Frame => &[("Point", "0", K::Other), ("Bezier", "1", K::Bezier), ("Data", "2", K::FrameData)],
|
||||
K::Bezier => &[("Points", "0", K::Other)],
|
||||
K::FrameData => &[
|
||||
("Type", "0", K::Other),
|
||||
("LineOrientation", "1", K::Other),
|
||||
("FrameMatrix", "2", K::Other),
|
||||
("RowCount", "3", K::Other),
|
||||
("ColumnCount", "4", K::Other),
|
||||
("TextOnPathTRange", "6", K::Other),
|
||||
("RowGutter", "7", K::Other),
|
||||
("ColumnGutter", "8", K::Other),
|
||||
("FirstBaselineAlignment", "10", K::FirstBaseline),
|
||||
("PathData", "11", K::PathOptions),
|
||||
],
|
||||
K::FirstBaseline => &[("Flag", "0", K::Other), ("Min", "1", K::Other)],
|
||||
K::PathOptions => &[("Flip", "0", K::Other), ("Effect", "1", K::Other), ("Alignment", "2", K::Other), ("CS3Behavior", "6", K::Other)],
|
||||
K::TextObject => &[("Model", "0", K::Model), ("View", "1", K::View)],
|
||||
K::Model => &[("Text", "0", K::Other), ("ParagraphRun", "5", K::ParaRuns), ("StyleRun", "6", K::CharRuns), ("KernRun", "8", K::KernRuns)],
|
||||
K::ParaRuns => &[("RunArray", "0", K::ParaRun)],
|
||||
K::ParaRun => &[("RunData", "0", K::ParaRunData), ("Length", "1", K::Other)],
|
||||
K::ParaRunData => &[("ParagraphSheet", "0", K::ParaSheet)],
|
||||
K::CharRuns => &[("RunArray", "0", K::CharRun)],
|
||||
K::CharRun => &[("RunData", "0", K::CharRunData), ("Length", "1", K::Other)],
|
||||
K::CharRunData => &[("StyleSheet", "0", K::CharSheet)],
|
||||
K::KernRuns => &[("RunArray", "0", K::KernRun)],
|
||||
K::KernRun => &[("RunData", "0", K::KernRunData), ("Length", "1", K::Other)],
|
||||
K::KernRunData => &[("ManualKern", "0", K::Other)],
|
||||
K::View => &[("Frames", "0", K::FrameRef), ("Strikes", "2", K::Strike)],
|
||||
K::FrameRef => &[("Resource", "0", K::Other)],
|
||||
K::Strike if matches!(tag, Some("GlyphStrike" | "G")) => &GLYPH_STRIKE,
|
||||
K::Strike => &STRIKE,
|
||||
K::Transform => &[("Origin", "0", K::Other)],
|
||||
K::CharFeatures => &CHAR_FEATURES,
|
||||
K::ParaFeatures => &PARA_FEATURES,
|
||||
K::TabStops => &[("TabStops", "0", K::TabStop)],
|
||||
K::TabStop => &[("TabAdvance", "0", K::Other), ("TabType", "1", K::Other)],
|
||||
K::Paint => &[("StreamTag", "99", K::Other), ("Color", "0", K::Color), ("CAIKnownStyleID", "5", K::Other)],
|
||||
K::Color => &[("Type", "0", K::Other), ("Values", "1", K::Other)],
|
||||
K::Other => &[],
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The text document `doc` (its top-level keys) with the keys it writes by name given their
|
||||
/// numbers, as later versions write them.
|
||||
pub(super) fn numbered(doc: Val) -> Val {
|
||||
value(doc, Kind::Root)
|
||||
}
|
||||
|
||||
/// `v`, whose dictionaries are of kind `kind`, with its keys numbered.
|
||||
fn value(v: Val, kind: Kind) -> Val {
|
||||
match v {
|
||||
Val::Dict(d) if kind != Kind::Other => Val::Dict(dict(d, kind)),
|
||||
Val::List(l) if kind != Kind::Other => Val::List(l.into_iter().map(|x| value(x, kind)).collect()),
|
||||
v => v,
|
||||
}
|
||||
}
|
||||
|
||||
/// A run's style written by name as the number `n` of a paragraph style (`kind` `ParaSheet`) or a
|
||||
/// character style (`CharSheet`) of the document: a style with nothing of its own, based on that
|
||||
/// one.
|
||||
fn based_on(kind: Kind, n: f64) -> Option<Val> {
|
||||
let parent = match kind {
|
||||
Kind::ParaSheet => "6",
|
||||
Kind::CharSheet => "5",
|
||||
_ => return None,
|
||||
};
|
||||
Some(Val::Dict(BTreeMap::from([(parent.to_string(), Val::Num(n))])))
|
||||
}
|
||||
|
||||
/// The entries of a dictionary of kind `kind` with their keys numbered.
|
||||
fn dict(d: BTreeMap<String, Val>, kind: Kind) -> BTreeMap<String, Val> {
|
||||
let tag = match d.get("StreamTag").or_else(|| d.get("99")) {
|
||||
Some(Val::Name(n)) => Some(n.clone()),
|
||||
_ => None,
|
||||
};
|
||||
let keys = kind.keys(tag.as_deref());
|
||||
let mut out = BTreeMap::new();
|
||||
let mut named = vec![];
|
||||
for (k, v) in d {
|
||||
match keys.iter().find(|(name, number, _)| *name == k || *number == k) {
|
||||
Some((name, number, child)) => {
|
||||
let v = match (*number, v) {
|
||||
("99", Val::Name(n)) => Val::Name(STRIKE_KINDS.iter().find(|(long, _)| *long == n).map_or(n, |(_, short)| short.to_string())),
|
||||
(_, Val::Num(n)) if k == *name => based_on(*child, n).unwrap_or(Val::Num(n)),
|
||||
(_, v) => value(v, *child),
|
||||
};
|
||||
if k == *name {
|
||||
named.push((number.to_string(), v));
|
||||
} else {
|
||||
out.insert(k, v);
|
||||
}
|
||||
}
|
||||
None => {
|
||||
out.insert(k, v);
|
||||
}
|
||||
}
|
||||
}
|
||||
for (k, v) in named {
|
||||
out.entry(k).or_insert(v);
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::super::read;
|
||||
use super::*;
|
||||
|
||||
fn parse(s: &str) -> Val {
|
||||
read(s.as_bytes()).unwrap_or_else(|| panic!("{s}"))
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn named_keys_take_the_numbers_of_the_dictionary_they_are_in() {
|
||||
// `Leading` is 5 in a character style and 11 in a line; `Name` is 0 in a style and in a font
|
||||
// record.
|
||||
let named = parse(
|
||||
"/DocumentResources << /FontSet << /Resources [ << /Resource << /StreamTag /CoolTypeFont /Identifier << /Name (Serif) \
|
||||
/VersionString (Version 2) >> >> >> ] >> /StyleSheetSet << /Resources [ << /Resource << /Name (Red) /Parent 0 \
|
||||
/Features << /Leading 18.0 /FillColor << /StreamTag /SimplePaint /Color << /Type 1 /Values [ 1.0 1.0 0.0 0.0 ] >> >> >> >> >> ] >> >> \
|
||||
/DocumentObjects << /TextObjects [ << /Model << /Text (Hi) /ParagraphRun << /RunArray [ << /RunData << /ParagraphSheet 1 >> /Length 2 >> ] >> \
|
||||
/StyleRun << /RunArray [ << /RunData << /StyleSheet << /Parent 0 /Features << /FontSize 9.0 >> >> >> /Length 2 >> ] >> >> \
|
||||
/View << /Frames [ << /Resource 0 >> ] /Strikes [ << /StreamTag /FrameStrike /Transform << /Origin [ 1.0 2.0 ] >> \
|
||||
/Children [ << /StreamTag /LineStrike /Leading 14.4 /Children [ << /StreamTag /GlyphStrike /Glyphs [ 1 2 ] >> ] >> ] >> ] >> >> ] >>",
|
||||
);
|
||||
let numbered_form = parse(
|
||||
"/0 << /1 << /0 [ << /0 << /99 /CoolTypeFont /0 << /0 (Serif) /5 (Version 2) >> >> >> ] >> /5 << /0 [ << /0 << /0 (Red) /5 0 \
|
||||
/6 << /5 18.0 /53 << /99 /SimplePaint /0 << /0 1 /1 [ 1.0 1.0 0.0 0.0 ] >> >> >> >> >> ] >> >> \
|
||||
/1 << /1 [ << /0 << /0 (Hi) /5 << /0 [ << /0 << /0 << /6 1 >> >> /1 2 >> ] >> \
|
||||
/6 << /0 [ << /0 << /0 << /5 0 /6 << /1 9.0 >> >> >> /1 2 >> ] >> >> \
|
||||
/1 << /0 [ << /0 0 >> ] /2 [ << /99 /F /0 << /0 [ 1.0 2.0 ] >> \
|
||||
/6 [ << /99 /L /Leading 14.4 /6 [ << /99 /G /Glyphs [ 1 2 ] >> ] >> ] >> ] >> >> ] >>",
|
||||
);
|
||||
// A character style's `Leading` is its key 5; a line's and a glyph's keys the reader doesn't
|
||||
// look at keep their names.
|
||||
assert_eq!(numbered(named), numbered_form);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_number_wins_over_the_same_key_by_name() {
|
||||
let doc = numbered(parse("/1 << /3 << /33 1 /ParagraphDirection 0 /Justification 2 >> >>"));
|
||||
assert_eq!(doc, parse("/1 << /3 << /33 1 /0 2 >> >>"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn keys_of_dictionaries_no_reader_looks_at_keep_their_names() {
|
||||
let doc = numbered(parse("/DocumentObjects << /DocumentSettings << /Name (x) /Leading 3 >> /Elsewhere << /Name (y) >> >>"));
|
||||
assert_eq!(doc, parse("/1 << /0 << /Name (x) /Leading 3 >> /Elsewhere << /Name (y) >> >>"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_numbered_document_stays_as_it_is() {
|
||||
let s = "/0 << /1 << /0 [ << /0 << /99 /CoolTypeFont /0 << /0 (Serif) >> >> >> ] >> >> \
|
||||
/1 << /1 [ << /0 << /0 (Hi) /5 << /0 [ << /0 << /0 1 >> /1 2 >> ] >> /6 << /0 [ << /0 << /0 0 >> /1 2 >> ] >> >> \
|
||||
/1 << /0 [ << /0 0 >> ] /2 [ << /99 /PC /6 [ << /99 /F /0 << /0 [ 1.0 2.0 ] >> >> ] >> ] >> >> ] /2 << /1 12.0 >> >>";
|
||||
// A run's style written as a number under its key's number stays a number.
|
||||
assert_eq!(numbered(parse(s)), parse(s));
|
||||
}
|
||||
}
|
||||
@@ -3169,6 +3169,29 @@ fn text_document(stories: &[Story<'_>]) -> String {
|
||||
)
|
||||
}
|
||||
|
||||
/// [`text_document`] with its keys written by name, as files saved for version 11 write them: a
|
||||
/// typed node's kind under `/StreamTag`, the strikes' kinds by their long names.
|
||||
fn named_text_document(stories: &[Story<'_>]) -> String {
|
||||
let story = |(text, align, anchor, _t, start): &Story<'_>| {
|
||||
let n = text.chars().count();
|
||||
format!(
|
||||
"<< /Model << /Text ({text}) /ParagraphRun << /RunArray [ << /RunData << /ParagraphSheet << /Name () /Features << /Justification {align} >> /Parent 0 >> >> /Length {n} >> ] >> \
|
||||
/StyleRun << /RunArray [ << /RunData << /StyleSheet << /Name () /Parent 0 /Features << /Font 0 /FontSize 14.0 /FillColor << /StreamTag /CAITextPaint /Color << /Type 2 /Values [ 1.0 0.0 1.0 0.0 0.0 ] >> >> >> >> >> /Length {n} >> ] >> >> \
|
||||
/View << /Frames [ << /Resource 0 >> ] /Strikes [ << /StreamTag /PathSelectGroupCharacter /Children [ << /StreamTag /FrameStrike /Transform << /Origin [ {} {} ] >> \
|
||||
/Children [ << /StreamTag /RowColStrike /Children [ << /StreamTag /RowColStrike /Children [ << /StreamTag /LineStrike /Children [ \
|
||||
<< /StreamTag /Segment /Transform << /Origin [ {start} 0.0 ] >> /Mapping << /CharacterCount {n} >> >> ] >> ] >> ] >> ] >> ] >> ] >> >>",
|
||||
anchor.0, anchor.1
|
||||
)
|
||||
};
|
||||
let frame = |(_, _, _, t, _): &Story<'_>| format!("<< /Resource << /Point [ 0 0 ] /Data << /FrameMatrix [ 1 0 0 1 {} {} ] >> >> >>", t.0, t.1);
|
||||
format!(
|
||||
"/DocumentResources << /FontSet << /Resources [ << /Resource << /StreamTag /CoolTypeFont /Identifier << /Name (Helvetica) >> >> >> ] >> \
|
||||
/TextFrameSet << /Resources [ {} ] >> >>\n/DocumentObjects << /TextObjects [ {} ] /OriginalNormalStyleFeatures << /FontSize 12.0 >> >>\n",
|
||||
stories.iter().map(frame).collect::<Vec<_>>().join(" "),
|
||||
stories.iter().map(story).collect::<Vec<_>>().join(" ")
|
||||
)
|
||||
}
|
||||
|
||||
/// The editing copy of a 100 × 100 file with `layers` and the text `document`; its template box is
|
||||
/// centred at (50, 50).
|
||||
fn native_with_text(layers: &str, document: &str) -> String {
|
||||
@@ -3319,6 +3342,8 @@ fn type_from_the_text_document_takes_the_version_its_font_record_keeps() {
|
||||
// A record whose name differs from the copies' in punctuation alone still picks its version.
|
||||
assert_eq!(version("/0 (Vertest-Ink-Regular) /5 (1.1)"), "1.1");
|
||||
assert_eq!(version("/0 (Vertest-Ink-Regular)"), "1.0");
|
||||
// Files saved for versions older than the key write it by name among the numbered ones.
|
||||
assert_eq!(version("/0 (VertestInk-Regular) /VersionString (1.1)"), "1.1");
|
||||
}
|
||||
|
||||
/// A story with no runs, and a run or paragraph that names no style, is set in `[Normal Character
|
||||
@@ -3728,6 +3753,81 @@ fn shown_type_keeps_the_pages_and_type_off_the_page_comes_from_the_text_document
|
||||
assert!(!r.warnings.iter().any(|w| w.contains("order the page paints")), "{:?}", r.warnings);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_text_document_with_named_keys_gives_type_off_the_page_too() {
|
||||
// Files saved for version 11 write the text document's keys by name. Story 1 is off the page:
|
||||
// only the text document has it, and story 0 stays in its own text object.
|
||||
let stories = [("Hi there\r", 0, (8200.0, 8180.0), (0.0, 0.0), 0.0), ("Away\r", 0, (8300.0, 8180.0), (0.0, 0.0), 0.0)];
|
||||
let art = native_with_text(&layer("Art", true, &(text_object(0) + &text_object(1))), &named_text_document(&stories));
|
||||
let page = "0 0 0 1 setcmykcolor /Helvetica findfont 14 scalefont setfont 58.5 61.5 moveto (Hi there) show";
|
||||
let r = import(&eps(page, &art)).unwrap();
|
||||
let texts = text_of(&r.document.layers[0]);
|
||||
assert_eq!(texts.iter().map(|t| t.plain_text()).collect::<Vec<_>>(), ["Hi there", "Away"], "{:?}", r.warnings);
|
||||
let x = |t: &vectorcraft_doc::TextObject| t.xf.as_coeffs()[4];
|
||||
assert!((x(texts[0]) - 58.5).abs() < 1e-6 && (x(texts[1]) - 158.5).abs() < 1e-6, "{:?} {:?}", texts[0].xf, texts[1].xf);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_text_document_with_named_keys_reads_as_the_same_one_with_numbers() {
|
||||
// Type on a hidden layer comes only from the text document: its styles, paints, frames and
|
||||
// layout read the same whichever way its keys are written.
|
||||
let stories = [("Hi there\r", 0, (8200.0, 8180.0), (0.0, 0.0), 0.0), ("Mid\r", 2, (8191.5, 8191.5), (0.0, 0.0), -10.0)];
|
||||
let spare = |doc: &str| {
|
||||
let art = native_with_text(&(layer("Back", true, &square(10, 10)) + &layer("Spare", false, &(text_object(0) + &text_object(1)))), doc);
|
||||
let r = import(&eps(&page_square(10, 10), &art)).unwrap();
|
||||
let texts: Vec<vectorcraft_doc::TextObject> = text_of(&r.document.layers[1]).into_iter().cloned().collect();
|
||||
(texts, r.warnings)
|
||||
};
|
||||
let (numbered, _) = spare(&text_document(&stories));
|
||||
let (named, warnings) = spare(&named_text_document(&stories));
|
||||
assert_eq!(numbered.len(), 2);
|
||||
assert_eq!(named, numbered, "{warnings:?}");
|
||||
assert_eq!(named[1].para.justify, vectorcraft_doc::Justify::Center);
|
||||
let magenta = vectorcraft_color::Paint::solid(vectorcraft_color::Color::cmyk(0.0, 1.0, 0.0, 0.0));
|
||||
assert_eq!(named[0].runs[0].style.fill, magenta);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_text_document_with_named_keys_takes_a_runs_style_by_its_number() {
|
||||
// Files saved for version 11 name a run's paragraph and character styles by their numbers in the
|
||||
// document's style sets, and give point type a curve of four equal points. Styles 1 center the
|
||||
// paragraph and paint the type magenta; paragraph style 2 sets its direction right to left.
|
||||
let magenta = "<< /StreamTag /CAITextPaint /Color << /Type 2 /Values [ 1.0 0.0 1.0 0.0 0.0 ] >> >>";
|
||||
let story = |text: &str, para: u8, chars: u8, x: f64| {
|
||||
let n = text.chars().count();
|
||||
format!(
|
||||
"<< /Model << /Text ({text}) /ParagraphRun << /RunArray [ << /RunData << /ParagraphSheet {para} >> /Length {n} >> ] >> \
|
||||
/StyleRun << /RunArray [ << /RunData << /StyleSheet {chars} >> /Length {n} >> ] >> >> \
|
||||
/View << /Frames [ << /Resource 0 >> ] /Strikes [ << /StreamTag /PathSelectGroupCharacter /Children [ << /StreamTag /FrameStrike \
|
||||
/Transform << /Origin [ {x} 8180.0 ] >> /Children [ << /StreamTag /RowColStrike /Children [ << /StreamTag /RowColStrike /Children [ \
|
||||
<< /StreamTag /LineStrike /Children [ << /StreamTag /Segment /Transform << /Origin [ 0.0 0.0 ] >> >> ] >> ] >> ] >> ] >> ] >> ] >> >>"
|
||||
)
|
||||
};
|
||||
let doc = format!(
|
||||
"/DocumentResources << /FontSet << /Resources [ << /Resource << /StreamTag /CoolTypeFont /Identifier << /Name (Helvetica) >> >> >> ] >> \
|
||||
/StyleSheetSet << /Resources [ << /Resource << /Name (Normal) /Features << /Font 0 /FontSize 14.0 >> >> >> \
|
||||
<< /Resource << /Name (Magenta) /Parent 0 /Features << /FillColor {magenta} >> >> >> ] >> \
|
||||
/ParagraphSheetSet << /Resources [ << /Resource << /Name (Normal) /Features << /Justification 0 >> >> >> \
|
||||
<< /Resource << /Name (Centered) /Parent 0 /Features << /Justification 2 >> >> >> \
|
||||
<< /Resource << /Name (Leftward) /Parent 0 /Features << /ParagraphDirection 1 >> >> >> ] >> \
|
||||
/TextFrameSet << /Resources [ << /Resource << /Bezier << /Points [ 8191.5 8191.5 8191.5 8191.5 8191.5 8191.5 8191.5 8191.5 ] >> \
|
||||
/Data << /Type 0 /FrameMatrix [ 1 0 0 1 0 0 ] >> >> >> ] >> >>\n\
|
||||
/DocumentObjects << /TextObjects [ {} {} ] /OriginalNormalStyleFeatures << /FontSize 12.0 >> >>\n",
|
||||
story("Mid\r", 1, 1, 8200.0),
|
||||
story("Back\r", 2, 0, 8300.0)
|
||||
);
|
||||
let art = native_with_text(&(layer("Back", true, &square(10, 10)) + &layer("Spare", false, &(text_object(0) + &text_object(1)))), &doc);
|
||||
let r = import(&eps(&page_square(10, 10), &art)).unwrap();
|
||||
let texts = text_of(&r.document.layers[1]);
|
||||
assert_eq!(texts.iter().map(|t| t.plain_text()).collect::<Vec<_>>(), ["Mid", "Back"], "{:?}", r.warnings);
|
||||
assert_eq!(texts[0].para.justify, vectorcraft_doc::Justify::Center);
|
||||
let magenta = vectorcraft_color::Paint::solid(vectorcraft_color::Color::cmyk(0.0, 1.0, 0.0, 0.0));
|
||||
assert_eq!(texts[0].runs[0].style.fill, magenta);
|
||||
assert_eq!(texts[1].para.direction, Some(vectorcraft_doc::ParaDirection::RightToLeft));
|
||||
assert_eq!(texts[1].para.justify, vectorcraft_doc::Justify::Right);
|
||||
assert_ne!(texts[1].runs[0].style.fill, magenta);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_text_object_written_as_comments_is_read_like_any_other() {
|
||||
let doc = text_document(&[("Hi\r", 0, (8200.0, 8180.0), (0.0, 0.0), 0.0)]);
|
||||
|
||||
@@ -137,7 +137,11 @@ pub(super) fn lost_attributes(t: &TextObject) -> bool {
|
||||
|| !t.fractional_widths
|
||||
|| (matches!(t.kind, TextKind::Area { .. }) && t.area != AreaOptions::default())
|
||||
|| (matches!(t.kind, TextKind::OnPath { .. })
|
||||
&& (t.path_effect != PathEffect::default() || t.path_align != PathAlign::default() || t.path_spacing != 0.0 || end_bracket(t)))
|
||||
&& (t.path_effect != PathEffect::default()
|
||||
|| t.path_align != PathAlign::default()
|
||||
|| t.path_spacing != 0.0
|
||||
|| end_bracket(t)
|
||||
|| t.kind.starts_before_path()))
|
||||
|| several_paths(&t.kind)
|
||||
}
|
||||
|
||||
|
||||
@@ -1560,6 +1560,22 @@ fn type_on_a_path_alignment_and_end_bracket_are_warned_as_left_out() {
|
||||
assert!(warned(&|t| t.kind = TextKind::OnPath { path: along(true), start: 0.7, end: Some(0.5) }));
|
||||
// An open path's end bracket before its start bracket.
|
||||
assert!(warned(&|t| t.kind = TextKind::OnPath { path: along(false), start: 0.5, end: Some(0.3) }));
|
||||
// Type that starts before its open path: the text path holds a start on the path only.
|
||||
assert!(warned(&|t| t.kind = TextKind::OnPath { path: along(false), start: -0.25, end: None }));
|
||||
assert!(!warned(&|t| t.kind = TextKind::OnPath { path: along(false), start: 0.0, end: None }));
|
||||
// It is written starting at the path's start: the same text object as a start of 0.
|
||||
let written = |start: f64| {
|
||||
let mut doc = Document::new(300.0, 200.0);
|
||||
let layer = doc.layers[0].id;
|
||||
let mut t = TextObject::point(vectorcraft_geom::Point::ZERO, "Along", style(12.0, Paint::solid(Color::BLACK)));
|
||||
t.kind = TextKind::OnPath { path: along(false), start, end: None };
|
||||
t.xf = Affine::IDENTITY;
|
||||
let n = Node::new(doc.alloc_id(), NodeKind::Text(Box::new(t)));
|
||||
doc.insert(Some(layer), usize::MAX, n).unwrap();
|
||||
write(&doc).bytes
|
||||
};
|
||||
assert_eq!(written(-0.25), written(0.0));
|
||||
assert_ne!(written(0.25), written(0.0));
|
||||
let two = |first_closed: bool| {
|
||||
let mut path = along(first_closed);
|
||||
let pts = [(150.0, 100.0).into(), (250.0, 100.0).into()];
|
||||
|
||||
@@ -800,7 +800,7 @@ pub const UI_COMMANDS: &[(&str, &str, &str, &str)] = &[
|
||||
"Cmd+Shift+F6",
|
||||
"{} make the open document before the current one in tab order the current one, as a click on its tab does (before the first document it goes to the last, and from the Home screen to the last); each view window.newWindow opened is a step of its own; returns {index}, with `view` for a document with more than one view",
|
||||
),
|
||||
("tool.select", "Select Tool", "", "{tool: id} (see tools)"),
|
||||
("tool.select", "Select Tool", "", "{tool: id} (see tools) → {tool}; a tool that cannot be chosen returns the error"),
|
||||
(
|
||||
"tool.setOption",
|
||||
"Tool Option",
|
||||
@@ -811,7 +811,7 @@ pub const UI_COMMANDS: &[(&str, &str, &str, &str)] = &[
|
||||
"tool.toggleTouchMode",
|
||||
"Switch Selection and Drawing Modes",
|
||||
"",
|
||||
"{} from the Selection, Direct Selection, Group Selection, Magic Wand, Lasso or Perspective Selection tool, choose the drawing tool chosen last in this window (none yet: the Pencil tool); from any other tool, choose the Selection tool, remembering the tool left as the drawing tool unless it is the Hand, Rotate View, Zoom or Print Tiling tool → {tool}. A two-finger tap on the canvas runs it in the Touch workspace (Enable Touch Gestures on)",
|
||||
"{} from the Selection, Direct Selection, Group Selection, Magic Wand, Lasso or Perspective Selection tool, choose the drawing tool chosen last in this window (none yet: the Pencil tool); from any other tool, choose the Selection tool, remembering the tool left as the drawing tool unless it is the Hand, Rotate View, Zoom or Print Tiling tool → {tool}; a tool that cannot be chosen returns the error and leaves the drawing tool as it was. A two-finger tap on the canvas runs it in the Touch workspace (Enable Touch Gestures on)",
|
||||
),
|
||||
(
|
||||
"effect.dialog",
|
||||
@@ -830,7 +830,7 @@ pub const UI_COMMANDS: &[(&str, &str, &str, &str)] = &[
|
||||
"ui.cropImage",
|
||||
"Crop Image",
|
||||
"",
|
||||
"{} show a crop box on the selected image (tool `cropImage`): it starts on the part over the image's artboard; drag its handles or inside it, then Enter or the Control bar's Apply crops the image to it (object.cropImage {rect}), Escape or Cancel leaves it as it is, both back to the Selection tool. tool.setOption {key: \"rect\", value: [x, y, width, height]} sets the box",
|
||||
"{} show a crop box on the selected image (tool `cropImage`): it starts on the part over the image's artboard; drag its handles or inside it, then Enter or the Control bar's Apply crops the image to it (object.cropImage {rect}), Escape or Cancel leaves it as it is, both back to the Selection tool. tool.setOption {key: \"rect\", value: [x, y, width, height]} sets the box. → {tool}; a tool that cannot be chosen returns the error",
|
||||
),
|
||||
("effect.applyLast", "Apply Last Effect", "Cmd+Shift+E", "{}"),
|
||||
(
|
||||
@@ -1737,7 +1737,7 @@ pub const UI_COMMANDS: &[(&str, &str, &str, &str)] = &[
|
||||
"ui.runInstalledScript",
|
||||
"Run Script",
|
||||
"",
|
||||
"{path: a script's path as script.listInstalled gives it} run a script of the Scripts folder, as its File › Scripts item does (the script is checked again, then script.run runs it by its path, so its #include lines and $.evalFile read beside it), without the warning Other Script… shows; a file that is no longer a .js or .jsx file in the folder or a subfolder at most 4 levels below it, or that is or lies behind a link or is hidden, is not run, and the menu's listing is read again",
|
||||
"{path: a script's path as script.listInstalled gives it} run a script of the Scripts folder, as its File › Scripts item does (script.run with installed reads the file once, checks it again and runs that text, so its #include lines and $.evalFile read in the folder that read found), without the warning Other Script… shows; a file that is no longer a .js or .jsx file in the folder or a subfolder at most 4 levels below it, or that is or lies behind a link or is hidden, is not run, and the menu's listing is read again",
|
||||
),
|
||||
(
|
||||
"ui.checkScript",
|
||||
|
||||
@@ -139,6 +139,7 @@ impl Exporter<'_> {
|
||||
let doc = self.doc;
|
||||
let out = self.out.clone();
|
||||
let profiles = !out.standard.cmyk_only();
|
||||
let to_srgb = out.image_srgb_intent().map(|intent| crate::images::ToSrgb { intent, sixteen_bit: self.sixteen_bit });
|
||||
let img = doc.image_blob(im).filter(|_| !self.left_out.contains(key)).and_then(|blob| {
|
||||
let cmyk_image = blob.is_cmyk();
|
||||
if !cmyk_image && self.convert && doc.color_mode == vectorcraft_doc::ColorMode::Cmyk {
|
||||
@@ -148,12 +149,14 @@ impl Exporter<'_> {
|
||||
let icc = out.cmyk_image_icc(blob);
|
||||
crate::images::recode_cmyk(blob, size, c, interpolate, out.cmyk_pixels(), None, icc.as_deref())
|
||||
} else {
|
||||
crate::images::recode(&blob.bytes, size, c, interpolate, out.pixels(), profiles)
|
||||
crate::images::recode(&blob.bytes, size, c, interpolate, out.pixels(), profiles, to_srgb)
|
||||
};
|
||||
if let Some(w) = r.warning {
|
||||
let img = r.image.or_else(|| embed(blob, interpolate, self.sixteen_bit, profiles));
|
||||
// An image left out gets the warning below instead.
|
||||
for w in [r.warning, r.note].into_iter().flatten().filter(|_| img.is_some()) {
|
||||
self.warn(w);
|
||||
}
|
||||
r.image.or_else(|| embed(blob, interpolate, self.sixteen_bit, profiles))
|
||||
img
|
||||
});
|
||||
if img.is_none() {
|
||||
self.warn(UNDECODED);
|
||||
|
||||
@@ -246,7 +246,8 @@ impl Exporter<'_> {
|
||||
// Spaces are written too (text extraction needs them); control characters and soft
|
||||
// hyphens draw nothing.
|
||||
let blank = g.outline.elements().is_empty() && !coloured;
|
||||
if blank && !ch.is_some_and(|c| c.is_whitespace() && !c.is_control()) {
|
||||
// Characters before the start of an open path are not drawn, spaces included.
|
||||
if g.off_path || (blank && !ch.is_some_and(|c| c.is_whitespace() && !c.is_control())) {
|
||||
continue;
|
||||
}
|
||||
// The glyph at the font size of its vertical scale: `frame` maps that size's glyph
|
||||
|
||||
+179
-19
@@ -6,6 +6,13 @@
|
||||
//! monochrome (greyscale that is only black and white). An image already as asked (not resampled,
|
||||
//! a JPEG kept as JPEG or another kept lossless) is embedded as it is; the others are decoded,
|
||||
//! resampled and encoded again. JPEG needs opaque pixels: images with transparency stay lossless.
|
||||
//! A gray or RGB image that carries a profile the Output settings convert from ([`ToSrgb`]) is
|
||||
//! always decoded: its pixels are converted to sRGB and written without the profile, at 16 bits a
|
||||
//! sample when it has them and is otherwise kept as it is; a JPEG kept as JPEG whose samples the
|
||||
//! conversion leaves unchanged (an sRGB profile) keeps its JPEG data. A black-and-white image whose
|
||||
//! converted levels are grey is compressed as greyscale. A colour image the Output settings also
|
||||
//! convert to their destination ([`Pixels`]) goes from its profile to sRGB, then to the
|
||||
//! destination; a grey one keeps its profile there, as grey pixels are not converted.
|
||||
//!
|
||||
//! CMYK images ([`ImageBlob::cmyk`]) stay CMYK: a CMYK JPEG already as asked is embedded as it is
|
||||
//! when its numbers are kept; the others' ink amounts are resampled, converted when the Output
|
||||
@@ -22,6 +29,7 @@ use image::imageops::FilterType;
|
||||
use image::{DynamicImage, ExtendedColorType, ImageDecoder, ImageFormat, ImageReader};
|
||||
use krilla::image::{BitsPerComponent, CustomImage, Image, ImageColorspace};
|
||||
|
||||
use vectorcraft_color::cms::{IccProfile, Intent};
|
||||
use vectorcraft_doc::ImageBlob;
|
||||
use vectorcraft_doc::cmyk::{Inks, jpeg_components};
|
||||
use vectorcraft_doc::resample::{Resample, premultiply, resized, unpremultiply};
|
||||
@@ -306,6 +314,100 @@ pub(crate) struct Recoded {
|
||||
pub image: Option<Image>,
|
||||
/// Set when the image isn't compressed as asked.
|
||||
pub warning: Option<&'static str>,
|
||||
/// Set when the image's colors are converted to the destination without its ICC profile,
|
||||
/// which can't be read.
|
||||
pub note: Option<&'static str>,
|
||||
}
|
||||
|
||||
/// How [`recode`] converts a gray or RGB image that carries an ICC profile to sRGB (Convert to
|
||||
/// Destination, [`crate::output::ColorOut::image_srgb_intent`]): the destination when it is sRGB,
|
||||
/// else the step before the image's [`Pixels`] conversion.
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
pub(crate) struct ToSrgb {
|
||||
/// The rendering intent of the conversion.
|
||||
pub intent: Intent,
|
||||
/// 16-bit samples may be written (PDF 1.5 and later).
|
||||
pub sixteen_bit: bool,
|
||||
}
|
||||
|
||||
/// The ICC profile image `bytes` carries ([`source_icc`]), when it is a well-formed profile of
|
||||
/// colour space `space` (`GRAY` or `RGB `, [`valid_icc`]): `None` without one, `Some(None)` when
|
||||
/// the CMS can't read it.
|
||||
fn source_profile(bytes: &[u8], space: [u8; 4]) -> Option<Option<IccProfile>> {
|
||||
let icc = source_icc(bytes).filter(|icc| valid_icc(icc, space))?;
|
||||
Some(IccProfile::from_bytes(None, &icc).ok())
|
||||
}
|
||||
|
||||
const UNCONVERTED: &str = "an image's ICC profile couldn't be read: the image keeps it and is not converted to sRGB";
|
||||
const CONVERTED_AS_SRGB: &str = "an image's ICC profile couldn't be read: its colors are converted to the destination as sRGB";
|
||||
|
||||
const UNDECODED: &str = "an image couldn't be decoded and is not converted to sRGB";
|
||||
|
||||
/// `img` converted from ICC profile `p` to sRGB with `intent`: at 16 bits a sample when it has
|
||||
/// more than 8, else at 8, with its alpha; and whether any sample changed. A grey image stays grey, each level as the sRGB grey it converts to. `None` when the
|
||||
/// pixels can't be converted.
|
||||
fn into_srgb(img: DynamicImage, p: &IccProfile, intent: Intent) -> Option<(DynamicImage, bool)> {
|
||||
let kind = img.color();
|
||||
let deep = kind.bits_per_pixel() > 8 * u16::from(kind.channel_count());
|
||||
let to8 = |v: f32| v.round() as u8;
|
||||
let to16 = |v: f32| v.round() as u16;
|
||||
Some(match (kind.has_color(), deep) {
|
||||
(true, true) => {
|
||||
let mut s = img.into_rgba16();
|
||||
let changed = convert_samples(&mut s, 3, p, intent, 65_535.0, to16)?;
|
||||
(DynamicImage::ImageRgba16(s), changed)
|
||||
}
|
||||
(true, false) => {
|
||||
let mut s = img.into_rgba8();
|
||||
let changed = convert_samples(&mut s, 3, p, intent, 255.0, to8)?;
|
||||
(DynamicImage::ImageRgba8(s), changed)
|
||||
}
|
||||
(false, true) => {
|
||||
let mut s = img.into_luma_alpha16();
|
||||
let changed = convert_samples(&mut s, 1, p, intent, 65_535.0, to16)?;
|
||||
(DynamicImage::ImageLumaA16(s), changed)
|
||||
}
|
||||
(false, false) => {
|
||||
let mut s = img.into_luma_alpha8();
|
||||
let changed = convert_samples(&mut s, 1, p, intent, 255.0, to8)?;
|
||||
(DynamicImage::ImageLumaA8(s), changed)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/// Convert the `n` colour samples (1: grey, 3: RGB) of each pixel of `samples` (followed by an
|
||||
/// alpha sample each, which is left as it is) from ICC profile `p` to sRGB with `intent`, a run of
|
||||
/// pixels at a time; `max` is a sample's largest value, `back` turns a value of 0 to `max` into a
|
||||
/// sample. Grey pixels take the mean of the sRGB values. Whether any sample changed; `None` when
|
||||
/// the transform fails.
|
||||
fn convert_samples<T: Copy + PartialEq + Into<f32>>(
|
||||
samples: &mut [T],
|
||||
n: usize,
|
||||
p: &IccProfile,
|
||||
intent: Intent,
|
||||
max: f32,
|
||||
back: impl Fn(f32) -> T,
|
||||
) -> Option<bool> {
|
||||
const RUN: usize = 4096;
|
||||
let mut src = Vec::with_capacity(RUN * n);
|
||||
let mut dst = vec![0.0f32; RUN * 3];
|
||||
let mut changed = false;
|
||||
for run in samples.chunks_mut(RUN * (n + 1)) {
|
||||
src.clear();
|
||||
src.extend(run.chunks_exact(n + 1).flat_map(|px| px.iter().take(n).map(|v| (*v).into() / max)));
|
||||
let out = dst.get_mut(..src.len() / n * 3)?;
|
||||
p.to_srgb_pixels(&src, out, intent)?;
|
||||
for (px, rgb) in run.chunks_exact_mut(n + 1).zip(out.as_chunks::<3>().0) {
|
||||
let grey = [rgb.iter().sum::<f32>() / 3.0];
|
||||
let values: &[f32] = if n == 1 { &grey } else { rgb };
|
||||
for (v, c) in px.iter_mut().zip(values) {
|
||||
let new = back(c * max);
|
||||
changed |= new != *v;
|
||||
*v = new;
|
||||
}
|
||||
}
|
||||
}
|
||||
Some(changed)
|
||||
}
|
||||
|
||||
/// Is `img` (a grey image) only black and white?
|
||||
@@ -372,8 +474,11 @@ fn resample_channels(img: DynamicImage, r: &Rule, (w, h): (u32, u32)) -> Dynamic
|
||||
/// Encoded image `bytes` placed `size` points wide and high, as the Compression settings `c` ask:
|
||||
/// resampled when its resolution is above the threshold, compressed with ZIP or JPEG, its colours
|
||||
/// converted by `convert` (colour images). Pixels that keep their colours keep the ICC profile
|
||||
/// they carry when `profiles` allows it (not in PDF/X-1a, which allows no ICC-based color spaces).
|
||||
/// `interpolate` lets viewers smooth it. An image that can't be decoded is kept as it is.
|
||||
/// they carry when `profiles` allows it (not in PDF/X-1a, which allows no ICC-based color spaces);
|
||||
/// with `to_srgb`, pixels in a gray or RGB profile other than sRGB are converted from it to sRGB
|
||||
/// instead ([`ToSrgb`]), and colour ones then by `convert`; grey ones that `convert` leaves as
|
||||
/// they are keep their profile. `interpolate` lets viewers smooth it. An image that can't be
|
||||
/// decoded is kept as it is, with a warning when its pixels were to be converted to sRGB.
|
||||
pub(crate) fn recode(
|
||||
bytes: &[u8],
|
||||
size: (f64, f64),
|
||||
@@ -381,39 +486,94 @@ pub(crate) fn recode(
|
||||
interpolate: bool,
|
||||
convert: Option<&Pixels>,
|
||||
profiles: bool,
|
||||
to_srgb: Option<ToSrgb>,
|
||||
) -> Recoded {
|
||||
let keep = Recoded { image: None, warning: None };
|
||||
let keep = Recoded { image: None, warning: None, note: None };
|
||||
let Ok(reader) = ImageReader::new(Cursor::new(bytes)).with_guessed_format() else { return keep };
|
||||
let jpeg = reader.format() == Some(ImageFormat::Jpeg);
|
||||
let Ok(mut decoder) = reader.into_decoder() else { return keep };
|
||||
let dims = decoder.dimensions();
|
||||
use ExtendedColorType as T;
|
||||
let grey = matches!(decoder.original_color_type(), T::L1 | T::La1 | T::L2 | T::La2 | T::L4 | T::La4 | T::L8 | T::La8 | T::L16 | T::La16);
|
||||
// Pixels in a profile they are converted from are decoded, whatever the Compression settings.
|
||||
// Grey pixels, which `convert` leaves as they are, keep their profile when it converts.
|
||||
let to_srgb = to_srgb.filter(|_| !(grey && convert.is_some()));
|
||||
// Converted to the destination as sRGB, as before, when the profile can't be read.
|
||||
let mut note = None;
|
||||
let source = match to_srgb.and_then(|t| Some((source_profile(bytes, if grey { *b"GRAY" } else { *b"RGB " })?, t))) {
|
||||
Some((Some(p), t)) => Some((p, t)),
|
||||
Some((None, _)) if convert.is_some() => {
|
||||
note = Some(CONVERTED_AS_SRGB);
|
||||
None
|
||||
}
|
||||
Some((None, _)) => return Recoded { image: None, warning: Some(UNCONVERTED), note: None },
|
||||
None => None,
|
||||
};
|
||||
// Pixels that can't be decoded are kept as they are, with a warning when they were to be
|
||||
// converted.
|
||||
let keep = if source.is_some() { Recoded { image: None, warning: Some(UNDECODED), note: None } } else { keep };
|
||||
// What the header declares is decoded within the image allowance, not whatever it asks for.
|
||||
if decoder.set_limits(limits()).is_err() {
|
||||
return keep;
|
||||
}
|
||||
let dims = decoder.dimensions();
|
||||
use ExtendedColorType as T;
|
||||
let grey = matches!(decoder.original_color_type(), T::L1 | T::La1 | T::L2 | T::La2 | T::L4 | T::La4 | T::L8 | T::La8 | T::L16 | T::La16);
|
||||
let (kind, pixels) = if grey {
|
||||
let (mut kind, pixels) = if grey {
|
||||
// Only the pixels tell monochrome images from greyscale ones: decoded unless both keep it.
|
||||
let (g, m) = (rule(c, Kind::Gray), rule(c, Kind::Mono));
|
||||
if kept(&g, dims, size, jpeg) && kept(&m, dims, size, jpeg) && g.instead == m.instead {
|
||||
return Recoded { image: None, warning: g.instead };
|
||||
if source.is_none() && kept(&g, dims, size, jpeg) && kept(&m, dims, size, jpeg) && g.instead == m.instead {
|
||||
return Recoded { image: None, warning: g.instead, note: None };
|
||||
}
|
||||
let Ok(img) = DynamicImage::from_decoder(decoder) else { return keep };
|
||||
(if bilevel(&img) { Kind::Mono } else { Kind::Gray }, Some(img))
|
||||
} else {
|
||||
(Kind::Color, None)
|
||||
};
|
||||
let rule = rule(c, kind);
|
||||
let mut rule = rule(c, kind);
|
||||
let convert = convert.filter(|_| kind == Kind::Color);
|
||||
if kept(&rule, dims, size, jpeg) && convert.is_none() {
|
||||
return Recoded { image: None, warning: rule.instead };
|
||||
if kept(&rule, dims, size, jpeg) && convert.is_none() && source.is_none() {
|
||||
return Recoded { image: None, warning: rule.instead, note: None };
|
||||
}
|
||||
let Some(mut img) = pixels.or_else(|| decoded(bytes)) else { return keep };
|
||||
if let Some((p, t)) = &source {
|
||||
let srgb = match into_srgb(img, p, t.intent) {
|
||||
Some(s) => Some(s),
|
||||
None if convert.is_some() => {
|
||||
// Converted to the destination as sRGB: decoded again (a colour image), as the
|
||||
// conversion took the pixels.
|
||||
note = Some(CONVERTED_AS_SRGB);
|
||||
None
|
||||
}
|
||||
None => return Recoded { image: None, warning: Some(UNCONVERTED), note: None },
|
||||
};
|
||||
img = match srgb {
|
||||
Some((srgb, changed)) => {
|
||||
// The converted levels of a black-and-white image may be grey.
|
||||
if kind == Kind::Mono && !bilevel(&srgb) {
|
||||
(kind, rule) = (Kind::Gray, self::rule(c, Kind::Gray));
|
||||
}
|
||||
// Written as it is only when nothing converts it further.
|
||||
let as_is = kept(&rule, dims, size, jpeg) && convert.is_none();
|
||||
// A JPEG kept as JPEG whose samples the conversion leaves unchanged (an sRGB
|
||||
// profile): its JPEG data, without the profile.
|
||||
if !changed && as_is && jpeg {
|
||||
let image = Image::from_jpeg_with_icc(bytes.to_vec().into(), None, interpolate).ok();
|
||||
return Recoded { image, warning: rule.instead, note: None };
|
||||
}
|
||||
// Not resampled and lossless: written at its own depth.
|
||||
if as_is && jpeg_quality(&rule, jpeg).is_none() {
|
||||
return Recoded { image: from_decoded(srgb, None, interpolate, t.sixteen_bit), warning: rule.instead, note: None };
|
||||
}
|
||||
srgb
|
||||
}
|
||||
None => {
|
||||
let Some(img) = decoded(bytes) else { return keep };
|
||||
img
|
||||
}
|
||||
};
|
||||
}
|
||||
let Some(img) = pixels.or_else(|| decoded(bytes)) else { return keep };
|
||||
// Pixels that keep their colours keep the profile they are in; converted ones are in the
|
||||
// destination's.
|
||||
let want = if kind == Kind::Color { *b"RGB " } else { *b"GRAY" };
|
||||
let icc = (profiles && convert.is_none()).then(|| source_icc(bytes)).flatten().filter(|icc| valid_icc(icc, want));
|
||||
let icc = (profiles && convert.is_none() && source.is_none()).then(|| source_icc(bytes)).flatten().filter(|icc| valid_icc(icc, want));
|
||||
let rgb_icc = icc.is_some() && kind == Kind::Color;
|
||||
let mut rgba = resample(img, &rule, sized(&rule, dims, size)).into_rgba8();
|
||||
if kind == Kind::Mono {
|
||||
@@ -441,7 +601,7 @@ pub(crate) fn recode(
|
||||
to_jpeg(&samples, space, width, height, q, interpolate, icc.as_deref().map(Vec::as_slice))
|
||||
});
|
||||
let image = jpeg.or_else(|| from_rgba(rgba.as_raw(), width, height, interpolate, convert, icc));
|
||||
Recoded { image, warning: rule.instead }
|
||||
Recoded { image, warning: rule.instead, note }
|
||||
}
|
||||
|
||||
/// A colorized image's coverage (one sample a pixel, `w` × `h`, see
|
||||
@@ -492,16 +652,16 @@ pub(crate) fn recode_cmyk(
|
||||
let image = (u64::from(w) * u64::from(h) <= MAX_EMBED_PIXELS)
|
||||
.then(|| Image::from_jpeg_with_icc(blob.bytes.as_ref().clone().into(), icc.map(|i| i.to_vec().into()), interpolate).ok())
|
||||
.flatten();
|
||||
return Recoded { image, warning: rule.instead };
|
||||
return Recoded { image, warning: rule.instead, note: None };
|
||||
}
|
||||
}
|
||||
let Some(inks) = blob.cmyk() else {
|
||||
return Recoded { image: None, warning: Some(if jpeg { CMYK_KEPT } else { CMYK_RGB }) };
|
||||
return Recoded { image: None, warning: Some(if jpeg { CMYK_KEPT } else { CMYK_RGB }), note: None };
|
||||
};
|
||||
let (width, height) = sized(&rule, (inks.width, inks.height), size);
|
||||
let (inks, alpha) = match resampled_inks(inks, alpha, &rule, (width, height)) {
|
||||
Ok(r) => r,
|
||||
Err(warning) => return Recoded { image: None, warning },
|
||||
Err(warning) => return Recoded { image: None, warning, note: None },
|
||||
};
|
||||
let (samples, space) = match convert {
|
||||
Some(p) => p.convert(&inks),
|
||||
@@ -510,7 +670,7 @@ pub(crate) fn recode_cmyk(
|
||||
// JPEG needs opaque pixels.
|
||||
let image = jpeg_quality(&rule, jpeg).filter(|_| alpha.is_none()).and_then(|q| to_jpeg(&samples, space, width, height, q, interpolate, icc));
|
||||
let image = image.or_else(|| lossless(samples, alpha, space, width, height, interpolate, icc));
|
||||
Recoded { image, warning: rule.instead }
|
||||
Recoded { image, warning: rule.instead, note: None }
|
||||
}
|
||||
|
||||
/// Ink amounts (four bytes a pixel) and their alpha (one byte a pixel), if any.
|
||||
|
||||
@@ -10,8 +10,12 @@
|
||||
//! colours of the other model (and Lab ones), keeping the numbers of those already in it.
|
||||
//! Grey stays grey. Images (and freeform gradients' images) are converted the same way, pixel
|
||||
//! by pixel (CMYK images too: they keep their numbers unless converted, see [`CmykPixels`]);
|
||||
//! printer's marks are not. The destination is a profile name; empty, the document's
|
||||
//! profile for its colour mode.
|
||||
//! printer's marks are not. With `destination` and an sRGB destination, a gray or RGB image
|
||||
//! that carries an ICC profile is converted from it to sRGB with the same intent and written
|
||||
//! without it ([`ColorOut::image_srgb_intent`]); with `destination` and a destination its
|
||||
//! pixels are converted to (CMYK, another RGB space), a colour image is converted from its
|
||||
//! profile to sRGB and then to the destination. Without conversion it keeps its profile. The
|
||||
//! destination is a profile name; empty, the document's profile for its colour mode.
|
||||
//! - Profiles ([`ProfileInclusion`]): with `all`, with `destination`, and with `taggedSource` on a
|
||||
//! document that was assigned profiles, colours are written in ICC-based colour spaces: CMYK
|
||||
//! with the CMYK profile in effect (the destination when converting to CMYK, else the
|
||||
@@ -50,6 +54,8 @@ struct Dest {
|
||||
keep: bool,
|
||||
/// The destination is sRGB (what images are).
|
||||
srgb: bool,
|
||||
/// Convert to Destination was asked for (not Preserve Numbers, nor PDF/X-1a's own conversion).
|
||||
destination: bool,
|
||||
}
|
||||
|
||||
/// How an export writes colours (see the module documentation).
|
||||
@@ -233,11 +239,12 @@ impl ColorOut {
|
||||
};
|
||||
let cms = Cms::new(&settings).map_err(|e| PdfError::BadSetting(format!("output.destination: {e}")))?;
|
||||
// PDF/X-1a converts without being asked: as Preserve Numbers does.
|
||||
let keep = set.output.conversion != ColorConversion::Destination || cms::canonical_name(&own) == name;
|
||||
let destination = set.output.conversion == ColorConversion::Destination;
|
||||
let keep = !destination || cms::canonical_name(&own) == name;
|
||||
if model == Model::Cmyk {
|
||||
out.cmyk_profile = name.clone();
|
||||
}
|
||||
out.dest = Some(Dest { srgb: name == cms::SRGB, cms, model, keep });
|
||||
out.dest = Some(Dest { srgb: name == cms::SRGB, cms, model, keep, destination });
|
||||
}
|
||||
out.tagged = matches!(set.standard, Standard::PdfA2b | Standard::PdfX3 | Standard::PdfX4)
|
||||
|| (!set.standard.cmyk_only()
|
||||
@@ -338,6 +345,18 @@ impl ColorOut {
|
||||
.as_ref()
|
||||
}
|
||||
|
||||
/// The intent a gray or RGB image that carries an ICC profile is converted from it to sRGB
|
||||
/// with ([`crate::images::recode`]): the colour settings' intent, with Convert to Destination
|
||||
/// and an sRGB destination, also when the document's RGB space is sRGB, or a destination
|
||||
/// colour images' pixels are then converted to ([`Self::pixels`]: CMYK, another RGB space).
|
||||
/// `None` otherwise, Preserve Numbers and an RGB destination that is the document's own RGB
|
||||
/// profile (which leaves RGB colours and RGB images as they are) included: such an image
|
||||
/// keeps its profile, or its numbers are converted as sRGB ones.
|
||||
pub(crate) fn image_srgb_intent(&self) -> Option<Intent> {
|
||||
let d = self.dest.as_ref().filter(|d| d.destination)?;
|
||||
((d.model == Model::Rgb && d.srgb) || self.pixels().is_some()).then_some(self.intent)
|
||||
}
|
||||
|
||||
/// The CMYK profile CMYK image `blob` is tagged with ([`crate::images::cmyk_icc`]): the one it
|
||||
/// carries, when its numbers are kept and the Profiles setting or the standard includes source
|
||||
/// profiles. `None` otherwise, and in PDF/X-1a, whose images are untagged CMYK.
|
||||
|
||||
@@ -297,6 +297,17 @@ impl Opened {
|
||||
bleed,
|
||||
}
|
||||
}
|
||||
|
||||
/// How far page `index`'s (0-based) bleed box reaches past its trim box, `[top, bottom, left,
|
||||
/// right]` in points, as the page shows (rotated); `None` past the last page, and when the bleed
|
||||
/// box doesn't hold the trim box.
|
||||
pub fn page_bleed(&self, index: usize) -> Option<[f64; 4]> {
|
||||
let page = self.pdf.pages().get(index)?;
|
||||
let init = Affine::new(page.initial_transform(true).as_coeffs());
|
||||
let (trim, bleed) = (init.transform_rect_bbox(page_box(page, CropTo::Trim)), init.transform_rect_bbox(page_box(page, CropTo::Bleed)));
|
||||
let sides = [trim.y0 - bleed.y0, bleed.y1 - trim.y1, trim.x0 - bleed.x0, bleed.x1 - trim.x1];
|
||||
sides.iter().all(|b| b.is_finite() && *b >= 0.0).then_some(sides)
|
||||
}
|
||||
}
|
||||
|
||||
/// A PDF's pages as an import on their trim boxes sizes them ([`Opened::trim_pages`]).
|
||||
|
||||
@@ -266,7 +266,7 @@ fn images_declaring_more_than_the_allowance_are_not_decoded() {
|
||||
assert_eq!(crate::images::mask_alpha(&blob, 60_000, 60_000, false), None);
|
||||
assert_eq!(crate::images::mask_alpha(&blob, 2, 2, false), None, "another size is not decoded at all");
|
||||
let c: CompressionSettings = serde_json::from_value(json!({"gray": {"downsample": "bicubic", "ppi": 72, "abovePpi": 100}})).unwrap();
|
||||
let r = crate::images::recode(&png, (10.0, 10.0), &c, false, None, true);
|
||||
let r = crate::images::recode(&png, (10.0, 10.0), &c, false, None, true, None);
|
||||
assert!(r.image.is_none(), "kept as it is");
|
||||
}
|
||||
|
||||
@@ -529,9 +529,14 @@ fn tagged_tiffs_keep_their_icc_profile() {
|
||||
);
|
||||
}
|
||||
}
|
||||
// Converted pixels are in the destination's colours, not the image's profile.
|
||||
// Converted pixels are in the destination's colours, not the image's profile, which the CMS
|
||||
// can't read (it has no tags): they are converted as sRGB ones, with a warning.
|
||||
for destination in [vectorcraft_color::cms::DISPLAY_P3, vectorcraft_color::cms::GENERIC_CMYK] {
|
||||
let spaces = export(&tiff(&colour(), &rgb), json!({"output": {"conversion": "destination", "destination": destination}}));
|
||||
let mut settings: PdfSettings = serde_json::from_value(json!({"output": {"conversion": "destination", "destination": destination}})).unwrap();
|
||||
settings.compression.compress_text = false;
|
||||
let r = export_with_report(&tiff(&colour(), &rgb), &PdfOptions { settings, ..Default::default() }).unwrap();
|
||||
assert!(r.warnings.len() == 1 && r.warnings[0].contains("converted to the destination as sRGB"), "{destination}: {:?}", r.warnings);
|
||||
let spaces = image_spaces(&r.bytes);
|
||||
assert!(spaces.len() == 1 && spaces[0].icc.as_deref() != Some(rgb.as_slice()), "converted to {destination}: {spaces:?}");
|
||||
}
|
||||
// PDF/X-1a allows no ICC-based color spaces: a tagged gray TIFF (8 or 16 bits), PNG or JPEG is
|
||||
@@ -555,3 +560,423 @@ fn tagged_tiffs_keep_their_icc_profile() {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A gray ICC profile (version 2.1, display class) with a D50 white point whose tone curve is `trc`:
|
||||
/// one entry, a gamma (u8Fixed8), or two, the ends of a table. 192 bytes.
|
||||
fn gray_profile(trc: &[u16]) -> Vec<u8> {
|
||||
let mut icc = vec![0u8; 192];
|
||||
icc[..4].copy_from_slice(&192u32.to_be_bytes());
|
||||
icc[8..12].copy_from_slice(&[2, 0x10, 0, 0]);
|
||||
icc[12..16].copy_from_slice(b"mntr");
|
||||
icc[16..20].copy_from_slice(b"GRAY");
|
||||
icc[20..24].copy_from_slice(b"XYZ ");
|
||||
icc[36..40].copy_from_slice(b"acsp");
|
||||
let d50 = [0x0000_F6D6u32, 0x0001_0000, 0x0000_D32D];
|
||||
for (i, v) in d50.iter().enumerate() {
|
||||
icc[68 + i * 4..72 + i * 4].copy_from_slice(&v.to_be_bytes());
|
||||
}
|
||||
icc[128..132].copy_from_slice(&2u32.to_be_bytes());
|
||||
let curve_len = 12 + 2 * trc.len() as u32;
|
||||
for (i, (sig, at, len)) in [(b"kTRC", 156u32, curve_len), (b"wtpt", 172, 20)].into_iter().enumerate() {
|
||||
let e = 132 + i * 12;
|
||||
icc[e..e + 4].copy_from_slice(sig);
|
||||
icc[e + 4..e + 8].copy_from_slice(&at.to_be_bytes());
|
||||
icc[e + 8..e + 12].copy_from_slice(&len.to_be_bytes());
|
||||
}
|
||||
icc[156..160].copy_from_slice(b"curv");
|
||||
icc[164..168].copy_from_slice(&(trc.len() as u32).to_be_bytes());
|
||||
for (i, v) in trc.iter().enumerate() {
|
||||
icc[168 + i * 2..170 + i * 2].copy_from_slice(&v.to_be_bytes());
|
||||
}
|
||||
icc[172..176].copy_from_slice(b"XYZ ");
|
||||
for (i, v) in d50.iter().enumerate() {
|
||||
icc[180 + i * 4..184 + i * 4].copy_from_slice(&v.to_be_bytes());
|
||||
}
|
||||
icc
|
||||
}
|
||||
|
||||
/// The samples of `img` converted from ICC profile `icc` to sRGB with `intent`, as an image
|
||||
/// XObject holds them: RGB (or grey, the mean of the sRGB values, when `img` is grey) at 8 bits a
|
||||
/// sample, or at 16 big-endian with `deep`.
|
||||
fn srgb_samples(img: &DynamicImage, icc: &[u8], intent: vectorcraft_color::cms::Intent, deep: bool) -> Vec<u8> {
|
||||
let p = vectorcraft_color::cms::IccProfile::from_bytes(None, icc).unwrap();
|
||||
let (max, n) = (if deep { 65_535.0 } else { 255.0 }, if img.color().has_color() { 3 } else { 1 });
|
||||
let values: Vec<f32> = match (n, deep) {
|
||||
(3, true) => img.to_rgb16().into_raw().into_iter().map(f32::from).collect(),
|
||||
(3, false) => img.to_rgb8().into_raw().into_iter().map(f32::from).collect(),
|
||||
(_, true) => img.to_luma16().into_raw().into_iter().map(f32::from).collect(),
|
||||
_ => img.to_luma8().into_raw().into_iter().map(f32::from).collect(),
|
||||
};
|
||||
let mut out = vec![];
|
||||
for px in values.chunks(n) {
|
||||
let v: Vec<f32> = px.iter().map(|v| v / max).collect();
|
||||
let rgb = p.to_srgb(&v, intent).unwrap();
|
||||
let rgb = if n == 1 { vec![rgb.iter().sum::<f32>() / 3.0] } else { rgb.to_vec() };
|
||||
for c in rgb {
|
||||
let s = (c * max).round();
|
||||
if deep { out.extend_from_slice(&(s as u16).to_be_bytes()) } else { out.push(s as u8) }
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Do samples `a` and `b` (8 bits each, or 16 big-endian with `deep`) differ by at most `tolerance`?
|
||||
fn samples_close(a: &[u8], b: &[u8], deep: bool, tolerance: i32) -> bool {
|
||||
let values = |s: &[u8]| -> Vec<i32> {
|
||||
if deep { s.chunks(2).map(|v| i32::from(u16::from_be_bytes([v[0], v[1]]))).collect() } else { s.iter().map(|v| i32::from(*v)).collect() }
|
||||
};
|
||||
let (a, b) = (values(a), values(b));
|
||||
a.len() == b.len() && a.iter().zip(&b).all(|(a, b)| (a - b).abs() <= tolerance)
|
||||
}
|
||||
|
||||
/// The image XObjects of `d` exported with `settings`, which add no warning, and the file.
|
||||
fn export_images(d: &Document, settings: Value) -> (Vec<Written>, Vec<u8>) {
|
||||
let mut settings: PdfSettings = serde_json::from_value(settings).unwrap();
|
||||
settings.compression.compress_text = false;
|
||||
let r = export_with_report(d, &PdfOptions { settings, ..Default::default() }).unwrap();
|
||||
assert!(r.warnings.is_empty(), "{:?}", r.warnings);
|
||||
(image_spaces(&r.bytes), r.bytes)
|
||||
}
|
||||
|
||||
/// With Convert to Destination and an sRGB destination, a gray or RGB image that carries an ICC
|
||||
/// profile is converted from it to sRGB and written without it: an 8-bit TIFF, a 16-bit one (at
|
||||
/// 16 bits a sample from PDF 1.5 on, else at 8), a PNG, a gray TIFF, a resampled image and a JPEG
|
||||
/// kept as JPEG. Without conversion each keeps its profile. An image in an sRGB profile keeps its
|
||||
/// samples (a JPEG its JPEG data), and a black-and-white image converted to grays stays gray.
|
||||
#[test]
|
||||
fn convert_to_srgb_converts_tagged_images_from_their_profile() {
|
||||
use vectorcraft_color::cms::{DISPLAY_P3, Intent, SRGB, icc_bytes};
|
||||
// Marked version 4.2: PDF 1.7 holds ICC profiles up to that version.
|
||||
let mut p3 = icc_bytes(DISPLAY_P3).unwrap().to_vec();
|
||||
p3[8..12].copy_from_slice(&[4, 0x20, 0, 0]);
|
||||
// Gamma 1.8.
|
||||
let gray = gray_profile(&[0x01CD]);
|
||||
let to_srgb = |more: Value| {
|
||||
let mut s = json!({"output": {"conversion": "destination", "destination": SRGB}});
|
||||
s.as_object_mut().unwrap().extend(more.as_object().unwrap().clone());
|
||||
s
|
||||
};
|
||||
let device = |space: &str, bits: u8, filter: &str| vec![Written { space: space.into(), icc: None, bits, filter: filter.into(), smask: false }];
|
||||
let flate = "FlateDecode";
|
||||
let deep = DynamicImage::ImageRgb16(image::ImageBuffer::from_fn(600, 600, |x, y| {
|
||||
image::Rgb([((x * 109 + y) % 65_521) as u16, ((y * 257 + 3) ^ 0x5a) as u16, ((x * y) % 65_521) as u16])
|
||||
}));
|
||||
let cases = [
|
||||
("RGB TIFF", colour(), tagged(&colour(), &p3, ImageFormat::Tiff), "image/tiff", &p3, json!({}), device("DeviceRGB", 8, flate), false),
|
||||
("RGB PNG", colour(), tagged(&colour(), &p3, ImageFormat::Png), "image/png", &p3, json!({}), device("DeviceRGB", 8, flate), false),
|
||||
(
|
||||
"16-bit RGB TIFF",
|
||||
deep.clone(),
|
||||
tagged(&deep, &p3, ImageFormat::Tiff),
|
||||
"image/tiff",
|
||||
&p3,
|
||||
json!({"compatibility": "1.7"}),
|
||||
device("DeviceRGB", 16, flate),
|
||||
true,
|
||||
),
|
||||
(
|
||||
"16-bit RGB TIFF, PDF 1.4",
|
||||
deep.clone(),
|
||||
tagged(&deep, &p3, ImageFormat::Tiff),
|
||||
"image/tiff",
|
||||
&p3,
|
||||
json!({"compatibility": "1.4"}),
|
||||
device("DeviceRGB", 8, flate),
|
||||
false,
|
||||
),
|
||||
(
|
||||
"gray TIFF",
|
||||
colour().grayscale(),
|
||||
tagged(&colour().grayscale(), &gray, ImageFormat::Tiff),
|
||||
"image/tiff",
|
||||
&gray,
|
||||
json!({}),
|
||||
device("DeviceGray", 8, flate),
|
||||
false,
|
||||
),
|
||||
];
|
||||
for (what, img, bytes, mime, icc, more, want, sixteen) in cases {
|
||||
let d = doc(bytes, mime);
|
||||
let (spaces, pdf) = export_images(&d, to_srgb(more.clone()));
|
||||
assert_eq!(spaces, want, "{what}");
|
||||
let samples = image_samples(&pdf);
|
||||
// 16-bit samples are converted, then reduced to 8 bits before PDF 1.5.
|
||||
let deep_source = matches!(img, DynamicImage::ImageRgb16(_));
|
||||
let want = srgb_samples(&img, icc, Intent::RelativeColorimetric, deep_source);
|
||||
let want: Vec<u8> = if deep_source && !sixteen {
|
||||
want.chunks(2).map(|v| (f32::from(u16::from_be_bytes([v[0], v[1]])) / 257.0).round() as u8).collect()
|
||||
} else {
|
||||
want
|
||||
};
|
||||
assert!(samples.len() == 1 && samples_close(&samples[0], &want, sixteen, if sixteen { 2 } else { 1 }), "{what}: converted samples");
|
||||
let unconverted: Vec<u8> = match &img {
|
||||
DynamicImage::ImageRgb16(s) if sixteen => s.iter().flat_map(|v| v.to_be_bytes()).collect(),
|
||||
s if s.color().has_color() => s.to_rgb8().into_raw(),
|
||||
s => s.to_luma8().into_raw(),
|
||||
};
|
||||
assert!(!samples_close(&samples[0], &unconverted, sixteen, 0), "{what}: the samples changed");
|
||||
// Without conversion the image keeps its profile (a version 4 profile from PDF 1.5 on).
|
||||
let mut plain = more;
|
||||
plain["compatibility"] = json!("1.7");
|
||||
let (spaces, _) = export_images(&d, plain);
|
||||
assert!(
|
||||
spaces.len() == 1 && spaces[0].space == "ICCBased" && spaces[0].icc.as_deref() == Some(icc.as_slice()),
|
||||
"{what}, no conversion: {spaces:?}"
|
||||
);
|
||||
}
|
||||
// Resampled, or kept as a JPEG: converted and written without the profile.
|
||||
let resampled = to_srgb(json!({"compression": {"color": {"downsample": "bicubic", "ppi": 150, "abovePpi": 225}}}));
|
||||
let (spaces, _) = export_images(&doc(tagged(&colour(), &p3, ImageFormat::Tiff), "image/tiff"), resampled);
|
||||
assert_eq!(spaces, device("DeviceRGB", 8, flate), "resampled");
|
||||
let (spaces, _) = export_images(&doc(tagged(&colour(), &p3, ImageFormat::Jpeg), "image/jpeg"), to_srgb(json!({})));
|
||||
assert_eq!(spaces, device("DeviceRGB", 8, "DCTDecode"), "JPEG");
|
||||
// An image in an sRGB profile keeps its samples, and a JPEG its JPEG data, written without the
|
||||
// profile; so does an image whose samples the conversion leaves unchanged (black in P3).
|
||||
let srgb = icc_bytes(SRGB).unwrap().to_vec();
|
||||
let (spaces, pdf) = export_images(&doc(tagged(&colour(), &srgb, ImageFormat::Png), "image/png"), to_srgb(json!({"compatibility": "1.7"})));
|
||||
assert_eq!(spaces, device("DeviceRGB", 8, flate), "sRGB PNG");
|
||||
assert!(image_samples(&pdf) == [colour().to_rgb8().into_raw()], "sRGB PNG: the samples unchanged");
|
||||
let jpeg = tagged(&colour(), &srgb, ImageFormat::Jpeg);
|
||||
let (spaces, pdf) = export_images(&doc(jpeg.clone(), "image/jpeg"), to_srgb(json!({"compatibility": "1.7"})));
|
||||
assert_eq!(spaces, device("DeviceRGB", 8, "DCTDecode"), "sRGB JPEG");
|
||||
assert!(image_data(&pdf) == [jpeg], "sRGB JPEG: the JPEG data unchanged");
|
||||
let black = DynamicImage::ImageRgb8(image::RgbImage::new(600, 600));
|
||||
let (spaces, _) = export_images(&doc(tagged(&black, &p3, ImageFormat::Png), "image/png"), to_srgb(json!({"compatibility": "1.7"})));
|
||||
assert_eq!(spaces, device("DeviceRGB", 8, flate), "black P3 PNG");
|
||||
// A black-and-white image whose profile makes black a gray is written in those grays, not
|
||||
// thresholded back to black and white when it is resampled.
|
||||
let bilevel =
|
||||
DynamicImage::ImageLuma8(image::GrayImage::from_fn(600, 600, |x, y| image::Luma([if (x / 10 + y / 10) % 2 == 0 { 0 } else { 255 }])));
|
||||
let lifted = gray_profile(&[0x3333, 0xFFFF]);
|
||||
let resample = json!({"downsample": "bicubic", "ppi": 150, "abovePpi": 225});
|
||||
let (spaces, pdf) = export_images(
|
||||
&doc(tagged(&bilevel, &lifted, ImageFormat::Tiff), "image/tiff"),
|
||||
to_srgb(json!({"compression": {"gray": resample.clone(), "mono": resample}})),
|
||||
);
|
||||
assert_eq!(spaces, device("DeviceGray", 8, flate), "lifted black and white");
|
||||
let samples = image_samples(&pdf);
|
||||
assert!(samples.len() == 1 && samples[0].iter().all(|v| *v > 0) && samples[0].iter().any(|v| *v > 200), "lifted black and white: grays kept");
|
||||
// A well-formed profile the CMS can't read (no tags) is kept, with a warning.
|
||||
let unread = icc_profile(b"RGB ", 600);
|
||||
let mut settings: PdfSettings = serde_json::from_value(to_srgb(json!({}))).unwrap();
|
||||
settings.compression.compress_text = false;
|
||||
let r = export_with_report(&doc(tagged(&colour(), &unread, ImageFormat::Tiff), "image/tiff"), &PdfOptions { settings, ..Default::default() })
|
||||
.unwrap();
|
||||
assert!(r.warnings.iter().any(|w| w.contains("couldn't be read")), "{:?}", r.warnings);
|
||||
let spaces = image_spaces(&r.bytes);
|
||||
assert!(spaces.len() == 1 && spaces[0].icc.as_deref() == Some(unread.as_slice()), "unread profile: {spaces:?}");
|
||||
// A tagged image whose header declares more pixels than the decoding allowance is kept as it
|
||||
// is, with a warning when it was to be converted.
|
||||
let declared = |img: &DynamicImage, icc: &[u8]| {
|
||||
let mut png = tagged(img, icc, ImageFormat::Png);
|
||||
png[16..24].copy_from_slice(&[0, 0, 0x75, 0x30, 0, 0, 0x75, 0x30]);
|
||||
let mut crc = flate2::Crc::new();
|
||||
crc.update(&png[12..29]);
|
||||
png[29..33].copy_from_slice(&crc.sum().to_be_bytes());
|
||||
png
|
||||
};
|
||||
let c: CompressionSettings = serde_json::from_value(json!({})).unwrap();
|
||||
let srgb_out = Some(crate::images::ToSrgb { intent: Intent::RelativeColorimetric, sixteen_bit: true });
|
||||
for (what, png) in [("RGB", declared(&colour(), &p3)), ("gray", declared(&colour().grayscale(), &gray))] {
|
||||
let r = crate::images::recode(&png, (100.0, 100.0), &c, false, None, true, srgb_out);
|
||||
assert!(r.image.is_none() && r.warning.is_some_and(|w| w.contains("not converted to sRGB")), "{what}: {:?}", r.warning);
|
||||
let r = crate::images::recode(&png, (100.0, 100.0), &c, false, None, true, None);
|
||||
assert!(r.image.is_none() && r.warning.is_none(), "{what}, no conversion: {:?}", r.warning);
|
||||
// Too large to embed as it is too: the export leaves it out, and says only that.
|
||||
let mut settings: PdfSettings = serde_json::from_value(to_srgb(json!({}))).unwrap();
|
||||
settings.compression.compress_text = false;
|
||||
let r = export_with_report(&doc(png, "image/png"), &PdfOptions { settings, ..Default::default() }).unwrap();
|
||||
assert!(r.warnings.len() == 1 && r.warnings[0].contains("missing from the picture"), "{what}, exported: {:?}", r.warnings);
|
||||
}
|
||||
}
|
||||
|
||||
/// An RGB ICC profile (version 2.1, display class, XYZ connection space) of two lookup tables of
|
||||
/// two grid points each: the colorimetric one (`A2B1`) maps device RGB linearly onto sRGB's
|
||||
/// primaries adapted to D50, the perceptual one (`A2B0`) onto half of them. 436 bytes.
|
||||
fn rgb_two_intents() -> Vec<u8> {
|
||||
let mut icc = vec![0u8; 436];
|
||||
icc[..4].copy_from_slice(&436u32.to_be_bytes());
|
||||
icc[8..12].copy_from_slice(&[2, 0x10, 0, 0]);
|
||||
icc[12..16].copy_from_slice(b"mntr");
|
||||
icc[16..20].copy_from_slice(b"RGB ");
|
||||
icc[20..24].copy_from_slice(b"XYZ ");
|
||||
icc[36..40].copy_from_slice(b"acsp");
|
||||
let d50 = [0x0000_F6D6u32, 0x0001_0000, 0x0000_D32D];
|
||||
for (i, v) in d50.iter().enumerate() {
|
||||
icc[68 + i * 4..72 + i * 4].copy_from_slice(&v.to_be_bytes());
|
||||
}
|
||||
icc[128..132].copy_from_slice(&3u32.to_be_bytes());
|
||||
for (i, (sig, at, len)) in [(b"A2B0", 168u32, 124u32), (b"A2B1", 292, 124), (b"wtpt", 416, 20)].into_iter().enumerate() {
|
||||
let e = 132 + i * 12;
|
||||
icc[e..e + 4].copy_from_slice(sig);
|
||||
icc[e + 4..e + 8].copy_from_slice(&at.to_be_bytes());
|
||||
icc[e + 8..e + 12].copy_from_slice(&len.to_be_bytes());
|
||||
}
|
||||
let primaries = [[0.4361f32, 0.2225, 0.0139], [0.3851, 0.7169, 0.0971], [0.1431, 0.0606, 0.7141]];
|
||||
for (at, scale) in [(168usize, 0.5f32), (292, 1.0)] {
|
||||
let mut lut = vec![];
|
||||
lut.extend_from_slice(b"mft2");
|
||||
lut.extend_from_slice(&[0, 0, 0, 0, 3, 3, 2, 0]);
|
||||
// The identity matrix, as s15Fixed16 numbers.
|
||||
for v in [1u32, 0, 0, 0, 1, 0, 0, 0, 1] {
|
||||
lut.extend_from_slice(&(v << 16).to_be_bytes());
|
||||
}
|
||||
lut.extend_from_slice(&2u16.to_be_bytes());
|
||||
lut.extend_from_slice(&2u16.to_be_bytes());
|
||||
for _ in 0..3 {
|
||||
lut.extend_from_slice(&[0, 0, 0xFF, 0xFF]);
|
||||
}
|
||||
for corner in 0..8u32 {
|
||||
let on = [corner >> 2 & 1, corner >> 1 & 1, corner & 1].map(|b| b as f32);
|
||||
let xyz = primaries.iter().zip(on).fold([0.0f32; 3], |sum, (p, o)| [sum[0] + o * p[0], sum[1] + o * p[1], sum[2] + o * p[2]]);
|
||||
for v in xyz {
|
||||
lut.extend_from_slice(&((v * scale * 32_768.0).round() as u16).to_be_bytes());
|
||||
}
|
||||
}
|
||||
for _ in 0..3 {
|
||||
lut.extend_from_slice(&[0, 0, 0xFF, 0xFF]);
|
||||
}
|
||||
icc[at..at + 124].copy_from_slice(&lut);
|
||||
}
|
||||
icc[416..420].copy_from_slice(b"XYZ ");
|
||||
for (i, v) in d50.iter().enumerate() {
|
||||
icc[424 + i * 4..428 + i * 4].copy_from_slice(&v.to_be_bytes());
|
||||
}
|
||||
icc
|
||||
}
|
||||
|
||||
/// The conversion of a tagged image to an sRGB destination uses the colour settings' intent: an
|
||||
/// RGB profile with a perceptual table and a colorimetric one converts with the table the intent
|
||||
/// picks.
|
||||
#[test]
|
||||
fn convert_to_srgb_converts_images_with_the_color_settings_intent() {
|
||||
use vectorcraft_color::cms::{self, ColorSettings, Intent, SRGB};
|
||||
let icc = rgb_two_intents();
|
||||
let img = colour();
|
||||
let d = doc(tagged(&img, &icc, ImageFormat::Tiff), "image/tiff");
|
||||
let settings = json!({"output": {"conversion": "destination", "destination": SRGB}});
|
||||
let mut seen = vec![];
|
||||
for intent in [Intent::RelativeColorimetric, Intent::Perceptual] {
|
||||
let active = std::sync::Arc::new(cms::Cms::new(&ColorSettings { intent, ..ColorSettings::default() }).unwrap());
|
||||
let samples = image_samples(&cms::with_active(active, || export_images(&d, settings.clone())).1);
|
||||
let want = srgb_samples(&img, &icc, intent, false);
|
||||
assert!(samples.len() == 1 && samples_close(&samples[0], &want, false, 1), "{intent:?}: converted samples");
|
||||
seen.push(samples[0].clone());
|
||||
}
|
||||
assert!(seen[0] != seen[1], "the two intents give different samples");
|
||||
}
|
||||
|
||||
/// A 600 × 600 image of sixteen blocks of colour, saturated ones (the primaries and secondaries)
|
||||
/// among them.
|
||||
fn colour_blocks() -> DynamicImage {
|
||||
const LEVELS: [u8; 4] = [0, 90, 200, 255];
|
||||
image::RgbImage::from_fn(600, 600, |x, y| {
|
||||
let (i, j) = ((x / 150) as usize, (y / 150) as usize);
|
||||
image::Rgb([LEVELS[i], LEVELS[j], LEVELS[(i + 2 * j) % 4]])
|
||||
})
|
||||
.into()
|
||||
}
|
||||
|
||||
/// The samples (8 bits each) of `img`'s pixels as `convert` gives each one's: each distinct
|
||||
/// colour converts once.
|
||||
fn converted_samples(img: &DynamicImage, convert: impl Fn([u8; 3]) -> Vec<u8>) -> Vec<u8> {
|
||||
let mut seen: std::collections::HashMap<[u8; 3], Vec<u8>> = std::collections::HashMap::new();
|
||||
let mut out = vec![];
|
||||
for p in img.to_rgb8().pixels() {
|
||||
out.extend_from_slice(seen.entry(p.0).or_insert_with(|| convert(p.0)));
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// The largest difference between samples `a` and `b` (8 bits each) and where it is: (difference,
|
||||
/// sample index, the two samples), or the two lengths when they differ.
|
||||
fn max_diff(a: &[u8], b: &[u8]) -> String {
|
||||
if a.len() != b.len() {
|
||||
return format!("lengths {} and {}", a.len(), b.len());
|
||||
}
|
||||
let worst = a.iter().zip(b).enumerate().max_by_key(|(_, (a, b))| a.abs_diff(**b));
|
||||
worst.map_or_else(String::new, |(i, (x, y))| format!("largest difference {} at sample {i}: {x} vs {y}", x.abs_diff(*y)))
|
||||
}
|
||||
|
||||
/// With Convert to Destination and a CMYK destination, or an RGB one other than sRGB, a colour
|
||||
/// image that carries an ICC profile is converted from that profile (to sRGB, then to the
|
||||
/// destination) rather than from its numbers read as sRGB ones; an untagged image is converted
|
||||
/// from sRGB as before. A gray image keeps its profile (gray stays gray), and an RGB profile the
|
||||
/// CMS can't read leaves the numbers converted as sRGB ones, with a warning. An RGB destination
|
||||
/// other than sRGB that is the document's own RGB profile leaves RGB images as they are, so the image keeps its
|
||||
/// profile.
|
||||
#[test]
|
||||
fn convert_to_destination_converts_tagged_images_from_their_profile() {
|
||||
use vectorcraft_color::cms::{self, ColorSettings, DISPLAY_P3, GENERIC_CMYK, Intent, WIDE_GAMUT_RGB, icc_bytes};
|
||||
let intent = Intent::RelativeColorimetric;
|
||||
let active = std::sync::Arc::new(cms::Cms::new(&ColorSettings { intent, ..ColorSettings::default() }).unwrap());
|
||||
let mut p3 = icc_bytes(DISPLAY_P3).unwrap().to_vec();
|
||||
p3[8..12].copy_from_slice(&[4, 0x20, 0, 0]);
|
||||
let p3_profile = cms::IccProfile::from_bytes(None, &p3).unwrap();
|
||||
// A P3 pixel's sRGB samples.
|
||||
let from_p3 = |rgb: [u8; 3]| p3_profile.to_srgb(&rgb.map(|v| f32::from(v) / 255.0), intent).unwrap().map(|v| (v * 255.0).round() as u8);
|
||||
let img = colour_blocks();
|
||||
let tagged_png = doc(tagged(&img, &p3, ImageFormat::Png), "image/png");
|
||||
let untagged_png = doc(encoded(img.clone(), ImageFormat::Png), "image/png");
|
||||
let device = |space: &str| vec![Written { space: space.into(), icc: None, bits: 8, filter: "FlateDecode".into(), smask: false }];
|
||||
// Each destination: the space images are written in, and how an sRGB pixel's samples convert
|
||||
// to it (the tables the export builds from the destination's color settings).
|
||||
let cmyk = cms::Cms::new(&ColorSettings { intent, cmyk: GENERIC_CMYK.into(), ..ColorSettings::default() }).unwrap();
|
||||
let wide = cms::Cms::new(&ColorSettings { intent, rgb: WIDE_GAMUT_RGB.into(), ..ColorSettings::default() }).unwrap();
|
||||
let cmy = cms::ProofLut::build(|rgb| {
|
||||
let v = cmyk.srgb_to_cmyk(rgb, intent);
|
||||
[v[0], v[1], v[2]]
|
||||
});
|
||||
let black = cms::ProofLut::build(|rgb| [cmyk.srgb_to_cmyk(rgb, intent)[3], 0.0, 0.0]);
|
||||
let wide_lut = cms::ProofLut::build(|rgb| wide.srgb_to_rgb(rgb));
|
||||
let to_cmyk = |rgb: [u8; 3]| [cmy.apply8(rgb).as_slice(), &black.apply8(rgb)[..1]].concat();
|
||||
let to_wide = |rgb: [u8; 3]| wide_lut.apply8(rgb).to_vec();
|
||||
type ToDest<'a> = &'a dyn Fn([u8; 3]) -> Vec<u8>;
|
||||
let destinations: [(&str, &str, ToDest<'_>); 2] = [(GENERIC_CMYK, "DeviceCMYK", &to_cmyk), (WIDE_GAMUT_RGB, "DeviceRGB", &to_wide)];
|
||||
for (name, space, to_dest) in destinations {
|
||||
let settings = json!({"output": {"conversion": "destination", "destination": name}});
|
||||
let export = |d: &Document| cms::with_active(active.clone(), || export_images(d, settings.clone()));
|
||||
let (spaces, pdf) = export(&tagged_png);
|
||||
assert_eq!(spaces, device(space), "{name}: tagged");
|
||||
let samples = image_samples(&pdf);
|
||||
let want = converted_samples(&img, |rgb| to_dest(from_p3(rgb)));
|
||||
let as_srgb = converted_samples(&img, to_dest);
|
||||
assert!(
|
||||
samples.len() == 1 && samples_close(&samples[0], &want, false, 1),
|
||||
"{name}: converted from the profile, {}",
|
||||
max_diff(&samples[0], &want)
|
||||
);
|
||||
assert!(!samples_close(&samples[0], &as_srgb, false, 8), "{name}: not converted as sRGB");
|
||||
// An untagged image is converted from sRGB, as before.
|
||||
let (spaces, pdf) = export(&untagged_png);
|
||||
assert_eq!(spaces, device(space), "{name}: untagged");
|
||||
let samples = image_samples(&pdf);
|
||||
assert!(
|
||||
samples.len() == 1 && samples_close(&samples[0], &as_srgb, false, 1),
|
||||
"{name}: untagged converted as sRGB, {}",
|
||||
max_diff(&samples[0], &as_srgb)
|
||||
);
|
||||
}
|
||||
// A destination that is the document's own RGB profile leaves RGB images as they are: the
|
||||
// image keeps its profile, as untagged images keep their numbers.
|
||||
let own = std::sync::Arc::new(cms::Cms::new(&ColorSettings { intent, rgb: WIDE_GAMUT_RGB.into(), ..ColorSettings::default() }).unwrap());
|
||||
let settings = json!({"compatibility": "1.7", "output": {"conversion": "destination", "destination": WIDE_GAMUT_RGB}});
|
||||
let (spaces, _) = cms::with_active(own, || export_images(&tagged_png, settings));
|
||||
assert!(spaces.len() == 1 && spaces[0].space == "ICCBased" && spaces[0].icc.as_deref() == Some(p3.as_slice()), "own RGB profile: {spaces:?}");
|
||||
// A gray image keeps its gray profile in a CMYK destination.
|
||||
let gray = gray_profile(&[0x01CD]);
|
||||
let settings = json!({"output": {"conversion": "destination", "destination": GENERIC_CMYK}});
|
||||
let gray_doc = doc(tagged(&colour().grayscale(), &gray, ImageFormat::Tiff), "image/tiff");
|
||||
let (spaces, _) = cms::with_active(active.clone(), || export_images(&gray_doc, settings.clone()));
|
||||
assert!(spaces.len() == 1 && spaces[0].space == "ICCBased" && spaces[0].icc.as_deref() == Some(gray.as_slice()), "gray: {spaces:?}");
|
||||
// A profile the CMS can't read: converted as sRGB, with a warning.
|
||||
let unread = icc_profile(b"RGB ", 600);
|
||||
let mut s: PdfSettings = serde_json::from_value(settings).unwrap();
|
||||
s.compression.compress_text = false;
|
||||
let unread_doc = doc(tagged(&img, &unread, ImageFormat::Png), "image/png");
|
||||
let r = cms::with_active(active, || export_with_report(&unread_doc, &PdfOptions { settings: s, ..Default::default() })).unwrap();
|
||||
assert!(r.warnings.iter().any(|w| w.contains("converted to the destination as sRGB")), "{:?}", r.warnings);
|
||||
assert_eq!(image_spaces(&r.bytes), device("DeviceCMYK"), "unread profile");
|
||||
let as_srgb = converted_samples(&img, to_cmyk);
|
||||
assert!(image_samples(&r.bytes).first().is_some_and(|s| samples_close(s, &as_srgb, false, 1)), "unread profile: converted as sRGB");
|
||||
}
|
||||
|
||||
@@ -304,6 +304,26 @@ fn real_text_lands_within_half_a_point_of_the_outlines() {
|
||||
}
|
||||
}
|
||||
|
||||
/// Type on an open path that starts before the path writes only the characters drawn on the path,
|
||||
/// as text and as outlines.
|
||||
#[test]
|
||||
fn type_starting_before_its_open_path_writes_only_what_is_drawn() {
|
||||
let mut line = BezPath::new();
|
||||
line.move_to((20.0, 150.0));
|
||||
line.line_to((380.0, 150.0));
|
||||
let mut t = TextObject::point(Point::ZERO, "A C DEF", style(20.0));
|
||||
t.kind = TextKind::OnPath { path: PathData::from_bezpath(&line), start: 0.0, end: None };
|
||||
t.xf = Affine::IDENTITY;
|
||||
let laid = vectorcraft_text::layout(FontDb::global(), &t);
|
||||
// Four glyphs, two of them spaces, and a little more before the start: "DEF" is drawn.
|
||||
let lead: f64 = laid.glyphs.iter().take(4).map(|g| g.advance).sum::<f64>() + 1.0;
|
||||
t.kind = TextKind::OnPath { path: PathData::from_bezpath(&line), start: -lead / 360.0, end: None };
|
||||
let d = doc(vec![t]);
|
||||
assert_eq!(texts(&import_as(&pdf(&d, false).bytes, TextAs::Text)).concat(), "DEF");
|
||||
let outlined = ink(&import_as(&pdf(&d, true).bytes, TextAs::Outlines));
|
||||
assert!(outlined.x0 > 20.0 - 1e-6, "{outlined:?}");
|
||||
}
|
||||
|
||||
/// Area type 200 points wide holding `text`, its paragraphs in lists `lists`, with an 18 pt
|
||||
/// hanging indent.
|
||||
fn listed(text: &str, lists: &[Option<ParaList>]) -> TextObject {
|
||||
|
||||
@@ -117,6 +117,24 @@ fn variable_font_settings_are_not_highlighted_as_substituted_glyphs() {
|
||||
assert!(g.alternate_glyphs(&t).elements().is_empty());
|
||||
}
|
||||
|
||||
/// Type in a missing font that starts before its open path: only the characters drawn on the path
|
||||
/// are highlighted as substituted.
|
||||
#[test]
|
||||
fn characters_before_an_open_path_are_not_highlighted() {
|
||||
let _steady = fonts_steady();
|
||||
let style = CharStyle { size: 20.0, font_family: "No Such Family 5932".into(), ..Default::default() };
|
||||
let mut t = TextObject::point(Point::ZERO, "MMMM", style);
|
||||
let mut line = vectorcraft_geom::BezPath::new();
|
||||
line.move_to((0.0, 50.0));
|
||||
line.line_to((500.0, 50.0));
|
||||
t.kind = TextKind::OnPath { path: PathData::from_bezpath(&line), start: 0.0, end: None };
|
||||
let whole = text_geom(&t).substituted_fonts;
|
||||
assert!(!whole.elements().is_empty());
|
||||
t.kind = TextKind::OnPath { path: PathData::from_bezpath(&line), start: -0.9, end: None };
|
||||
let g = text_geom(&t);
|
||||
assert!(g.all.elements().is_empty() && g.substituted_fonts.elements().is_empty(), "{:?}", g.substituted_fonts);
|
||||
}
|
||||
|
||||
/// Type in a composite font draws each class of character in its part's face, which is not a
|
||||
/// substitute: Document Setup's highlight of substituted glyphs leaves it alone.
|
||||
#[test]
|
||||
|
||||
@@ -183,7 +183,8 @@ pub(crate) fn text_geom_snapped(t: &TextObject, snap: Option<Affine>) -> TextGeo
|
||||
let (mut substituted_fonts, mut substituted_glyphs, mut alternate_candidates) = (BezPath::new(), BezPath::new(), vec![]);
|
||||
let plain = t.plain_text();
|
||||
let (mut images, mut colours) = (vec![], vec![]);
|
||||
for g in &layout.glyphs {
|
||||
// Glyphs before the start of an open path draw nothing and have no highlight cells.
|
||||
for g in layout.glyphs.iter().filter(|g| !g.off_path) {
|
||||
push(&mut all, g.run, &g.outline);
|
||||
if let Some((image, xf)) = g.placed_image() {
|
||||
images.push((g.run, image.clone(), xf));
|
||||
|
||||
@@ -28,6 +28,8 @@ serde_json = { workspace = true }
|
||||
# SVGZ (gzip, pure-Rust backend: builds for wasm).
|
||||
flate2 = { workspace = true }
|
||||
image = { workspace = true }
|
||||
# Image data URLs decoded as usvg decodes them, within a byte limit (MIT OR Apache-2.0; already used by usvg).
|
||||
data-url = "0.3.2"
|
||||
# Colour / transform / length parsing for the <text> fallback (MIT OR Apache-2.0; already used by usvg).
|
||||
svgtypes = "0.16"
|
||||
simplecss = "0.2.2"
|
||||
|
||||
+25
-4
@@ -168,8 +168,8 @@ pub(crate) fn type_props(st: &CharStyle, len: &dyn Fn(f64) -> String) -> Props {
|
||||
if spacing != 0.0 {
|
||||
p.push(("letter-spacing", len(spacing / 1000.0 * st.size * st.v_scale / 100.0)));
|
||||
}
|
||||
if st.kerning.is_some() {
|
||||
// Manual kerning replaces the font's pair kerning.
|
||||
if st.kerning.is_some() || st.glyph_form.is_width() {
|
||||
// Manual kerning replaces the font's pair kerning, and a monospaced width form turns it off.
|
||||
p.push(("font-kerning", "none".into()));
|
||||
}
|
||||
let mut v: Vec<String> = st
|
||||
@@ -190,8 +190,9 @@ pub(crate) fn type_props(st: &CharStyle, len: &dyn Fn(f64) -> String) -> Props {
|
||||
.filter(|(tag, _)| tag.len() == 4 && tag.bytes().all(|b| b.is_ascii_alphanumeric()))
|
||||
.map(|(tag, value)| format!("\"{tag}\" {value}"))
|
||||
.collect();
|
||||
// Proportional Metrics: `palt` (SVG writes vertical type as outlines).
|
||||
if st.proportional_metrics {
|
||||
// Proportional Metrics: `palt` (SVG writes vertical type as outlines), not with a monospaced
|
||||
// width form, which sets the characters on a fixed width.
|
||||
if st.proportional_metrics && !st.glyph_form.is_width() {
|
||||
v.push("\"palt\" 1".into());
|
||||
}
|
||||
// The East Asian glyph form: its one feature.
|
||||
@@ -876,4 +877,24 @@ mod tests {
|
||||
assert_eq!(css_string("w\r\"</style>"), r#""w\d \"\3c /style\3e ""#);
|
||||
assert_eq!(css_string("wght"), "\"wght\"");
|
||||
}
|
||||
|
||||
/// A monospaced width form sets the characters on a fixed width: Proportional Metrics' `palt`
|
||||
/// is not written with it and the font's pair kerning is turned off, and with the other forms
|
||||
/// `palt` is written and kerning stays.
|
||||
#[test]
|
||||
fn width_forms_leave_out_proportional_metrics() {
|
||||
use vectorcraft_doc::GlyphForm;
|
||||
let prop = |form, key: &str| {
|
||||
let st = CharStyle { proportional_metrics: true, glyph_form: form, ..CharStyle::default() };
|
||||
type_props(&st, &|v| format!("{v}px")).into_iter().find(|(k, _)| *k == key).map(|(_, v)| v)
|
||||
};
|
||||
assert_eq!(prop(GlyphForm::Default, "font-feature-settings").as_deref(), Some("\"palt\" 1"));
|
||||
assert_eq!(prop(GlyphForm::Jis78, "font-feature-settings").as_deref(), Some("\"palt\" 1, \"jp78\" 1"));
|
||||
assert_eq!(prop(GlyphForm::Jis78, "font-kerning"), None);
|
||||
for form in [GlyphForm::FullWidth, GlyphForm::HalfWidth, GlyphForm::ThirdWidth, GlyphForm::QuarterWidth] {
|
||||
let tag = std::str::from_utf8(form.feature().unwrap()).unwrap();
|
||||
assert_eq!(prop(form, "font-feature-settings"), Some(format!("\"{tag}\" 1")), "{form:?}");
|
||||
assert_eq!(prop(form, "font-kerning").as_deref(), Some("none"), "{form:?}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -571,6 +571,9 @@ impl Writer<'_> {
|
||||
let base = self.char_props(&drawn.first_style(), space);
|
||||
let mut props = base.clone();
|
||||
let (from, to) = t.kind.path_span().unwrap_or((start, 1.0));
|
||||
// Type on an open path may start before the path: a negative `startOffset`, and a reader
|
||||
// does not draw the characters before the path's start, as the layout does not.
|
||||
let from = t.kind.path_text_start().unwrap_or(from);
|
||||
// The anchor sits where the layout aligns the line within its span.
|
||||
let at = match t.para_at(0).justify {
|
||||
Justify::Center | Justify::JustifyCenter => {
|
||||
|
||||
@@ -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, MAX_NESTED_SVG_READS, MAX_NESTED_SVG_TEXT};
|
||||
pub use files::{MAX_DATA_URL_BYTES, 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
|
||||
@@ -205,7 +205,7 @@ pub(crate) fn import_finding(
|
||||
symbol_suffixes: HashMap::new(),
|
||||
pattern_suffixes: HashMap::new(),
|
||||
blends: fx::blends(&xml, &scan.markers),
|
||||
filter_sources: fx::sources(&xml, &scan.markers),
|
||||
filter_sources: fx::sources(&xml, &scan.markers, |h| files.data_href(h)),
|
||||
files,
|
||||
non_scaling: found.non_scaling,
|
||||
data: found.data,
|
||||
|
||||
+388
-33
@@ -11,6 +11,12 @@
|
||||
//! A raster image, linked or a data URL, whose orientation (EXIF, or a TIFF's Orientation tag)
|
||||
//! turns or flips its pixels is turned before usvg gets it, so it is sized, fitted and stored as
|
||||
//! it is shown.
|
||||
//!
|
||||
//! usvg decodes a data URL each time it converts the element holding it, a `<use>` copy
|
||||
//! included, before the resolver sees it. So before usvg reads an SVG (the imported one, and
|
||||
//! each SVG image), each `<image>` and `<feImage>` href that is a data URL is changed to a key,
|
||||
//! and the resolver decodes the URL once, the first time a key comes, within what the data URLs
|
||||
//! may decode to together ([`MAX_DATA_URL_BYTES`]); every later use of it gets the same image.
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::path::Path;
|
||||
@@ -34,6 +40,13 @@ 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;
|
||||
|
||||
/// Most bytes the image data URLs an imported SVG shows may decode to, those in its SVG images
|
||||
/// included, all together; each URL is decoded once, however often it is shown.
|
||||
pub const MAX_DATA_URL_BYTES: u64 = 512 << 20;
|
||||
|
||||
/// What a data URL's key starts with, the URL's number following it.
|
||||
const DATA_URL_KEY: &str = "vectorcraft-data-url-";
|
||||
|
||||
/// The files an SVG's images link to.
|
||||
#[derive(Default)]
|
||||
pub(in crate::import) struct Files {
|
||||
@@ -74,6 +87,12 @@ pub(in crate::import) struct Files {
|
||||
/// 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)>>,
|
||||
/// The data URLs changed to keys before usvg read them.
|
||||
data: Mutex<DataUrls>,
|
||||
/// The bytes the image data URLs decoded to so far, whether or not they are images.
|
||||
data_bytes: AtomicU64,
|
||||
/// Images were left out for their data URLs decoding past [`MAX_DATA_URL_BYTES`].
|
||||
too_much_data: AtomicBool,
|
||||
/// What usvg makes of the SVG and of the SVG images read so far, all together ([`budget`]):
|
||||
/// elements, bytes of copied style values and selector tests.
|
||||
spent: Mutex<budget::Cost>,
|
||||
@@ -82,6 +101,34 @@ pub(in crate::import) struct Files {
|
||||
refused: Mutex<Option<SvgError>>,
|
||||
}
|
||||
|
||||
/// The hrefs changed to keys, by number: an href's key is [`DATA_URL_KEY`] and its number.
|
||||
#[derive(Default)]
|
||||
struct DataUrls {
|
||||
/// The number of each href.
|
||||
numbers: HashMap<Arc<str>, usize>,
|
||||
/// Each href: a data URL, then what it decoded to, or a link.
|
||||
urls: Vec<DataUrl>,
|
||||
/// Each href as it was written, for the filters kept as written ([`Files::data_href`]).
|
||||
hrefs: Vec<Arc<str>>,
|
||||
}
|
||||
|
||||
enum DataUrl {
|
||||
Encoded(Arc<str>),
|
||||
Decoded(Decoded),
|
||||
/// A link that starts as a key does, looked up as it is.
|
||||
Link(Arc<str>),
|
||||
}
|
||||
|
||||
/// What a data URL decoded to, as usvg gets it.
|
||||
#[derive(Clone)]
|
||||
enum Decoded {
|
||||
Raster(RasterKind, Bytes),
|
||||
/// SVG text (unpacked when it was SVGZ), read where it is shown ([`Files::svg`]).
|
||||
Svg(Arc<str>),
|
||||
/// Not an image VectorCraft reads, or left out.
|
||||
Nothing,
|
||||
}
|
||||
|
||||
/// Encoded image bytes, shared.
|
||||
type Bytes = Arc<Vec<u8>>;
|
||||
|
||||
@@ -254,7 +301,7 @@ fn key(bytes: &Arc<Vec<u8>>) -> usize {
|
||||
impl Files {
|
||||
/// Read the files the `<image>` elements of `xml` (the text `svg`) link to. Placeholders
|
||||
/// stretch to the image's box (`preserveAspectRatio="none"`), which the file fills once
|
||||
/// relinked.
|
||||
/// relinked. The data URLs of its images are changed to keys ([`Files::take_data_urls`]).
|
||||
pub(in crate::import) fn read(svg: &str, xml: &roxmltree::Document, opts: &ImportOptions, edits: &mut Edits, warnings: &mut Vec<String>) -> Self {
|
||||
let mut files = Files::default();
|
||||
let mut reading = Reading {
|
||||
@@ -269,9 +316,41 @@ impl Files {
|
||||
measured: budget::Cost::default(),
|
||||
};
|
||||
files.sources = files.links_of(svg, xml, opts.folder, None, edits, &mut reading);
|
||||
files.take_data_urls(xml, edits);
|
||||
files
|
||||
}
|
||||
|
||||
/// Change each href of the `<image>` and `<feImage>` elements of `xml` that usvg would decode
|
||||
/// as a data URL to that URL's key, numbering the URLs not seen before: usvg gives a key to
|
||||
/// the resolver as it is ([`Files::data_url`]). An href that starts as a key does, a link,
|
||||
/// gets a key too, which the resolver reads as that link.
|
||||
fn take_data_urls(&self, xml: &roxmltree::Document, edits: &mut Edits) {
|
||||
let Ok(mut data) = self.data.lock() else { return };
|
||||
let hrefs = xml
|
||||
.descendants()
|
||||
.filter(|n| n.is_element() && matches!(n.tag_name().name(), "image" | "feImage"))
|
||||
.flat_map(|n| n.attributes())
|
||||
.filter(|a| a.name() == "href" && matches!(a.namespace(), None | Some("http://www.w3.org/1999/xlink")));
|
||||
for a in hrefs {
|
||||
let url = data_url::DataUrl::process(a.value()).is_ok();
|
||||
if !url && !a.value().starts_with(DATA_URL_KEY) {
|
||||
continue;
|
||||
}
|
||||
let n = match data.numbers.get(a.value()) {
|
||||
Some(n) => *n,
|
||||
None => {
|
||||
let href: Arc<str> = Arc::from(a.value());
|
||||
let n = data.urls.len();
|
||||
data.numbers.insert(href.clone(), n);
|
||||
data.hrefs.push(href.clone());
|
||||
data.urls.push(if url { DataUrl::Encoded(href) } else { DataUrl::Link(href) });
|
||||
n
|
||||
}
|
||||
};
|
||||
edits.set_value(a, &format!("{DATA_URL_KEY}{n}"));
|
||||
}
|
||||
}
|
||||
|
||||
/// What usvg gets for each href the `<image>` elements of `xml` (the text `svg`) link to,
|
||||
/// relative paths taken from `folder`. `inside` is how deep the SVG images `xml` shows nest in
|
||||
/// it and the SVGs around it, measured when an SVG file is read (`None` for `xml` itself).
|
||||
@@ -507,6 +586,9 @@ impl Files {
|
||||
MAX_NESTED_SVG_TEXT >> 20
|
||||
));
|
||||
}
|
||||
if self.too_much_data.load(Ordering::Relaxed) {
|
||||
notes.push(format!("images were left out: together their data URLs decode to more than {} MB", MAX_DATA_URL_BYTES >> 20));
|
||||
}
|
||||
notes
|
||||
}
|
||||
|
||||
@@ -537,47 +619,145 @@ impl Files {
|
||||
}
|
||||
}
|
||||
|
||||
/// usvg's image resolver, for an SVG whose elements nest `height` deep: data URLs as usvg
|
||||
/// reads them, other hrefs only from the files read. SVG images are read here
|
||||
/// ([`Files::svg_data`], [`Files::svg`]), never by usvg's own resolver, which caps neither how
|
||||
/// deep they nest nor what they unpack to.
|
||||
/// usvg's image resolver, for an SVG whose elements nest `height` deep: data URLs by their
|
||||
/// keys ([`Files::data_url`]), and those usvg decoded itself as it reads them, within
|
||||
/// [`MAX_DATA_URL_BYTES`]; other hrefs only from the files read. SVG images are read here
|
||||
/// ([`Files::svg`]), never by usvg's own resolver, which caps neither how deep they nest nor
|
||||
/// what they unpack to.
|
||||
pub(in crate::import) fn resolver(&self, height: usize) -> ImageHrefResolver<'_> {
|
||||
self.depth.store(height, Ordering::Relaxed);
|
||||
ImageHrefResolver {
|
||||
// By type as usvg reads them, and TIFF; one without a type (`text/plain`) by its
|
||||
// bytes: one of those rasters, else SVG. A raster as [`shown`] gives it.
|
||||
resolve_data: Box::new(move |mime, bytes, opts| {
|
||||
use image::ImageFormat as F;
|
||||
let format = match mime {
|
||||
"image/jpg" | "image/jpeg" => F::Jpeg,
|
||||
"image/png" => F::Png,
|
||||
"image/gif" => F::Gif,
|
||||
"image/webp" => F::WebP,
|
||||
"image/tiff" => F::Tiff,
|
||||
"image/svg+xml" => return self.svg_data(&bytes, opts),
|
||||
"text/plain" => match image::guess_format(&bytes).ok().filter(|f| raster(*f).is_some() || *f == F::Tiff) {
|
||||
Some(format) => format,
|
||||
None => return self.svg_data(&bytes, opts),
|
||||
},
|
||||
_ => return None,
|
||||
};
|
||||
let (kind, bytes) = self.keep(shown(bytes, format)?);
|
||||
Some(kind(bytes))
|
||||
let spent = self.data_bytes.load(Ordering::Relaxed).saturating_add(bytes.len() as u64);
|
||||
if spent > MAX_DATA_URL_BYTES {
|
||||
return leave_out(&self.too_much_data, true);
|
||||
}
|
||||
self.data_bytes.store(spent, Ordering::Relaxed);
|
||||
self.show(self.decoded(mime, bytes), opts)
|
||||
}),
|
||||
resolve_string: Box::new(move |h, opts| match self.find(h)? {
|
||||
Source::Raster(kind, bytes) => Some(kind(bytes)),
|
||||
Source::Svg(file) => self.svg(&file.text.clone(), Some(file), opts),
|
||||
resolve_string: Box::new(move |h, opts| {
|
||||
let link = match self.data_number(h).map(|n| self.data_url(n, opts)) {
|
||||
Some(Ok(image)) => return image,
|
||||
Some(Err(link)) => link,
|
||||
None => Arc::from(h),
|
||||
};
|
||||
match self.find(&link)? {
|
||||
Source::Raster(kind, bytes) => Some(kind(bytes)),
|
||||
Source::Svg(file) => self.svg(&file.text.clone(), Some(file), opts),
|
||||
}
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
/// The SVG image of data `bytes` (SVG or SVGZ), as [`Files::svg`] reads it. Unpacked here,
|
||||
/// within what the SVGZ images have left, so usvg unpacks nothing itself; past it, `None`,
|
||||
/// noted.
|
||||
fn svg_data(&self, bytes: &[u8], opts: &usvg::Options) -> Option<ImageKind> {
|
||||
/// The number of the href whose key is `h`.
|
||||
fn data_number(&self, h: &str) -> Option<usize> {
|
||||
let n: usize = h.strip_prefix(DATA_URL_KEY)?.parse().ok()?;
|
||||
let known = n < self.data.lock().ok()?.urls.len();
|
||||
(known && h.len() == DATA_URL_KEY.len() + n.to_string().len()).then_some(n)
|
||||
}
|
||||
|
||||
/// The href whose key is `h`, as it was written: a filter kept as an effect holds its
|
||||
/// `<feImage>` hrefs as written, not their keys ([`super::fx::sources`]).
|
||||
pub(in crate::import) fn data_href(&self, h: &str) -> Option<Arc<str>> {
|
||||
let n = self.data_number(h)?;
|
||||
self.data.lock().ok()?.hrefs.get(n).cloned()
|
||||
}
|
||||
|
||||
/// The image of data URL `n`: decoded the first time ([`Files::decode`]), then kept; the link
|
||||
/// when href `n` is one.
|
||||
fn data_url(&self, n: usize, opts: &usvg::Options) -> Result<Option<ImageKind>, Arc<str>> {
|
||||
// The lock is let go before the image is shown: an SVG image takes it again.
|
||||
let state = match self.data.lock().ok().as_deref().and_then(|d| d.urls.get(n)) {
|
||||
Some(DataUrl::Decoded(decoded)) => Ok(decoded.clone()),
|
||||
Some(DataUrl::Encoded(url)) => Err(url.clone()),
|
||||
Some(DataUrl::Link(link)) => return Err(link.clone()),
|
||||
None => return Ok(None),
|
||||
};
|
||||
let url = match state {
|
||||
Ok(decoded) => return Ok(self.show(decoded, opts)),
|
||||
Err(url) => url,
|
||||
};
|
||||
let decoded = self.decode(&url);
|
||||
if let Ok(mut data) = self.data.lock()
|
||||
&& let Some(slot) = data.urls.get_mut(n)
|
||||
{
|
||||
*slot = DataUrl::Decoded(decoded.clone());
|
||||
}
|
||||
Ok(self.show(decoded, opts))
|
||||
}
|
||||
|
||||
/// What data URL `url` decodes to, as usvg decodes it, in what is left of
|
||||
/// [`MAX_DATA_URL_BYTES`]: decoding stops where it would go past, the image left out, noted.
|
||||
fn decode(&self, url: &str) -> Decoded {
|
||||
let Ok(parsed) = data_url::DataUrl::process(url) else { return Decoded::Nothing };
|
||||
let spent = self.data_bytes.load(Ordering::Relaxed);
|
||||
let room = MAX_DATA_URL_BYTES.saturating_sub(spent);
|
||||
let mut body = Vec::new();
|
||||
let decoded = parsed.decode(|bytes| {
|
||||
if (body.len() as u64).saturating_add(bytes.len() as u64) > room {
|
||||
return Err(());
|
||||
}
|
||||
body.extend_from_slice(bytes);
|
||||
Ok(())
|
||||
});
|
||||
self.data_bytes.store(spent.saturating_add(body.len() as u64), Ordering::Relaxed);
|
||||
match decoded {
|
||||
Ok(_) => {}
|
||||
Err(data_url::forgiving_base64::DecodeError::WriteError(())) => {
|
||||
self.too_much_data.store(true, Ordering::Relaxed);
|
||||
return Decoded::Nothing;
|
||||
}
|
||||
Err(_) => return Decoded::Nothing,
|
||||
}
|
||||
let mime = format!("{}/{}", parsed.mime_type().type_, parsed.mime_type().subtype);
|
||||
self.decoded(&mime, Arc::new(body))
|
||||
}
|
||||
|
||||
/// What usvg gets for data `bytes` of type `mime`: by type as usvg reads them, and TIFF; one
|
||||
/// without a type (`text/plain`) by its bytes: one of those rasters, else SVG. A raster as
|
||||
/// [`shown`] gives it; SVG or SVGZ as [`Files::svg_text`] gives it.
|
||||
fn decoded(&self, mime: &str, bytes: Bytes) -> Decoded {
|
||||
use image::ImageFormat as F;
|
||||
let format = match mime {
|
||||
"image/jpg" | "image/jpeg" => F::Jpeg,
|
||||
"image/png" => F::Png,
|
||||
"image/gif" => F::Gif,
|
||||
"image/webp" => F::WebP,
|
||||
"image/tiff" => F::Tiff,
|
||||
"image/svg+xml" => return self.svg_text(&bytes),
|
||||
"text/plain" => match image::guess_format(&bytes).ok().filter(|f| raster(*f).is_some() || *f == F::Tiff) {
|
||||
Some(format) => format,
|
||||
None => return self.svg_text(&bytes),
|
||||
},
|
||||
_ => return Decoded::Nothing,
|
||||
};
|
||||
match shown(bytes, format) {
|
||||
Some(shown) => {
|
||||
let (kind, bytes) = self.keep(shown);
|
||||
Decoded::Raster(kind, bytes)
|
||||
}
|
||||
None => Decoded::Nothing,
|
||||
}
|
||||
}
|
||||
|
||||
/// The text of SVG image data `bytes` (SVG or SVGZ). Unpacked here, within what the SVGZ
|
||||
/// images have left, so usvg unpacks nothing itself; past it, nothing, noted.
|
||||
fn svg_text(&self, bytes: &[u8]) -> Decoded {
|
||||
match crate::text_within(bytes, &self.unpacked) {
|
||||
Ok(text) => self.svg(&text, None, opts),
|
||||
Err(e) => leave_out(&self.too_big, matches!(e, SvgError::TooBig(_))),
|
||||
Ok(text) => Decoded::Svg(Arc::from(text.as_ref())),
|
||||
Err(e) => {
|
||||
leave_out(&self.too_big, matches!(e, SvgError::TooBig(_)));
|
||||
Decoded::Nothing
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The image usvg gets for `decoded`: an SVG image read here, where it is shown.
|
||||
fn show(&self, decoded: Decoded, opts: &usvg::Options) -> Option<ImageKind> {
|
||||
match decoded {
|
||||
Decoded::Raster(kind, bytes) => Some(kind(bytes)),
|
||||
Decoded::Svg(text) => self.svg(&text, None, opts),
|
||||
Decoded::Nothing => None,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -641,6 +821,9 @@ impl Files {
|
||||
// usvg's parser would overflow on it, as on the SVG holding it (`import`).
|
||||
super::convolve_matrices_ok(&xml).ok()?;
|
||||
self.set_spent(spent.add(cost));
|
||||
let mut edits = Edits::default();
|
||||
self.take_data_urls(&xml, &mut edits);
|
||||
let keyed = edits.apply(text);
|
||||
self.depth.store(used + height, Ordering::Relaxed);
|
||||
let within = self.within.lock().ok().map(|mut w| w.push(file)).is_some();
|
||||
// usvg's `Tree::from_data_nested`, except that hrefs to files go to the resolver.
|
||||
@@ -659,7 +842,7 @@ impl Files {
|
||||
},
|
||||
..usvg::Options::default()
|
||||
};
|
||||
let tree = usvg::Tree::from_str(text, &nested);
|
||||
let tree = usvg::Tree::from_str(&keyed, &nested);
|
||||
if within && let Ok(mut w) = self.within.lock() {
|
||||
w.pop();
|
||||
}
|
||||
@@ -1096,6 +1279,178 @@ mod tests {
|
||||
assert_eq!(files.left_out().len(), 2, "{:?}", files.left_out());
|
||||
}
|
||||
|
||||
/// A base64 data URL of `bytes` of type `mime`.
|
||||
fn data(mime: &str, bytes: &[u8]) -> String {
|
||||
format!("data:{mime};base64,{}", crate::base64_encode(bytes))
|
||||
}
|
||||
|
||||
/// A PNG of `w` × 1.
|
||||
fn png_of(w: u32) -> Vec<u8> {
|
||||
let mut out = vec![];
|
||||
image::RgbaImage::new(w, 1).write_to(&mut std::io::Cursor::new(&mut out), image::ImageFormat::Png).unwrap();
|
||||
out
|
||||
}
|
||||
|
||||
/// SVG text of 2 × 2 holding `body`.
|
||||
fn doc(body: &str) -> String {
|
||||
format!(r#"<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="2" height="2">{body}</svg>"#)
|
||||
}
|
||||
|
||||
/// `svg` read as the import reads it, its data URLs changed to keys first, `spent` bytes of
|
||||
/// [`MAX_DATA_URL_BYTES`] already decoded: the files, and the PNG bytes of the images usvg
|
||||
/// made, filters' included, in order.
|
||||
fn read_keyed(svg: &str, spent: u64) -> (Files, Vec<Arc<Vec<u8>>>) {
|
||||
let xml = super::super::parse(svg).unwrap();
|
||||
let mut edits = Edits::default();
|
||||
let files = Files::read(svg, &xml, &ImportOptions::default(), &mut edits, &mut vec![]);
|
||||
files.data_bytes.store(spent, Ordering::Relaxed);
|
||||
let text = edits.apply(svg).into_owned();
|
||||
let tree = {
|
||||
let height = depth::height(&xml, MAX_ELEMENT_DEPTH).unwrap();
|
||||
let opts = usvg::Options { image_href_resolver: files.resolver(height), ..usvg::Options::default() };
|
||||
usvg::Tree::from_str(&text, &opts).unwrap()
|
||||
};
|
||||
// Filters' images and SVG images' trees are subroots.
|
||||
fn pngs(g: &usvg::Group, out: &mut Vec<Arc<Vec<u8>>>) {
|
||||
for n in g.children() {
|
||||
match n {
|
||||
usvg::Node::Group(g) => pngs(g, out),
|
||||
usvg::Node::Image(im) => {
|
||||
if let ImageKind::PNG(b) = im.kind() {
|
||||
out.push(b.clone());
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
n.subroots(|g| pngs(g, out));
|
||||
}
|
||||
}
|
||||
let mut out = vec![];
|
||||
pngs(tree.root(), &mut out);
|
||||
(files, out)
|
||||
}
|
||||
|
||||
/// A data URL shown by many `<use>` elements is decoded once, and every use gets the same
|
||||
/// image, where usvg decoded it again for each.
|
||||
#[test]
|
||||
fn a_data_url_is_decoded_once_however_often_it_is_used() {
|
||||
let png = png();
|
||||
let uses = r##"<use href="#p"/>"##.repeat(64);
|
||||
let svg = doc(&format!(r#"<defs><image id="p" href="{}" width="1" height="1"/></defs>{uses}"#, data("image/png", &png)));
|
||||
let (files, shown) = read_keyed(&svg, 0);
|
||||
assert_eq!(shown.len(), 64);
|
||||
assert!(shown.iter().all(|b| Arc::ptr_eq(b, &shown[0]) && **b == png));
|
||||
assert_eq!(files.data_bytes.load(Ordering::Relaxed), png.len() as u64);
|
||||
assert!(files.left_out().is_empty(), "{:?}", files.left_out());
|
||||
}
|
||||
|
||||
/// The data URLs inside an SVG image get keys too: each is decoded once, together with the
|
||||
/// SVG image's own URL.
|
||||
#[test]
|
||||
fn data_urls_in_svg_images_are_decoded_once() {
|
||||
let png = png();
|
||||
let uses = r##"<use href="#p"/>"##.repeat(16);
|
||||
let inner = doc(&format!(r#"<defs><image id="p" href="{}" width="1" height="1"/></defs>{uses}"#, data("image/png", &png)));
|
||||
let svg = doc(&format!(r#"<image href="{}" width="2" height="2"/>"#, data("image/svg+xml", inner.as_bytes())));
|
||||
let (files, shown) = read_keyed(&svg, 0);
|
||||
assert_eq!(shown.len(), 16);
|
||||
assert_eq!(files.data_bytes.load(Ordering::Relaxed), (inner.len() + png.len()) as u64);
|
||||
}
|
||||
|
||||
/// An `<feImage>`'s data URL gets a key too.
|
||||
#[test]
|
||||
fn fe_image_data_urls_are_decoded_once() {
|
||||
let png = png();
|
||||
let rects: String = (1..=8).map(|i| format!(r#"<rect width="{i}" height="1" filter="url(#f)"/>"#)).collect();
|
||||
let svg = doc(&format!(r#"<filter id="f"><feImage href="{}"/></filter>{rects}"#, data("image/png", &png)));
|
||||
let (files, shown) = read_keyed(&svg, 0);
|
||||
assert_eq!(shown.len(), 8);
|
||||
assert_eq!(files.data_bytes.load(Ordering::Relaxed), png.len() as u64);
|
||||
}
|
||||
|
||||
/// Both hrefs get keys, and usvg still takes the plain `href` over `xlink:href`, in either
|
||||
/// order: only its URL is decoded.
|
||||
#[test]
|
||||
fn the_plain_href_still_wins_over_xlink_href() {
|
||||
let (one, two) = (png_of(1), png_of(2));
|
||||
let (one_url, two_url) = (data("image/png", &one), data("image/png", &two));
|
||||
for image in [
|
||||
format!(r#"<image href="{one_url}" xlink:href="{two_url}" width="1" height="1"/>"#),
|
||||
format!(r#"<image xlink:href="{two_url}" href="{one_url}" width="1" height="1"/>"#),
|
||||
] {
|
||||
let (files, shown) = read_keyed(&doc(&image), 0);
|
||||
assert_eq!(shown.len(), 1);
|
||||
assert_eq!(*shown[0], one);
|
||||
assert_eq!(files.data_bytes.load(Ordering::Relaxed), one.len() as u64);
|
||||
}
|
||||
}
|
||||
|
||||
/// An image whose data URL is written in an entity, and so in every element the entity
|
||||
/// makes, gets a key too.
|
||||
#[test]
|
||||
fn data_urls_written_in_entities_are_decoded_once() {
|
||||
let png = png();
|
||||
let uses = r##"<use href="#p"/>"##.repeat(16);
|
||||
let svg = format!(
|
||||
r#"<!DOCTYPE svg [<!ENTITY img "<image id='p' href='{}' width='1' height='1'/>">]>{}"#,
|
||||
data("image/png", &png),
|
||||
doc(&format!("<defs>&img;</defs>{uses}"))
|
||||
);
|
||||
let (files, shown) = read_keyed(&svg, 0);
|
||||
assert_eq!(shown.len(), 16);
|
||||
assert_eq!(files.data_bytes.load(Ordering::Relaxed), png.len() as u64);
|
||||
}
|
||||
|
||||
/// The data URLs decode to at most [`MAX_DATA_URL_BYTES`] together: a URL that fits what is
|
||||
/// left is decoded, and decoding stops where a URL would go past it, that image left out and
|
||||
/// noted.
|
||||
#[test]
|
||||
fn data_urls_past_the_limit_are_left_out() {
|
||||
let (small, big) = (png(), png_of(64));
|
||||
assert!(small.len() < big.len());
|
||||
let svg = doc(&format!(
|
||||
r#"<image href="{}" width="1" height="1"/><image href="{}" width="1" height="1"/>"#,
|
||||
data("image/png", &small),
|
||||
data("image/png", &big)
|
||||
));
|
||||
let spent = MAX_DATA_URL_BYTES - (small.len() + big.len()) as u64 + 1;
|
||||
let (files, shown) = read_keyed(&svg, spent);
|
||||
assert_eq!(shown.len(), 1);
|
||||
assert_eq!(*shown[0], small);
|
||||
assert!(files.data_bytes.load(Ordering::Relaxed) <= MAX_DATA_URL_BYTES);
|
||||
assert_eq!(files.left_out(), [format!("images were left out: together their data URLs decode to more than {} MB", MAX_DATA_URL_BYTES >> 20)]);
|
||||
// A data URL usvg decodes itself counts too.
|
||||
let files = Files::default();
|
||||
files.data_bytes.store(MAX_DATA_URL_BYTES, Ordering::Relaxed);
|
||||
let resolver = files.resolver(1);
|
||||
assert!((resolver.resolve_data)("image/png", Arc::new(small.clone()), &usvg::Options::default()).is_none());
|
||||
drop(resolver);
|
||||
assert_eq!(files.left_out().len(), 1);
|
||||
}
|
||||
|
||||
/// A link that looks like a key, spelled plainly or with character references, in the
|
||||
/// imported SVG or in an SVG image, stays that link: it never shows a data URL's image.
|
||||
#[test]
|
||||
fn links_that_look_like_keys_stay_links() {
|
||||
let png = png();
|
||||
let url = data("image/png", &png);
|
||||
let inner = doc(&format!(r#"<image href="{DATA_URL_KEY}0" width="1" height="1"/>"#));
|
||||
let svg = doc(&format!(
|
||||
r#"<image href="{url}" width="1" height="1"/><image href="{DATA_URL_KEY}0" width="1" height="1"/><image href="v{}0" width="1" height="1"/><image href="{}" width="2" height="2"/>"#,
|
||||
&DATA_URL_KEY[1..],
|
||||
data("image/svg+xml", inner.as_bytes())
|
||||
));
|
||||
let (files, shown) = read_keyed(&svg, 0);
|
||||
// The data URL's image, then the placeholder of the missing file, twice; the SVG image's
|
||||
// link names no file there, so it shows nothing.
|
||||
assert_eq!(shown.len(), 3);
|
||||
assert_eq!(*shown[0], png);
|
||||
assert!(shown[1..].iter().all(|b| **b != png && files.links.get(&key(b)).is_some_and(|l| l.missing)));
|
||||
assert_eq!(files.data_number(&format!("{DATA_URL_KEY}00")), None);
|
||||
assert_eq!(files.data_number(&format!("{DATA_URL_KEY}+0")), None);
|
||||
assert_eq!(files.data_number(&format!("{DATA_URL_KEY}9")), None);
|
||||
}
|
||||
|
||||
/// 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.
|
||||
|
||||
@@ -61,8 +61,10 @@ impl Source {
|
||||
|
||||
/// Each `<filter>` element with an id, as written, by id, with the document's style sheets read
|
||||
/// once for all of them. A source is checked and read by usvg only when art uses it, once.
|
||||
/// `markers` is the text with its data URLs left out ([`super::depth::prescan`]).
|
||||
pub(super) fn sources(xml: &roxmltree::Document, markers: &str) -> HashMap<String, Source> {
|
||||
/// `markers` is the text with its data URLs left out ([`super::depth::prescan`]). `href` gives the
|
||||
/// href, as written, of each key the prepass wrote in place of an `<feImage>` href
|
||||
/// ([`super::files::Files::data_href`]): the source holds that href.
|
||||
pub(super) fn sources(xml: &roxmltree::Document, markers: &str, href: impl Fn(&str) -> Option<Arc<str>>) -> HashMap<String, Source> {
|
||||
if !markers.contains("<filter") && !markers.contains(":filter") {
|
||||
return HashMap::new();
|
||||
}
|
||||
@@ -74,13 +76,40 @@ pub(super) fn sources(xml: &roxmltree::Document, markers: &str) -> HashMap<Strin
|
||||
.filter_map(|(order, n)| {
|
||||
let id = n.attribute("id")?.to_string();
|
||||
let primitives = n.children().filter(roxmltree::Node::is_element).map(|p| p.tag_name().name().to_string()).collect();
|
||||
let xml = vectorcraft_effects::svg_filter::element_xml(n, text)?;
|
||||
let xml = hrefs_as_written(n, vectorcraft_effects::svg_filter::element_xml(n, text)?, &href);
|
||||
let styled = vectorcraft_effects::svg_filter::depends_on_styles(n, &styles);
|
||||
Some((id, Source { xml, primitives, order, styled, ..Default::default() }))
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// `xml`, the text of filter `n` ([`vectorcraft_effects::svg_filter::element_xml`]), with each
|
||||
/// `<feImage>` href that `href` gives a written href for put back as written, XML-escaped.
|
||||
fn hrefs_as_written(n: roxmltree::Node, mut xml: String, href: &impl Fn(&str) -> Option<Arc<str>>) -> String {
|
||||
// `element_xml` adds namespace declarations to the start tag only, ahead of every child.
|
||||
let (start, end) = (n.range().start, n.range().end);
|
||||
let Some(shift) = xml.len().checked_sub(end - start) else { return xml };
|
||||
let mut written: Vec<_> = n
|
||||
.descendants()
|
||||
.filter(|p| p.is_element() && p.tag_name().name() == "feImage")
|
||||
.flat_map(|p| p.attributes())
|
||||
.filter(|a| a.name() == "href" && matches!(a.namespace(), None | Some("http://www.w3.org/1999/xlink")))
|
||||
.filter_map(|a| {
|
||||
let r = a.range_value();
|
||||
// A value written through an entity lies outside the element's text: it stays.
|
||||
(r.start > start && r.end < end).then_some(())?;
|
||||
Some((r.start - start + shift..r.end - start + shift, a.value(), href(a.value())?))
|
||||
})
|
||||
.collect();
|
||||
written.sort_by_key(|(r, ..)| std::cmp::Reverse(r.start));
|
||||
for (r, key, h) in written {
|
||||
if xml.get(r.clone()) == Some(key) {
|
||||
xml.replace_range(r, &crate::xml_escape(&h));
|
||||
}
|
||||
}
|
||||
xml
|
||||
}
|
||||
|
||||
/// The element name of primitive `k` (`feGaussianBlur`).
|
||||
fn element_name(k: &Kind) -> String {
|
||||
let debug = format!("{k:?}");
|
||||
|
||||
@@ -95,6 +95,11 @@ impl Edits {
|
||||
}
|
||||
}
|
||||
|
||||
/// Set the value of attribute `a` to `value` (XML-escaped already).
|
||||
pub(crate) fn set_value(&mut self, a: roxmltree::Attribute, value: &str) {
|
||||
self.list.push((a.range_value(), value.to_string()));
|
||||
}
|
||||
|
||||
/// Add the declaration `decl` (XML-escaped already) to the `style` of element `n` (in `svg`),
|
||||
/// after the ones already there (so it wins over them and over style sheet rules).
|
||||
fn style(&mut self, svg: &str, n: XNode, decl: &str) {
|
||||
@@ -163,7 +168,8 @@ fn named_made_up_ids(xml: &roxmltree::Document) -> HashSet<usize> {
|
||||
/// * so do `<circle>` and `<ellipse>` elements, recorded: usvg makes their arcs into a varying
|
||||
/// number of curves, and they come back as the ellipse VectorCraft draws (not in a file whose
|
||||
/// style sheet has an attribute selector);
|
||||
/// * linked files are read ([`files`]);
|
||||
/// * linked files are read, and the data URLs of `<image>` and `<feImage>` elements are each
|
||||
/// changed to a key that the resolver decodes once ([`files`]);
|
||||
/// * percentage lengths in a `<symbol>` with a `viewBox` become numbers ([`symbol_units`]);
|
||||
/// * what style sheet rules with a `:not()` set goes in `style` attributes ([`not_rules`]).
|
||||
///
|
||||
@@ -190,7 +196,7 @@ pub(super) fn prepass<'s>(
|
||||
&& !events
|
||||
&& !foreign_attrs
|
||||
&& !ellipses
|
||||
&& !["<a", "display", "<use", ":use", "<image", ":image", vector_effect::NON_SCALING, "data-", "class", "groupmode", "script"]
|
||||
&& !["<a", "display", "<use", ":use", "<image", ":image", "feImage", vector_effect::NON_SCALING, "data-", "class", "groupmode", "script"]
|
||||
.iter()
|
||||
.any(|t| markers.contains(t))
|
||||
{
|
||||
|
||||
@@ -1473,16 +1473,26 @@ impl Ctx {
|
||||
let offset = tp.attribute("startOffset").and_then(|v| self.length(v, em, len)).unwrap_or(0.0) + dx[0];
|
||||
let width = if anchor > 0.0 { advance(&point_text(&cells)) } else { 0.0 };
|
||||
// On a path of one closed subpath, text that starts before the path's start or past its
|
||||
// end wraps around the path. Otherwise it starts at the path's start or end.
|
||||
// end wraps around the path. On an open path text may start before the path's start,
|
||||
// and its characters before the start are not drawn; text that starts past the end
|
||||
// starts at the end. Other paths start it at the path's start or end.
|
||||
let path = PathData::from_bezpath(&bp);
|
||||
let wraps = path.subpaths.len() == 1 && path.is_closed();
|
||||
let open = !path.is_closed();
|
||||
let wrap = |s: f64| {
|
||||
// `rem_euclid` can round a tiny negative value up to 1.
|
||||
let s = s.rem_euclid(1.0);
|
||||
if s < 1.0 { s } else { 0.0 }
|
||||
};
|
||||
let start =
|
||||
Some((offset - anchor * width) / len).filter(|s| s.is_finite()).map_or(0.0, |s| if wraps { wrap(s) } else { s.clamp(0.0, 1.0) });
|
||||
let start = Some((offset - anchor * width) / len).filter(|s| s.is_finite()).map_or(0.0, |s| {
|
||||
if wraps {
|
||||
wrap(s)
|
||||
} else if open {
|
||||
s.clamp(-vectorcraft_doc::PATH_LEAD_MAX, 1.0)
|
||||
} else {
|
||||
s.clamp(0.0, 1.0)
|
||||
}
|
||||
});
|
||||
obj.kind = TextKind::OnPath { path, start, end: None };
|
||||
obj.xf = Affine::IDENTITY;
|
||||
} else if vertical {
|
||||
|
||||
@@ -533,7 +533,10 @@ 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_NESTED_SVG_READS, MAX_NESTED_SVG_TEXT, MAX_NODES};
|
||||
pub use import::{
|
||||
MAX_DATA_URL_BYTES, 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> {
|
||||
|
||||
@@ -949,6 +949,21 @@ 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"))));
|
||||
}
|
||||
|
||||
/// A data URL shown by `<use>` elements, in the SVG and in an SVG image of it, imports as one
|
||||
/// image shown at each use.
|
||||
#[test]
|
||||
fn data_url_images_shown_by_uses_import_at_each_use() {
|
||||
let png = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR4nGNgYGD4DwABBAEAwS2OUAAAAABJRU5ErkJggg==";
|
||||
let image = format!(r#"<image id="p" href="data:image/png;base64,{png}" width="4" height="4"/>"#);
|
||||
let uses = r##"<use href="#p" x="5"/>"##.repeat(3);
|
||||
let inner = format!(r#"<svg {SVG_NS} width="20" height="10"><defs>{image}</defs>{uses}</svg>"#);
|
||||
let (d, w) = import_with_report(&svg(&format!("<defs>{image}</defs>{uses}{}", image_of("image/svg+xml", &inner)))).unwrap();
|
||||
assert!(w.is_empty(), "{w:?}");
|
||||
let found = images(&d);
|
||||
assert_eq!(found.len(), 6);
|
||||
assert!(found.iter().all(|(_, im)| im.key == found[0].1.key && (im.width, im.height) == (1, 1)));
|
||||
}
|
||||
|
||||
/// 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"/>"#];
|
||||
|
||||
@@ -335,10 +335,12 @@ fn text_on_a_closed_path_wraps_past_the_path_start() {
|
||||
// A negative startOffset, in user units or as a percentage, counts back from the path's end.
|
||||
assert!(close(start_of(t[4]), 0.9, 1e-9), "{}", start_of(t[4]));
|
||||
assert!(close(start_of(t[5]), 0.9, 1e-9), "{}", start_of(t[5]));
|
||||
// A path of two closed subpaths, and an open path, do not wrap: the text starts at the path's
|
||||
// start or end.
|
||||
// A path of two closed subpaths does not wrap: the text starts at the path's start. An open
|
||||
// path does not wrap either: text anchored before its start starts there
|
||||
// (`text_on_an_open_path_starts_before_the_path`), and text offset past its end starts at
|
||||
// its end.
|
||||
assert_eq!(start_of(t[6]), 0.0);
|
||||
assert_eq!(start_of(t[7]), 0.0);
|
||||
assert!(close(start_of(t[7]), -width / 300.0, 1e-6), "{} {width}", start_of(t[7]));
|
||||
assert_eq!(start_of(t[8]), 1.0);
|
||||
|
||||
// Laid out, the end-anchored text draws every glyph up the left side and ends at the start
|
||||
@@ -372,6 +374,52 @@ fn text_on_a_closed_path_wraps_past_the_path_start() {
|
||||
assert!(lay.glyphs.iter().enumerate().all(|(i, g)| g.byte == i), "{:?}", lay.glyphs.iter().map(|g| g.byte).collect::<Vec<_>>());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn text_on_an_open_path_starts_before_the_path() {
|
||||
// An open path 300 long. Text anchored, or offset, before its start starts that far before it,
|
||||
// and only the characters whose middle is on the path are drawn; the text keeps every character.
|
||||
let d = open(
|
||||
r##"<path id="o" d="M50 50 H150 V150 H50" fill="none" stroke="black"/>
|
||||
<text font-size="20" text-anchor="end"><textPath href="#o">Wave</textPath></text>
|
||||
<text font-size="20" text-anchor="middle"><textPath href="#o">Wave</textPath></text>
|
||||
<text font-size="20"><textPath href="#o" startOffset="-20%">Wave</textPath></text>
|
||||
<text font-size="20"><textPath href="#o">Wave</textPath></text>"##,
|
||||
);
|
||||
let t = texts(&d);
|
||||
assert_eq!(t.len(), 4);
|
||||
let start_of = |t: &TextObject| match t.kind {
|
||||
TextKind::OnPath { start, .. } => start,
|
||||
_ => panic!("{:?}", t.kind),
|
||||
};
|
||||
let whole = layout(FontDb::global(), t[3]);
|
||||
let width: f64 = whole.glyphs.iter().map(|g| g.advance).sum();
|
||||
assert!(width > 0.0 && width < 60.0, "{width}");
|
||||
assert!(close(start_of(t[0]), -width / 300.0, 1e-6), "{} {width}", start_of(t[0]));
|
||||
assert!(close(start_of(t[1]), -width / 600.0, 1e-6), "{} {width}", start_of(t[1]));
|
||||
assert!(close(start_of(t[2]), -0.2, 1e-9), "{}", start_of(t[2]));
|
||||
let drawn = |t: &TextObject| {
|
||||
let lay = layout(FontDb::global(), t);
|
||||
assert!(!lay.overflow && lay.glyphs.len() == 4, "{} {}", lay.overflow, lay.glyphs.len());
|
||||
assert_eq!(t.plain_text(), "Wave");
|
||||
lay.glyphs.iter().map(|g| !g.outline.elements().is_empty()).collect::<Vec<_>>()
|
||||
};
|
||||
// Ending at the path's start: nothing is drawn.
|
||||
assert_eq!(drawn(t[0]), [false; 4]);
|
||||
// Centered on the path's start: the characters whose middle is past the start are drawn, the
|
||||
// last ones of the text.
|
||||
// (A hidden glyph has no advance: the advances are the ones the text takes on the path.)
|
||||
let middle = drawn(t[1]);
|
||||
let mut x = -width / 2.0;
|
||||
for (g, shown) in whole.glyphs.iter().zip(&middle) {
|
||||
assert_eq!(*shown, x + g.advance / 2.0 >= 0.0, "{x} {middle:?}");
|
||||
x += g.advance;
|
||||
}
|
||||
assert!(middle.iter().any(|d| *d) && !middle[0] && middle[3], "{middle:?}");
|
||||
// 60 before the start, more than the text's width: nothing is drawn.
|
||||
assert_eq!(drawn(t[2]), [false; 4]);
|
||||
assert_eq!(drawn(t[3]), [true; 4]);
|
||||
}
|
||||
|
||||
/// The unit vector along a glyph's baseline.
|
||||
fn baseline_dir(angle: f64) -> vectorcraft_geom::Vec2 {
|
||||
vectorcraft_geom::Vec2::new(angle.cos(), angle.sin())
|
||||
|
||||
@@ -478,6 +478,31 @@ fn aligned_text_on_path_anchors_within_its_span() {
|
||||
}
|
||||
}
|
||||
|
||||
/// Type on an open path that starts before the path is written with a negative `startOffset` and
|
||||
/// reads back starting there, with the same characters drawn.
|
||||
#[test]
|
||||
fn text_on_an_open_path_starting_before_it_round_trips() {
|
||||
let mut line = vectorcraft_geom::BezPath::new();
|
||||
line.move_to((0.0, 100.0));
|
||||
line.line_to((200.0, 100.0));
|
||||
let mut d = Document::new(200.0, 200.0);
|
||||
let mut t = TextObject::point(Point::ZERO, "Before", CharStyle { size: 14.0, ..CharStyle::default() });
|
||||
t.kind = TextKind::OnPath { path: PathData::from_bezpath(&line), start: -0.1, end: None };
|
||||
let shown = |t: &TextObject| {
|
||||
vectorcraft_text::layout(vectorcraft_text::FontDb::global(), t).glyphs.iter().map(|g| !g.outline.elements().is_empty()).collect::<Vec<_>>()
|
||||
};
|
||||
let before = shown(&t);
|
||||
assert!(before.contains(&true) && before.contains(&false), "{before:?}");
|
||||
let id = d.alloc_id();
|
||||
let svg = export(&doc_with(vec![Node::new(id, NodeKind::Text(Box::new(t)))]), &ExportOptions::default());
|
||||
assert!(svg.contains("startOffset=\"-10%\""), "{svg}");
|
||||
let r = import(&svg).unwrap();
|
||||
let NodeKind::Text(t) = &art(&r)[0].kind else { panic!() };
|
||||
let TextKind::OnPath { start, .. } = &t.kind else { panic!("not type on a path: {:?}", t.kind) };
|
||||
assert!((start + 0.1).abs() < 1e-6, "start {start}");
|
||||
assert_eq!(shown(t), before);
|
||||
}
|
||||
|
||||
/// An end bracket, Align to Path or Spacing, which `<textPath>` can't express, keep their look as
|
||||
/// outlines (#429).
|
||||
#[test]
|
||||
|
||||
@@ -295,7 +295,8 @@ impl OtFeatures {
|
||||
let own = self.with_tags(listed.iter().map(String::as_str));
|
||||
let s = &own;
|
||||
let mut v = Vec::with_capacity(8);
|
||||
if st.kerning.is_some() {
|
||||
// A monospaced width form turns the font's pair kerning off as well.
|
||||
if st.kerning.is_some() || st.glyph_form.is_width() {
|
||||
v.push(f(b"kern", false));
|
||||
}
|
||||
let liga = s.ligatures && (!ligatures_suppressed_by(st.tracking) || explicit_ligatures(listed));
|
||||
@@ -342,15 +343,20 @@ impl OtFeatures {
|
||||
// Kerning for the glyphs `palt` makes proportional (OpenType `apkn`), with automatic
|
||||
// kerning only.
|
||||
v.push(f(b"apkn", st.kerning.is_none()));
|
||||
} else if st.glyph_form.is_width() {
|
||||
// Listed as off, not left out: in runs shaped together with proportional text, a
|
||||
// feature left out is set back to the shaper's default, which for `apkn` is on.
|
||||
v.push(f(b"apkn", false));
|
||||
}
|
||||
v
|
||||
}
|
||||
|
||||
/// Does text in style `st` take proportional widths (`palt`)? Proportional Metrics in
|
||||
/// horizontal type; vertical type takes proportional heights instead
|
||||
/// horizontal type, except with a monospaced width form ([`vectorcraft_doc::GlyphForm::is_width`]),
|
||||
/// which sets the characters on a fixed width; vertical type takes proportional heights instead
|
||||
/// ([`Self::proportional_vertical`]).
|
||||
pub(crate) fn proportional(&self, st: &CharStyle) -> bool {
|
||||
st.proportional_metrics && !self.vertical
|
||||
st.proportional_metrics && !self.vertical && !st.glyph_form.is_width()
|
||||
}
|
||||
|
||||
/// Does text in style `st` take proportional heights (`vpal`)? Proportional Metrics in
|
||||
@@ -630,4 +636,44 @@ mod tests {
|
||||
assert!(serbian.iter().zip(&russian).all(|(s, r)| s != r), "every letter in its Serbian form");
|
||||
assert_eq!(outlines("sr-Latn"), serbian);
|
||||
}
|
||||
|
||||
/// A monospaced width form (`fwid`, `hwid`, `twid`, `qwid`) and Proportional Metrics' `palt`
|
||||
/// are mutually exclusive: with a width form `palt` is left out and the font's pair kerning
|
||||
/// (`kern`, and `apkn` for `palt`'s glyphs) is turned off, with automatic or manual kerning,
|
||||
/// and with the other forms they stay.
|
||||
/// <https://learn.microsoft.com/en-us/typography/opentype/spec/features_fj#tag-fwid>
|
||||
#[test]
|
||||
fn width_forms_turn_off_proportional_metrics() {
|
||||
use vectorcraft_doc::GlyphForm;
|
||||
let tags = |form, kerning, value| {
|
||||
let st = CharStyle { proportional_metrics: true, glyph_form: form, kerning, ..CharStyle::default() };
|
||||
OtFeatures::default().resolve(&st).iter().filter(|f| f.value == value).map(|f| f.tag.to_string()).collect::<Vec<_>>()
|
||||
};
|
||||
for form in [GlyphForm::Default, GlyphForm::Traditional, GlyphForm::Jis78] {
|
||||
assert!(tags(form, None, 1).contains(&"palt".to_string()), "{form:?}: {:?}", tags(form, None, 1));
|
||||
assert!(!tags(form, None, 0).contains(&"kern".to_string()), "{form:?}: {:?}", tags(form, None, 0));
|
||||
}
|
||||
for form in [GlyphForm::FullWidth, GlyphForm::HalfWidth, GlyphForm::ThirdWidth, GlyphForm::QuarterWidth] {
|
||||
for kerning in [None, Some(0.0)] {
|
||||
let on = tags(form, kerning, 1);
|
||||
assert!(!on.contains(&"palt".to_string()) && !on.contains(&"apkn".to_string()), "{form:?}: {on:?}");
|
||||
assert!(on.iter().any(|t| t.as_bytes() == form.feature().unwrap()), "{form:?}: {on:?}");
|
||||
// Listed as off, so that runs shaped together with proportional text keep them off.
|
||||
let off = tags(form, kerning, 0);
|
||||
assert!(off.contains(&"kern".to_string()) && off.contains(&"apkn".to_string()), "{form:?} {kerning:?}: {off:?}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A face's `GPOS` features are found apart from its `GSUB` ones: Source Sans 3 kerns with
|
||||
/// `GPOS` and makes ligatures with `GSUB`.
|
||||
#[test]
|
||||
fn positioning_and_substitution_features_are_found_in_their_own_tables() {
|
||||
let face = FontDb::global().face("Source Sans 3", "Regular").unwrap();
|
||||
assert!(face.has_positioning_feature(*b"kern"));
|
||||
assert!(!face.has_substitution_feature(*b"kern"));
|
||||
assert!(face.has_substitution_feature(*b"liga"));
|
||||
assert!(!face.has_positioning_feature(*b"liga"));
|
||||
assert!(!face.has_positioning_feature(*b"fwid"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -158,6 +158,17 @@ fn ligature_glyphs(t: &LigatureSubstFormat1<'_>, room: usize) -> Vec<u32> {
|
||||
out
|
||||
}
|
||||
|
||||
/// Does `features` (a `GSUB` or `GPOS` feature list) have feature `tag` with at least one lookup?
|
||||
/// At most [`MAX_FEATURES`] feature records are read.
|
||||
fn has_feature(features: &skrifa::raw::tables::layout::FeatureList<'_>, tag: [u8; 4]) -> bool {
|
||||
features
|
||||
.feature_records()
|
||||
.iter()
|
||||
.take(MAX_FEATURES)
|
||||
.filter(|r| r.feature_tag().to_be_bytes() == tag)
|
||||
.any(|r| r.feature(features.offset_data()).is_ok_and(|f| !f.lookup_list_indices().is_empty()))
|
||||
}
|
||||
|
||||
impl GsubIndex {
|
||||
/// Read `face`'s `GSUB` table. Empty when the face has none or it cannot be read; glyph ids past
|
||||
/// the face's glyph count are left out; the caps above bound what a damaged table makes it read.
|
||||
@@ -309,13 +320,15 @@ impl FontFace {
|
||||
/// cannot be read.
|
||||
pub fn has_substitution_feature(&self, tag: [u8; 4]) -> bool {
|
||||
let Some(font) = self.skrifa() else { return false };
|
||||
let Some(features) = font.gsub().ok().and_then(|g| g.feature_list().ok()) else { return false };
|
||||
features
|
||||
.feature_records()
|
||||
.iter()
|
||||
.take(MAX_FEATURES)
|
||||
.filter(|r| r.feature_tag().to_be_bytes() == tag)
|
||||
.any(|r| r.feature(features.offset_data()).is_ok_and(|f| !f.lookup_list_indices().is_empty()))
|
||||
font.gsub().ok().and_then(|g| g.feature_list().ok()).is_some_and(|features| has_feature(&features, tag))
|
||||
}
|
||||
|
||||
/// Does the face's `GPOS` table have feature `tag` with at least one lookup? At most
|
||||
/// [`MAX_FEATURES`] feature records are read; false when the face has no `GPOS` table or it
|
||||
/// cannot be read.
|
||||
pub fn has_positioning_feature(&self, tag: [u8; 4]) -> bool {
|
||||
let Some(font) = self.skrifa() else { return false };
|
||||
font.gpos().ok().and_then(|g| g.feature_list().ok()).is_some_and(|features| has_feature(&features, tag))
|
||||
}
|
||||
|
||||
/// What the features `aalt`, `salt`, `ss01` to `ss20`, `swsh`, `cv01` to `cv99`, `hist`,
|
||||
|
||||
@@ -626,6 +626,7 @@ impl Ctx<'_> {
|
||||
width_start,
|
||||
warichu: g.wari.map(|w| w.start),
|
||||
warichu_line: g.wari.map_or(0.0, |w| w.across),
|
||||
off_path: false,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -380,6 +380,35 @@ pub(crate) fn layout_trying_each_start(db: &crate::fontdb::FontDb, t: &TextObjec
|
||||
l
|
||||
}
|
||||
|
||||
/// For type that starts before its open path `ap` (`lead`), a glyph whose middle is `mid` along the
|
||||
/// path and falls before the path's start is not drawn, as SVG 2 lays out text on a path
|
||||
/// (https://www.w3.org/TR/SVG2/text.html#TextLayoutPath): glyph `at` keeps its place in the layout
|
||||
/// for the caret, with no length at the path's start (moved off the path by `rise`, as Align to Path
|
||||
/// moves the glyph), and draws nothing ([`crate::PositionedGlyph::off_path`]). True when it is hidden.
|
||||
fn hide_before_open_path(cx: &mut Ctx<'_>, ap: &ArcPath, lead: bool, at: usize, mid: f64, rise: f64) -> bool {
|
||||
if !lead || ap.is_closed() || mid >= 0.0 {
|
||||
return false;
|
||||
}
|
||||
let (p, dir) = ap.at(0.0);
|
||||
let start = p + Vec2::new(-dir.y, dir.x) * rise;
|
||||
if let Some(glyph) = cx.out.glyphs.get_mut(at) {
|
||||
glyph.origin = start;
|
||||
glyph.advance = 0.0;
|
||||
glyph.width = 0.0;
|
||||
glyph.width_start = 0.0;
|
||||
glyph.outline = BezPath::new();
|
||||
glyph.image = None;
|
||||
glyph.colour = None;
|
||||
// A ligature's caret stops stay, at the path's start with the glyph.
|
||||
for c in &mut glyph.carets {
|
||||
c.1 = 0.0;
|
||||
}
|
||||
glyph.off_path = true;
|
||||
}
|
||||
cx.out.inlines.retain(|i| i.glyph != at);
|
||||
true
|
||||
}
|
||||
|
||||
/// Lay type on a path out along `path` (text space), between its brackets.
|
||||
pub(super) fn on_path(cx: &mut Ctx<'_>, paras: &[Range<usize>], t: &TextObject, path: &PathData) {
|
||||
cx.out.on_path = true;
|
||||
@@ -453,6 +482,11 @@ pub(super) fn on_path(cx: &mut Ctx<'_>, paras: &[Range<usize>], t: &TextObject,
|
||||
}
|
||||
let centre = path.bounds().unwrap_or_default().center();
|
||||
let (from, to) = t.kind.path_span().unwrap_or((0.0, 1.0));
|
||||
// On an open path the text may start before the path's start: the span runs from there, so
|
||||
// Dynamic Text fills it to the end bracket, and the drawn text runs from the path's start to the
|
||||
// end bracket.
|
||||
let from = t.kind.path_text_start().unwrap_or(from);
|
||||
let lead = t.kind.starts_before_path();
|
||||
let s_start = from * ap.len();
|
||||
let avail = (to - from) * ap.len();
|
||||
let spacing = if t.path_spacing.is_finite() { t.path_spacing } else { 0.0 };
|
||||
@@ -491,7 +525,7 @@ pub(super) fn on_path(cx: &mut Ctx<'_>, paras: &[Range<usize>], t: &TextObject,
|
||||
}
|
||||
}
|
||||
} else {
|
||||
(l.min(s_start), r.min((ap.len() - s_start - avail).max(0.0)))
|
||||
(l.min(s_start.max(0.0)), r.min((ap.len() - s_start - avail).max(0.0)))
|
||||
};
|
||||
(l, r, hung_r)
|
||||
};
|
||||
@@ -673,10 +707,12 @@ pub(super) fn on_path(cx: &mut Ctx<'_>, paras: &[Range<usize>], t: &TextObject,
|
||||
m.tab = at;
|
||||
}
|
||||
}
|
||||
let at = cx.out.glyphs.len();
|
||||
cx.emit(g, pre, origin, angle, g.adv, 0);
|
||||
let hidden = hide_before_open_path(cx, &ap, lead, at, s + step * 0.5, rise);
|
||||
// A glyph of a script whose letters join is bent along a curved path (Rainbow), so its
|
||||
// joining strokes meet its neighbours' instead of standing off the curve.
|
||||
if t.path_effect == PathEffect::Rainbow && g.cursive && !cx.vertical && g.inline.is_none() {
|
||||
if !hidden && t.path_effect == PathEffect::Rainbow && g.cursive && !cx.vertical && g.inline.is_none() {
|
||||
let bend = GlyphBend { s, step, advance: g.adv, rise };
|
||||
if let Some(last) = cx.out.glyphs.last_mut()
|
||||
&& last.image.is_none()
|
||||
|
||||
@@ -379,5 +379,6 @@ fn ruby_glyph(g: &PositionedGlyph, m: Affine, run: usize, byte: usize, line: usi
|
||||
width_start: 0.0,
|
||||
warichu: None,
|
||||
warichu_line: 0.0,
|
||||
off_path: false,
|
||||
}
|
||||
}
|
||||
|
||||
+13
-3
@@ -188,6 +188,12 @@ pub struct PositionedGlyph {
|
||||
/// line's baseline before Character Alignment moves the glyph (y down). It names the block
|
||||
/// line whatever the alignment of each character on it. 0 for any other glyph.
|
||||
pub warichu_line: f64,
|
||||
/// Type on an open path that starts before the path: the glyph's middle falls before the
|
||||
/// path's start, so it draws nothing (its `outline` is empty and it has no image or color
|
||||
/// art), hit testing and Touch Type skip it while a glyph is drawn, and exports leave it out.
|
||||
/// It stays in the layout for the caret, at the path's start with no advance or width, and its
|
||||
/// ligature carets stand there too.
|
||||
pub off_path: bool,
|
||||
}
|
||||
|
||||
/// How a glyph on a path is bent along it: a point of its pen space (x along its advance from its
|
||||
@@ -600,11 +606,15 @@ pub fn hit_byte(layout: &TextLayout, p: Point) -> usize {
|
||||
.map(|(_, d)| (p - (g.origin + along * if g.rtl { g.advance - d } else { d })).hypot2())
|
||||
.fold(mid, f64::min)
|
||||
};
|
||||
let best = layout.glyphs.iter().enumerate().min_by(|(_, a), (_, b)| near(a).total_cmp(&near(b)));
|
||||
// Glyphs before the start of an open path draw nothing: a click goes to a drawn one when
|
||||
// there is one.
|
||||
let drawn = layout.glyphs.iter().any(|g| !g.off_path);
|
||||
let best = layout.glyphs.iter().enumerate().filter(|(_, g)| !(drawn && g.off_path)).min_by(|(_, a), (_, b)| near(a).total_cmp(&near(b)));
|
||||
let Some((i, g)) = best else { return layout.lines.first().map_or(0, |l| l.start) };
|
||||
// `first` and `last` are the first and last glyphs of the cluster. A click before the middle of
|
||||
// the cluster, measured along the first glyph's direction, goes to the cluster's start.
|
||||
let same = |o: &&PositionedGlyph| o.byte == g.byte && o.len == g.len;
|
||||
// the cluster, measured along the first glyph's direction, goes to the cluster's start. A
|
||||
// cluster's glyphs before an open path's start are left out when one of its glyphs is drawn.
|
||||
let same = |o: &&PositionedGlyph| o.byte == g.byte && o.len == g.len && !(drawn && o.off_path);
|
||||
let first = layout.glyphs[..=i].iter().rev().take_while(same).last().unwrap_or(g);
|
||||
let last = layout.glyphs[i..].iter().take_while(same).last().unwrap_or(g);
|
||||
let along = dir(first.angle);
|
||||
|
||||
@@ -1545,7 +1545,8 @@ fn shape_segment(text: &str, context: &Range<usize>, seg: &Segment, feats: &OtFe
|
||||
char_level.set(false);
|
||||
*chars = crate::harakat::own_chars(raw, &by_char, range.end, text, |gid| classes.get(skrifa::GlyphId::new(gid)) == 3);
|
||||
}
|
||||
if feats.proportional(st) {
|
||||
// Runs shaped together may differ in their glyph form, and a width form turns `palt` off.
|
||||
if feats.proportional(st) || runs.get(source_runs.clone()).unwrap_or_default().iter().any(|(_, s)| feats.proportional(s)) {
|
||||
// `palt` is positioning only: the same glyphs set on their full widths tell which
|
||||
// advances it changed (kerning and the rest change both alike).
|
||||
let full = shape(&with_more(&|s| feats.resolve_fixed_width(s)), along);
|
||||
|
||||
@@ -635,6 +635,85 @@ fn on_path_placement() {
|
||||
assert_eq!(hit_byte(&l, mid), 1);
|
||||
}
|
||||
|
||||
/// Type on an open path that starts before the path's start draws only the glyphs whose middle is
|
||||
/// on the path; the others stay in the layout, drawing nothing. On a closed path a start below 0 is
|
||||
/// the path's start, as before.
|
||||
#[test]
|
||||
fn on_path_open_start_before_the_path_hides_the_glyphs_before_it() {
|
||||
let mut line = BezPath::new();
|
||||
line.move_to((0.0, 50.0));
|
||||
line.line_to((500.0, 50.0));
|
||||
let mut t = point("MMMM", style(20.0));
|
||||
t.kind = TextKind::OnPath { path: PathData::from_bezpath(&line), start: 0.0, end: None };
|
||||
let whole = layout(db(), &t);
|
||||
let adv = whole.glyphs[0].advance;
|
||||
assert!(adv > 1.0 && whole.glyphs.iter().all(|g| !g.outline.elements().is_empty()));
|
||||
// 2.4 glyphs before the start: the first two are before it, and the third's middle is just past
|
||||
// the path's start, so it is drawn.
|
||||
t.kind = TextKind::OnPath { path: PathData::from_bezpath(&line), start: -2.4 * adv / 500.0, end: None };
|
||||
let l = layout(db(), &t);
|
||||
assert!(l.on_path && !l.overflow);
|
||||
assert_eq!(l.glyphs.len(), 4);
|
||||
let drawn: Vec<bool> = l.glyphs.iter().map(|g| !g.outline.elements().is_empty()).collect();
|
||||
assert_eq!(drawn, [false, false, true, true]);
|
||||
assert_eq!(l.glyphs.iter().map(|g| g.off_path).collect::<Vec<_>>(), [true, true, false, false]);
|
||||
assert!(l.glyphs[..2].iter().all(|g| g.carets.iter().all(|c| c.1 == 0.0) && g.advance == 0.0 && g.width == 0.0));
|
||||
assert!(whole.glyphs.iter().all(|g| !g.off_path));
|
||||
// Type that starts on the path keeps drawing every glyph, even where negative tracking steps
|
||||
// its glyphs back before the path's start.
|
||||
let mut packed = t.clone();
|
||||
packed.kind = TextKind::OnPath { path: PathData::from_bezpath(&line), start: 0.0, end: None };
|
||||
packed.runs[0].style.tracking = -1000.0;
|
||||
let p = layout(db(), &packed);
|
||||
assert!(p.glyphs.len() == 4 && p.glyphs.iter().all(|g| !g.off_path && !g.outline.elements().is_empty()));
|
||||
assert!((l.glyphs[2].origin.x + 0.4 * adv).abs() < 1e-6, "{:?}", l.glyphs[2].origin);
|
||||
assert!((l.glyphs[3].origin.x - 0.6 * adv).abs() < 1e-6, "{:?}", l.glyphs[3].origin);
|
||||
assert_eq!(l.lines[0].end, 4);
|
||||
// A click at the path's start goes before the first drawn character, not into a hidden one, and
|
||||
// the caret in the hidden characters stands at the path's start.
|
||||
assert_eq!(hit_byte(&l, Point::new(0.0, 50.0)), 2);
|
||||
for b in [0, 1] {
|
||||
let (top, bottom) = caret_position(&l, b);
|
||||
assert!(top.x.abs() < 1e-6 && bottom.x.abs() < 1e-6, "{b}: {top:?} {bottom:?}");
|
||||
}
|
||||
// An All Caps ß is two glyphs. With the first before the path's start, a click is measured
|
||||
// against the drawn one: just past its middle, the caret goes after the character.
|
||||
let mut caps = point("ße", style(20.0));
|
||||
caps.runs[0].style.all_caps = true;
|
||||
caps.kind = TextKind::OnPath { path: PathData::from_bezpath(&line), start: 0.0, end: None };
|
||||
let s = layout(db(), &caps).glyphs[0].advance;
|
||||
caps.kind = TextKind::OnPath { path: PathData::from_bezpath(&line), start: -1.2 * s / 500.0, end: None };
|
||||
let c = layout(db(), &caps);
|
||||
assert!(c.glyphs[0].byte == 0 && c.glyphs[1].byte == 0 && c.glyphs[0].off_path && !c.glyphs[1].off_path);
|
||||
let mid = c.glyphs[1].origin.x + c.glyphs[1].advance / 2.0;
|
||||
assert_eq!(hit_byte(&c, Point::new(mid + 0.05 * s, 50.0)), 2);
|
||||
assert_eq!(hit_byte(&c, Point::new(mid - 0.05 * s, 50.0)), 0);
|
||||
// With Align to Path, the hidden characters' caret is moved off the path as the drawn ones are.
|
||||
let mut aligned = t.clone();
|
||||
aligned.path_align = vectorcraft_doc::PathAlign::Ascender;
|
||||
let a = layout(db(), &aligned);
|
||||
let rise = a.glyphs[2].origin.y - 50.0;
|
||||
assert!(rise.abs() > 1.0, "{rise}");
|
||||
for g in &a.glyphs[..2] {
|
||||
assert!(g.off_path && g.origin.x.abs() < 1e-6 && (g.origin.y - 50.0 - rise).abs() < 1e-6, "{:?}", g.origin);
|
||||
}
|
||||
// The whole text before the path: nothing is drawn, and nothing overflows.
|
||||
t.kind = TextKind::OnPath { path: PathData::from_bezpath(&line), start: -0.9, end: None };
|
||||
let l = layout(db(), &t);
|
||||
assert!(!l.overflow && l.glyphs.len() == 4 && l.glyphs.iter().all(|g| g.outline.elements().is_empty()));
|
||||
// A closed path starts the text at the path's start.
|
||||
let mut square = BezPath::new();
|
||||
square.move_to((0.0, 0.0));
|
||||
square.line_to((200.0, 0.0));
|
||||
square.line_to((200.0, 200.0));
|
||||
square.line_to((0.0, 200.0));
|
||||
square.close_path();
|
||||
t.kind = TextKind::OnPath { path: PathData::from_bezpath(&square), start: -0.1, end: None };
|
||||
let l = layout(db(), &t);
|
||||
assert!(l.glyphs.iter().all(|g| !g.outline.elements().is_empty()));
|
||||
assert!(l.glyphs[0].origin.x.abs() < 1e-6 && l.glyphs[0].origin.y.abs() < 1e-6, "{:?}", l.glyphs[0].origin);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn on_path_effects_orient_glyphs() {
|
||||
use vectorcraft_doc::PathEffect;
|
||||
|
||||
@@ -170,6 +170,7 @@ fn lines_of_clusters(lines: &[&[(usize, usize, f64)]], rtl: bool) -> TextLayout
|
||||
width_start: 0.0,
|
||||
warichu: None,
|
||||
warichu_line: 0.0,
|
||||
off_path: false,
|
||||
});
|
||||
x += advance;
|
||||
}
|
||||
|
||||
@@ -649,6 +649,7 @@ fn duplicate_authored_glyphs_keep_occurrence_order() {
|
||||
width_start: 0.0,
|
||||
warichu: None,
|
||||
warichu_line: 0.0,
|
||||
off_path: false,
|
||||
};
|
||||
let before = TextLayout { glyphs: vec![glyph(0), glyph(1)], ..TextLayout::default() };
|
||||
assert_eq!(assert_kashida_ownership(&text, &before, &before, &red(), "duplicate glyphs", false), 0);
|
||||
|
||||
@@ -14,7 +14,7 @@ use vectorcraft_geom::{Affine, Anchor, AnchorKind, BezPath, PathData, Point, Sub
|
||||
use vectorcraft_pathops as po;
|
||||
use vectorcraft_pathops::{Cap, Join};
|
||||
|
||||
use crate::{Action, Tool, ToolContext};
|
||||
use crate::{Action, PenPose, Tool, ToolContext};
|
||||
|
||||
pub use anchor::AnchorTool;
|
||||
pub use curvature::{
|
||||
@@ -87,6 +87,34 @@ pub fn pressure_size(size: f64, variation: f64, pressure: f64) -> f64 {
|
||||
/// The narrowest a Blob Brush or Eraser stroke gets, in points (their Size field's minimum).
|
||||
pub const MIN_BRUSH_SIZE: f64 = 0.1;
|
||||
|
||||
/// A pen's tilt as a lean on the page (each tilt's tangent) and its angle from upright in degrees,
|
||||
/// as Pointer Events' tiltX and tiltY convert to an altitude and an azimuth.
|
||||
fn pen_lean(tilt: [f32; 2]) -> ([f64; 2], f64) {
|
||||
let [x, y] = tilt.map(|d| f64::from(d).to_radians().tan());
|
||||
([x, y], x.hypot(y).atan().to_degrees())
|
||||
}
|
||||
|
||||
/// Which way pose `q` leans, in degrees counter-clockwise on the page from leaning to the right,
|
||||
/// when it leans 1° or more from upright (`path.eraseRegion` keeps the bearing before a pen
|
||||
/// nearer upright than that).
|
||||
pub(crate) fn pen_bearing(q: PenPose) -> Option<f64> {
|
||||
let ([x, y], from_upright) = pen_lean(q.tilt?);
|
||||
(from_upright >= 1.0).then(|| (-y).atan2(x).to_degrees())
|
||||
}
|
||||
|
||||
/// The 0..1 that each of the Eraser's variation modes Pressure, Tilt, Bearing and Rotation reads
|
||||
/// from a pen at `pressure` (0..1) held as `pose`, leaning toward `bearing` (degrees, from
|
||||
/// [`pen_bearing`]): 0 gives the value less its variation and 1 the value plus it, as
|
||||
/// `path.eraseRegion` reads a stroke's points. Tilt reads 1 upright and 0 lying flat; Bearing and
|
||||
/// Rotation read ½ plus the angle over 360°, the rotation turned counter-clockwise. What the pen
|
||||
/// does not give reads ½, the value itself.
|
||||
pub(crate) fn pen_controls(pressure: f64, pose: Option<PenPose>, bearing: Option<f64>) -> [f64; 4] {
|
||||
let tilt = pose.and_then(|q| q.tilt).map_or(0.5, |t| 1.0 - pen_lean(t).1 / 90.0);
|
||||
let rotation = pose.and_then(|q| q.rotation).map_or(0.5, |r| 0.5 + vectorcraft_geom::normalize_deg(-f64::from(r)) / 360.0);
|
||||
let bearing = bearing.map_or(0.5, |b| 0.5 + b / 360.0);
|
||||
[pressure, tilt, bearing, rotation].map(|c| if c.is_finite() { c.clamp(0.0, 1.0) } else { 0.5 })
|
||||
}
|
||||
|
||||
/// Create a drawing tool by id (None = not one of ours).
|
||||
pub fn create(id: &str) -> Option<Box<dyn Tool>> {
|
||||
Some(match id {
|
||||
|
||||
@@ -12,7 +12,10 @@
|
||||
//! pressed less than fully on sends `[x, y, pressure]` points) for pressure-sensitive brushes, and
|
||||
//! the pen's tilt and barrel rotation where the pen reads them (`[x, y, pressure, tiltX, tiltY,
|
||||
//! rotation]` points, `null` for what a point lacks) for brushes that vary with them. The Blob
|
||||
//! Brush and Eraser also record pressure when their Size varies with Pressure. The other tools
|
||||
//! Brush and Eraser also record pressure when their Size varies with Pressure, and the Eraser
|
||||
//! records the pressure, tilt and rotation that its Angle, Roundness and Size variations follow;
|
||||
//! while it erases, its tip outline at the pointer takes the angle, roundness and size the pen
|
||||
//! gives there. The other tools
|
||||
//! collect the drag polyline (shown as an overlay) and run one command on release. With Shift
|
||||
//! held, the Blob Brush's points stay on the horizontal or
|
||||
//! vertical line through the press point. Alt held at the press, or before the Blob Brush's drag
|
||||
@@ -29,6 +32,7 @@ use std::cell::Cell;
|
||||
use std::collections::{BTreeMap, BTreeSet};
|
||||
|
||||
use serde_json::{Value, json};
|
||||
use vectorcraft_brush::{Calligraphic, Variation};
|
||||
use vectorcraft_doc::{NodeId, NodeKind};
|
||||
use vectorcraft_geom::{Affine, BezPath, FillRule, ParamCurve, PathData, PathSeg, Point, Rect, Vec2, shapes};
|
||||
use vectorcraft_pathops as po;
|
||||
@@ -88,10 +92,11 @@ pub struct GestureTool {
|
||||
previewed: usize,
|
||||
id: &'static str,
|
||||
points: Vec<Point>,
|
||||
/// The pen pressure of each of `points` (the Paintbrush, and the Blob Brush and the Eraser
|
||||
/// with `size_pressure`).
|
||||
/// The pen pressure of each of `points` (the Paintbrush, the Blob Brush with Size on Pressure,
|
||||
/// and the Eraser with any of its variations on Pressure).
|
||||
pressures: Vec<f32>,
|
||||
/// The pen's tilt and rotation at each of `points` (the Paintbrush only).
|
||||
/// The pen's tilt and rotation at each of `points` (the Paintbrush, and the Eraser with any of
|
||||
/// its variations on Tilt, Bearing or Rotation).
|
||||
poses: Vec<Option<PenPose>>,
|
||||
active: bool,
|
||||
began: bool,
|
||||
@@ -117,16 +122,22 @@ pub struct GestureTool {
|
||||
pub fidelity: f64,
|
||||
/// Brush / eraser diameter in points (the Eraser's across its tip's long axis).
|
||||
pub size: f64,
|
||||
/// Blob Brush and Eraser Tool Options' Size set to Pressure (else Fixed): the tip is `size` −
|
||||
/// `size_variation` wide at the lightest pen pressure and `size` + `size_variation` at the
|
||||
/// heaviest.
|
||||
pub size_pressure: bool,
|
||||
/// Blob Brush and Eraser Tool Options' Size mode: Fixed or Pressure for the Blob Brush (with
|
||||
/// Pressure the tip is `size` − `size_variation` wide at the lightest pen pressure and `size` +
|
||||
/// `size_variation` at the heaviest), and any [`Variation`] for the Eraser.
|
||||
pub size_mode: Variation,
|
||||
/// Blob Brush and Eraser Tool Options' Size Variation, in points.
|
||||
pub size_variation: f64,
|
||||
/// The angle of the Blob Brush or Eraser tip's long axis, in degrees counter-clockwise from horizontal.
|
||||
pub angle: f64,
|
||||
/// The Blob Brush or Eraser tip's short axis in percent of its long one (100: round).
|
||||
pub roundness: f64,
|
||||
/// Eraser Tool Options' Angle mode and its Variation in degrees (0 to 180).
|
||||
pub angle_mode: Variation,
|
||||
pub angle_variation: f64,
|
||||
/// Eraser Tool Options' Roundness mode and its Variation in percent (0 to 100).
|
||||
pub roundness_mode: Variation,
|
||||
pub roundness_variation: f64,
|
||||
/// Fill new pencil/brush strokes.
|
||||
pub fill: bool,
|
||||
/// "Keep selected": select the path a Pencil, Paintbrush or Blob Brush stroke draws, continues
|
||||
@@ -176,6 +187,9 @@ pub struct GestureTool {
|
||||
tip_at: Option<Point>,
|
||||
/// A pen pressed less than fully at a point of the stroke whose pressure is kept.
|
||||
light: bool,
|
||||
/// Which way the pen leaned at the last point of the stroke where it leaned 1° or more from
|
||||
/// upright ([`super::pen_bearing`]), for the Eraser's tip outline.
|
||||
bearing: Option<f64>,
|
||||
/// The length of the stroke, in points.
|
||||
length: f64,
|
||||
/// The outline of the area a Blob Brush or Eraser drag covers, as far as it is built
|
||||
@@ -829,10 +843,14 @@ impl GestureTool {
|
||||
redraw: None,
|
||||
fidelity,
|
||||
size: 10.0,
|
||||
size_pressure: false,
|
||||
size_mode: Variation::Fixed,
|
||||
size_variation: 0.0,
|
||||
angle: 0.0,
|
||||
roundness: 100.0,
|
||||
angle_mode: Variation::Fixed,
|
||||
angle_variation: 0.0,
|
||||
roundness_mode: Variation::Fixed,
|
||||
roundness_variation: 0.0,
|
||||
fill: false,
|
||||
keep_selected: id != "blobBrush",
|
||||
// Off, as Pencil Tool Options ships it: Alt keeps drawing a closed path until it is on.
|
||||
@@ -852,6 +870,7 @@ impl GestureTool {
|
||||
pressed: Point::ZERO,
|
||||
tip_at: None,
|
||||
light: false,
|
||||
bearing: None,
|
||||
length: 0.0,
|
||||
area: Cell::new(None),
|
||||
area_pending: Cell::new(false),
|
||||
@@ -908,15 +927,40 @@ impl GestureTool {
|
||||
self.length += last.distance(p);
|
||||
}
|
||||
self.points.push(p);
|
||||
if self.id == "paintbrush" || (self.varies_size() && self.size_pressure) {
|
||||
let modes = self.tip_modes();
|
||||
// The poses go out beside the pressures (`stroke_json`), so a stroke that keeps one keeps both.
|
||||
let posed = self.id == "paintbrush" || modes.iter().any(|m| matches!(m, Variation::Tilt | Variation::Bearing | Variation::Rotation));
|
||||
if posed || modes.contains(&Variation::Pressure) {
|
||||
self.pressures.push(ev.pressure);
|
||||
self.light |= ev.pressure < 1.0;
|
||||
}
|
||||
if self.id == "paintbrush" {
|
||||
self.poses.push(ev.pen.map(PenPose::sanitized));
|
||||
if posed {
|
||||
let pose = ev.pen.map(PenPose::sanitized);
|
||||
self.bearing = pose.and_then(super::pen_bearing).or(self.bearing);
|
||||
self.poses.push(pose);
|
||||
}
|
||||
}
|
||||
|
||||
/// Give the Eraser's last stroke sample the pen's tilt and rotation of the event `ev`, a drag
|
||||
/// within a pixel of that sample that adds none: a pen turned or tilted in place turns its tip
|
||||
/// there, in the outline and in what the release erases. The sample keeps its pressure, which
|
||||
/// the area outline of the drag was built with, and its pose when `ev` reads none.
|
||||
fn refresh_last_pose(&mut self, ev: &PointerEvent) {
|
||||
if self.id != "eraser" || self.poses.len() != self.points.len() || ev.pen.is_none() {
|
||||
return;
|
||||
}
|
||||
let pose = ev.pen.map(PenPose::sanitized);
|
||||
let Some(last) = self.poses.last_mut() else { return };
|
||||
let had_bearing = last.and_then(super::pen_bearing).is_some();
|
||||
*last = pose;
|
||||
// The bearing is that of the last sample the pen leaned at, as `push` keeps it.
|
||||
self.bearing = match pose.and_then(super::pen_bearing) {
|
||||
Some(b) => Some(b),
|
||||
None if had_bearing => self.poses.iter().rev().find_map(|q| q.and_then(super::pen_bearing)),
|
||||
None => self.bearing,
|
||||
};
|
||||
}
|
||||
|
||||
/// Would a press with `m` held draw a straight Pencil line? Shift or Alt held, unless Alt lends
|
||||
/// the Smooth tool: the rule `PointerKind::Down` sets `straight` by.
|
||||
fn straight_at_press(&self, m: Mods) -> bool {
|
||||
@@ -975,12 +1019,14 @@ impl GestureTool {
|
||||
}
|
||||
|
||||
/// The Blob Brush's or the Eraser's tip at the pointer, before and while painting: its outline
|
||||
/// `size` across at its angle and roundness, at the zoom, black on a white halo so it reads on
|
||||
/// `size` across at its angle and roundness (the Eraser's as the pen varies them while it
|
||||
/// erases, [`Self::tip_now`]), at the zoom, black on a white halo so it reads on
|
||||
/// any art. Precise cursors show only the crosshair, and smoothing shows no tip.
|
||||
fn tip_outline(&self, cx: &ToolContext) -> Vec<Overlay> {
|
||||
let Some(at) = self.tip_at.filter(|_| !self.smoothing && !cx.precise_cursors) else { return vec![] };
|
||||
let tip = if self.id == "blobBrush" { blob_tip(self.angle, self.roundness) } else { eraser_tip(self.angle, self.roundness) };
|
||||
let r = self.size / 2.0;
|
||||
let [angle, roundness, size] = self.tip_now();
|
||||
let tip = if self.id == "blobBrush" { blob_tip(angle, roundness) } else { eraser_tip(angle, roundness) };
|
||||
let r = size / 2.0;
|
||||
// A tolerance relative to the radius: four arcs at any size or zoom.
|
||||
let circle = kurbo::Shape::to_path(&kurbo::Circle::new(Point::ORIGIN, r), 0.1 * r.max(1e-6));
|
||||
let path = Affine::translate(at.to_vec2()) * tip * circle;
|
||||
@@ -999,6 +1045,7 @@ impl GestureTool {
|
||||
self.previewed = 0;
|
||||
self.shift_line = None;
|
||||
self.light = false;
|
||||
self.bearing = None;
|
||||
self.length = 0.0;
|
||||
self.area.set(None);
|
||||
self.area_pending.set(false);
|
||||
@@ -1064,7 +1111,53 @@ impl GestureTool {
|
||||
/// keeps its Size).
|
||||
fn size_pressures(&self) -> Option<&[f32]> {
|
||||
let pressed = self.pressures.len() == self.points.len() && self.light;
|
||||
(self.varies_size() && self.size_pressure && !self.smoothing && pressed).then_some(self.pressures.as_slice())
|
||||
(self.tip_modes()[2] == Variation::Pressure && !self.smoothing && pressed).then_some(self.pressures.as_slice())
|
||||
}
|
||||
|
||||
/// How the tip's angle, roundness and size vary: the Eraser's three modes, the Blob Brush's
|
||||
/// Size mode (its angle and roundness stay fixed), and Fixed for the other tools. A mode whose
|
||||
/// Variation is 0 is Fixed.
|
||||
fn tip_modes(&self) -> [Variation; 3] {
|
||||
let fixed_at_zero = |mode: Variation, v: f64| if v > 0.0 { mode } else { Variation::Fixed };
|
||||
match self.id {
|
||||
"eraser" => [
|
||||
fixed_at_zero(self.angle_mode, self.angle_variation),
|
||||
fixed_at_zero(self.roundness_mode, self.roundness_variation),
|
||||
fixed_at_zero(self.size_mode, self.size_variation),
|
||||
],
|
||||
"blobBrush" => [Variation::Fixed, Variation::Fixed, fixed_at_zero(self.size_mode, self.size_variation)],
|
||||
_ => [Variation::Fixed; 3],
|
||||
}
|
||||
}
|
||||
|
||||
/// The Eraser's angle, roundness and size where its tip outline shows: while it erases, each
|
||||
/// value on a mode that follows the pen goes from the value less its Variation to the value
|
||||
/// plus it as the pen reads now (the pressure once the pen has pressed less than fully, the
|
||||
/// tilt and rotation of the last point, and the bearing of the last point the pen leaned 1° or
|
||||
/// more at), as `path.eraseRegion` reads the stroke; before a press, and for Random, Fixed and a
|
||||
/// pen that gives no reading, the values themselves. The Blob Brush's are its own values.
|
||||
fn tip_now(&self) -> [f64; 3] {
|
||||
let base = [self.angle, self.roundness, self.size];
|
||||
if self.id != "eraser" || !self.active {
|
||||
return base;
|
||||
}
|
||||
let pressure = if self.light { self.pressures.last().map_or(0.5, |p| f64::from(*p)) } else { 0.5 };
|
||||
let controls = super::pen_controls(pressure, self.poses.last().copied().flatten(), self.bearing);
|
||||
let variations = [self.angle_variation, self.roundness_variation, self.size_variation];
|
||||
let mut out = base;
|
||||
for (i, (v, mode)) in out.iter_mut().zip(self.tip_modes()).enumerate() {
|
||||
let c = match mode {
|
||||
Variation::Pressure => controls[0],
|
||||
Variation::Tilt => controls[1],
|
||||
Variation::Bearing => controls[2],
|
||||
Variation::Rotation => controls[3],
|
||||
Variation::Fixed | Variation::Random => continue,
|
||||
};
|
||||
// The size goes past a nib's 1296 pt, as `path.eraseRegion` sweeps it.
|
||||
*v = if i == 2 { super::pressure_size(*v, variations[i], c) } else { Calligraphic::moved(i, *v, variations[i] * (2.0 * c - 1.0)) };
|
||||
}
|
||||
let [angle, roundness, size] = out;
|
||||
[angle, roundness.clamp(1.0, 100.0), size.max(super::MIN_BRUSH_SIZE)]
|
||||
}
|
||||
|
||||
/// The stroke's points: `[x, y, pressure, tiltX, tiltY, rotation]` when a pen read its tilt or
|
||||
@@ -1254,7 +1347,7 @@ impl GestureTool {
|
||||
json!({
|
||||
"points": self.stroke_json(),
|
||||
"size": self.size,
|
||||
"sizeMode": if self.size_pressure { "pressure" } else { "fixed" },
|
||||
"sizeMode": self.size_mode.id(),
|
||||
"sizeVariation": self.size_variation,
|
||||
"fidelity": self.fidelity,
|
||||
"angle": self.angle,
|
||||
@@ -1268,10 +1361,14 @@ impl GestureTool {
|
||||
json!({
|
||||
"points": self.stroke_json(),
|
||||
"size": self.size,
|
||||
"sizeMode": if self.size_pressure { "pressure" } else { "fixed" },
|
||||
"sizeMode": self.size_mode.id(),
|
||||
"sizeVariation": self.size_variation,
|
||||
"angle": self.angle,
|
||||
"roundness": self.roundness
|
||||
"angleMode": self.angle_mode.id(),
|
||||
"angleVariation": self.angle_variation,
|
||||
"roundness": self.roundness,
|
||||
"roundnessMode": self.roundness_mode.id(),
|
||||
"roundnessVariation": self.roundness_variation
|
||||
}),
|
||||
)],
|
||||
"knife" if pts.len() >= 2 => vec![Action::Exec("path.knife".into(), json!({"points": points_json(pts)}))],
|
||||
@@ -1385,6 +1482,9 @@ impl Tool for GestureTool {
|
||||
let release_end = kept.unwrap_or(end);
|
||||
self.near_start = self.release_closes_near_start(cx, release_end);
|
||||
self.connects = self.connects_at(cx, release_end).is_some();
|
||||
if self.points.last().is_some_and(|l| l.distance(p) < cx.tol(1.0)) {
|
||||
self.refresh_last_pose(ev);
|
||||
}
|
||||
return vec![];
|
||||
}
|
||||
self.push(p, ev);
|
||||
@@ -1438,13 +1538,17 @@ impl Tool for GestureTool {
|
||||
}
|
||||
} else if self.points.last().is_some_and(|l| l.distance(p) >= cx.tol(1.0)) {
|
||||
self.push(end, ev);
|
||||
} else if self.grid_snaps(cx)
|
||||
&& !self.smoothing
|
||||
&& self.points.len() > 1
|
||||
&& let Some(last) = self.points.last_mut()
|
||||
{
|
||||
// The last step was where the button went up: it moves where that snaps.
|
||||
*last = end;
|
||||
} else {
|
||||
// A release within a pixel of the last step gives it the pen's pose there.
|
||||
self.refresh_last_pose(ev);
|
||||
if self.grid_snaps(cx)
|
||||
&& !self.smoothing
|
||||
&& self.points.len() > 1
|
||||
&& let Some(last) = self.points.last_mut()
|
||||
{
|
||||
// The last step was where the button went up: it moves where that snaps.
|
||||
*last = end;
|
||||
}
|
||||
}
|
||||
let out = if cancel { vec![Action::Cancel] } else { self.finish(cx, ev.mods) };
|
||||
self.active = false;
|
||||
@@ -1666,7 +1770,7 @@ impl Tool for GestureTool {
|
||||
json!({
|
||||
"fidelity": self.fidelity,
|
||||
"size": self.size,
|
||||
"sizeMode": if self.size_pressure { "pressure" } else { "fixed" },
|
||||
"sizeMode": self.size_mode.id(),
|
||||
"sizeVariation": self.size_variation,
|
||||
"angle": self.angle,
|
||||
"roundness": self.roundness,
|
||||
@@ -1676,10 +1780,14 @@ impl Tool for GestureTool {
|
||||
}
|
||||
"eraser" => json!({
|
||||
"size": self.size,
|
||||
"sizeMode": if self.size_pressure { "pressure" } else { "fixed" },
|
||||
"sizeMode": self.size_mode.id(),
|
||||
"sizeVariation": self.size_variation,
|
||||
"angle": self.angle,
|
||||
"roundness": self.roundness
|
||||
"angleMode": self.angle_mode.id(),
|
||||
"angleVariation": self.angle_variation,
|
||||
"roundness": self.roundness,
|
||||
"roundnessMode": self.roundness_mode.id(),
|
||||
"roundnessVariation": self.roundness_variation
|
||||
}),
|
||||
_ => Value::Null,
|
||||
}
|
||||
@@ -1688,12 +1796,19 @@ impl Tool for GestureTool {
|
||||
match key {
|
||||
"fidelity" => self.fidelity = v.as_f64().unwrap_or(self.fidelity).clamp(super::min_fidelity(self.id), 100.0),
|
||||
"size" => self.size = v.as_f64().unwrap_or(self.size).clamp(0.1, 1000.0),
|
||||
"sizeMode" if self.varies_size() => match v.as_str() {
|
||||
Some("pressure") => self.size_pressure = true,
|
||||
Some("fixed") => self.size_pressure = false,
|
||||
// The Blob Brush's Size is Fixed or Pressure; the Eraser's Angle, Roundness and Size
|
||||
// take any mode.
|
||||
"sizeMode" if self.varies_size() => match v.as_str().and_then(Variation::parse) {
|
||||
Some(m) if self.id == "eraser" || matches!(m, Variation::Fixed | Variation::Pressure) => self.size_mode = m,
|
||||
_ => {}
|
||||
},
|
||||
"angleMode" if self.id == "eraser" => self.angle_mode = v.as_str().and_then(Variation::parse).unwrap_or(self.angle_mode),
|
||||
"roundnessMode" if self.id == "eraser" => self.roundness_mode = v.as_str().and_then(Variation::parse).unwrap_or(self.roundness_mode),
|
||||
"sizeVariation" if self.varies_size() => self.size_variation = v.as_f64().unwrap_or(self.size_variation).clamp(0.0, 1000.0),
|
||||
"angleVariation" if self.id == "eraser" => self.angle_variation = v.as_f64().unwrap_or(self.angle_variation).clamp(0.0, 180.0),
|
||||
"roundnessVariation" if self.id == "eraser" => {
|
||||
self.roundness_variation = v.as_f64().unwrap_or(self.roundness_variation).clamp(0.0, 100.0)
|
||||
}
|
||||
"angle" => self.angle = v.as_f64().unwrap_or(self.angle).clamp(-180.0, 180.0),
|
||||
"roundness" => self.roundness = v.as_f64().unwrap_or(self.roundness).clamp(if self.id == "blobBrush" { 0.0 } else { 1.0 }, 100.0),
|
||||
"fill" => self.fill = v.as_bool().unwrap_or(self.fill),
|
||||
|
||||
@@ -527,10 +527,20 @@ fn blob_brush_sends_keep_selected_and_merge_only_with_selection() {
|
||||
"{acts:?}"
|
||||
);
|
||||
}
|
||||
// The Eraser keeps Size (Fixed by default), Angle and Roundness: a round tip by default.
|
||||
// The Eraser keeps Size, Angle and Roundness, each Fixed by default: a round tip by default.
|
||||
assert_eq!(
|
||||
GestureTool::new("eraser").options(),
|
||||
json!({"size": 10.0, "sizeMode": "fixed", "sizeVariation": 0.0, "angle": 0.0, "roundness": 100.0})
|
||||
json!({
|
||||
"size": 10.0,
|
||||
"sizeMode": "fixed",
|
||||
"sizeVariation": 0.0,
|
||||
"angle": 0.0,
|
||||
"angleMode": "fixed",
|
||||
"angleVariation": 0.0,
|
||||
"roundness": 100.0,
|
||||
"roundnessMode": "fixed",
|
||||
"roundnessVariation": 0.0
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
@@ -2768,6 +2778,161 @@ fn the_blob_brush_and_the_eraser_show_their_tip_at_the_pointer() {
|
||||
}
|
||||
}
|
||||
|
||||
/// Eraser Tool Options' Angle, Roundness and Size variation menus: the Eraser keeps each mode and
|
||||
/// Variation, the release sends them with the pen's readings along the drag, and while it erases
|
||||
/// its tip outline at the pointer takes the angle, roundness and size the pen gives there. The Blob
|
||||
/// Brush's Size takes Fixed and Pressure only, and it has no Angle or Roundness menus.
|
||||
#[test]
|
||||
fn the_eraser_tip_follows_its_variation_modes() {
|
||||
let (d, _) = doc_with_rect();
|
||||
let s = Selection::default();
|
||||
let p = paint();
|
||||
let cx = cx(&d, &s, &p);
|
||||
// The bounds of the tip outline, the last two overlays (its halo and its line).
|
||||
let tip = |t: &GestureTool| match &t.overlays(&cx)[..] {
|
||||
[.., Overlay::Path { path: halo, width: 3.0, .. }, Overlay::Path { path, color: [0, 0, 0], width: 1.0, .. }] => {
|
||||
assert_eq!(halo, path);
|
||||
vectorcraft_geom::Shape::bounding_box(path)
|
||||
}
|
||||
o => panic!("{o:?}"),
|
||||
};
|
||||
let near = |b: vectorcraft_geom::Rect, want: (f64, f64, f64, f64)| {
|
||||
assert!([b.x0 - want.0, b.y0 - want.1, b.x1 - want.2, b.y1 - want.3].iter().all(|e| e.abs() < 0.01), "{b:?} vs {want:?}");
|
||||
};
|
||||
let mut t = GestureTool::new("eraser");
|
||||
assert_eq!(
|
||||
(t.options()["angleMode"].clone(), t.options()["roundnessMode"].clone(), t.options()["sizeMode"].clone()),
|
||||
(json!("fixed"), json!("fixed"), json!("fixed")),
|
||||
"Fixed by default"
|
||||
);
|
||||
for (k, v) in [
|
||||
("size", json!(20)),
|
||||
("roundness", json!(25)),
|
||||
("angleMode", json!("rotation")),
|
||||
("angleVariation", json!(90)),
|
||||
("roundnessMode", json!("random")),
|
||||
("roundnessVariation", json!(10)),
|
||||
("sizeMode", json!("tilt")),
|
||||
("sizeVariation", json!(8)),
|
||||
] {
|
||||
t.set_option(k, &v);
|
||||
}
|
||||
let o = t.options();
|
||||
assert_eq!(
|
||||
[&o["angleMode"], &o["angleVariation"], &o["roundnessMode"], &o["roundnessVariation"], &o["sizeMode"], &o["sizeVariation"]],
|
||||
[&json!("rotation"), &json!(90.0), &json!("random"), &json!(10.0), &json!("tilt"), &json!(8.0)]
|
||||
);
|
||||
// A mode it does not have leaves the mode as it was; a Variation is kept within its range.
|
||||
t.set_option("angleMode", &json!("wheel"));
|
||||
t.set_option("roundnessVariation", &json!(500));
|
||||
assert_eq!((t.options()["angleMode"].clone(), t.options()["roundnessVariation"].clone()), (json!("rotation"), json!(100.0)));
|
||||
t.set_option("roundnessVariation", &json!(10));
|
||||
// Before a press the outline is the set tip: 20 by 5 pt, lying level.
|
||||
t.pointer(&cx, &PointerEvent::new(PointerKind::Move, 100.0, 50.0));
|
||||
near(tip(&t), (90.0, 47.5, 110.0, 52.5));
|
||||
// Erasing with the barrel turned half way round and the pen upright, the tip stands upright and
|
||||
// is 28 by 7 pt; Random leaves the roundness as set.
|
||||
let pen = Some(PenPose { tilt: Some([0.0, 0.0]), rotation: Some(180.0) });
|
||||
t.pointer(&cx, &PointerEvent { pen, pressure: 0.5, ..PointerEvent::new(PointerKind::Down, 100.0, 50.0) });
|
||||
t.pointer(&cx, &PointerEvent { pen, pressure: 0.5, ..PointerEvent::new(PointerKind::Drag, 200.0, 50.0) });
|
||||
near(tip(&t), (196.5, 36.0, 203.5, 64.0));
|
||||
let acts = t.pointer(&cx, &PointerEvent { pen, pressure: 0.5, ..PointerEvent::new(PointerKind::Up, 200.0, 50.0) });
|
||||
let Some(Action::Exec(c, v)) = acts.first() else { panic!("{acts:?}") };
|
||||
assert_eq!(c, "path.eraseRegion");
|
||||
assert_eq!(
|
||||
[&v["angleMode"], &v["angleVariation"], &v["roundnessMode"], &v["roundnessVariation"], &v["sizeMode"], &v["sizeVariation"]],
|
||||
[&json!("rotation"), &json!(90.0), &json!("random"), &json!(10.0), &json!("tilt"), &json!(8.0)]
|
||||
);
|
||||
assert_eq!(
|
||||
v["points"],
|
||||
json!([[100.0, 50.0, 0.5, 0.0, 0.0, 180.0], [200.0, 50.0, 0.5, 0.0, 0.0, 180.0]]),
|
||||
"the pen's readings go with the points"
|
||||
);
|
||||
// A mouse drag, which reads no tilt or rotation, keeps the set tip.
|
||||
t.pointer(&cx, &PointerEvent::new(PointerKind::Down, 100.0, 50.0));
|
||||
t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, 200.0, 50.0));
|
||||
near(tip(&t), (190.0, 47.5, 210.0, 52.5));
|
||||
t.pointer(&cx, &PointerEvent::new(PointerKind::Up, 200.0, 50.0));
|
||||
// A point without a reading after one with it goes back to the set tip there, as the release
|
||||
// erases it.
|
||||
t.pointer(&cx, &PointerEvent { pen, pressure: 0.5, ..PointerEvent::new(PointerKind::Down, 100.0, 50.0) });
|
||||
t.pointer(&cx, &PointerEvent { pressure: 0.5, ..PointerEvent::new(PointerKind::Drag, 200.0, 50.0) });
|
||||
near(tip(&t), (190.0, 47.5, 210.0, 52.5));
|
||||
t.pointer(&cx, &PointerEvent { pressure: 0.5, ..PointerEvent::new(PointerKind::Up, 200.0, 50.0) });
|
||||
// The Blob Brush's Size is Fixed or Pressure, and its Angle and Roundness have no menus.
|
||||
let mut b = GestureTool::new("blobBrush");
|
||||
b.set_option("sizeMode", &json!("tilt"));
|
||||
b.set_option("angleMode", &json!("rotation"));
|
||||
assert_eq!(b.options()["sizeMode"], json!("fixed"));
|
||||
assert!(b.options().get("angleMode").is_none() && b.options().get("roundnessVariation").is_none());
|
||||
b.set_option("sizeMode", &json!("pressure"));
|
||||
assert_eq!(b.options()["sizeMode"], json!("pressure"));
|
||||
}
|
||||
|
||||
/// The Eraser's tip outline follows a pen turned in place, which adds no stroke point, and the
|
||||
/// release erases with that reading; and its size passes 1296 pt as `path.eraseRegion` sweeps it.
|
||||
#[test]
|
||||
fn the_eraser_tip_turns_in_place_and_passes_a_nib_size() {
|
||||
let (d, _) = doc_with_rect();
|
||||
let s = Selection::default();
|
||||
let p = paint();
|
||||
let cx = cx(&d, &s, &p);
|
||||
let tip = |t: &GestureTool| match &t.overlays(&cx)[..] {
|
||||
[.., Overlay::Path { path, color: [0, 0, 0], width: 1.0, .. }] => vectorcraft_geom::Shape::bounding_box(path),
|
||||
o => panic!("{o:?}"),
|
||||
};
|
||||
let near = |b: vectorcraft_geom::Rect, want: (f64, f64, f64, f64)| {
|
||||
assert!([b.x0 - want.0, b.y0 - want.1, b.x1 - want.2, b.y1 - want.3].iter().all(|e| e.abs() < 0.01), "{b:?} vs {want:?}");
|
||||
};
|
||||
let mut t = GestureTool::new("eraser");
|
||||
for (k, v) in [("size", json!(20)), ("roundness", json!(25)), ("angleMode", json!("rotation")), ("angleVariation", json!(90))] {
|
||||
t.set_option(k, &v);
|
||||
}
|
||||
let level = Some(PenPose { tilt: None, rotation: Some(0.0) });
|
||||
let turned = Some(PenPose { tilt: None, rotation: Some(180.0) });
|
||||
t.pointer(&cx, &PointerEvent { pen: level, pressure: 0.5, ..PointerEvent::new(PointerKind::Down, 100.0, 50.0) });
|
||||
t.pointer(&cx, &PointerEvent { pen: level, pressure: 0.5, ..PointerEvent::new(PointerKind::Drag, 200.0, 50.0) });
|
||||
near(tip(&t), (190.0, 47.5, 210.0, 52.5));
|
||||
// The barrel turns half way round with the pointer still: the tip stands upright.
|
||||
t.pointer(&cx, &PointerEvent { pen: turned, pressure: 0.5, ..PointerEvent::new(PointerKind::Drag, 200.0, 50.0) });
|
||||
near(tip(&t), (197.5, 40.0, 202.5, 60.0));
|
||||
let acts = t.pointer(&cx, &PointerEvent { pen: turned, pressure: 0.5, ..PointerEvent::new(PointerKind::Up, 200.0, 50.0) });
|
||||
let Some(Action::Exec(_, v)) = acts.first() else { panic!("{acts:?}") };
|
||||
assert_eq!(v["points"], json!([[100.0, 50.0, 0.5, null, null, 0.0], [200.0, 50.0, 0.5, null, null, 180.0]]));
|
||||
// A release at the last point erases with the pose the pen has as it lifts.
|
||||
t.pointer(&cx, &PointerEvent { pen: level, pressure: 0.5, ..PointerEvent::new(PointerKind::Down, 100.0, 50.0) });
|
||||
t.pointer(&cx, &PointerEvent { pen: level, pressure: 0.5, ..PointerEvent::new(PointerKind::Drag, 200.0, 50.0) });
|
||||
let acts = t.pointer(&cx, &PointerEvent { pen: turned, pressure: 0.5, ..PointerEvent::new(PointerKind::Up, 200.0, 50.0) });
|
||||
let Some(Action::Exec(_, v)) = acts.first() else { panic!("{acts:?}") };
|
||||
assert_eq!(v["points"], json!([[100.0, 50.0, 0.5, null, null, 0.0], [200.0, 50.0, 0.5, null, null, 180.0]]));
|
||||
// A release there that reads no pose keeps the last one.
|
||||
t.pointer(&cx, &PointerEvent { pen: level, pressure: 0.5, ..PointerEvent::new(PointerKind::Down, 100.0, 50.0) });
|
||||
t.pointer(&cx, &PointerEvent { pen: turned, pressure: 0.5, ..PointerEvent::new(PointerKind::Drag, 200.0, 50.0) });
|
||||
let acts = t.pointer(&cx, &PointerEvent { pressure: 0.5, ..PointerEvent::new(PointerKind::Up, 200.0, 50.0) });
|
||||
let Some(Action::Exec(_, v)) = acts.first() else { panic!("{acts:?}") };
|
||||
assert_eq!(v["points"], json!([[100.0, 50.0, 0.5, null, null, 0.0], [200.0, 50.0, 0.5, null, null, 180.0]]));
|
||||
// With Angle on Bearing, a pen that leaned only at the last point and stands up there again
|
||||
// leaves no bearing: the tip lies level, as set.
|
||||
t.set_option("angleMode", &json!("bearing"));
|
||||
let upright = Some(PenPose { tilt: Some([0.0, 0.0]), rotation: None });
|
||||
let leaning = Some(PenPose { tilt: Some([0.0, 45.0]), rotation: None });
|
||||
t.pointer(&cx, &PointerEvent { pen: upright, pressure: 0.5, ..PointerEvent::new(PointerKind::Down, 100.0, 50.0) });
|
||||
t.pointer(&cx, &PointerEvent { pen: leaning, pressure: 0.5, ..PointerEvent::new(PointerKind::Drag, 200.0, 50.0) });
|
||||
assert!(tip(&t).height() > 6.0, "{:?}", tip(&t));
|
||||
t.pointer(&cx, &PointerEvent { pen: upright, pressure: 0.5, ..PointerEvent::new(PointerKind::Drag, 200.0, 50.0) });
|
||||
near(tip(&t), (190.0, 47.5, 210.0, 52.5));
|
||||
t.pointer(&cx, &PointerEvent { pen: upright, pressure: 0.5, ..PointerEvent::new(PointerKind::Up, 200.0, 50.0) });
|
||||
// Size 1000 with a Variation of 1000 on Tilt, the pen upright: a tip 2000 pt across.
|
||||
let mut t = GestureTool::new("eraser");
|
||||
for (k, v) in [("size", json!(1000)), ("sizeMode", json!("tilt")), ("sizeVariation", json!(1000))] {
|
||||
t.set_option(k, &v);
|
||||
}
|
||||
let upright = Some(PenPose { tilt: Some([0.0, 0.0]), rotation: None });
|
||||
t.pointer(&cx, &PointerEvent { pen: upright, pressure: 0.5, ..PointerEvent::new(PointerKind::Down, 100.0, 50.0) });
|
||||
t.pointer(&cx, &PointerEvent { pen: upright, pressure: 0.5, ..PointerEvent::new(PointerKind::Drag, 200.0, 50.0) });
|
||||
near(tip(&t), (-800.0, -950.0, 1200.0, 1050.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn smooth_needs_selection_and_brackets_resize() {
|
||||
let (d, _) = doc_with_rect();
|
||||
|
||||
@@ -59,6 +59,8 @@ pub struct Brackets {
|
||||
arc: ArcPath,
|
||||
/// [`TextKind::path_span`].
|
||||
span: (f64, f64),
|
||||
/// [`TextKind::path_text_start`]: below 0 when the type starts before an open path's start.
|
||||
text_start: f64,
|
||||
/// The end bracket was placed (not the end of the path, or once round it).
|
||||
end_set: bool,
|
||||
}
|
||||
@@ -78,7 +80,9 @@ impl Brackets {
|
||||
if !det.is_finite() || det.abs() < 1e-12 || arc.is_empty() {
|
||||
return None;
|
||||
}
|
||||
Some(Self { id, xf: t.xf, arc, span: t.kind.path_span()?, end_set: end.is_some() })
|
||||
let span = t.kind.path_span()?;
|
||||
let text_start = t.kind.path_text_start().unwrap_or(span.0);
|
||||
Some(Self { id, xf: t.xf, arc, span, text_start, end_set: end.is_some() })
|
||||
}
|
||||
|
||||
/// Where bracket `b` stands, as a fraction of the path's length (past 1 round a closed path).
|
||||
@@ -269,7 +273,13 @@ impl BracketDrag {
|
||||
d -= d.round();
|
||||
}
|
||||
let place = |x: f64| if closed { x.rem_euclid(1.0) } else { x.clamp(0.0, 1.0) };
|
||||
let start = place(s + d);
|
||||
// Type that starts before an open path's start slides from there, and goes no
|
||||
// further before the path than it was: both brackets stay where they are.
|
||||
let lead = !closed && b.text_start < 0.0;
|
||||
if lead {
|
||||
d = d.max(0.0);
|
||||
}
|
||||
let start = if lead { (b.text_start + d).min(1.0) } else { place(s + d) };
|
||||
m.insert("start".into(), json!(start));
|
||||
if b.end_set {
|
||||
m.insert("end".into(), json!(if closed { place(e + d) } else { place(e + d).max(start) }));
|
||||
@@ -409,6 +419,35 @@ mod tests {
|
||||
assert!(v.get("flip").is_none(), "{v}");
|
||||
}
|
||||
|
||||
/// Type that starts before its open path slides from where it starts, not from the path's start.
|
||||
#[test]
|
||||
fn dragging_the_center_bracket_slides_type_that_starts_before_the_path() {
|
||||
let (mut d, id) = doc_with_path_type();
|
||||
if let Some(vectorcraft_doc::NodeKind::Text(t)) = d.node_mut(id).map(|n| &mut n.kind)
|
||||
&& let TextKind::OnPath { start, .. } = &mut t.kind
|
||||
{
|
||||
*start = -0.25;
|
||||
}
|
||||
let s = selected(id);
|
||||
let p = paint();
|
||||
let cx = cx(&d, &s, &p);
|
||||
// The center bracket stands in the middle of the brackets (the start bracket at the path's start).
|
||||
let v = preview(&drag(&cx, Point::new(250.0, 295.0), Point::new(280.0, 295.0), Mods::default())).clone();
|
||||
assert!((v["start"].as_f64().unwrap() + 0.15).abs() < 1e-6, "{v}");
|
||||
// Back past where it started: it stays there, and so does a placed end bracket.
|
||||
let v = preview(&drag(&cx, Point::new(250.0, 295.0), Point::new(150.0, 295.0), Mods::default())).clone();
|
||||
assert!((v["start"].as_f64().unwrap() + 0.25).abs() < 1e-6, "{v}");
|
||||
if let Some(NodeKind::Text(t)) = d.node_mut(id).map(|n| &mut n.kind)
|
||||
&& let TextKind::OnPath { end, .. } = &mut t.kind
|
||||
{
|
||||
*end = Some(0.5);
|
||||
}
|
||||
let ended = crate::testutil::cx(&d, &s, &p);
|
||||
// The center bracket stands at 0.25 of the path, between the start bracket (0) and the end.
|
||||
let v = preview(&drag(&ended, Point::new(175.0, 295.0), Point::new(130.0, 295.0), Mods::default())).clone();
|
||||
assert!((v["start"].as_f64().unwrap() + 0.25).abs() < 1e-6 && (v["end"].as_f64().unwrap() - 0.5).abs() < 1e-6, "{v}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dragging_the_start_and_end_brackets_moves_them() {
|
||||
let (d, id) = doc_with_path_type();
|
||||
|
||||
@@ -43,6 +43,8 @@ const ROWS: &[Row] = &[
|
||||
(&["blobBrush"], None, &["fidelity", "keepSelected", "mergeOnlySelection"]),
|
||||
// Eraser Tool Options' tip shape.
|
||||
(&["blobBrush", "eraser"], None, &["angle", "roundness"]),
|
||||
// Eraser Tool Options' Angle and Roundness variation menus and fields.
|
||||
(&["eraser"], None, &["angleMode", "angleVariation", "roundnessMode", "roundnessVariation"]),
|
||||
// Live Paint Selection Tool Options (`builder::LivePaintSelectionTool`).
|
||||
(&["livePaintSelection"], None, &["selectFills", "selectStrokes", "color", "width"]),
|
||||
// Live Paint Bucket Options (`builder::LivePaintBucketTool`).
|
||||
|
||||
@@ -150,7 +150,7 @@ fn is_inline(lay: &TextLayout, gi: usize) -> bool {
|
||||
/// inline graphics, glyphs whose font is gone and type whose transform is collapsed.
|
||||
fn char_box(t: &TextObject, lay: &TextLayout, gi: usize) -> Option<CharBox> {
|
||||
let g: &PositionedGlyph = lay.glyphs.get(gi)?;
|
||||
if is_inline(lay, gi) {
|
||||
if is_inline(lay, gi) || g.off_path {
|
||||
return None;
|
||||
}
|
||||
let m = g.xf;
|
||||
@@ -161,7 +161,7 @@ fn char_box(t: &TextObject, lay: &TextLayout, gi: usize) -> Option<CharBox> {
|
||||
let corners: Vec<Point> = lay
|
||||
.glyphs
|
||||
.iter()
|
||||
.filter(|h| h.byte == g.byte && h.len > 0 && h.line == g.line)
|
||||
.filter(|h| h.byte == g.byte && h.len > 0 && h.line == g.line && !h.off_path)
|
||||
.filter_map(glyph_quad)
|
||||
.flatten()
|
||||
.map(|p| to_glyph * p)
|
||||
@@ -218,7 +218,8 @@ impl TouchTypeTool {
|
||||
let selected = std::iter::successors(Some(id), |c| cx.doc.parent_of(*c)).any(|c| cx.selection.contains(c));
|
||||
let t = text_of(cx, id).filter(|_| selected)?;
|
||||
let lay = self.layout(t);
|
||||
let gi = lay.glyphs.iter().position(|g| g.byte <= start && start < g.byte + g.len)?;
|
||||
// The character's first drawn glyph: glyphs before an open path's start are not drawn.
|
||||
let gi = lay.glyphs.iter().position(|g| g.byte <= start && start < g.byte + g.len && !g.off_path)?;
|
||||
Some((id, t, char_box(t, &lay, gi)?))
|
||||
}
|
||||
|
||||
@@ -230,7 +231,7 @@ impl TouchTypeTool {
|
||||
let lay = self.layout(t);
|
||||
let local = t.xf.inverse() * p;
|
||||
let boxes: Vec<(usize, [Point; 4])> = (lay.glyphs.iter().enumerate())
|
||||
.filter(|(gi, g)| g.len > 0 && !is_inline(&lay, *gi))
|
||||
.filter(|(gi, g)| g.len > 0 && !g.off_path && !is_inline(&lay, *gi))
|
||||
.filter_map(|(_, g)| Some((g.byte, glyph_quad(g)?)))
|
||||
.collect();
|
||||
let center = |q: &[Point; 4]| q[0].midpoint(q[2]);
|
||||
@@ -360,7 +361,9 @@ impl Tool for TouchTypeTool {
|
||||
let Some(t) = text_of(cx, id) else { return out };
|
||||
let lay = self.layout(t);
|
||||
let start = self.target.map_or(0, |(_, s)| s);
|
||||
let Some(b) = lay.glyphs.iter().position(|g| g.byte == start && g.len > 0).and_then(|gi| char_box(t, &lay, gi)) else {
|
||||
// The character's first drawn glyph: glyphs before an open path's start are not drawn.
|
||||
let first = lay.glyphs.iter().position(|g| g.byte == start && g.len > 0 && !g.off_path);
|
||||
let Some(b) = first.and_then(|gi| char_box(t, &lay, gi)) else {
|
||||
return out;
|
||||
};
|
||||
(Part::Move, b)
|
||||
|
||||
@@ -388,6 +388,60 @@ fn the_box_of_a_character_drawn_with_two_glyphs_holds_both() {
|
||||
}
|
||||
}
|
||||
|
||||
/// A character of type on an open path that is not drawn, because it falls before the path's
|
||||
/// start, has no box, so the tool shows no handles for it; a drawn character of the same type has
|
||||
/// one.
|
||||
#[test]
|
||||
fn a_character_before_an_open_paths_start_has_no_box() {
|
||||
let mut d = Document::new(600.0, 500.0);
|
||||
let l = d.layers[0].id;
|
||||
let id = d.alloc_id();
|
||||
let mut text = TextObject::point(Point::ZERO, "Type", CharStyle { size: 40.0, ..Default::default() });
|
||||
let line =
|
||||
vectorcraft_geom::PathData::new(vec![vectorcraft_geom::SubPath::polyline(&[Point::new(100.0, 200.0), Point::new(500.0, 200.0)], false)]);
|
||||
text.kind = vectorcraft_doc::TextKind::OnPath { path: line, start: -0.05, end: None };
|
||||
d.insert(Some(l), 0, Node::new(id, NodeKind::Text(Box::new(text.clone())))).unwrap();
|
||||
let mut s = Selection::default();
|
||||
s.add(id);
|
||||
let p = paint();
|
||||
let cx = cx(&d, &s, &p);
|
||||
let lay = vectorcraft_text::layout(FontDb::global(), &text);
|
||||
assert!(lay.glyphs[0].off_path && !lay.glyphs[1].off_path, "the T is before the path's start, the y is on it");
|
||||
assert!(char_box(&text, &lay, 0).is_none());
|
||||
assert!(TouchTypeTool { target: Some((id, 0)), ..Default::default() }.current(&cx).is_none());
|
||||
assert!(TouchTypeTool { target: Some((id, 1)), ..Default::default() }.current(&cx).is_some());
|
||||
// An All Caps ß is two glyphs: with the first before the path's start and the second drawn, the
|
||||
// character has the second's box.
|
||||
let mut caps = text.clone();
|
||||
caps.runs = vec![vectorcraft_doc::TextRun::new("ße", CharStyle { size: 40.0, all_caps: true, ..Default::default() })];
|
||||
if let vectorcraft_doc::TextKind::OnPath { start, .. } = &mut caps.kind {
|
||||
*start = 0.0;
|
||||
}
|
||||
let adv = vectorcraft_text::layout(FontDb::global(), &caps).glyphs[0].advance;
|
||||
if let vectorcraft_doc::TextKind::OnPath { start, .. } = &mut caps.kind {
|
||||
*start = -1.2 * adv / 400.0;
|
||||
}
|
||||
let mut d = Document::new(600.0, 500.0);
|
||||
let l = d.layers[0].id;
|
||||
let id = d.alloc_id();
|
||||
d.insert(Some(l), 0, Node::new(id, NodeKind::Text(Box::new(caps.clone())))).unwrap();
|
||||
let mut s = Selection::default();
|
||||
s.add(id);
|
||||
let cx = crate::testutil::cx(&d, &s, &p);
|
||||
let lay = vectorcraft_text::layout(FontDb::global(), &caps);
|
||||
assert!(lay.glyphs[0].byte == 0 && lay.glyphs[1].byte == 0 && lay.glyphs[0].off_path && !lay.glyphs[1].off_path);
|
||||
let t = TouchTypeTool { target: Some((id, 0)), ..Default::default() };
|
||||
let (_, _, b) = t.current(&cx).expect("the ß has a box");
|
||||
assert!((b.pen - lay.glyphs[1].xf * Point::ZERO).hypot() < 1e-9, "{:?}", b.pen);
|
||||
// A press on its drawn glyph, on the path, starts a drag of it.
|
||||
let q = glyph_quad(&lay.glyphs[1]).unwrap();
|
||||
let at = Point::new(q[0].midpoint(q[2]).x, 200.0);
|
||||
assert!(in_quad(&q, at), "{at:?} {q:?}");
|
||||
let mut t = TouchTypeTool::default();
|
||||
let acts = drag(&mut t, &cx, at, at + Vec2::new(0.0, -5.0));
|
||||
assert_eq!(preview(&acts[1])["start"], 0, "{:?}", acts[1]);
|
||||
}
|
||||
|
||||
/// A glyph skewed by its transform (the Skew effect of type on a path) keeps its own box.
|
||||
#[test]
|
||||
fn a_skewed_glyph_keeps_its_own_box() {
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user