mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-11-12 23:05:05 +00:00
Merge main into feat/weed-box-and-lines
This commit is contained in:
@@ -176,6 +176,7 @@ pub const BUILTIN: &[BuiltinBrushLibrary] = &[
|
||||
BuiltinBrushLibrary { id: "scatter-shapes", name: "Scatter Shapes", make: scatter_shapes },
|
||||
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 },
|
||||
];
|
||||
|
||||
/// Built-in library `id`, made once.
|
||||
@@ -474,6 +475,170 @@ fn energy_beams() -> BrushLibrary {
|
||||
built("Energy Beams", Document::new(100.0, 100.0), brushes)
|
||||
}
|
||||
|
||||
/// The fixed-seed number source of the Dry Media grain: integer steps and plain arithmetic, so the
|
||||
/// art is the same on every run and every platform.
|
||||
struct Grain(u64);
|
||||
|
||||
impl Grain {
|
||||
/// The next number in 0..1.
|
||||
fn next(&mut self) -> f64 {
|
||||
self.0 = self.0.wrapping_mul(6_364_136_223_846_793_005).wrapping_add(1_442_695_040_888_963_407);
|
||||
(self.0 >> 11) as f64 / (1u64 << 53) as f64
|
||||
}
|
||||
/// The next number in `a`..`b`.
|
||||
fn range(&mut self, a: f64, b: f64) -> f64 {
|
||||
a + (b - a) * self.next()
|
||||
}
|
||||
}
|
||||
|
||||
/// `floor` at x = `x0` and `x1`, rising smoothly to 1 within `ramp` pt of each.
|
||||
fn ends(x: f64, x0: f64, x1: f64, ramp: f64, floor: f64) -> f64 {
|
||||
let t = ((x - x0).min(x1 - x) / ramp).clamp(0.0, 1.0);
|
||||
floor + (1.0 - floor) * t * t * (3.0 - 2.0 * t)
|
||||
}
|
||||
|
||||
/// A closed band from x = `x0` to `x1` in `n` steps, at most `half` pt either side of y = 0 times
|
||||
/// `taper(x)`; each edge point is pulled in by up to `rough` of that.
|
||||
fn rough_band(g: &mut Grain, (x0, x1): (f64, f64), n: usize, half: f64, rough: f64, taper: impl Fn(f64) -> f64) -> PathData {
|
||||
let xs: Vec<f64> = (0..=n).map(|i| x0 + (x1 - x0) * i as f64 / n as f64).collect();
|
||||
let mut pts: Vec<Point> = xs.iter().map(|&x| Point::new(x, -half * taper(x) * (1.0 - rough * g.next()))).collect();
|
||||
let bottom: Vec<Point> = xs.iter().rev().map(|&x| Point::new(x, half * taper(x) * (1.0 - rough * g.next()))).collect();
|
||||
pts.extend(bottom);
|
||||
PathData::single(SubPath::polyline(&pts, true))
|
||||
}
|
||||
|
||||
/// A small four-cornered fleck around (`cx`, `cy`), at most `w` × `h` pt either side; one corner
|
||||
/// in each direction, so its outline never crosses itself.
|
||||
fn fleck(g: &mut Grain, cx: f64, cy: f64, w: f64, h: f64) -> PathData {
|
||||
let mut side = |a: f64, b: f64| g.range(a, b);
|
||||
let pts = [
|
||||
(cx - w * side(0.6, 1.0), cy + h * side(-0.3, 0.3)),
|
||||
(cx + w * side(-0.3, 0.3), cy - h * side(0.6, 1.0)),
|
||||
(cx + w * side(0.6, 1.0), cy + h * side(-0.3, 0.3)),
|
||||
(cx + w * side(-0.3, 0.3), cy + h * side(0.6, 1.0)),
|
||||
];
|
||||
polygon(&pts)
|
||||
}
|
||||
|
||||
/// `body` with `holes` (inside it, apart from each other) cut out, even-odd, in gray `k`: what
|
||||
/// lies under the stroke shows through the holes.
|
||||
fn holed(body: PathData, holes: Vec<PathData>, k: f32) -> Node {
|
||||
let children = std::iter::once(body).chain(holes).map(|p| Arc::new(Node::path(NodeId(0), p, Appearance::default()))).collect();
|
||||
let mut n = Node::new(NodeId(0), NodeKind::Compound { children, rule: FillRule::EvenOdd });
|
||||
n.appearance = Appearance { items: vec![AppearanceItem::Fill(FillLayer::new(Paint::solid(Color::gray(k))))], ..Default::default() }.into();
|
||||
n
|
||||
}
|
||||
|
||||
/// Path `p` filled with gray `k` (1 is black).
|
||||
fn ink(p: PathData, k: f32) -> Node {
|
||||
filled(p, Paint::solid(Color::gray(k)))
|
||||
}
|
||||
|
||||
/// Slivers `w` × `h` pt (ranges) in `count` slots along x = 6..94 inside a band `half` pt wide
|
||||
/// times `taper`. Each stays within `reach` of the band's half width from y = 0, taken where the
|
||||
/// band is narrowest over the sliver and the 3 pt either side of it, so with `reach` below the
|
||||
/// band's roughness margin no sliver touches the band's edge or another sliver.
|
||||
fn slivers(g: &mut Grain, count: usize, half: f64, reach: f64, (w, h): ((f64, f64), (f64, f64)), taper: impl Fn(f64) -> f64) -> Vec<PathData> {
|
||||
let slot = 88.0 / count as f64;
|
||||
(0..count)
|
||||
.map(|i| {
|
||||
let cx = 6.0 + slot * (i as f64 + 0.5);
|
||||
let (w, h) = (g.range(w.0, w.1), g.range(h.0, h.1));
|
||||
let room = reach * half * taper(cx - w - 3.0).min(taper(cx + w + 3.0));
|
||||
let cy = g.range(-1.0, 1.0) * (room - h).max(0.0);
|
||||
fleck(g, cx, cy, w, h)
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Dry Media: art brushes of grainy dry drawing marks, chalk, pencil, crayon and charcoal (original
|
||||
/// vector grain from fixed seeds; every brush 100 pt long, black and grays that take the stroke
|
||||
/// color as tints, gaps cut out so the paper shows).
|
||||
pub(crate) fn dry_media() -> BrushLibrary {
|
||||
// Chalk Grain, 100 × 8 pt: a soft band with narrow scratches and chips along its edges.
|
||||
let chalk = {
|
||||
let mut g = Grain(0x0c4a_1c0a);
|
||||
let taper = |x: f64| ends(x, 0.0, 100.0, 10.0, 0.35);
|
||||
let body = rough_band(&mut g, (0.0, 100.0), 36, 4.0, 0.25, taper);
|
||||
let scratches = slivers(&mut g, 14, 4.0, 0.7, ((1.2, 2.4), (0.15, 0.3)), taper);
|
||||
let mut parts = vec![holed(body, scratches, 0.8)];
|
||||
for i in 0..18 {
|
||||
let cx = 3.0 + 94.0 * (i as f64 + g.range(0.2, 0.8)) / 18.0;
|
||||
let side = if i % 2 == 0 { -1.0 } else { 1.0 };
|
||||
let cy = side * 4.0 * taper(cx) * g.range(0.7, 0.95);
|
||||
let (w, h) = (g.range(0.3, 0.7), g.range(0.25, 0.5));
|
||||
let f = fleck(&mut g, cx, cy, w, h);
|
||||
parts.push(ink(f, 1.0));
|
||||
}
|
||||
group(parts)
|
||||
};
|
||||
// Graphite Pencil, 100 × 2.5 pt: a solid tapered core with fine slanted hatching at its edges.
|
||||
let pencil = {
|
||||
let mut g = Grain(0x009e_4c11);
|
||||
let core = rough_band(&mut g, (0.0, 100.0), 40, 0.8, 0.12, |x| ends(x, 0.0, 100.0, 18.0, 0.25));
|
||||
let mut parts = vec![ink(core, 1.0)];
|
||||
for i in 0..24 {
|
||||
let x0 = 2.0 + 92.0 * (i as f64 + g.range(0.0, 0.4)) / 24.0;
|
||||
let len = g.range(1.5, 3.0);
|
||||
let t = g.range(0.12, 0.2);
|
||||
let slant = g.range(0.1, 0.3);
|
||||
let y0 = if i % 2 == 0 { -g.range(0.85, 1.05) } else { g.range(0.85, 1.25) - t - slant };
|
||||
parts.push(ink(polygon(&[(x0, y0 + slant), (x0 + len, y0), (x0 + len, y0 + t), (x0, y0 + slant + t)]), 0.6));
|
||||
}
|
||||
group(parts)
|
||||
};
|
||||
// Wax Crayon, 100 × 6 pt: a dense band with small gaps in its middle and short overlapping
|
||||
// marks along both edges.
|
||||
let crayon = {
|
||||
let mut g = Grain(0x00c0_a7e5);
|
||||
let taper = |x: f64| ends(x, 0.0, 100.0, 6.0, 0.5);
|
||||
let body = rough_band(&mut g, (0.0, 100.0), 30, 2.6, 0.2, taper);
|
||||
let gaps = slivers(&mut g, 16, 2.6, 0.34, ((0.6, 1.6), (0.15, 0.3)), taper);
|
||||
let mut parts = vec![holed(body, gaps, 0.9)];
|
||||
for i in 0..36 {
|
||||
let w = g.range(1.0, 2.2);
|
||||
let cx = (1.0 + w).max(98.0 * (i as f64 + g.range(0.0, 1.0)) / 36.0).min(99.0 - w);
|
||||
let side = if i % 2 == 0 { -1.0 } else { 1.0 };
|
||||
let h = g.range(0.4, 0.6);
|
||||
// Clear of the gaps, which stay within 0.9 pt of the middle.
|
||||
let inner = 0.9 + h;
|
||||
let cy = side * g.range(inner, (2.4 * taper(cx)).max(inner));
|
||||
let f = fleck(&mut g, cx, cy, w, h);
|
||||
parts.push(ink(f, 1.0));
|
||||
}
|
||||
group(parts)
|
||||
};
|
||||
// Charcoal Dust, 100 × 7 pt: a dark band broken into pieces, with flecks of dust either side.
|
||||
let dust = {
|
||||
let mut g = Grain(0xc4a2_c0a1);
|
||||
let mut parts = vec![];
|
||||
let mut x0 = 0.0;
|
||||
while x0 < 100.0 {
|
||||
let x1 = (x0 + g.range(14.0, 24.0)).min(100.0);
|
||||
let x1 = if 100.0 - x1 < 8.0 { 100.0 } else { x1 };
|
||||
let piece = rough_band(&mut g, (x0, x1), 10, 2.0, 0.3, |x| ends(x, 0.0, 100.0, 8.0, 0.4) * ends(x, x0, x1, 2.0, 0.3));
|
||||
parts.push(ink(piece, 1.0));
|
||||
x0 = x1 + g.range(1.5, 3.0);
|
||||
}
|
||||
for i in 0..30 {
|
||||
let cx = 2.0 + 96.0 * (i as f64 + g.range(0.2, 0.8)) / 30.0;
|
||||
let side = if g.next() < 0.5 { -1.0 } else { 1.0 };
|
||||
let s = g.range(0.15, 0.3);
|
||||
let cy = side * g.range(2.3, 3.5 - s);
|
||||
let f = fleck(&mut g, cx, cy, s, s);
|
||||
parts.push(ink(f, if i % 3 == 0 { 0.7 } else { 1.0 }));
|
||||
}
|
||||
group(parts)
|
||||
};
|
||||
let brushes = vec![
|
||||
art("Chalk Grain", chalk, Colorization::Tints),
|
||||
art("Graphite Pencil", pencil, Colorization::Tints),
|
||||
art("Wax Crayon", crayon, Colorization::Tints),
|
||||
art("Charcoal Dust", dust, Colorization::Tints),
|
||||
];
|
||||
built("Dry Media", Document::new(100.0, 100.0), brushes)
|
||||
}
|
||||
|
||||
// ---------- commands ----------
|
||||
|
||||
fn builtin_info(b: &BuiltinBrushLibrary) -> LibraryInfo {
|
||||
|
||||
@@ -92,9 +92,9 @@ pub use pdfimport::{LoadOptions, page_document};
|
||||
pub(crate) use save::job_for;
|
||||
pub(crate) use save::save as save_document;
|
||||
pub use save::{
|
||||
SAVE_FORMATS, SaveJob, SaveMode, SavePlan, StreamWrite, TEMPLATE_FORMATS, dialog_folder, export_folder, leave_out_unreadable_save_paths,
|
||||
reads_option as save_reads_option, remembered_save_options, save_filters, save_format, save_job, save_plan, save_with, stamp_save_dates,
|
||||
template_filters, templates_dialog_folder, templates_folder,
|
||||
SAVE_FORMATS, SaveJob, SaveMode, SavePlan, StreamWrite, TEMPLATE_FORMATS, dialog_folder, documents_dialog_folder, export_folder,
|
||||
leave_out_unreadable_save_paths, reads_option as save_reads_option, remembered_save_options, save_filters, save_format, save_job, save_plan,
|
||||
save_with, stamp_save_dates, template_filters, templates_dialog_folder, templates_folder,
|
||||
};
|
||||
pub(crate) use screens::{
|
||||
PARAM_KEYS as SCREEN_PARAM_KEYS, ROW_KEYS as SCREEN_ROW_KEYS, UNWRITTEN_FORMATS as SCREEN_UNWRITTEN_FORMATS,
|
||||
@@ -123,7 +123,10 @@ mod formats;
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
pub(crate) use access::list_dir;
|
||||
pub use access::write_file_with;
|
||||
pub use access::{write_file_with, write_save_file, write_save_file_with};
|
||||
|
||||
#[cfg(all(test, not(target_arch = "wasm32")))]
|
||||
mod tests_readonly;
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
pub(crate) use access::{Placed, copy_new, file_metadata};
|
||||
pub(crate) use access::{
|
||||
|
||||
@@ -112,6 +112,31 @@ pub fn write_file_with(path: &str, fill: &mut dyn FnMut(&mut dyn std::io::Write)
|
||||
vectorcraft_format::write_atomic_with(std::path::Path::new(path), |f| fill(f)).map_err(|e| EngineError::Other(format!("{path}: {e}")))
|
||||
}
|
||||
|
||||
/// The save commands' writer: preserves a filesystem location refusal as a typed error. Other
|
||||
/// writers keep their existing errors; an automation-root refusal is never reclassified.
|
||||
pub fn write_save_file(path: &str, bytes: &[u8]) -> Result<()> {
|
||||
write_save_file_with(path, &mut |w| w.write_all(bytes))
|
||||
}
|
||||
|
||||
/// [`write_save_file`] for a streamed save.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
pub fn write_save_file_with(path: &str, fill: &mut dyn FnMut(&mut dyn std::io::Write) -> std::io::Result<()>) -> Result<()> {
|
||||
vectorcraft_doc::cancel::check()?;
|
||||
may_write(path)?;
|
||||
vectorcraft_format::write_atomic_with(std::path::Path::new(path), |f| fill(f)).map_err(|e| save_write_error(path, e))
|
||||
}
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
pub(super) fn save_write_error(path: &str, error: std::io::Error) -> EngineError {
|
||||
let reason = format!("{path}: {error}");
|
||||
if vectorcraft_format::read_only_location(&error) { EngineError::ReadOnlyLocation(reason) } else { EngineError::Other(reason) }
|
||||
}
|
||||
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
pub fn write_save_file_with(path: &str, fill: &mut dyn FnMut(&mut dyn std::io::Write) -> std::io::Result<()>) -> Result<()> {
|
||||
write_file_with(path, fill)
|
||||
}
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
pub(crate) fn create_dir(path: &str) -> Result<()> {
|
||||
may_write(path)?;
|
||||
|
||||
@@ -825,6 +825,9 @@ pub(crate) fn size_note(bytes: &[u8]) -> Option<String> {
|
||||
/// A TIFF with more strips or tiles than the decoder can count
|
||||
/// ([`vectorcraft_doc::cmyk::oversized_tiff`]) is refused as too large to draw. JPEG XL images are
|
||||
/// read with [`jxlread`] and stored as PNG, with the ICC profile their colors are left in.
|
||||
/// A PNG, JPEG, GIF, WebP or TIFF whose orientation ([`exif_orientation`]) turns or flips it is
|
||||
/// stored as it is shown, with its resolution's axes swapped when it turns a quarter; a CMYK JPEG
|
||||
/// or TIFF keeps its inks, 8 bits each, as a CMYK TIFF ([`vectorcraft_doc::cmyk::turned_cmyk`]).
|
||||
pub fn raster_image(bytes: &[u8]) -> Result<RasterImage> {
|
||||
let ppi = super::ppi::resolution(bytes);
|
||||
if let Some((w, h)) = vectorcraft_doc::cmyk::oversized_tiff(bytes) {
|
||||
@@ -863,7 +866,16 @@ pub fn raster_image(bytes: &[u8]) -> Result<RasterImage> {
|
||||
let f = image_format(kind).ok_or_else(|| err(format!("{kind:?} images can't be opened (see document.formats)")))?;
|
||||
// A photo whose EXIF orientation turns or flips it (a camera held upright) is stored as it is
|
||||
// seen, so it places the way it was taken (#4284); the others keep their own bytes.
|
||||
let turned = matches!(f.id, "png" | "jpg" | "gif" | "webp" | "tiff") && exif_orientation(bytes).is_some();
|
||||
let orientation = matches!(f.id, "png" | "jpg" | "gif" | "webp" | "tiff").then(|| exif_orientation(bytes)).flatten();
|
||||
let turned = orientation.is_some();
|
||||
// The resolution describes the stored pixels; a quarter turn swaps its axes.
|
||||
let quarter = orientation.is_some_and(|o| o.to_exif() >= 5);
|
||||
let shown_ppi = ppi.map(|(x, y)| if quarter { (y, x) } else { (x, y) });
|
||||
if let Some(orientation) = orientation.filter(|_| matches!(f.id, "jpg" | "tiff"))
|
||||
&& let Some((tiff, size)) = vectorcraft_doc::cmyk::turned_cmyk(bytes, orientation, shown_ppi)
|
||||
{
|
||||
return stored(tiff, "image/tiff", size, shown_ppi);
|
||||
}
|
||||
let kept = match f.id {
|
||||
_ if turned => None,
|
||||
"png" | "jpg" | "gif" | "webp" => Some(bytes.to_vec()),
|
||||
@@ -879,36 +891,39 @@ pub fn raster_image(bytes: &[u8]) -> Result<RasterImage> {
|
||||
vectorcraft_doc::cmyk::extra_samples_tiff_rgba(bytes, |c| cms.cmyk_to_srgb(c, false))
|
||||
};
|
||||
let mut img = match (f.id == "tiff").then(extra_samples).flatten() {
|
||||
Some(img) => img,
|
||||
Some(img) => image::DynamicImage::ImageRgba8(img),
|
||||
None => match (f.id == "tiff").then(|| vectorcraft_doc::cmyk::group3_tiff_rgba(bytes)).flatten() {
|
||||
Some(read) => read.map_err(err)?,
|
||||
Some(read) => image::DynamicImage::ImageRgba8(read.map_err(err)?),
|
||||
None => {
|
||||
use image::ImageDecoder as _;
|
||||
let mut decoder = reader.into_decoder().map_err(err)?;
|
||||
let decoder = reader.into_decoder().map_err(err)?;
|
||||
// The allowance counts the decoded samples and the RGBA pixels they become (#1717).
|
||||
let (w, h) = decoder.dimensions();
|
||||
if decoder.total_bytes() > MAX_RASTER_ALLOC || u64::from(w) * u64::from(h) * 4 > MAX_RASTER_ALLOC {
|
||||
return Err(err(format!("an image of {w} × {h} pixels is too large to draw")));
|
||||
}
|
||||
let orientation = decoder.orientation().unwrap_or(image::metadata::Orientation::NoTransforms);
|
||||
let mut img = image::DynamicImage::from_decoder(decoder).map_err(err)?;
|
||||
img.apply_orientation(orientation);
|
||||
img.to_rgba8()
|
||||
image::DynamicImage::from_decoder(decoder).map_err(err)?
|
||||
}
|
||||
},
|
||||
};
|
||||
img.apply_orientation(orientation.unwrap_or(image::metadata::Orientation::NoTransforms));
|
||||
let mut img = img.into_rgba8();
|
||||
if f.id == "tga" {
|
||||
super::tgaread::fix_alpha(bytes, &mut img);
|
||||
}
|
||||
as_png(img, None, ppi)
|
||||
as_png(img, None, shown_ppi)
|
||||
}
|
||||
|
||||
/// The EXIF orientation of encoded image `bytes` when it turns or flips the pixels; `None` when
|
||||
/// it leaves them as stored, or the image has none.
|
||||
/// it leaves them as stored, or the image has none. A TIFF the image decoder can't open (one with
|
||||
/// extra samples) has its Orientation tag read from its first image
|
||||
/// ([`vectorcraft_doc::cmyk::tiff_orientation`]).
|
||||
pub(crate) fn exif_orientation(bytes: &[u8]) -> Option<image::metadata::Orientation> {
|
||||
use image::ImageDecoder as _;
|
||||
let mut decoder = image::ImageReader::new(Cursor::new(bytes)).with_guessed_format().ok()?.into_decoder().ok()?;
|
||||
decoder.orientation().ok().filter(|o| *o != image::metadata::Orientation::NoTransforms)
|
||||
use image::metadata::Orientation;
|
||||
let decoded = || image::ImageReader::new(Cursor::new(bytes)).with_guessed_format().ok()?.into_decoder().ok()?.orientation().ok();
|
||||
let tagged = || vectorcraft_doc::cmyk::tiff_orientation(bytes).and_then(|v| Orientation::from_exif(u8::try_from(v).ok()?));
|
||||
decoded().or_else(tagged).filter(|o| *o != Orientation::NoTransforms)
|
||||
}
|
||||
|
||||
/// `img` stored as a PNG that keeps the file's resolution and carries `icc`, the ICC profile its
|
||||
|
||||
@@ -157,6 +157,11 @@ fn documents_folder() -> Option<std::path::PathBuf> {
|
||||
Some(home.join("Documents"))
|
||||
}
|
||||
|
||||
/// A Save As after a refused Save starts in Documents, else home, never the refused folder.
|
||||
pub fn documents_dialog_folder() -> Option<String> {
|
||||
documents_folder().filter(|p| p.is_dir()).map(|p| p.to_string_lossy().into_owned()).or_else(home_folder)
|
||||
}
|
||||
|
||||
/// `XDG_DOCUMENTS_DIR` in a `user-dirs.dirs` file's `text`, `$HOME` being `home`. None when it isn't
|
||||
/// set, is relative, or is the home folder itself (the spec's way of turning it off).
|
||||
fn xdg_documents(text: &str, home: &std::path::Path) -> Option<std::path::PathBuf> {
|
||||
@@ -1043,7 +1048,7 @@ pub(crate) fn save(s: &mut Session, mode: SaveMode, p: &Value) -> Result<Value>
|
||||
let plan = save_plan(s, mode, p)?;
|
||||
let job = save_job(s, plan)?;
|
||||
let uid = s.doc()?.uid;
|
||||
let mut out = job.write_streamed(&mut super::write_file_with)?;
|
||||
let mut out = job.write_streamed(&mut super::write_save_file_with)?;
|
||||
job.complete(s, uid);
|
||||
leave_out_unreadable_save_paths(&mut out, str_param(p, "path"));
|
||||
Ok(out)
|
||||
@@ -1170,7 +1175,7 @@ pub(super) fn specs() -> Vec<CommandSpec> {
|
||||
"Save Document",
|
||||
[],
|
||||
None,
|
||||
"{path?, format?: vectorcraft|template|pdf|svg|svgz|ai|eps (default: the path's extension, else the document's own format), options?: {…the format's options, see file.formatOptions; default: as last saved}, svg?: {…SVG options} (an SVG save keeps hidden layers, display:none, unless hiddenLayers is false), …the SVG and PDF options given flat instead (an option given flat or in svg and in options with different values is an error), compress?: bool (native, also in options: gzip; default: the useCompression preference; warnings say when the format doesn't read compress, version, preview or precision given flat, or a key given in options), version?: 4 (3, 2 or 1: for older VectorCraft versions; 1 and 2 are never compressed), preview?: false (embed a PNG of the first artboard, at most 256 px), precision?: 0..9 (native: round path anchors and handles to this many decimal places, a smaller file; colors, transforms and type stay exact; default: exact, and a later save without it writes exactly again), separateArtboards?: false (native and .ai: also save each artboard of `range` to <name>-<artboard>.<ext> beside the file, holding that artboard and the art touching it; the result's files lists every file written), range?: \"1-3, 5\"|\"all\" (default; with separateArtboards), includeLinked?: false (keep linked files' own pixels, not just their previews; they stay linked), embedProfiles?: true (carry the ICC profiles loaded from files that the document is tagged with; opening the file installs them where missing), pdfCompatible?: true (native: also carry a PDF of every artboard; false omits it) | true for .ai (false: blank pages, only the native document; a .ai file's compress, default true, is its compression.compressText), modified?: Unix seconds|null (the File Info modified date, and created date when there is none, a save to a file stamps; default now, recorded in the journal so a replay matches; null: leave the dates)} → {path, format, bytes, warnings, files?: [path…] (the file and its artboards' files), linked?: [path…] (images an SVG links to)}. Save writes one artboard, except a .ai file and EPS (below): a .ai file is a PDF-compatible file of every artboard carrying the native document (preserveEditing always on; PDF options flat or in options), which document.open restores exactly, on the active artboard (the app's, else the one the document opened on; with editingData: false, the first); separateArtboards writes more files, one per artboard. EPS takes the options of document.exportEps: by default one file whose bounding box fits the visible art (the first artboard when no art shows), carrying the whole editable document; useArtboards: true (also when artboard, artboards or range names artboards; file.formatOptions then reports it true) makes the chosen artboard the page, drawn with its background color (without Use Artboards, only the first artboard's page when no art shows has its color), and with two or more chosen the file at the path is the central file of the visible art (again the first artboard when none shows) and each artboard is also written to <name>_<artboard>.eps beside it, holding that artboard; selectedOnly is refused; its PostScript appearance flattens transparency, as EPS requires. Without a path it writes the document's own file in its own format: a document opened from or saved as SVG/PDF/EPS saves as that again (warnings name what the format loses). No path known (never saved, converted from an older version, restored by Data Recovery, or another format) → {dataBase64, format, name, folder?, warnings} and the document stays modified. Under automation read roots, a reply leaves out the document's own file outside them when no path is given (files and linked then name the files by their file names), and a suggested folder outside them. A key that no save format takes, beside the path or in options, and an unknown key inside a PDF option's object or svg, are refused",
|
||||
"{path?, format?: vectorcraft|template|pdf|svg|svgz|ai|eps (default: the path's extension, else the document's own format), options?: {…the format's options, see file.formatOptions; default: as last saved}, svg?: {…SVG options} (an SVG save keeps hidden layers, display:none, unless hiddenLayers is false), …the SVG and PDF options given flat instead (an option given flat or in svg and in options with different values is an error), compress?: bool (native, also in options: gzip; default: the useCompression preference; warnings say when the format doesn't read compress, version, preview or precision given flat, or a key given in options), version?: 4 (3, 2 or 1: for older VectorCraft versions; 1 and 2 are never compressed), preview?: false (embed a PNG of the first artboard, at most 256 px), precision?: 0..9 (native: round path anchors and handles to this many decimal places, a smaller file; colors, transforms and type stay exact; default: exact, and a later save without it writes exactly again), separateArtboards?: false (native and .ai: also save each artboard of `range` to <name>-<artboard>.<ext> beside the file, holding that artboard and the art touching it; the result's files lists every file written), range?: \"1-3, 5\"|\"all\" (default; with separateArtboards), includeLinked?: false (keep linked files' own pixels, not just their previews; they stay linked), embedProfiles?: true (carry the ICC profiles loaded from files that the document is tagged with; opening the file installs them where missing), pdfCompatible?: true (native: also carry a PDF of every artboard; false omits it) | true for .ai (false: blank pages, only the native document; a .ai file's compress, default true, is its compression.compressText), modified?: Unix seconds|null (the File Info modified date, and created date when there is none, a save to a file stamps; default now, recorded in the journal so a replay matches; null: leave the dates)} → {path, format, bytes, warnings, files?: [path…] (the file and its artboards' files), linked?: [path…] (images an SVG links to)}. Save writes one artboard, except a .ai file and EPS (below): a .ai file is a PDF-compatible file of every artboard carrying the native document (preserveEditing always on; PDF options flat or in options), which document.open restores exactly, on the active artboard (the app's, else the one the document opened on; with editingData: false, the first); separateArtboards writes more files, one per artboard. EPS takes the options of document.exportEps: by default one file whose bounding box fits the visible art (the first artboard when no art shows), carrying the whole editable document; useArtboards: true (also when artboard, artboards or range names artboards; file.formatOptions then reports it true) makes the chosen artboard the page, drawn with its background color (without Use Artboards, only the first artboard's page when no art shows has its color), and with two or more chosen the file at the path is the central file of the visible art (again the first artboard when none shows) and each artboard is also written to <name>_<artboard>.eps beside it, holding that artboard; selectedOnly is refused; its PostScript appearance flattens transparency, as EPS requires. Without a path it writes the document's own file in its own format: a document opened from or saved as SVG/PDF/EPS saves as that again (warnings name what the format loses). No path known (never saved, converted from an older version, restored by Data Recovery, or another format) → {dataBase64, format, name, folder?, warnings} and the document stays modified. Under automation read roots, a reply leaves out the document's own file outside them when no path is given (files and linked then name the files by their file names), and a suggested folder outside them. A key that no save format takes, beside the path or in options, and an unknown key inside a PDF option's object or svg, are refused. A terminal filesystem permission/read-only refusal returns READ_ONLY_LOCATION: followed by the reason; recovery-copy failures keep their original error. Non-UI callers never open a save panel",
|
||||
has_doc,
|
||||
|s, p| save(s, SaveMode::Save, p)
|
||||
),
|
||||
|
||||
@@ -907,6 +907,254 @@ fn a_photo_places_the_way_its_exif_orientation_says() {
|
||||
assert!(red(px.get_pixel(4, 3)) && !red(px.get_pixel(4, 12)), "the left half is on top");
|
||||
}
|
||||
|
||||
/// Where the first stored pixel shows once EXIF (or TIFF) `orientation` turns the image, as
|
||||
/// (right, bottom): orientations 1 and 5 keep it at the top left, 2 and 6 put it at the top right,
|
||||
/// 3 and 7 at the bottom right, 4 and 8 at the bottom left. Values outside 1 to 8 leave it.
|
||||
fn first_pixel_at(orientation: u16) -> (bool, bool) {
|
||||
match orientation {
|
||||
2 | 6 => (true, false),
|
||||
3 | 7 => (true, true),
|
||||
4 | 8 => (false, true),
|
||||
_ => (false, false),
|
||||
}
|
||||
}
|
||||
|
||||
/// A little-endian EXIF block (a TIFF header and directory, as a PNG `eXIf` chunk or a WebP
|
||||
/// `EXIF` chunk holds it) with an Orientation of `orientation` and an XResolution and YResolution
|
||||
/// of `x` and `y` pixels per inch.
|
||||
fn exif_block(orientation: u16, (x, y): (u32, u32)) -> Vec<u8> {
|
||||
let entry = |tag: u16, kind: u16, value: u32| [&tag.to_le_bytes()[..], &kind.to_le_bytes(), &1u32.to_le_bytes(), &value.to_le_bytes()].concat();
|
||||
// Orientation, XResolution, YResolution, ResolutionUnit inches; the two rationals follow.
|
||||
[
|
||||
b"II".as_slice(),
|
||||
&[0x2a, 0, 8, 0, 0, 0],
|
||||
&4u16.to_le_bytes(),
|
||||
&entry(0x0112, 3, u32::from(orientation)),
|
||||
&entry(0x011a, 5, 62),
|
||||
&entry(0x011b, 5, 70),
|
||||
&entry(0x0128, 3, 2),
|
||||
&[0, 0, 0, 0],
|
||||
&[x.to_le_bytes(), 1u32.to_le_bytes(), y.to_le_bytes(), 1u32.to_le_bytes()].concat(),
|
||||
]
|
||||
.concat()
|
||||
}
|
||||
|
||||
/// A photo turned a quarter by its EXIF orientation keeps its physical size: the resolution's
|
||||
/// axes swap with the pixels', in what Place reports and in the stored image. Every orientation
|
||||
/// puts the first stored pixel in its corner once, and the stored image, read again, is not turned
|
||||
/// a second time. Values outside 1 to 8 leave the photo as stored.
|
||||
#[test]
|
||||
fn a_quarter_turn_swaps_the_resolution_axes() {
|
||||
// 48 × 32: the top-left 16 × 16 (one whole JPEG MCU) red, the rest white.
|
||||
let rgb = image::RgbImage::from_fn(48, 32, |x, y| if x < 16 && y < 16 { image::Rgb([220, 0, 0]) } else { image::Rgb([255, 255, 255]) });
|
||||
let mut jpg = Vec::new();
|
||||
image::DynamicImage::ImageRgb8(rgb).write_to(&mut Cursor::new(&mut jpg), image::ImageFormat::Jpeg).unwrap();
|
||||
for orientation in [0u16, 1, 2, 3, 4, 5, 6, 7, 8, 9] {
|
||||
let exif = [b"Exif\0\0".as_slice(), &exif_block(orientation, (300, 150))].concat();
|
||||
// Without a JFIF density the resolution comes from the EXIF block.
|
||||
let mut turned = jpg[..2].to_vec();
|
||||
turned.extend([0xff, 0xe1]);
|
||||
turned.extend(u16::try_from(exif.len() + 2).unwrap().to_be_bytes());
|
||||
turned.extend(&exif);
|
||||
turned.extend(&jpg[2..]);
|
||||
let what = format!("orientation {orientation}");
|
||||
assert_eq!(super::ppi::resolution(&turned), Some((300.0, 150.0)), "{what}: as stored");
|
||||
let quarter = (5..=8).contains(&orientation);
|
||||
let (size, ppi) = if quarter { ((32, 48), (150.0, 300.0)) } else { ((48, 32), (300.0, 150.0)) };
|
||||
let img = raster_image(&turned).unwrap();
|
||||
assert_eq!(((img.width, img.height), img.ppi), (size, Some(ppi)), "{what}");
|
||||
assert_eq!(super::ppi::resolution(&img.blob.bytes), Some(ppi), "{what}: the stored image");
|
||||
let px = image::load_from_memory(&img.blob.bytes).unwrap().to_rgb8();
|
||||
let red = |x: u32, y: u32| px.get_pixel(x, y)[1] < 80;
|
||||
let (w, h) = size;
|
||||
let corners = [(false, false), (true, false), (true, true), (false, true)];
|
||||
let reds: Vec<_> =
|
||||
corners.into_iter().filter(|&(right, bottom)| red(if right { w - 4 } else { 4 }, if bottom { h - 4 } else { 4 })).collect();
|
||||
assert_eq!(reds, [first_pixel_at(orientation)], "{what}: the first stored pixel's corner");
|
||||
let again = raster_image(&img.blob.bytes).unwrap();
|
||||
assert_eq!(((again.width, again.height), again.ppi), (size, Some(ppi)), "{what}: read again, it isn't turned again");
|
||||
}
|
||||
}
|
||||
|
||||
/// `tiff` (little-endian) with an Orientation tag (274) of `value`, a SHORT or, when `long`, a
|
||||
/// LONG, and, when `y_ppi` is given, a YResolution (283, which `tiff` must have) of that many
|
||||
/// pixels per inch: its first directory copied to the end of the file with the tag added in order,
|
||||
/// and the header pointing there, so that every other offset in the file still holds.
|
||||
fn tiff_oriented(tiff: &[u8], value: u16, long: bool, y_ppi: Option<u32>) -> Vec<u8> {
|
||||
let at = usize::try_from(u32::from_le_bytes(tiff[4..8].try_into().unwrap())).unwrap();
|
||||
let n = usize::from(u16::from_le_bytes([tiff[at], tiff[at + 1]]));
|
||||
let mut entries: Vec<Vec<u8>> = tiff[at + 2..at + 2 + 12 * n].chunks(12).map(<[u8]>::to_vec).collect();
|
||||
let i = entries.iter().position(|e| u16::from_le_bytes([e[0], e[1]]) > 274).unwrap_or(n);
|
||||
let (kind, v) = if long { (4u16, u32::from(value).to_le_bytes()) } else { (3, [value.to_le_bytes(), [0, 0]].concat().try_into().unwrap()) };
|
||||
entries.insert(i, [&274u16.to_le_bytes()[..], &kind.to_le_bytes(), &1u32.to_le_bytes(), &v].concat());
|
||||
let mut out = tiff.to_vec();
|
||||
if out.len() % 2 == 1 {
|
||||
out.push(0);
|
||||
}
|
||||
if let Some(y) = y_ppi {
|
||||
let rational = u32::try_from(out.len()).unwrap();
|
||||
out.extend([y.to_le_bytes(), 1u32.to_le_bytes()].concat());
|
||||
let e = entries.iter_mut().find(|e| u16::from_le_bytes([e[0], e[1]]) == 283).unwrap();
|
||||
e[8..12].copy_from_slice(&rational.to_le_bytes());
|
||||
}
|
||||
let ifd = u32::try_from(out.len()).unwrap();
|
||||
out[4..8].copy_from_slice(&ifd.to_le_bytes());
|
||||
out.extend(u16::try_from(n + 1).unwrap().to_le_bytes());
|
||||
out.extend(entries.concat());
|
||||
out.extend([0; 4]);
|
||||
out
|
||||
}
|
||||
|
||||
/// TIFFs the image decoder can't read, grayscale with an alpha channel and 1-bit ones compressed
|
||||
/// with CCITT Group 3, turn and flip as their Orientation tag says, as the others do (#4284),
|
||||
/// whether the tag is a SHORT or a LONG. A quarter turn swaps the resolution's axes.
|
||||
#[test]
|
||||
fn tiffs_read_without_the_image_decoder_follow_their_orientation() {
|
||||
// 3 × 2 at 150 × 300 ppi, the first pixel black and the rest white, opaque.
|
||||
let gray = hand_tiff(1, 8, &[2], false, &[vec![0, 255, 255, 255, 255, 255], vec![255; 6]]);
|
||||
let rows: Vec<Vec<bool>> = (0..2).map(|y| (0..3).map(|x| x == 0 && y == 0).collect()).collect();
|
||||
let g = vectorcraft_testkit::tiff::Group3 {
|
||||
rows: &rows,
|
||||
compression: 2,
|
||||
t4_options: 0,
|
||||
photometric: 0,
|
||||
fill_order: 1,
|
||||
rows_per_strip: 2,
|
||||
tile: None,
|
||||
rtc: false,
|
||||
};
|
||||
let fax = vectorcraft_testkit::tiff::group3(&g);
|
||||
// The Group 3 file declares no resolution.
|
||||
for (kind, file, y_ppi) in [("Group 3", fax, None), ("gray with alpha", gray, Some(300))] {
|
||||
assert!(image::load_from_memory(&file).is_err(), "{kind}: the image decoder reads it now");
|
||||
for (orientation, long) in (1..=8).flat_map(|o| [(o, false), (o, true)]) {
|
||||
let what = format!("{kind}, orientation {orientation}, LONG {long}");
|
||||
let img = raster_image(&tiff_oriented(&file, orientation, long, y_ppi)).unwrap_or_else(|e| panic!("{what}: {e}"));
|
||||
let quarter = orientation >= 5;
|
||||
let size = if quarter { (2, 3) } else { (3, 2) };
|
||||
let ppi = y_ppi.map(|y| if quarter { (f64::from(y), 150.0) } else { (150.0, f64::from(y)) });
|
||||
assert_eq!(((img.width, img.height), img.ppi), (size, ppi), "{what}");
|
||||
assert_eq!(super::ppi::resolution(&img.blob.bytes), ppi, "{what}: the stored image");
|
||||
let px = image::load_from_memory(&img.blob.bytes).unwrap().to_luma8();
|
||||
let (right, bottom) = first_pixel_at(orientation);
|
||||
let (x, y) = (if right { size.0 - 1 } else { 0 }, if bottom { size.1 - 1 } else { 0 });
|
||||
let black: Vec<_> = px.enumerate_pixels().filter(|p| p.2[0] < 128).map(|p| (p.0, p.1)).collect();
|
||||
assert_eq!(black, [(x, y)], "{what}: the first stored pixel's corner");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A CMYK TIFF that its Orientation tag turns or flips keeps its inks, turned, whether it stores
|
||||
/// them together or one plane per ink, at 8 or 16 bits; a quarter turn swaps the resolution's axes.
|
||||
/// Read again, the stored image is not turned a second time.
|
||||
#[test]
|
||||
fn cmyk_tiffs_turned_by_their_orientation_keep_their_inks() {
|
||||
// 3 × 2 at 150 × 300 ppi: the first pixel full cyan, the others 30% black.
|
||||
let first: [u8; 4] = [255, 0, 0, 0];
|
||||
let other: [u8; 4] = [0, 0, 0, 77];
|
||||
let channel = |i: usize| -> Vec<u16> { (0..6).map(|p| u16::from(if p == 0 { first[i] } else { other[i] })).collect() };
|
||||
for (bits, planar) in [(8, false), (8, true), (16, false), (16, true)] {
|
||||
let channels: Vec<Vec<u16>> = (0..4).map(|i| channel(i).iter().map(|v| if bits == 16 { v * 257 } else { *v }).collect()).collect();
|
||||
let file = hand_tiff(5, bits, &[], planar, &channels);
|
||||
for orientation in 1..=8 {
|
||||
let what = format!("{bits} bits, planar {planar}, orientation {orientation}");
|
||||
let img = raster_image(&tiff_oriented(&file, orientation, false, Some(300))).unwrap_or_else(|e| panic!("{what}: {e}"));
|
||||
let quarter = orientation >= 5;
|
||||
let size = if quarter { (2, 3) } else { (3, 2) };
|
||||
let ppi = if quarter { (300.0, 150.0) } else { (150.0, 300.0) };
|
||||
assert_eq!(((img.width, img.height), img.ppi, img.blob.mime.as_str()), (size, Some(ppi), "image/tiff"), "{what}");
|
||||
assert_eq!(super::ppi::resolution(&img.blob.bytes), Some(ppi), "{what}: the stored image");
|
||||
let inks = img.blob.cmyk().unwrap_or_else(|| panic!("{what}: the inks are lost"));
|
||||
assert_eq!((inks.width, inks.height), size, "{what}: the inks' size");
|
||||
let (right, bottom) = first_pixel_at(orientation);
|
||||
let at = (if right { size.0 - 1 } else { 0 }, if bottom { size.1 - 1 } else { 0 });
|
||||
for (i, px) in inks.data.chunks(4).enumerate() {
|
||||
let (x, y) = (u32::try_from(i).unwrap() % size.0, u32::try_from(i).unwrap() / size.0);
|
||||
assert_eq!(px, if (x, y) == at { first } else { other }, "{what}: the inks of pixel ({x}, {y})");
|
||||
}
|
||||
let again = raster_image(&img.blob.bytes).unwrap();
|
||||
assert_eq!(((again.width, again.height), again.blob.cmyk()), (size, Some(inks)), "{what}: read again, it isn't turned again");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A CMYK JPEG that its EXIF orientation turns or flips keeps its inks, turned, stored as a CMYK
|
||||
/// TIFF; a quarter turn swaps the resolution's axes. Orientation 1 keeps the JPEG as it is, and
|
||||
/// the stored image, read again, is not turned a second time.
|
||||
#[test]
|
||||
fn cmyk_jpegs_turned_by_their_orientation_keep_their_inks() {
|
||||
use vectorcraft_render::encode::jpeg::{self, ColorModel, JpegOptions};
|
||||
// 48 × 32: the top-left 16 × 16 (one whole JPEG MCU) full cyan, the rest 30% black.
|
||||
let inks: Vec<u8> =
|
||||
(0..32u32).flat_map(|y| (0..48u32).flat_map(move |x| if x < 16 && y < 16 { [255, 0, 0, 0] } else { [0, 0, 0, 77] })).collect();
|
||||
let o = JpegOptions { color_model: ColorModel::Cmyk, embed_icc: false, ..Default::default() };
|
||||
let jpg = jpeg::encode(&inks, 48, 32, 100, None, &o).unwrap();
|
||||
for orientation in 1..=8u16 {
|
||||
let exif = [b"Exif\0\0".as_slice(), &exif_block(orientation, (300, 150))].concat();
|
||||
let mut file = jpg[..2].to_vec();
|
||||
file.extend([0xff, 0xe1]);
|
||||
file.extend(u16::try_from(exif.len() + 2).unwrap().to_be_bytes());
|
||||
file.extend(&exif);
|
||||
file.extend(&jpg[2..]);
|
||||
let what = format!("orientation {orientation}");
|
||||
let quarter = orientation >= 5;
|
||||
let (size, ppi) = if quarter { ((32, 48), (150.0, 300.0)) } else { ((48, 32), (300.0, 150.0)) };
|
||||
let mime = if orientation == 1 { "image/jpeg" } else { "image/tiff" };
|
||||
let img = raster_image(&file).unwrap_or_else(|e| panic!("{what}: {e}"));
|
||||
assert_eq!(((img.width, img.height), img.ppi, img.blob.mime.as_str()), (size, Some(ppi), mime), "{what}");
|
||||
assert_eq!(super::ppi::resolution(&img.blob.bytes), Some(ppi), "{what}: the stored image");
|
||||
let inks = img.blob.cmyk().unwrap_or_else(|| panic!("{what}: the inks are lost"));
|
||||
assert_eq!((inks.width, inks.height), size, "{what}: the inks' size");
|
||||
let (w, h) = size;
|
||||
let at = |x: u32, y: u32| inks.data[usize::try_from((y * w + x) * 4).unwrap()..][..4].to_vec();
|
||||
let cyan = |p: &[u8]| p[0] > 200 && p[3] < 40;
|
||||
let corners = [(false, false), (true, false), (true, true), (false, true)];
|
||||
let cyans: Vec<_> =
|
||||
corners.into_iter().filter(|&(right, bottom)| cyan(&at(if right { w - 4 } else { 4 }, if bottom { h - 4 } else { 4 }))).collect();
|
||||
assert_eq!(cyans, [first_pixel_at(orientation)], "{what}: the first stored pixel's corner");
|
||||
let black = at(w / 2, h / 2);
|
||||
assert!(black[0] < 40 && (60..=95).contains(&black[3]), "{what}: the other inks, {black:?}");
|
||||
let again = raster_image(&img.blob.bytes).unwrap();
|
||||
assert_eq!(((again.width, again.height), again.blob.cmyk()), (size, Some(inks)), "{what}: read again, it isn't turned again");
|
||||
}
|
||||
}
|
||||
|
||||
/// A PNG (its `eXIf` chunk and `pHYs` resolution), a WebP (its `EXIF` chunk) and an RGB TIFF read
|
||||
/// by the image decoder, turned a quarter by their orientation, swap their resolution's axes; a
|
||||
/// half turn keeps them.
|
||||
#[test]
|
||||
fn png_webp_and_rgb_tiff_turned_a_quarter_swap_their_resolution() {
|
||||
use image::ImageEncoder as _;
|
||||
let rgb = image::RgbImage::from_fn(3, 2, |x, y| image::Rgb(if x == 0 && y == 0 { [0, 0, 0] } else { [255, 255, 255] }));
|
||||
for orientation in [3u16, 6, 8] {
|
||||
let mut png = Vec::new();
|
||||
let mut enc = image::codecs::png::PngEncoder::new(&mut png);
|
||||
enc.set_exif_metadata(exif_block(orientation, (150, 300))).unwrap();
|
||||
enc.write_image(rgb.as_raw(), 3, 2, image::ExtendedColorType::Rgb8).unwrap();
|
||||
let png = super::ppi::with_png_resolution(&png, (150.0, 300.0));
|
||||
let mut webp = Vec::new();
|
||||
let mut enc = image::codecs::webp::WebPEncoder::new_lossless(&mut webp);
|
||||
enc.set_exif_metadata(exif_block(orientation, (150, 300))).unwrap();
|
||||
enc.write_image(rgb.as_raw(), 3, 2, image::ExtendedColorType::Rgb8).unwrap();
|
||||
let channel = |c: usize| -> Vec<u16> { rgb.pixels().map(|p| u16::from(p[c])).collect() };
|
||||
let tiff = tiff_oriented(&hand_tiff(2, 8, &[], false, &[channel(0), channel(1), channel(2)]), orientation, false, Some(300));
|
||||
for (kind, file) in [("PNG", png), ("WebP", webp), ("TIFF", tiff)] {
|
||||
let what = format!("{kind}, orientation {orientation}");
|
||||
assert_eq!(super::ppi::resolution(&file), Some((150.0, 300.0)), "{what}: as stored");
|
||||
let quarter = orientation >= 5;
|
||||
let (size, ppi) = if quarter { ((2, 3), (300.0, 150.0)) } else { ((3, 2), (150.0, 300.0)) };
|
||||
let img = raster_image(&file).unwrap_or_else(|e| panic!("{what}: {e}"));
|
||||
assert_eq!(((img.width, img.height), img.ppi), (size, Some(ppi)), "{what}");
|
||||
assert_eq!(super::ppi::resolution(&img.blob.bytes), Some(ppi), "{what}: the stored image");
|
||||
let px = image::load_from_memory(&img.blob.bytes).unwrap().to_luma8();
|
||||
let (right, bottom) = first_pixel_at(orientation);
|
||||
let at = (if right { size.0 - 1 } else { 0 }, if bottom { size.1 - 1 } else { 0 });
|
||||
let black: Vec<_> = px.enumerate_pixels().filter(|p| p.2[0] < 128).map(|p| (p.0, p.1)).collect();
|
||||
assert_eq!(black, [at], "{what}: the first stored pixel's corner");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// With `importer` named, a file up to the importer's limit is read whole and a larger one is an
|
||||
/// error, whatever its content.
|
||||
#[test]
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
//! Save errors retain their type without changing the other filesystem writers (#6146).
|
||||
|
||||
use std::io::{self, ErrorKind};
|
||||
|
||||
use serde_json::json;
|
||||
|
||||
use super::{SaveMode, access, save_plan, save_with, write_file_with, write_save_file_with};
|
||||
use crate::{EngineError, READ_ONLY_LOCATION, Session};
|
||||
|
||||
#[test]
|
||||
fn readonly_error_is_typed_and_other_writers_keep_their_error() {
|
||||
let dir = vectorcraft_testkit::temp_dir("save-refusal");
|
||||
let path = dir.join("Poster.vectorcraft").to_string_lossy().into_owned();
|
||||
for kind in [ErrorKind::PermissionDenied, ErrorKind::ReadOnlyFilesystem, ErrorKind::StorageFull, ErrorKind::NotFound, ErrorKind::Other] {
|
||||
let typed = write_save_file_with(&path, &mut |_| Err(io::Error::new(kind, "injected"))).unwrap_err();
|
||||
let ordinary = write_file_with(&path, &mut |_| Err(io::Error::new(kind, "injected"))).unwrap_err();
|
||||
let reason = format!("{path}: injected");
|
||||
assert!(matches!(&ordinary, EngineError::Other(_)));
|
||||
assert_eq!(ordinary.to_string(), reason);
|
||||
if matches!(kind, ErrorKind::PermissionDenied | ErrorKind::ReadOnlyFilesystem) {
|
||||
assert!(matches!(&typed, EngineError::ReadOnlyLocation(_)));
|
||||
assert_eq!(typed.to_string(), format!("{READ_ONLY_LOCATION}: {reason}"));
|
||||
} else {
|
||||
assert!(matches!(&typed, EngineError::Other(_)));
|
||||
assert_eq!(typed.to_string(), reason);
|
||||
}
|
||||
assert!(!std::path::Path::new(&path).exists());
|
||||
}
|
||||
std::fs::remove_dir_all(dir).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn readonly_failed_save_keeps_document_and_options() {
|
||||
let mut session = Session::new();
|
||||
session.execute("file.new", &json!({"width": 40, "height": 40})).unwrap();
|
||||
session.execute("shape.rectangle", &json!({"x": 10, "y": 10, "width": 10, "height": 10})).unwrap();
|
||||
let st = session.doc_mut().unwrap();
|
||||
st.path = Some("Poster.svg".into());
|
||||
st.format = "svg";
|
||||
st.save_options = json!({"decimals": 3}).as_object().unwrap().clone();
|
||||
let before = serde_json::to_value(&*st.doc).unwrap();
|
||||
let options = st.save_options.clone();
|
||||
let plan = save_plan(&session, SaveMode::Save, &json!({"modified": null})).unwrap();
|
||||
let e = save_with(&mut session, plan, |path, _| Err(access::save_write_error(path, io::Error::from(ErrorKind::PermissionDenied)))).unwrap_err();
|
||||
assert!(matches!(e, EngineError::ReadOnlyLocation(_)));
|
||||
let st = session.doc().unwrap();
|
||||
assert_eq!(st.path.as_deref(), Some("Poster.svg"));
|
||||
assert_eq!(st.format, "svg");
|
||||
assert_eq!(st.save_options, options);
|
||||
assert!(st.is_dirty());
|
||||
assert_eq!(serde_json::to_value(&*st.doc).unwrap(), before);
|
||||
}
|
||||
@@ -730,7 +730,7 @@ fn a_refused_export_into_a_presets_folder_outside_the_read_root_names_it_by_its_
|
||||
let settings = json!({"formats": [{"format": "png"}], "folder": parent});
|
||||
s.execute("document.exportPresets.save", &json!({"name": "Parent", "settings": settings})).unwrap();
|
||||
let e = confine(confined.as_ref(), || s.execute("document.exportForScreens", &json!({"exportPreset": "Parent"}))).unwrap_err().to_string();
|
||||
let root = inside.display().to_string();
|
||||
let root = crate::cmd::findfiles::plain(inside.canonicalize().unwrap()).display().to_string();
|
||||
let name = tmp.0.file_name().unwrap().to_string_lossy().into_owned();
|
||||
assert!(e.contains(&format!("root {root}: {name}")), "{e}");
|
||||
}
|
||||
|
||||
@@ -183,6 +183,16 @@ fn a_picked_folder_inside_other_apps_folders_is_refused() {
|
||||
assert!(p.error().is_some_and(|e| e.ends_with("is not a folder")), "{p:?}");
|
||||
let p = search(&root, "missing", Limits::default(), 1, &[]);
|
||||
assert!(p.error().is_some_and(|e| e.starts_with(&root.join("missing").display().to_string())), "{p:?}");
|
||||
// Each failure has the same reason without the folder's path.
|
||||
let briefs = [
|
||||
("missing", "the folder can't be read"),
|
||||
("file.ttf", "the path isn't a folder"),
|
||||
("refused", "VectorCraft doesn't search other apps' or the system's folders"),
|
||||
("Users/Public/x", "VectorCraft doesn't search the Shared or Public folder in Users"),
|
||||
];
|
||||
for (picked, why) in briefs {
|
||||
assert_eq!(search(&root, picked, Limits::default(), 1, &[]).failure(), Some(why), "{picked}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -362,7 +372,7 @@ fn a_picked_folder_that_cant_be_listed_fails_the_search() {
|
||||
let p = search(&root, "locked", Limits::default(), 2, &["y.ttf"]);
|
||||
std::fs::set_permissions(&locked, std::fs::Permissions::from_mode(0o755)).unwrap();
|
||||
if enforced {
|
||||
assert!(matches!(&p.end, Some(End::Failed(e)) if e.contains("locked")), "{p:?}");
|
||||
assert!(matches!(&p.end, Some(End::Failed(e, "the folder can't be listed")) if e.contains("locked")), "{p:?}");
|
||||
} else {
|
||||
assert_eq!(found(&root, &p), ["locked/y.ttf"]);
|
||||
}
|
||||
|
||||
@@ -23,6 +23,13 @@ const CHECK_EVERY: u64 = 1024;
|
||||
/// How a search ends when every walker thread stopped on a bug.
|
||||
const INTERNAL: &str = "every search thread stopped on an internal error (please report this bug)";
|
||||
|
||||
/// Why a search couldn't start, without the folder's path ([`Progress::failure`]).
|
||||
const UNREADABLE: &str = "the folder can't be read";
|
||||
const NOT_A_FOLDER: &str = "the path isn't a folder";
|
||||
const SHARED: &str = "VectorCraft doesn't search the Shared or Public folder in Users";
|
||||
const APPS: &str = "VectorCraft doesn't search other apps' or the system's folders";
|
||||
const UNLISTED: &str = "the folder can't be listed";
|
||||
|
||||
/// What a search looks for.
|
||||
pub trait Visitor: Send + Sync {
|
||||
/// Whether the regular file named `name` is to be read: asked of the files a folder lists until
|
||||
@@ -77,8 +84,9 @@ pub enum End {
|
||||
/// reached their [`Limits`]. Folders deeper than [`Limits::depth`] are skipped and an item
|
||||
/// keeps at most [`Limits::per_item`] files, without ending the search.
|
||||
Limit,
|
||||
/// It couldn't start: the folder can't be read, isn't a folder or isn't one to search.
|
||||
Failed(String),
|
||||
/// It couldn't start: the folder can't be read, isn't a folder or isn't one to search. Why,
|
||||
/// and the same without the folder's path.
|
||||
Failed(String, &'static str),
|
||||
}
|
||||
|
||||
/// Where a search stands ([`Search::progress`]).
|
||||
@@ -114,7 +122,7 @@ impl Progress {
|
||||
None => "searching",
|
||||
Some(End::Done | End::Found) => "done",
|
||||
Some(End::Stopped | End::Time | End::Limit) => "stopped",
|
||||
Some(End::Failed(_)) => "failed",
|
||||
Some(End::Failed(..)) => "failed",
|
||||
}
|
||||
}
|
||||
|
||||
@@ -131,10 +139,38 @@ impl Progress {
|
||||
/// Why a failed search failed.
|
||||
pub fn error(&self) -> Option<&str> {
|
||||
match &self.end {
|
||||
Some(End::Failed(e)) => Some(e),
|
||||
Some(End::Failed(e, _)) => Some(e),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Why a failed search failed, without the folder's path.
|
||||
pub fn failure(&self) -> Option<&'static str> {
|
||||
match &self.end {
|
||||
Some(End::Failed(_, why)) => Some(why),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Keep what the caller asking now may read under its read roots ([`crate::file_access::current`]),
|
||||
/// for a reply to it: the files found inside them. Returns the folder searched (`given` until a
|
||||
/// walker checked it) and why the search failed, when that folder lies inside them too;
|
||||
/// otherwise no folder and why without its path ([`Self::failure`]). Without read roots in
|
||||
/// force, everything is kept.
|
||||
pub fn keep_readable(&mut self, given: &str) -> (Option<String>, Option<String>) {
|
||||
let folder = if self.folder.as_os_str().is_empty() { given.to_string() } else { self.folder.to_string_lossy().into_owned() };
|
||||
if crate::file_access::current().is_none() {
|
||||
return (Some(folder), self.error().map(str::to_string));
|
||||
}
|
||||
// A path that isn't valid text is never shown: its lossy form names another file.
|
||||
let readable = |p: &Path| p.to_str().is_some_and(|p| crate::file_access::check_read(p).is_ok());
|
||||
self.hits.retain(|(path, _)| readable(path));
|
||||
if readable(Path::new(given)) && (self.folder.as_os_str().is_empty() || readable(&self.folder)) {
|
||||
(Some(folder), self.error().map(str::to_string))
|
||||
} else {
|
||||
(None, self.failure().map(str::to_string))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Walker threads shared out among searches: all of them together run at most `cap` threads.
|
||||
@@ -306,7 +342,7 @@ pub(crate) fn start_in(
|
||||
return Err("the search couldn't start: try again in a moment".into());
|
||||
}
|
||||
if w.alive == 0 && w.end.is_none() {
|
||||
shared.finish(&mut w, End::Failed(INTERNAL.into()));
|
||||
shared.finish(&mut w, End::Failed(INTERNAL.into(), INTERNAL));
|
||||
}
|
||||
}
|
||||
Ok(Search { shared })
|
||||
@@ -401,13 +437,13 @@ impl Shared {
|
||||
fn prepare(&self, root: &Path) {
|
||||
let rules = self.rules.get_or_init(|| self.given.clone().unwrap_or_else(Rules::system));
|
||||
let path = root.display();
|
||||
let checked = match std::fs::canonicalize(root) {
|
||||
Err(e) => Err(format!("{path}: {e}")),
|
||||
Ok(folder) if !folder.is_dir() => Err(format!("{path} is not a folder")),
|
||||
let (checked, brief) = match std::fs::canonicalize(root) {
|
||||
Err(e) => (Err(format!("{path}: {e}")), UNREADABLE),
|
||||
Ok(folder) if !folder.is_dir() => (Err(format!("{path} is not a folder")), NOT_A_FOLDER),
|
||||
Ok(folder) => match rules.refusal(&folder) {
|
||||
None => Ok(folder),
|
||||
Some(Refusal::Shared) => Err(format!("VectorCraft doesn't search the Shared or Public folder in Users: {path}")),
|
||||
Some(Refusal::Apps) => Err(format!("VectorCraft doesn't search other apps' or the system's folders: {path}")),
|
||||
None => (Ok(folder), INTERNAL),
|
||||
Some(Refusal::Shared) => (Err(format!("VectorCraft doesn't search the Shared or Public folder in Users: {path}")), SHARED),
|
||||
Some(Refusal::Apps) => (Err(format!("VectorCraft doesn't search other apps' or the system's folders: {path}")), APPS),
|
||||
},
|
||||
};
|
||||
let mut w = self.lock();
|
||||
@@ -416,7 +452,7 @@ impl Shared {
|
||||
w.folder = folder.clone();
|
||||
w.stack.push((folder, 0));
|
||||
}
|
||||
Err(e) => self.finish(&mut w, End::Failed(e)),
|
||||
Err(e) => self.finish(&mut w, End::Failed(e, brief)),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -430,7 +466,7 @@ impl Shared {
|
||||
let mut w = self.lock();
|
||||
// The picked folder itself: the search can't run, and says why.
|
||||
if depth == 0 {
|
||||
self.finish(&mut w, End::Failed(format!("{}: {e}", plain(dir.to_path_buf()).display())));
|
||||
self.finish(&mut w, End::Failed(format!("{}: {e}", plain(dir.to_path_buf()).display()), UNLISTED));
|
||||
} else {
|
||||
w.unreadable += 1;
|
||||
w.folders += 1;
|
||||
@@ -610,7 +646,7 @@ fn walker(s: &Shared) {
|
||||
if done.is_err() {
|
||||
let mut w = s.lock();
|
||||
match job {
|
||||
Job::Prepare(_) => s.finish(&mut w, End::Failed(INTERNAL.into())),
|
||||
Job::Prepare(_) => s.finish(&mut w, End::Failed(INTERNAL.into(), INTERNAL)),
|
||||
Job::List(..) => w.unreadable += 1,
|
||||
}
|
||||
}
|
||||
@@ -640,7 +676,7 @@ impl Drop for Alive {
|
||||
let mut w = self.0.lock();
|
||||
w.alive = w.alive.saturating_sub(1);
|
||||
if w.alive == 0 && w.end.is_none() {
|
||||
self.0.finish(&mut w, End::Failed(INTERNAL.into()));
|
||||
self.0.finish(&mut w, End::Failed(INTERNAL.into(), INTERNAL));
|
||||
}
|
||||
drop(w);
|
||||
self.0.pool.release(1);
|
||||
|
||||
@@ -76,7 +76,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Find Font Files",
|
||||
[],
|
||||
None,
|
||||
"{folder?: an absolute path, fonts?: [{family, style?}] (1 to 1000; default: the active document's missing fonts, as text.missingFonts lists them), maxSeconds?: 60 (1 to 600), stop?: false (true: stop the last search)} with folder: look in it and its subfolders for font files that make the fonts available, on separate threads, and answer at once (state: searching); without folder: the last search's state, with the files found so far. A search reads the table directories and the name, fvar and OS/2 tables of .ttf, .otf, .ttc and .otc files of at most 256 MB and, on macOS, of suitcase fonts (files without an extension or with .suit, whose fonts are in a resource fork of at most 8 MB), and lists a file for a font when the file, added with text.addFontFiles, makes the font resolve exactly as text.fonts resolves it; fonts that resolve exactly already are left out. It doesn't enter app packages, folders named Program Files (also x86 and Arm), ProgramData, $Recycle.Bin or System Volume Information, the system's folders at the root of a volume, the Shared and Public folders in Users, a home folder's Library, AppData, Applications, snap and dot folders, a Library folder's app data folders or the folders the environment names for apps' data, and a search of a folder inside one of them fails; the walk also skips other hidden folders, folders whose contents are in the cloud and folders more than 64 levels deep, and follows no links (a picked path is resolved through its links); none on the web → {id (absent while idle), state: idle|searching|done|stopped|failed, folder?, fonts: [{family, style, status, files: [paths]}], searched?: {folders, files, fontFiles}, skipped?, unreadable?, stopped?: stop|time|limit, error?, seconds?}",
|
||||
"{folder?: an absolute path, fonts?: [{family, style?}] (1 to 1000; default: the active document's missing fonts, as text.missingFonts lists them), maxSeconds?: 60 (1 to 600), stop?: false (true: stop the last search)} with folder: look in it and its subfolders for font files that make the fonts available, on separate threads, and answer at once (state: searching); without folder: the last search's state, with the files found so far. A search reads the table directories and the name, fvar and OS/2 tables of .ttf, .otf, .ttc and .otc files of at most 256 MB and, on macOS, of suitcase fonts (files without an extension or with .suit, whose fonts are in a resource fork of at most 8 MB), and lists a file for a font when the file, added with text.addFontFiles, makes the font resolve exactly as text.fonts resolves it; fonts that resolve exactly already are left out. It doesn't enter app packages, folders named Program Files (also x86 and Arm), ProgramData, $Recycle.Bin or System Volume Information, the system's folders at the root of a volume, the Shared and Public folders in Users, a home folder's Library, AppData, Applications, snap and dot folders, a Library folder's app data folders or the folders the environment names for apps' data, and a search of a folder inside one of them fails; the walk also skips other hidden folders, folders whose contents are in the cloud and folders more than 64 levels deep, and follows no links (a picked path is resolved through its links). With automation read roots, a reply leaves out the files found outside the caller's roots, and the folder when it is outside them (error then gives the reason without the path); none on the web → {id (absent while idle), state: idle|searching|done|stopped|failed, folder?, fonts: [{family, style, status, files: [paths]}], searched?: {folders, files, fontFiles}, skipped?, unreadable?, stopped?: stop|time|limit, error?, seconds?}",
|
||||
always,
|
||||
find
|
||||
),
|
||||
@@ -85,7 +85,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Add Font Files",
|
||||
[],
|
||||
None,
|
||||
"{files: [paths the last text.findFontFiles search found] (1 to 1000)} copy the font files into VectorCraft's own Fonts folder (next to the preferences; the desktop app and vectorcraft-cli read it), never replacing a file there: the names Name.otf, Name 2.otf … Name 99.otf are tried in turn until one is free, which gets the copy, or is a file with the same contents, which is kept; then scan the fonts again, as text.rescanFonts does, and type set in them redraws. Only regular .ttf, .otf, .ttc, .otc, .woff and .woff2 files and suitcase fonts (with their resource fork) are copied, at most 256 MB a file and 1 GB in all; any other file is skipped. Add only fonts you own or are licensed to install; none on the web → {folder, copied: [{from, to}], kept: [{from, to}], skipped: [{file, reason}], families?, faces?}",
|
||||
"{files: [paths the last text.findFontFiles search found] (1 to 1000)} copy the font files into VectorCraft's own Fonts folder (next to the preferences; the desktop app and vectorcraft-cli read it), never replacing a file there: the names Name.otf, Name 2.otf … Name 99.otf are tried in turn until one is free, which gets the copy, or is a file with the same contents, which is kept; then scan the fonts again, as text.rescanFonts does, and type set in them redraws. Only regular .ttf, .otf, .ttc, .otc, .woff and .woff2 files and suitcase fonts (with their resource fork) are copied, at most 256 MB a file and 1 GB in all; any other file is skipped. Add only fonts you own or are licensed to install. With automation read roots, folder is null when the Fonts folder is outside them, and a to or file path outside them is given as its file name; none on the web → {folder, copied: [{from, to}], kept: [{from, to}], skipped: [{file, reason}], families?, faces?}",
|
||||
always,
|
||||
add
|
||||
),
|
||||
@@ -275,7 +275,9 @@ fn find(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
/// Where the session's last search stands (`text.findFontFiles` without `folder`).
|
||||
fn state(s: &Session) -> Value {
|
||||
let Some(f) = &s.font_search else { return json!({ "state": "idle", "fonts": [] }) };
|
||||
let p = f.search.progress();
|
||||
let mut p = f.search.progress();
|
||||
// Only what the caller asking now may read: a search another caller started can hold more.
|
||||
let (folder, error) = p.keep_readable(&f.folder);
|
||||
let mut files: Vec<Vec<String>> = vec![vec![]; f.fonts.len()];
|
||||
for (path, items) in &p.hits {
|
||||
for i in items {
|
||||
@@ -293,16 +295,18 @@ fn state(s: &Session) -> Value {
|
||||
json!({ "family": font.family, "style": font.style, "status": font.status.as_str(), "files": files })
|
||||
})
|
||||
.collect();
|
||||
let folder = if p.folder.as_os_str().is_empty() { f.folder.clone() } else { p.folder.to_string_lossy().into_owned() };
|
||||
let mut out = json!({
|
||||
"id": f.id, "state": p.state(), "folder": folder, "fonts": fonts,
|
||||
"id": f.id, "state": p.state(), "fonts": fonts,
|
||||
"searched": { "folders": p.folders, "files": p.files, "fontFiles": p.read },
|
||||
"skipped": p.skipped, "unreadable": p.unreadable, "seconds": (p.seconds * 10.0).round() / 10.0,
|
||||
});
|
||||
if let Some(folder) = folder {
|
||||
out["folder"] = json!(folder);
|
||||
}
|
||||
if let Some(why) = p.stopped() {
|
||||
out["stopped"] = json!(why);
|
||||
}
|
||||
if let Some(e) = p.error() {
|
||||
if let Some(e) = error {
|
||||
out["error"] = json!(e);
|
||||
}
|
||||
out
|
||||
@@ -348,12 +352,16 @@ fn add(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
// VectorCraft's own Fonts folder: no automation root applies to it.
|
||||
crate::file_access::unconfined(|| super::fileio::create_dir(&dir.to_string_lossy()))?;
|
||||
let report = copy_into(&files, dir);
|
||||
let pairs = |v: &[(PathBuf, PathBuf)]| {
|
||||
v.iter().map(|(from, to)| json!({ "from": from.to_string_lossy(), "to": to.to_string_lossy() })).collect::<Vec<_>>()
|
||||
// The caller sees the Fonts folder and the copies in it only where its read roots reach.
|
||||
let readable = |p: &Path| crate::file_access::current().is_none() || p.to_str().is_some_and(|p| crate::file_access::check_read(p).is_ok());
|
||||
let shown = |p: &Path| -> String {
|
||||
let shown = if readable(p) { Some(p.as_os_str()) } else { p.file_name() };
|
||||
shown.map(|s| s.to_string_lossy().into_owned()).unwrap_or_default()
|
||||
};
|
||||
let pairs = |v: &[(PathBuf, PathBuf)]| v.iter().map(|(from, to)| json!({ "from": from.to_string_lossy(), "to": shown(to) })).collect::<Vec<_>>();
|
||||
let mut out = json!({
|
||||
"folder": dir.to_string_lossy(), "copied": pairs(&report.copied), "kept": pairs(&report.kept),
|
||||
"skipped": report.skipped.iter().map(|(file, why)| json!({ "file": file.to_string_lossy(), "reason": why })).collect::<Vec<_>>(),
|
||||
"folder": readable(dir).then(|| dir.to_string_lossy()), "copied": pairs(&report.copied), "kept": pairs(&report.kept),
|
||||
"skipped": report.skipped.iter().map(|(file, why)| json!({ "file": shown(file), "reason": why })).collect::<Vec<_>>(),
|
||||
});
|
||||
if !report.copied.is_empty() || !report.kept.is_empty() {
|
||||
// The fonts are cataloged and type set in them redraws, as Refresh Font List does it.
|
||||
|
||||
@@ -29,7 +29,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Find Linked Files",
|
||||
[],
|
||||
None,
|
||||
"{folder?: an absolute path, ids?: [image or placed document ids] (default: the active document's missing links), maxSeconds?: 60 (1 to 600), stop?: false (true: stop the last search)} with folder: look in it and its subfolders, on separate threads, for files with the names of the linked files (in any letter case), and answer at once with the search's state (searching while it runs); without folder: the last search's state, with the files found so far. Each linked file's files come best first: same (the size and hash saved with the link), size (the saved size, where there is no saved hash or the file is larger than 1 GiB), then name. Within each kind, a file whose name has the link's letter case comes first. Then each file's folder names are compared with the saved path's, from the innermost outward up to the first one that differs, and a file with more matching names comes first; files that still tie are listed by path. Before the comparison, the saved path's parent-folder steps (.., or the empty components of a classic Mac :: run) are resolved, and . components in a slash path are skipped. A search looks for at most 1000 linked files and keeps the first 10 files of each one's name, and up to 10 that are the same file; it ends once every linked file has its same file, and searches only the folders text.findFontFiles may search, with the same maxSeconds, depth and count limits; none on the web → {id (absent while idle), state: idle|searching|done|stopped|failed, folder?, links: [{name, path, ids, files: [{path, size, match: same|size|name, caseDiffers?: true}]}], searched?: {folders, files, read}, skipped?, unreadable?, stopped?: stop|time|limit, error?, seconds?}",
|
||||
"{folder?: an absolute path, ids?: [image or placed document ids] (default: the active document's missing links), maxSeconds?: 60 (1 to 600), stop?: false (true: stop the last search)} with folder: look in it and its subfolders, on separate threads, for files with the names of the linked files (in any letter case), and answer at once with the search's state (searching while it runs); without folder: the last search's state, with the files found so far. Each linked file's files come best first: same (the size and hash saved with the link), size (the saved size, where there is no saved hash or the file is larger than 1 GiB), then name. Within each kind, a file whose name has the link's letter case comes first. Then each file's folder names are compared with the saved path's, from the innermost outward up to the first one that differs, and a file with more matching names comes first; files that still tie are listed by path. Before the comparison, the saved path's parent-folder steps (.., or the empty components of a classic Mac :: run) are resolved, and . components in a slash path are skipped. A search looks for at most 1000 linked files and keeps the first 10 files of each one's name, and up to 10 that are the same file; it ends once every linked file has its same file, and searches only the folders text.findFontFiles may search, with the same maxSeconds, depth and count limits. With automation read roots, a reply leaves out the files found outside the caller's roots, a link's path outside them, and the folder when it is outside them (error then gives the reason without the path); none on the web → {id (absent while idle), state: idle|searching|done|stopped|failed, folder?, links: [{name, path?, ids, files: [{path, size, match: same|size|name, caseDiffers?: true}]}], searched?: {folders, files, read}, skipped?, unreadable?, stopped?: stop|time|limit, error?, seconds?}",
|
||||
always,
|
||||
find
|
||||
)]
|
||||
@@ -52,6 +52,8 @@ pub(crate) struct LinkSearch {
|
||||
pub(crate) id: u64,
|
||||
/// The folder as given.
|
||||
pub(crate) folder: String,
|
||||
/// The path of the document the links were taken from, which a relative link path starts from.
|
||||
pub(crate) doc: Option<String>,
|
||||
pub(crate) links: Vec<Sought>,
|
||||
/// The size of each file read, by the path the search reports ([`LinkFiles::sizes`]).
|
||||
pub(crate) sizes: Arc<Mutex<HashMap<PathBuf, u64>>>,
|
||||
@@ -163,6 +165,7 @@ fn find(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
if links.is_empty() {
|
||||
return Err(bad(C, if ids.is_some() { "these objects show no linked files" } else { "no linked files are missing" }));
|
||||
}
|
||||
let doc = super::links::LinkBase::of(st).doc.map(str::to_string);
|
||||
// A new search stops the last one.
|
||||
s.link_search = None;
|
||||
let threads = s.search_threads.unwrap_or_else(findfiles::threads);
|
||||
@@ -171,7 +174,7 @@ fn find(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let visitor = Arc::new(LinkFiles::new(&links, sizes.clone()));
|
||||
let search =
|
||||
findfiles::start(PathBuf::from(&folder), s.search_rules.clone(), limits, threads, 2 * links.len(), visitor).map_err(EngineError::Other)?;
|
||||
s.link_search = Some(LinkSearch { id: NEXT.fetch_add(1, Ordering::Relaxed), folder, links, sizes, search });
|
||||
s.link_search = Some(LinkSearch { id: NEXT.fetch_add(1, Ordering::Relaxed), folder, doc, links, sizes, search });
|
||||
Ok(state(s))
|
||||
}
|
||||
|
||||
@@ -209,7 +212,9 @@ fn shared_folders(saved: &str, found: &Path) -> usize {
|
||||
/// Where the session's last search stands (`links.findFiles` without `folder`).
|
||||
fn state(s: &Session) -> Value {
|
||||
let Some(l) = &s.link_search else { return json!({ "state": "idle", "links": [] }) };
|
||||
let p = l.search.progress();
|
||||
let mut p = l.search.progress();
|
||||
// Only what the caller asking now may read: a search another caller started can hold more.
|
||||
let (folder, error) = p.keep_readable(&l.folder);
|
||||
let n = l.links.len();
|
||||
let sizes = lock(&l.sizes).clone();
|
||||
// Per linked file: the files found, each with whether it is the same file.
|
||||
@@ -267,19 +272,23 @@ fn state(s: &Session) -> Value {
|
||||
f
|
||||
})
|
||||
.collect();
|
||||
json!({ "name": link.name, "path": link.path, "ids": link.ids, "files": files })
|
||||
let mut row = json!({ "name": link.name, "ids": link.ids, "files": files });
|
||||
super::links::note_path(&mut row, "path", &link.path, l.doc.as_deref());
|
||||
row
|
||||
})
|
||||
.collect();
|
||||
let folder = if p.folder.as_os_str().is_empty() { l.folder.clone() } else { p.folder.to_string_lossy().into_owned() };
|
||||
let mut out = json!({
|
||||
"id": l.id, "state": p.state(), "folder": folder, "links": links,
|
||||
"id": l.id, "state": p.state(), "links": links,
|
||||
"searched": { "folders": p.folders, "files": p.files, "read": p.read },
|
||||
"skipped": p.skipped, "unreadable": p.unreadable, "seconds": (p.seconds * 10.0).round() / 10.0,
|
||||
});
|
||||
if let Some(folder) = folder {
|
||||
out["folder"] = json!(folder);
|
||||
}
|
||||
if let Some(why) = p.stopped() {
|
||||
out["stopped"] = json!(why);
|
||||
}
|
||||
if let Some(e) = p.error() {
|
||||
if let Some(e) = error {
|
||||
out["error"] = json!(e);
|
||||
}
|
||||
out
|
||||
|
||||
@@ -436,7 +436,7 @@ pub(crate) fn full_linked_images<'a>(d: &'a Document, base: LinkBase) -> (Cow<'a
|
||||
continue;
|
||||
};
|
||||
let preview = d.images.get(&key).map(|b| b.bytes.clone());
|
||||
// A file read as it is keeps its own type; one turned upright (EXIF orientation) is the PNG it became.
|
||||
// A file read as it is keeps its own type; one turned upright by its orientation is the PNG, or CMYK TIFF, it became.
|
||||
let mime = match fileio::format_for_name(g.link.name()) {
|
||||
Some(f) if img.blob.bytes.as_slice() == bytes.as_slice() => f.mime.to_string(),
|
||||
_ => img.blob.mime.clone(),
|
||||
@@ -898,12 +898,12 @@ fn rows<'a>(gs: impl Iterator<Item = &'a Group>, doc_path: Option<&str>) -> Vec<
|
||||
/// May a reply to the caller asking now carry the link path `path` (of the document saved at
|
||||
/// `doc_path`)? Always without read roots in force; with them, only when the file it names (a
|
||||
/// relative path taken from the document's folder) lies inside them.
|
||||
fn path_shown(path: &str, doc_path: Option<&str>) -> bool {
|
||||
pub(crate) fn path_shown(path: &str, doc_path: Option<&str>) -> bool {
|
||||
crate::file_access::current().is_none() || fileio::document_path(path, doc_path).is_ok_and(|p| crate::file_access::check_read(&p).is_ok())
|
||||
}
|
||||
|
||||
/// `row[key] = path` when [`path_shown`] says so; otherwise `row` has no `key`.
|
||||
fn note_path(row: &mut Value, key: &str, path: &str, doc_path: Option<&str>) {
|
||||
pub(crate) fn note_path(row: &mut Value, key: &str, path: &str, doc_path: Option<&str>) {
|
||||
if path_shown(path, doc_path) {
|
||||
row[key] = json!(path);
|
||||
} else if let Some(o) = row.as_object_mut() {
|
||||
|
||||
@@ -145,7 +145,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Make with Warp…",
|
||||
["Object", "Envelope Distort"],
|
||||
Some("Cmd+Alt+Shift+W"),
|
||||
"{ids?, style?: arc|arcLower|arcUpper|arch|bulge|shellLower|shellUpper|flag|wave|fish|rise|fisheye|inflate|squeeze|twist (arc), bend?: % -100..100 (50), h?: horizontal distortion % -100..100 (0), v?: vertical distortion % (0), horizontal?: bool (true) | orientation?: horizontal|vertical, horizontalDistortion?: as h, verticalDistortion?: as v} envelope the selected objects with a warp (Envelope Options from object.envelope.options with nothing selected; type maps from its glyph outline bounds) → {id}",
|
||||
"{ids?, style?: arc|arcLower|arcUpper|arch|bulge|shellLower|shellUpper|flag|wave|fish|rise|fisheye|inflate|squeeze|twist (arc), bend?: % -100..100 (50), h?: horizontal distortion % -100..100 (0), v?: vertical distortion % (0), horizontal?: bool (true) | orientation?: horizontal|vertical, horizontalDistortion?: as h, verticalDistortion?: as v, version?: 0|1 (1: each style bends the bounds as one Bézier patch; 0: VectorCraft's earlier map)} envelope the selected objects with a warp (Envelope Options from object.envelope.options with nothing selected; type maps from its glyph outline bounds) → {id}",
|
||||
has_selection,
|
||||
env_make_warp
|
||||
),
|
||||
@@ -172,7 +172,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Reset with Warp…",
|
||||
["Object", "Envelope Distort"],
|
||||
None,
|
||||
"{style?, bend?, h?, v?, horizontal? | orientation? (as makeWithWarp; unset: the envelope's current warp, else arc 50 %), horizontalDistortion?: as h, verticalDistortion?: as v} make the selected envelopes warp envelopes, keeping their content",
|
||||
"{style?, bend?, h?, v?, horizontal? | orientation? (as makeWithWarp; unset: the envelope's current warp, else arc 50 %), horizontalDistortion?: as h, verticalDistortion?: as v, version?: as makeWithWarp (unset: a warp envelope's own, else 1)} make the selected envelopes warp envelopes, keeping their content",
|
||||
has_envelope,
|
||||
env_reset_warp
|
||||
),
|
||||
@@ -190,7 +190,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Envelope Info",
|
||||
[],
|
||||
None,
|
||||
"{} the selected envelope's settings, or with none selected the defaults new envelopes get → {id: n|null, type: warp|mesh|topObject, style, bend, h, v, horizontal, rows, cols, fidelity, antiAlias, preserveShape: clippingMask|transparency, distortAppearance, distortLinearGradients, distortPatternFills, boundsMargin?: [top, bottom, left, right] (shares of the content's height and width, reported only when not zero: with distortAppearance, how far the box the content maps from reaches past its geometric bounds), editing}",
|
||||
"{} the selected envelope's settings, or with none selected the defaults new envelopes get → {id: n|null, type: warp|mesh|topObject, style, bend, h, v, horizontal, version (a warp's map: 1, each style as one Bézier patch; 0, the earlier map), rows, cols, fidelity, antiAlias, preserveShape: clippingMask|transparency, distortAppearance, distortLinearGradients, distortPatternFills, boundsMargin?: [top, bottom, left, right] (shares of the content's height and width, reported only when not zero: with distortAppearance, how far the box the content maps from reaches past its geometric bounds), editing}",
|
||||
has_doc,
|
||||
env_info
|
||||
),
|
||||
@@ -208,7 +208,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Envelope Options…",
|
||||
["Object", "Envelope Distort"],
|
||||
None,
|
||||
"{fidelity?: 0..100, antiAlias?: bool, preserveShape?: clippingMask|transparency, distortAppearance?: bool, distortLinearGradients?: bool, distortPatternFills?: bool (the last two work with distortAppearance; on an envelope with boundsMargin, distortAppearance also switches the content between the box with the margins and its geometric box), style?, bend?, h?, v?, horizontal? (warp envelopes), horizontalDistortion?: as h, verticalDistortion?: as v, orientation?: horizontal|vertical} set the selected envelopes' options; with no envelope selected, the options new envelopes get (see object.envelope.info)",
|
||||
"{fidelity?: 0..100, antiAlias?: bool, preserveShape?: clippingMask|transparency, distortAppearance?: bool, distortLinearGradients?: bool, distortPatternFills?: bool (the last two work with distortAppearance; on an envelope with boundsMargin, distortAppearance also switches the content between the box with the margins and its geometric box), style?, bend?, h?, v?, horizontal? (warp envelopes), horizontalDistortion?: as h, verticalDistortion?: as v, orientation?: horizontal|vertical, version?: 0|1 (warp envelopes' map, as makeWithWarp)} set the selected envelopes' options; with no envelope selected, the options new envelopes get (see object.envelope.info)",
|
||||
has_doc,
|
||||
env_options
|
||||
),
|
||||
|
||||
@@ -13,9 +13,14 @@ const WARP_NUMBERS: &[&str] = &["bend", "h", "v", "horizontalDistortion", "verti
|
||||
|
||||
fn warp_kind(p: &Value, cmd: &str, cur: Option<&EnvelopeKind>) -> Result<EnvelopeKind> {
|
||||
check_numbers(p, WARP_NUMBERS, cmd)?;
|
||||
let (mut style, mut bend, mut h, mut v, mut horizontal) = ("arc".to_string(), 50.0, 0.0, 0.0, true);
|
||||
if let Some(EnvelopeKind::Warp { style: s0, bend: b0, h: h0, v: v0, horizontal: z0 }) = cur {
|
||||
(style, bend, h, v, horizontal) = (s0.clone(), *b0, *h0, *v0, *z0);
|
||||
// A new warp takes its style's Bézier net; an edited one keeps the map it has unless `version`
|
||||
// names one.
|
||||
let (mut style, mut bend, mut h, mut v, mut horizontal, mut version) = ("arc".to_string(), 50.0, 0.0, 0.0, true, live::WARP_NET);
|
||||
if let Some(EnvelopeKind::Warp { style: s0, bend: b0, h: h0, v: v0, horizontal: z0, version: n0 }) = cur {
|
||||
(style, bend, h, v, horizontal, version) = (s0.clone(), *b0, *h0, *v0, *z0, *n0);
|
||||
}
|
||||
if let Some(n) = p.get("version") {
|
||||
version = live::warp_version(n).ok_or_else(|| bad(cmd, format!("`version` must be 0 or {}", live::WARP_NET)))?;
|
||||
}
|
||||
if let Some(s) = text_param(p, "style", cmd)? {
|
||||
let s = s.strip_prefix("warp.").unwrap_or(s);
|
||||
@@ -32,7 +37,7 @@ fn warp_kind(p: &Value, cmd: &str, cur: Option<&EnvelopeKind>) -> Result<Envelop
|
||||
Some(o) => o == "horizontal",
|
||||
None => given,
|
||||
};
|
||||
Ok(EnvelopeKind::Warp { style, bend, h, v, horizontal })
|
||||
Ok(EnvelopeKind::Warp { style, bend, h, v, horizontal, version })
|
||||
}
|
||||
|
||||
/// The options and fidelity new envelopes get (Envelope Options with no envelope selected).
|
||||
@@ -206,8 +211,8 @@ pub(super) fn env_info(s: &mut Session, _: &Value) -> Result<Value> {
|
||||
return Ok(envelope_options_json(json!({ "id": null }), &options, fidelity));
|
||||
};
|
||||
let v = match &**kind {
|
||||
EnvelopeKind::Warp { style, bend, h, v, horizontal } => {
|
||||
json!({"type": "warp", "style": style, "bend": bend, "h": h, "v": v, "horizontal": horizontal})
|
||||
EnvelopeKind::Warp { style, bend, h, v, horizontal, version } => {
|
||||
json!({"type": "warp", "style": style, "bend": bend, "h": h, "v": v, "horizontal": horizontal, "version": version})
|
||||
}
|
||||
EnvelopeKind::Mesh { rows, cols, .. } => json!({"type": "mesh", "rows": rows, "cols": cols}),
|
||||
EnvelopeKind::TopObject { .. } => json!({"type": "topObject"}),
|
||||
|
||||
@@ -9,6 +9,10 @@
|
||||
//! from the tool's options (Objects on Path Tool Options). `object.objectsOnPath.options` sets the
|
||||
//! group's Pivot, Rotate and Space (the distance between the objects along the path, and where the
|
||||
//! first one attaches), and `object.objectsOnPath.expand` makes it a plain group.
|
||||
//! `object.objectsOnPath.selectMember` chooses one object of a selected group (its Select/Move
|
||||
//! widget), and `object.objectsOnPath.moveMember` moves one object to another's place along the
|
||||
//! path, the objects between it and that place each moving one place toward where it was; hidden
|
||||
//! and locked objects keep their places.
|
||||
//!
|
||||
//! After every edit, [`refresh`] puts the objects of each Objects on Path group that changed back
|
||||
//! on its path: editing the path moves them, an object moved on its own goes back to its place,
|
||||
@@ -19,7 +23,7 @@
|
||||
//! Object › Objects on Path › Attach; the order on the path, the attachment point, the distribution,
|
||||
//! the objects squared to the path; Pivot and Rotate; path edits realign the objects; Expand).
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::collections::{HashMap, HashSet};
|
||||
use std::sync::Arc;
|
||||
|
||||
use serde_json::{Value, json};
|
||||
@@ -28,7 +32,7 @@ use vectorcraft_doc::{Document, Node, NodeId, NodeKind};
|
||||
use vectorcraft_geom::Rect;
|
||||
|
||||
use super::edit::{roots_of, selected_roots};
|
||||
use super::{CommandSpec, EngineError, Result, Session, bad, check_numbers, checked_id, checked_ids_param, cmd, has_selection};
|
||||
use super::{CommandSpec, EngineError, Result, Session, bad, check_numbers, checked_id, checked_ids_param, cmd, has_doc, has_selection};
|
||||
|
||||
/// The id of the Objects on Path tool.
|
||||
pub const TOOL: &str = "objectsOnPath";
|
||||
@@ -53,6 +57,24 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
has_objects_on_path,
|
||||
options
|
||||
),
|
||||
cmd!(
|
||||
"object.objectsOnPath.selectMember",
|
||||
"Select Object on Path",
|
||||
[],
|
||||
None,
|
||||
"{member: id of an object of an Objects on Path group} select the group alone with that object chosen, as a click on the object's Select/Move widget does; any other selection change clears the choice → {group, member}",
|
||||
has_doc,
|
||||
select_member
|
||||
),
|
||||
cmd!(
|
||||
"object.objectsOnPath.moveMember",
|
||||
"Move Object on Path",
|
||||
[],
|
||||
None,
|
||||
"{member: id of an object of an Objects on Path group, target: id of another object of the same group, group?: the group's id (when given, it must be the group of both); neither object nor the group may be hidden or locked} move the object to the target's place along the path; the objects between them each move one place toward where it was, except hidden and locked objects, which keep their places. Only the order along the path changes: Space, the start, Pivot, Rotate and the order in the Layers panel stay. A target that is the object itself changes nothing → {group, member, index}",
|
||||
has_doc,
|
||||
move_member
|
||||
),
|
||||
cmd!(
|
||||
"object.objectsOnPath.expand",
|
||||
"Expand",
|
||||
@@ -296,6 +318,71 @@ fn options(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
Ok(json!({ "ids": groups.iter().map(|g| g.0).collect::<Vec<_>>() }))
|
||||
}
|
||||
|
||||
// ---------- Select/Move ----------
|
||||
|
||||
/// The Objects on Path group whose object `key` of `p` names, checked: an object listed in the
|
||||
/// group's record (not its path), in a group that is not hidden or locked.
|
||||
fn member_param(s: &Session, p: &Value, key: &str, cmd: &str) -> Result<(NodeId, NodeId)> {
|
||||
let v = p.get(key).filter(|v| !v.is_null()).ok_or_else(|| bad(cmd, format!("missing `{key}`")))?;
|
||||
let id = checked_id(s, v, cmd)?;
|
||||
let d = &s.doc()?.doc;
|
||||
let not_on_path = || bad(cmd, format!("`{key}` must name an object attached to the path of an Objects on Path group"));
|
||||
let g =
|
||||
d.ancestry(id).unwrap_or_default().into_iter().rev().nth(1).filter(|g| d.node(*g).is_some_and(is_objects_on_path)).ok_or_else(not_on_path)?;
|
||||
let listed = d.node(g).and_then(|n| n.extras().objects_on_path.as_deref()).is_some_and(|rec| rec.members.iter().any(|m| m.id == id));
|
||||
if !listed {
|
||||
return Err(not_on_path());
|
||||
}
|
||||
if !d.is_editable(g) {
|
||||
return Err(bad(cmd, "the Objects on Path group is hidden or locked"));
|
||||
}
|
||||
if !d.is_editable(id) {
|
||||
return Err(bad(cmd, format!("`{key}` names an object that is hidden or locked")));
|
||||
}
|
||||
Ok((g, id))
|
||||
}
|
||||
|
||||
fn select_member(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let (g, member) = member_param(s, p, "member", "object.objectsOnPath.selectMember")?;
|
||||
s.select(|_, sel| sel.set_on_path_member(g, member))?;
|
||||
Ok(json!({ "group": g.0, "member": member.0 }))
|
||||
}
|
||||
|
||||
fn move_member(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const C: &str = "object.objectsOnPath.moveMember";
|
||||
let (g, member) = member_param(s, p, "member", C)?;
|
||||
let (target_group, target) = member_param(s, p, "target", C)?;
|
||||
if target_group != g {
|
||||
return Err(bad(C, "`target` must be an object of the same Objects on Path group as `member`"));
|
||||
}
|
||||
if let Some(v) = p.get("group").filter(|v| !v.is_null())
|
||||
&& checked_id(s, v, C)? != g
|
||||
{
|
||||
return Err(bad(C, "`group` must be the Objects on Path group of `member` and `target`"));
|
||||
}
|
||||
let at = |s: &Session| -> Result<usize> {
|
||||
let rec = s.doc()?.doc.node(g).and_then(|n| n.extras().objects_on_path.as_deref().cloned()).ok_or(EngineError::NoNode(g))?;
|
||||
rec.members.iter().position(|m| m.id == member).ok_or(EngineError::NoNode(member))
|
||||
};
|
||||
// Onto its own place: nothing changes, and no undo step is recorded.
|
||||
if target == member {
|
||||
return Ok(json!({ "group": g.0, "member": member.0, "index": at(s)? }));
|
||||
}
|
||||
// Hidden and locked objects between them keep their places.
|
||||
let index = s.edit("Move Object on Path", |d, _| {
|
||||
let mut rec = d.node(g).and_then(|n| n.extras().objects_on_path.as_deref().cloned()).ok_or(EngineError::NoNode(g))?;
|
||||
let fixed: HashSet<NodeId> = {
|
||||
let editable = d.editable();
|
||||
rec.members.iter().map(|m| m.id).filter(|&id| !editable(id)).collect()
|
||||
};
|
||||
let index = rec.move_member(member, target, |id| fixed.contains(&id)).ok_or(EngineError::NoNode(member))?;
|
||||
let n = d.node_mut(g).ok_or(EngineError::NoNode(g))?;
|
||||
n.extras_mut().objects_on_path = Some(Box::new(rec));
|
||||
Ok(index)
|
||||
})?;
|
||||
Ok(json!({ "group": g.0, "member": member.0, "index": index }))
|
||||
}
|
||||
|
||||
// ---------- Expand ----------
|
||||
|
||||
fn expand(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
|
||||
@@ -317,6 +317,14 @@ pub fn check_effect_params(id: &str, effect: &str, params: &Value) -> Result<()>
|
||||
if effect == vectorcraft_render::effects::SVG_FILTER && given.contains_key("name") && !given.contains_key("xml") {
|
||||
return Err(bad(id, "an SVG filter is named by its id: give `xml` with `name`"));
|
||||
}
|
||||
// A Warp's map is one this build draws: 0, the earlier map, or its style's Bézier net.
|
||||
let newest = vectorcraft_render::effects::WARP_NET;
|
||||
if effect.starts_with("warp.")
|
||||
&& let Some(v) = given.get("version")
|
||||
&& vectorcraft_render::effects::warp_version(v).is_none()
|
||||
{
|
||||
return Err(bad(id, format!("`version` must be 0 or {newest}")));
|
||||
}
|
||||
check_effect_color(id, effect, params)?;
|
||||
check_effect_channel(id, effect, params)
|
||||
}
|
||||
|
||||
@@ -26,6 +26,7 @@ pub struct BuiltinPatternLibrary {
|
||||
pub const PATTERN_LIBRARIES: &[BuiltinPatternLibrary] = &[
|
||||
BuiltinPatternLibrary { id: "geometric-patterns", name: "Geometric Patterns", make: geometric_patterns },
|
||||
BuiltinPatternLibrary { id: "grit-textures", name: "Grit Textures", make: grit_textures },
|
||||
BuiltinPatternLibrary { id: "optical-effects", name: "Optical Effects", make: optical_effects },
|
||||
];
|
||||
|
||||
/// The library category of the built-in pattern libraries.
|
||||
@@ -256,6 +257,162 @@ fn grit_textures(d: &mut Document) {
|
||||
});
|
||||
}
|
||||
|
||||
/// `n` + 1 points of the curve `(t, f(t))` for `t` from `t0` to `t1`, evenly spaced.
|
||||
fn curve(t0: f64, t1: f64, n: u32, f: impl Fn(f64) -> f64) -> Vec<(f64, f64)> {
|
||||
(0..=n)
|
||||
.map(|i| {
|
||||
let t = t0 + (t1 - t0) * f64::from(i) / f64::from(n);
|
||||
(t, f(t))
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// The closed band between two curves drawn from the same side to the same side: `upper` forward,
|
||||
/// then `lower` back.
|
||||
fn band(upper: &[(f64, f64)], lower: &[(f64, f64)]) -> PathData {
|
||||
let points: Vec<(f64, f64)> = upper.iter().chain(lower.iter().rev()).copied().collect();
|
||||
polygon(&points)
|
||||
}
|
||||
|
||||
/// 72 corners of the circle of radius `r` about (`cx`, `cy`), turning the other way with `reverse`.
|
||||
fn circle(cx: f64, cy: f64, r: f64, reverse: bool) -> SubPath {
|
||||
const CORNERS: u32 = 72;
|
||||
let turn = if reverse { -std::f64::consts::TAU } else { std::f64::consts::TAU };
|
||||
let points: Vec<Point> = (0..CORNERS)
|
||||
.map(|i| {
|
||||
let a = turn * f64::from(i) / f64::from(CORNERS);
|
||||
Point::new(cx + r * a.cos(), cy + r * a.sin())
|
||||
})
|
||||
.collect();
|
||||
SubPath::polyline(&points, true)
|
||||
}
|
||||
|
||||
/// The ring from radius `inner` to `outer` about (`cx`, `cy`): the outer circle and the inner one
|
||||
/// turning the other way, so the middle stays clear under either fill rule. An `inner` of 0 gives
|
||||
/// a disk.
|
||||
fn ring(cx: f64, cy: f64, inner: f64, outer: f64) -> PathData {
|
||||
let mut subpaths = vec![circle(cx, cy, outer, false)];
|
||||
if inner > 0.0 {
|
||||
subpaths.push(circle(cx, cy, inner, true));
|
||||
}
|
||||
PathData::new(subpaths)
|
||||
}
|
||||
|
||||
/// A lens `2 × l` long and `2 × h` high about (`cx`, `cy`), lying along x: two half sine waves that
|
||||
/// meet at its tips, turning the other way with `reverse`.
|
||||
fn lens(cx: f64, cy: f64, l: f64, h: f64, reverse: bool) -> SubPath {
|
||||
const STEPS: u32 = 32;
|
||||
let at = |i: u32, side: f64| {
|
||||
let t = f64::from(i) / f64::from(STEPS);
|
||||
Point::new(cx - l + 2.0 * l * t, cy + side * h * (std::f64::consts::PI * t).sin())
|
||||
};
|
||||
let mut points: Vec<Point> = (0..=STEPS).map(|i| at(i, -1.0)).chain((1..STEPS).rev().map(|i| at(i, 1.0))).collect();
|
||||
if reverse {
|
||||
points.reverse();
|
||||
}
|
||||
SubPath::polyline(&points, true)
|
||||
}
|
||||
|
||||
/// Optical Effects: black waves, rings, warped checks, pinched stripes, chevrons and lenses on a
|
||||
/// clear background. Each tile is one period of its design, so a band that leaves one edge comes
|
||||
/// back at the same place on the opposite edge. Art that reaches past an edge is kept whole: the
|
||||
/// tiling draws the part outside the tile on the next tile.
|
||||
fn optical_effects(d: &mut Document) {
|
||||
use std::f64::consts::TAU;
|
||||
// Three bands 5 pt wide, 12 pt apart, each one sine wave across the tile.
|
||||
let bands = (0..3u32)
|
||||
.map(|k| {
|
||||
let mid = move |x: f64| 6.0 + 12.0 * f64::from(k) + 3.0 * (TAU * x / 36.0).sin();
|
||||
band(&curve(0.0, 36.0, 48, |x| mid(x) - 2.5), &curve(0.0, 36.0, 48, |x| mid(x) + 2.5))
|
||||
})
|
||||
.collect();
|
||||
add(d, "Sine Bands", 36.0, 36.0, TileType::Grid, bands);
|
||||
// Nine thin waves 4 pt apart, two waves across the tile, each a ninth of a wave on from the one
|
||||
// above it, so the ninth leads into the first wave of the next tile down.
|
||||
let lines = (0..9u32)
|
||||
.map(|k| {
|
||||
let (y, phase) = (2.0 + 4.0 * f64::from(k), TAU * f64::from(k) / 9.0);
|
||||
let mid = move |x: f64| y + 2.5 * (TAU * x / 18.0 + phase).sin();
|
||||
band(&curve(0.0, 36.0, 72, |x| mid(x) - 0.6), &curve(0.0, 36.0, 72, |x| mid(x) + 0.6))
|
||||
})
|
||||
.collect();
|
||||
add(d, "Ripple Lines", 36.0, 36.0, TileType::Grid, lines);
|
||||
// Thin rings 3 pt apart about the center and rings 3.3 pt apart about a point 4 pt to its
|
||||
// right. The two sets cross, and the outer rings reach into the next tile across.
|
||||
let rings = (1..=7u32)
|
||||
.map(|k| (24.0, 3.0 * f64::from(k)))
|
||||
.chain((1..=7u32).map(|k| (28.0, 3.3 * f64::from(k))))
|
||||
.map(|(cx, r)| ring(cx, 24.0, r - 0.6, r + 0.6))
|
||||
.collect();
|
||||
add(d, "Interference Rings", 48.0, 48.0, TileType::Grid, rings);
|
||||
// Rings 2 pt and 1 pt wide by turns about the tile's corner and about its center, the center's
|
||||
// starting with the narrower one. The corner's rings are the same rings at all four corners.
|
||||
let mut rings = vec![];
|
||||
for (c, first) in [(0.0, 0), (20.0, 1)] {
|
||||
let mut r = 0.0;
|
||||
for k in 0..5 {
|
||||
let width = if (k + first) % 2 == 0 { 2.0 } else { 1.0 };
|
||||
rings.push(ring(c, c, r, r + width));
|
||||
r += width + 1.2;
|
||||
}
|
||||
}
|
||||
add(d, "Offset Rings", 40.0, 40.0, TileType::Grid, rings);
|
||||
// An 8 × 8 checkerboard of 4 pt cells whose columns are bent by a sine wave down the tile and
|
||||
// whose rows by one across it, each edge drawn through five points.
|
||||
let warp = |x: f64, y: f64| (x + 2.0 * (TAU * y / 32.0).sin(), y + 2.0 * (TAU * x / 32.0).sin());
|
||||
let cells = (0..8u32)
|
||||
.flat_map(|row| (0..8u32).map(move |col| (col, row)))
|
||||
.filter(|(col, row)| (col + row) % 2 == 0)
|
||||
.map(|(col, row)| {
|
||||
let (x, y) = (4.0 * f64::from(col), 4.0 * f64::from(row));
|
||||
let corners = [(x, y), (x + 4.0, y), (x + 4.0, y + 4.0), (x, y + 4.0), (x, y)];
|
||||
let points: Vec<(f64, f64)> = corners
|
||||
.windows(2)
|
||||
.flat_map(|e| {
|
||||
let (a, b) = (e[0], e[1]);
|
||||
(0..4u32).map(move |i| {
|
||||
let t = f64::from(i) / 4.0;
|
||||
warp(a.0 + (b.0 - a.0) * t, a.1 + (b.1 - a.1) * t)
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
polygon(&points)
|
||||
})
|
||||
.collect();
|
||||
add(d, "Warped Checks", 32.0, 32.0, TileType::Grid, cells);
|
||||
// Six upright stripes 6 pt apart that widen from 0.8 pt to 5.6 pt and narrow again down the tile,
|
||||
// each a sixth of a wave on from the one to its left.
|
||||
let stripes = (0..6u32)
|
||||
.map(|k| {
|
||||
let (x, phase) = (3.0 + 6.0 * f64::from(k), TAU * f64::from(k) / 6.0);
|
||||
let half = move |y: f64| 1.6 + 1.2 * (TAU * y / 36.0 + phase).cos();
|
||||
let side = |s: f64| -> Vec<(f64, f64)> { curve(0.0, 36.0, 48, |y| x + s * half(y)).into_iter().map(|(y, at)| (at, y)).collect() };
|
||||
band(&side(-1.0), &side(1.0))
|
||||
})
|
||||
.collect();
|
||||
add(d, "Pinched Stripes", 36.0, 36.0, TileType::Grid, stripes);
|
||||
// Four zigzag bands 8 pt apart, two zigzags across the tile; the bend and the width of the bands
|
||||
// rise and fall from band to band.
|
||||
let chevrons = [(0.0, 3.0, 4.0), (8.0, 4.5, 2.5), (16.0, 3.0, 1.0), (24.0, 1.5, 2.5)]
|
||||
.into_iter()
|
||||
.map(|(y, bend, width)| {
|
||||
let upper: Vec<(f64, f64)> = (0..=4u32).map(|i| (8.0 * f64::from(i), if i % 2 == 1 { y + bend } else { y })).collect();
|
||||
let lower: Vec<(f64, f64)> = upper.iter().map(|&(x, y)| (x, y + width)).collect();
|
||||
band(&upper, &lower)
|
||||
})
|
||||
.collect();
|
||||
add(d, "Chevron Pulse", 32.0, 32.0, TileType::Grid, chevrons);
|
||||
// Five nested lens outlines 1.5 pt wide about the center, 14 pt to 78 pt long. The longer ones
|
||||
// reach across the side edges and cross the next tiles' lenses.
|
||||
let lenses = (0..5u32)
|
||||
.map(|k| {
|
||||
let (l, h) = (7.0 + 8.0 * f64::from(k), 4.0 + 4.0 * f64::from(k));
|
||||
PathData::new(vec![lens(24.0, 24.0, l, h, false), lens(24.0, 24.0, l - 1.5, h - 1.5, true)])
|
||||
})
|
||||
.collect();
|
||||
add(d, "Radiating Lenses", 48.0, 48.0, TileType::Grid, lenses);
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
@@ -326,4 +483,101 @@ mod tests {
|
||||
}
|
||||
assert!(crossing > 0, "no mark reaches past an edge");
|
||||
}
|
||||
|
||||
const OPTICAL: [&str; 8] =
|
||||
["Sine Bands", "Ripple Lines", "Interference Rings", "Offset Rings", "Warped Checks", "Pinched Stripes", "Chevron Pulse", "Radiating Lenses"];
|
||||
|
||||
fn optical_doc() -> Document {
|
||||
let mut d = Document::new(100.0, 100.0);
|
||||
optical_effects(&mut d);
|
||||
d
|
||||
}
|
||||
|
||||
/// The paths of pattern `p`'s art, each with its fill rule.
|
||||
fn paths(p: &PatternDef) -> Vec<(&PathData, vectorcraft_geom::FillRule)> {
|
||||
p.art
|
||||
.iter()
|
||||
.map(|n| match &n.kind {
|
||||
vectorcraft_doc::NodeKind::Path { path, rule, .. } => (path, *rule),
|
||||
_ => panic!("{}: art that is not a path", p.name),
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn optical_effects_is_a_pattern_library_of_eight_bounded_tiles() {
|
||||
let b = PATTERN_LIBRARIES.iter().find(|b| b.id == "optical-effects").expect("Optical Effects is listed");
|
||||
assert_eq!(b.name, "Optical Effects");
|
||||
let names: Vec<String> = library_doc(b.id).unwrap().patterns.iter().map(|p| p.name.clone()).collect();
|
||||
assert_eq!(names, OPTICAL);
|
||||
// Made twice, the art is the same.
|
||||
let (a, again) = (optical_doc(), optical_doc());
|
||||
assert_eq!(a.patterns, again.patterns);
|
||||
for p in &a.patterns {
|
||||
let (w, h) = (p.width(), p.height());
|
||||
assert!((32.0..=48.0).contains(&w) && w == h && p.tile.x0 == 0.0 && p.tile.y0 == 0.0, "{}", p.name);
|
||||
assert_eq!(p.tile_type, TileType::Grid, "{}", p.name);
|
||||
let paths = paths(p);
|
||||
let segments: usize = paths.iter().flat_map(|(path, _)| &path.subpaths).map(SubPath::segment_count).sum();
|
||||
assert!(!paths.is_empty() && paths.len() <= 600 && segments <= 20_000, "{}: {} paths, {segments} segments", p.name, paths.len());
|
||||
for (path, _) in &paths {
|
||||
let b = path.bounds().unwrap_or_else(|| panic!("{}: an empty path", p.name));
|
||||
assert!([b.x0, b.y0, b.x1, b.y1].iter().all(|v| v.is_finite()) && b.width() > 0.0 && b.height() > 0.0, "{}: {b:?}", p.name);
|
||||
assert!(b.x1 > 0.0 && b.y1 > 0.0 && b.x0 < w && b.y0 < h, "{}: {b:?} does not touch the tile", p.name);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Just either side of a tile's right and bottom edges, the tiling paints the same points: a band
|
||||
/// cut off at an edge, or one that comes back at another height or width, paints one side only.
|
||||
/// No path is another moved by a whole tile, which the tiling would draw twice in one place.
|
||||
#[test]
|
||||
fn optical_tiles_join_without_a_seam() {
|
||||
const SAMPLES: u32 = 400;
|
||||
const STEP: f64 = 1e-4;
|
||||
for p in &optical_doc().patterns {
|
||||
let (w, h) = (p.width(), p.height());
|
||||
let shapes: Vec<(BezPathFill, Rect)> =
|
||||
paths(p).into_iter().map(|(path, rule)| ((path.to_bezpath(), rule), path.bounds().unwrap_or(Rect::ZERO))).collect();
|
||||
let paints = |q: Point| {
|
||||
let near = Rect::new(q.x - STEP, q.y - STEP, q.x + STEP, q.y + STEP);
|
||||
p.offsets_covering(near).into_iter().any(|o| {
|
||||
let at = q - o;
|
||||
shapes.iter().any(|((bp, rule), b)| {
|
||||
let inside = b.x0 <= at.x && at.x <= b.x1 && b.y0 <= at.y && at.y <= b.y1;
|
||||
let turns = if inside { kurbo::Shape::winding(bp, at) } else { 0 };
|
||||
match rule {
|
||||
vectorcraft_geom::FillRule::EvenOdd => turns % 2 != 0,
|
||||
vectorcraft_geom::FillRule::NonZero => turns != 0,
|
||||
}
|
||||
})
|
||||
})
|
||||
};
|
||||
let (mut differ, mut painted) = (0, 0);
|
||||
for i in 0..SAMPLES {
|
||||
let t = (f64::from(i) + 0.37) / f64::from(SAMPLES);
|
||||
let (right, bottom) = (Point::new(w, t * h), Point::new(t * w, h));
|
||||
let (dx, dy) = (kurbo::Vec2::new(STEP, 0.0), kurbo::Vec2::new(0.0, STEP));
|
||||
for (a, b) in [(right - dx, right + dx), (bottom - dy, bottom + dy)] {
|
||||
let (pa, pb) = (paints(a), paints(b));
|
||||
differ += usize::from(pa != pb);
|
||||
painted += usize::from(pa);
|
||||
}
|
||||
}
|
||||
assert!(differ <= 2, "{}: {differ} of {} points across the edges differ", p.name, 2 * SAMPLES);
|
||||
assert!(painted > 0, "{}: nothing crosses an edge", p.name);
|
||||
let bounds: Vec<Rect> = shapes.iter().map(|(_, b)| *b).collect();
|
||||
let same = |a: &Rect, b: Rect| {
|
||||
(a.x0 - b.x0).abs() < 1e-9 && (a.y0 - b.y0).abs() < 1e-9 && (a.x1 - b.x1).abs() < 1e-9 && (a.y1 - b.y1).abs() < 1e-9
|
||||
};
|
||||
for b in &bounds {
|
||||
for (dx, dy) in [(w, 0.0), (-w, 0.0), (0.0, h), (0.0, -h), (w, h), (w, -h), (-w, h), (-w, -h)] {
|
||||
let moved = *b + kurbo::Vec2::new(dx, dy);
|
||||
assert!(!bounds.iter().any(|o| same(o, moved)), "{}: {b:?} is drawn twice by the tiling", p.name);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
type BezPathFill = (vectorcraft_geom::BezPath, vectorcraft_geom::FillRule);
|
||||
}
|
||||
|
||||
@@ -87,7 +87,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Image Info",
|
||||
[],
|
||||
None,
|
||||
"{id?} an image object (default: the one selected) → {id, name, linked, link, colorMode: RGB|Grayscale|CMYK, pixelWidth, pixelHeight, ppi: [x, y] at its placed size, width, height}",
|
||||
"{id?} an image object (default: the one selected) → {id, name, linked, link (the linked file's path; null when embedded, or when automation read roots are in force and the file is outside them), colorMode: RGB|Grayscale|CMYK, pixelWidth, pixelHeight, ppi: [x, y] at its placed size, width, height}",
|
||||
has_doc,
|
||||
image_info
|
||||
),
|
||||
@@ -891,11 +891,13 @@ pub(crate) fn image_info(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let [a, b, c, d, ..] = im.xf.as_coeffs();
|
||||
let ppi = |x: f64, y: f64| Some(x.hypot(y)).filter(|l| *l > 1e-12).map_or(0.0, |l| 72.0 / l);
|
||||
let size = n.geometric_bounds().unwrap_or_default();
|
||||
// A linked file outside the read roots of the caller asking now is left unnamed, as in Link Info.
|
||||
let doc_path = super::links::LinkBase::of(st).doc;
|
||||
Ok(json!({
|
||||
"id": id.0,
|
||||
"name": n.display_name(),
|
||||
"linked": im.link.is_some(),
|
||||
"link": im.link.as_ref().map(|l| &l.path),
|
||||
"link": im.link.as_ref().map(|l| &l.path).filter(|path| super::links::path_shown(path, doc_path)),
|
||||
"colorMode": st.doc.images.get(&im.key).map_or("RGB", |b| color_mode(&b.bytes)),
|
||||
"pixelWidth": im.width,
|
||||
"pixelHeight": im.height,
|
||||
|
||||
@@ -62,13 +62,28 @@ fn err(e: vectorcraft_plugins::Error) -> EngineError {
|
||||
}
|
||||
}
|
||||
|
||||
/// Under automation read roots, a reply names a file or folder outside them by its name alone.
|
||||
fn shown_file(path: &str) -> String {
|
||||
super::fileio::shown_path(path).into_owned()
|
||||
}
|
||||
|
||||
/// A plug-in turned off for the next launch, or the one a launch stopped at, as a reply shows it: a
|
||||
/// file outside the automation read roots is named by its file name.
|
||||
fn shown_item(item: &safe_mode::Item) -> Value {
|
||||
let mut v = item.to_json();
|
||||
if let Some(file) = item.file.as_deref() {
|
||||
v["file"] = json!(shown_file(file));
|
||||
}
|
||||
v
|
||||
}
|
||||
|
||||
/// JSON description of an installed plug-in.
|
||||
pub fn describe(p: &Plugin) -> Value {
|
||||
let m = p.manifest();
|
||||
let mut v = json!({
|
||||
"id": m.id, "name": m.name, "version": m.version, "kind": m.kind.name(), "author": m.author, "description": m.description,
|
||||
"params": m.params_doc(), "paramsSchema": serde_json::to_value(m).ok().and_then(|v| v.get("params").cloned()),
|
||||
"size": p.size(), "source": p.source(),
|
||||
"size": p.size(), "source": p.source().map(shown_file),
|
||||
"permissions": permissions::names(&m.permissions()), "hash": p.hash(), "api": m.api,
|
||||
});
|
||||
match m.kind {
|
||||
@@ -932,9 +947,46 @@ fn describe_installed(p: &Plugin) -> Value {
|
||||
v
|
||||
}
|
||||
|
||||
/// The folder load `report` as a reply shows it: under automation read roots, a folder outside them
|
||||
/// is `null` and a failed file outside them is named by its file name, in the errors too.
|
||||
fn shown_report(mut report: Value) -> Value {
|
||||
let mut hidden: Vec<(String, String)> = vec![];
|
||||
if let Some(failed) = report.get_mut("failed").and_then(Value::as_array_mut) {
|
||||
for row in failed {
|
||||
let Some(file) = row.get("file").and_then(Value::as_str).map(str::to_string) else { continue };
|
||||
let shown = shown_file(&file);
|
||||
if shown != file {
|
||||
row["file"] = json!(shown);
|
||||
hidden.push((file, shown));
|
||||
}
|
||||
}
|
||||
}
|
||||
if let Some(folder) = report.get("folder").and_then(Value::as_str).map(str::to_string) {
|
||||
let shown = shown_file(&folder);
|
||||
if shown != folder {
|
||||
report["folder"] = Value::Null;
|
||||
hidden.push((folder, shown));
|
||||
}
|
||||
}
|
||||
// A failed file's path holds its folder's: the longer paths are put back first.
|
||||
hidden.sort_by_key(|(path, _)| std::cmp::Reverse(path.len()));
|
||||
let unnamed = |text: &str| hidden.iter().fold(text.to_string(), |t, (path, name)| t.replace(path.as_str(), name));
|
||||
if let Some(e) = report.get("error").and_then(Value::as_str).map(unnamed) {
|
||||
report["error"] = json!(e);
|
||||
}
|
||||
if let Some(failed) = report.get_mut("failed").and_then(Value::as_array_mut) {
|
||||
for row in failed {
|
||||
if let Some(e) = row.get("error").and_then(Value::as_str).map(unnamed) {
|
||||
row["error"] = json!(e);
|
||||
}
|
||||
}
|
||||
}
|
||||
report
|
||||
}
|
||||
|
||||
fn list(_: &mut Session, _: &Value) -> Result<Value> {
|
||||
let folder = FOLDER.lock().unwrap_or_else(|e| e.into_inner()).as_ref().map(|(_, _, r)| r.clone());
|
||||
let disabled: Vec<Value> = safe_mode::disabled().iter().map(safe_mode::Item::to_json).collect();
|
||||
let folder = FOLDER.lock().unwrap_or_else(|e| e.into_inner()).as_ref().map(|(_, _, r)| shown_report(r.clone()));
|
||||
let disabled: Vec<Value> = safe_mode::disabled().iter().map(shown_item).collect();
|
||||
Ok(
|
||||
json!({"plugins": registry::list().iter().map(|p| describe_installed(p)).collect::<Vec<_>>(), "folder": folder, "safeMode": safe_mode::active(), "disabled": disabled}),
|
||||
)
|
||||
@@ -946,11 +998,33 @@ fn safe_mode_state(_: &mut Session, _: &Value) -> Result<Value> {
|
||||
Ok(json!({
|
||||
"active": safe_mode::active(),
|
||||
"source": safe_mode::source().map(safe_mode::Source::name),
|
||||
"disabled": safe_mode::disabled().iter().map(safe_mode::Item::to_json).collect::<Vec<_>>(),
|
||||
"pendingItem": safe_mode::previous().map(|i| i.to_json()),
|
||||
"disabled": safe_mode::disabled().iter().map(shown_item).collect::<Vec<_>>(),
|
||||
"pendingItem": safe_mode::previous().map(|i| shown_item(&i)),
|
||||
}))
|
||||
}
|
||||
|
||||
/// The file of a plug-in `plugin.safeMode` lists as `file`, or `plugin.list` as `source`: one they name
|
||||
/// by its file name (outside the automation read roots) is found by that name, when no other listed
|
||||
/// file has it.
|
||||
fn listed_file(file: String) -> String {
|
||||
let mut listed: Vec<String> = safe_mode::disabled()
|
||||
.into_iter()
|
||||
.chain(safe_mode::previous())
|
||||
.filter_map(|i| i.file)
|
||||
.chain(registry::list().iter().filter_map(|p| p.source().map(str::to_string)))
|
||||
.collect();
|
||||
listed.sort();
|
||||
listed.dedup();
|
||||
if listed.contains(&file) {
|
||||
return file;
|
||||
}
|
||||
let mut named = listed.into_iter().filter(|f| shown_file(f) == file);
|
||||
match (named.next(), named.next()) {
|
||||
(Some(f), None) => f,
|
||||
_ => file,
|
||||
}
|
||||
}
|
||||
|
||||
/// `plugin.setEnabled`: turns a plug-in on or off for the next launch, by `id` or by `file` (a
|
||||
/// plug-in that stopped a launch while loading may be known by its file alone). It neither loads
|
||||
/// nor unloads anything now, and uninstalls nothing.
|
||||
@@ -972,7 +1046,7 @@ fn set_enabled(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let stored = if enabled { s.stored_plugin_file(&id).map(safe_mode::Item::file) } else { None };
|
||||
(registry::get(&id).map(|p| safe_mode::Item::of(&p)).unwrap_or(safe_mode::Item { id: Some(id), ..Default::default() }), stored)
|
||||
}
|
||||
(None, Some(file)) => (safe_mode::Item::file(file), None),
|
||||
(None, Some(file)) => (safe_mode::Item::file(listed_file(file)), None),
|
||||
(None, None) => return Err(bad(C, "give `id` or `file` (see plugin.safeMode)")),
|
||||
};
|
||||
let items: Vec<&safe_mode::Item> = std::iter::once(&item).chain(stored.as_ref()).collect();
|
||||
@@ -986,7 +1060,7 @@ fn set_enabled(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
// Without a plug-in folder (a session confined to automation roots), or when the list there
|
||||
// can't take the change, the change stays in this process and goes with the next change that saves.
|
||||
let saved = !changed || s.store_disabled_plugins();
|
||||
Ok(json!({"changed": changed, "saved": saved, "disabled": safe_mode::disabled().iter().map(safe_mode::Item::to_json).collect::<Vec<_>>()}))
|
||||
Ok(json!({"changed": changed, "saved": saved, "disabled": safe_mode::disabled().iter().map(shown_item).collect::<Vec<_>>()}))
|
||||
}
|
||||
|
||||
fn info(_: &mut Session, p: &Value) -> Result<Value> {
|
||||
@@ -1055,7 +1129,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"List Plug-ins",
|
||||
[],
|
||||
None,
|
||||
"{} → {plugins: [{id, name, version, kind: \"filter\"|\"effect\"|\"exporter\"|\"importer\", author, description, params (doc), paramsSchema, effect? (the effect id of a live effect: plugin.<id>), format? and extensions? (an exporter's or importer's format id, plugin.<id>, for document.export or document.open's importer, and its file extensions), budget?: \"heavy\" (a filter that may compute for longer, see plugin.install's allowHeavy), inputs?: [\"type\"|\"image\"|\"symbolInstance\"] (the objects besides paths a filter reads, see plugin.run), size, source, permissions: [name] (what it may do: the ones its manifest lists, else the ones its kind implies; progress: it may report its progress, see Cancel and write log lines; settings: it may keep settings of its own; cache: it may keep a cache of its own; commands: a filter may run the commands its commandAllowlist names, see docs/plugins.md), commandAllowlist?: [command id] (the commands a filter with the commands permission may run while it runs, with vc_call), hash (the module's SHA-256), api (the plug-in API level it was built for, 1 or 2), paintOutput?: true (a live effect whose pieces have paints of their own, see docs/plugins.md), cacheBytes (the bytes its cache holds; see plugin.clearCache), commands?: [{id, command (its command id, plugin.<id>.<command id>, which runs it), label, params (doc), paramsSchema}] (the menu commands a filter or live effect adds)}], folder: the Additional Plug-ins Folder load report {folder, loaded, failed: [{file, error}]} | null, safeMode: bool (no plug-in loads or runs, see plugin.safeMode), disabled: [{id, file, name, label}] (turned off for the next launch, see plugin.setEnabled)}",
|
||||
"{} → {plugins: [{id, name, version, kind: \"filter\"|\"effect\"|\"exporter\"|\"importer\", author, description, params (doc), paramsSchema, effect? (the effect id of a live effect: plugin.<id>), format? and extensions? (an exporter's or importer's format id, plugin.<id>, for document.export or document.open's importer, and its file extensions), budget?: \"heavy\" (a filter that may compute for longer, see plugin.install's allowHeavy), inputs?: [\"type\"|\"image\"|\"symbolInstance\"] (the objects besides paths a filter reads, see plugin.run), size, source, permissions: [name] (what it may do: the ones its manifest lists, else the ones its kind implies; progress: it may report its progress, see Cancel and write log lines; settings: it may keep settings of its own; cache: it may keep a cache of its own; commands: a filter may run the commands its commandAllowlist names, see docs/plugins.md), commandAllowlist?: [command id] (the commands a filter with the commands permission may run while it runs, with vc_call), hash (the module's SHA-256), api (the plug-in API level it was built for, 1 or 2), paintOutput?: true (a live effect whose pieces have paints of their own, see docs/plugins.md), cacheBytes (the bytes its cache holds; see plugin.clearCache), commands?: [{id, command (its command id, plugin.<id>.<command id>, which runs it), label, params (doc), paramsSchema}] (the menu commands a filter or live effect adds)}], folder: the Additional Plug-ins Folder load report {folder, loaded, failed: [{file, error}]} | null (under automation read roots, a folder outside them is null, and a failed file and a plug-in's source outside them are named by their file name), safeMode: bool (no plug-in loads or runs, see plugin.safeMode), disabled: [{id, file, name, label}] (turned off for the next launch, see plugin.setEnabled)}",
|
||||
always,
|
||||
list
|
||||
),
|
||||
@@ -1127,7 +1201,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Safe Mode",
|
||||
[],
|
||||
None,
|
||||
"{} → {active: bool (no plug-in loads or runs: plugin.install, .run, .reload and exporters fail, live effects draw their object's geometry unchanged and the objects keep their effects), source: \"flag\" (started with --safe-mode) | \"startup\" (the user chose Safe Mode, or hasn't chosen yet, after a launch that stopped while a plug-in was loading) | null, disabled: [{id, file, name, label}] (turned off for the next launch; id is null for one known only by its file), pendingItem: {id, file, name, label} | null (the plug-in the last launch was loading when it stopped, until the user chooses; it may not be what stopped it)}. Safe Mode is chosen at launch, never by a command",
|
||||
"{} → {active: bool (no plug-in loads or runs: plugin.install, .run, .reload and exporters fail, live effects draw their object's geometry unchanged and the objects keep their effects), source: \"flag\" (started with --safe-mode) | \"startup\" (the user chose Safe Mode, or hasn't chosen yet, after a launch that stopped while a plug-in was loading) | null, disabled: [{id, file, name, label}] (turned off for the next launch; id is null for one known only by its file; under automation read roots, a file outside them is named by its file name here and in pendingItem, and plugin.setEnabled takes that name), pendingItem: {id, file, name, label} | null (the plug-in the last launch was loading when it stopped, until the user chooses; it may not be what stopped it)}. Safe Mode is chosen at launch, never by a command",
|
||||
always,
|
||||
safe_mode_state
|
||||
),
|
||||
@@ -1136,7 +1210,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Enable on Re-launch",
|
||||
[],
|
||||
None,
|
||||
"{id | file: a plug-in as plugin.safeMode's disabled lists it, enabled: bool} turn a plug-in on or off for the next launch (Help › Safe Mode's Enable on Re-launch); nothing loads, unloads or is uninstalled now. Turning on by id also turns on the file the plug-in is stored in. Turning on one that isn't disabled is an error. plugin.install of a disabled plug-in turns it on → {changed, saved: false when the change isn't in the plug-in folder for the next launch (a session without one, or the list there can't be read or written or is full; it is saved with the next change that saves), disabled}",
|
||||
"{id | file: a plug-in as plugin.safeMode's disabled lists it or as plugin.list's source (under automation read roots, a file outside them by the file name shown, when no other listed file has it), enabled: bool} turn a plug-in on or off for the next launch (Help › Safe Mode's Enable on Re-launch); nothing loads, unloads or is uninstalled now. Turning on by id also turns on the file the plug-in is stored in. Turning on one that isn't disabled is an error. plugin.install of a disabled plug-in turns it on → {changed, saved: false when the change isn't in the plug-in folder for the next launch (a session without one, or the list there can't be read or written or is full; it is saved with the next change that saves), disabled}",
|
||||
always,
|
||||
set_enabled
|
||||
),
|
||||
|
||||
@@ -308,7 +308,9 @@ pub(super) const ALLOWED: &[&str] = &[
|
||||
"object.nudge",
|
||||
"object.objectsOnPath.attach",
|
||||
"object.objectsOnPath.expand",
|
||||
"object.objectsOnPath.moveMember",
|
||||
"object.objectsOnPath.options",
|
||||
"object.objectsOnPath.selectMember",
|
||||
"object.path.addAnchorPoints",
|
||||
"object.path.cleanUp",
|
||||
"object.path.divideObjectsBelow",
|
||||
@@ -646,6 +648,8 @@ pub(super) const ALLOWED: &[&str] = &[
|
||||
"type.opticalMarginAlignment",
|
||||
"type.orientation.horizontal",
|
||||
"type.orientation.vertical",
|
||||
"type.pathCenter.bottom",
|
||||
"type.pathCenter.top",
|
||||
"type.pathOptions",
|
||||
"type.resetHorizontalScale",
|
||||
"type.resetTracking",
|
||||
|
||||
@@ -97,6 +97,10 @@ pub trait RecoveryStore: Send + Sync {
|
||||
fn remove(&self, name: &str) -> std::result::Result<(), String>;
|
||||
/// Where the entries are, as the user knows it (a folder, "browser storage").
|
||||
fn location(&self) -> String;
|
||||
/// Is [`RecoveryStore::location`] a folder on this computer (default: no)?
|
||||
fn in_folder(&self) -> bool {
|
||||
false
|
||||
}
|
||||
/// The areas in the store (default: those with entries).
|
||||
fn areas(&self) -> std::result::Result<Vec<String>, String> {
|
||||
let names = self.list()?;
|
||||
@@ -477,6 +481,9 @@ impl RecoveryStore for FolderStore {
|
||||
fn location(&self) -> String {
|
||||
self.0.to_string_lossy().into_owned()
|
||||
}
|
||||
fn in_folder(&self) -> bool {
|
||||
true
|
||||
}
|
||||
fn areas(&self) -> std::result::Result<Vec<String>, String> {
|
||||
Self::names(&self.0, true)
|
||||
}
|
||||
@@ -938,6 +945,21 @@ fn sweep(store: &dyn RecoveryStore, area: &str) {
|
||||
}
|
||||
}
|
||||
|
||||
/// The document's file `path` as a reply names it: under automation read roots, only inside them.
|
||||
fn shown_path(path: Option<String>) -> Option<String> {
|
||||
path.filter(|p| crate::file_access::check_read(p).is_ok())
|
||||
}
|
||||
|
||||
/// Where `store` keeps its copies, as a reply names it: a recovery folder outside the automation
|
||||
/// read roots is `null`.
|
||||
fn shown_location(store: &dyn RecoveryStore) -> Value {
|
||||
let location = store.location();
|
||||
if store.in_folder() && crate::file_access::check_read(&location).is_err() {
|
||||
return Value::Null;
|
||||
}
|
||||
json!(location)
|
||||
}
|
||||
|
||||
fn read_meta(store: &dyn RecoveryStore, file: &str) -> Meta {
|
||||
store.read(&format!("{file}{META_EXT}")).ok().and_then(|b| serde_json::from_slice(&b).ok()).unwrap_or_default()
|
||||
}
|
||||
@@ -1235,7 +1257,7 @@ fn save(s: &mut Session, _: &Value) -> Result<Value> {
|
||||
}
|
||||
forget_clean(s);
|
||||
heartbeat(s);
|
||||
Ok(json!({ "saved": saved, "skipped": skipped, "location": store.location() }))
|
||||
Ok(json!({ "saved": saved, "skipped": skipped, "location": shown_location(store.as_ref()) }))
|
||||
}
|
||||
|
||||
/// Who an area of the store belongs to.
|
||||
@@ -1361,7 +1383,7 @@ fn list(s: &mut Session, _: &Value) -> Result<Value> {
|
||||
let meta = read_meta(store.as_ref(), f);
|
||||
let title = if meta.title.is_empty() { f.rsplit('/').next().unwrap_or(f).to_string() } else { meta.title };
|
||||
rows.push(json!({
|
||||
"file": f, "title": title, "path": meta.path, "format": meta.format, "saved": meta.saved,
|
||||
"file": f, "title": title, "path": shown_path(meta.path), "format": meta.format, "saved": meta.saved,
|
||||
"open": (who == Owner::Mine && open.contains(f)) || left.contains(f), "running": who == Owner::Running,
|
||||
}));
|
||||
}
|
||||
@@ -1370,7 +1392,7 @@ fn list(s: &mut Session, _: &Value) -> Result<Value> {
|
||||
tidy(store.as_ref(), &area, hold);
|
||||
}
|
||||
}
|
||||
Ok(json!({ "copies": rows, "location": store.location() }))
|
||||
Ok(json!({ "copies": rows, "location": shown_location(store.as_ref()) }))
|
||||
}
|
||||
|
||||
/// A gone app's area to act on: its name, the copies to act on, and its lock.
|
||||
@@ -1483,7 +1505,10 @@ fn restore_one(s: &mut Session, store: &Arc<dyn RecoveryStore>, file: &str) -> R
|
||||
// The copy stays until the document is saved or closed.
|
||||
st.recovery = Some(RecoveryCopy { file: mine.clone(), doc: st.doc.clone() });
|
||||
let title = st.title();
|
||||
Ok(super::fileio::merge(json!({ "index": index, "title": title, "file": mine, "path": meta.path, "warnings": warnings }), links.to_json()))
|
||||
Ok(super::fileio::merge(
|
||||
json!({ "index": index, "title": title, "file": mine, "path": shown_path(meta.path), "warnings": warnings }),
|
||||
links.to_json(),
|
||||
))
|
||||
}
|
||||
|
||||
fn restore(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
@@ -1527,7 +1552,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Save Recovery Data",
|
||||
[],
|
||||
None,
|
||||
"{} write a recovery copy of every modified document changed since its last copy, in this app's own area of the store (the app does this every autosaveInterval minutes while autosaveRecovery is on; documents of more than 20000 objects are skipped while recoveryOffForComplex is on). Images are stored apart, each once while unchanged. A copy goes when its document is saved, reverted or closed → {saved: [{file, title, bytes, imageBytes (images written this time)}], skipped: [{title, reason}], location} (location: the recovery folder, or browser storage on the web)",
|
||||
"{} write a recovery copy of every modified document changed since its last copy, in this app's own area of the store (the app does this every autosaveInterval minutes while autosaveRecovery is on; documents of more than 20000 objects are skipped while recoveryOffForComplex is on). Images are stored apart, each once while unchanged. A copy goes when its document is saved, reverted or closed → {saved: [{file, title, bytes, imageBytes (images written this time)}], skipped: [{title, reason}], location} (location: the recovery folder, or browser storage on the web; null under automation read roots when the folder is outside them)",
|
||||
has_store,
|
||||
save
|
||||
),
|
||||
@@ -1536,7 +1561,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Recovery Data",
|
||||
[],
|
||||
None,
|
||||
"{} the recovery copies in the store → {copies: [{file (\"<area>/<name>\"), title, path (the document's file, if it had one), format, saved (Unix seconds or null), open (the copy of a document open here), running (kept by another VectorCraft that is running)}], location}; copies neither open nor running were left behind by a crash",
|
||||
"{} the recovery copies in the store → {copies: [{file (\"<area>/<name>\"), title, path (the document's file, if it had one), format, saved (Unix seconds or null), open (the copy of a document open here), running (kept by another VectorCraft that is running)}], location}; copies neither open nor running were left behind by a crash; under automation read roots, path is null when the document's file is outside them, and location when the recovery folder is",
|
||||
has_store,
|
||||
list
|
||||
),
|
||||
@@ -1545,7 +1570,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Restore Recovered Documents",
|
||||
[],
|
||||
None,
|
||||
"{file?} open a copy left behind by a crash (default: every one; a running VectorCraft's copies are never taken) as a new document titled \"<name> [Recovered]\": modified, and Save asks where to save it (suggesting its original file) → {restored: [{index, title, file, path, warnings, missingLinks, modifiedLinks, updatedLinks}], failed: [{file, error}] (damaged copies, when restoring every one; a named one fails the command)}. The copy moves to this app's area and stays until the document is saved or closed",
|
||||
"{file?} open a copy left behind by a crash (default: every one; a running VectorCraft's copies are never taken) as a new document titled \"<name> [Recovered]\": modified, and Save asks where to save it (suggesting its original file) → {restored: [{index, title, file, path, warnings, missingLinks, modifiedLinks, updatedLinks}], failed: [{file, error}] (damaged copies, when restoring every one; a named one fails the command)}; under automation read roots, path is null when the original file is outside them. The copy moves to this app's area and stays until the document is saved or closed",
|
||||
has_store,
|
||||
restore
|
||||
),
|
||||
|
||||
@@ -233,6 +233,43 @@ impl Session {
|
||||
self.scripts_dir.as_deref().filter(|_| !cfg!(target_arch = "wasm32"))
|
||||
}
|
||||
|
||||
/// The last run's report ([`Session::script_report`]) as a reply shows it: under automation read
|
||||
/// roots, a file the run wrote outside them (`written`) and the file a failed run stopped in
|
||||
/// (`file`, also in the run's error line in `console`) are named by their file name. What the
|
||||
/// script wrote itself (`log`, its output lines) stays as written.
|
||||
pub fn shown_script_report(&self) -> Option<Value> {
|
||||
let mut report = self.script_report()?.clone();
|
||||
if let Some(written) = report.get_mut("written").and_then(Value::as_array_mut) {
|
||||
for path in written.iter_mut() {
|
||||
if let Some(name) = path.as_str().and_then(hidden_name) {
|
||||
*path = json!(name);
|
||||
}
|
||||
}
|
||||
}
|
||||
if let Some(file) = report.get("file").and_then(Value::as_str).map(str::to_string)
|
||||
&& let Some(name) = hidden_name(&file)
|
||||
{
|
||||
// The run's error lines come after every line the script wrote. They are written again
|
||||
// from the report with the file's name, which also covers a line the console cut inside
|
||||
// the path. A console that dropped its oldest lines may hold only the last of them.
|
||||
let before = script::error_lines(&report);
|
||||
report["file"] = json!(name);
|
||||
if let Some(message) = report.get("message").and_then(Value::as_str) {
|
||||
report["message"] = json!(message.replace(&file, &name));
|
||||
}
|
||||
let after = script::error_lines(&report);
|
||||
if let Some(lines) = report.pointer_mut("/console/lines").and_then(Value::as_array_mut) {
|
||||
let kept = (1..=before.len().min(lines.len()))
|
||||
.rev()
|
||||
.find(|&n| lines.iter().rev().take(n).map(Value::as_str).eq(before.iter().rev().take(n).map(|l| Some(l.as_str()))))
|
||||
.unwrap_or(0);
|
||||
lines.truncate(lines.len() - kept);
|
||||
lines.extend(after.iter().skip(after.len().saturating_sub(kept)).map(|l| json!(l)));
|
||||
}
|
||||
}
|
||||
Some(report)
|
||||
}
|
||||
|
||||
/// The text of the script at `path`, which a listing of the Scripts folder gave
|
||||
/// ([`script::folder::read`]), checked as `script.run` checks a file's.
|
||||
pub fn installed_script(&self, path: &str) -> Result<String> {
|
||||
@@ -248,6 +285,13 @@ impl Session {
|
||||
}
|
||||
}
|
||||
|
||||
/// The file name a reply names `path` by when the file is outside the automation read roots; `None`
|
||||
/// when a reply may name it whole.
|
||||
fn hidden_name(path: &str) -> Option<String> {
|
||||
let shown = super::fileio::shown_path(path);
|
||||
(shown != path).then(|| shown.into_owned())
|
||||
}
|
||||
|
||||
fn list_installed(s: &mut Session, _: &Value) -> Result<Value> {
|
||||
let Some(root) = s.scripts_dir() else {
|
||||
return Ok(json!({"folder": null, "scripts": [], "truncated": false, "unreadable": false}));
|
||||
|
||||
@@ -184,7 +184,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Not Aligned to Pixel Grid",
|
||||
["Select", "Object"],
|
||||
None,
|
||||
"{} the paths (not guides) and compound paths whose pixel-aligned property (object.setPixelAligned) is off, looking into groups but not into compound paths → {count}",
|
||||
"{} the paths (not guides) and compound paths whose pixel-aligned property (object.setPixelAligned) is off, looking into groups, clip groups, blends, envelopes, repeats and Intertwine groups but not into compound paths → {count}",
|
||||
has_doc,
|
||||
not_pixel_aligned
|
||||
),
|
||||
|
||||
@@ -1365,6 +1365,7 @@ fn set_path_handle(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
#[cfg(test)]
|
||||
mod area_tests;
|
||||
mod block;
|
||||
pub(crate) use block::needed_room;
|
||||
#[cfg(test)]
|
||||
mod block_tests;
|
||||
|
||||
|
||||
@@ -89,6 +89,39 @@ fn line_widths(db: &vectorcraft_text::FontDb, t: &TextObject) -> std::collection
|
||||
out
|
||||
}
|
||||
|
||||
/// The room along its lines that a frame of `t`'s text needs for Block to keep the widest line as
|
||||
/// typed at its present size: that line's width plus its paragraph's indents, as [`block_fit`]
|
||||
/// measures them, and a margin of under half a size step so that the line is not made a step
|
||||
/// smaller; with the widest line's first byte and its paragraph's index. A line whose negative
|
||||
/// indents take more than its width needs its own width. None when no line has a width.
|
||||
pub(crate) fn needed_room(t: &TextObject) -> Option<(f64, usize, usize)> {
|
||||
let points = t.style_scale().map(|s| s.points).filter(|p| p.is_finite() && *p > 1e-12)?;
|
||||
let text = t.plain_text();
|
||||
let lines = typed_lines(&text);
|
||||
let widths = line_widths(vectorcraft_text::FontDb::global(), t);
|
||||
let sizes = first_sizes(&t.runs, &lines);
|
||||
lines
|
||||
.iter()
|
||||
.zip(&sizes)
|
||||
.filter_map(|(l, size)| {
|
||||
let width = widths.get(&l.start).copied().filter(|w| w.is_finite() && *w > 1e-9)?;
|
||||
let size = size * points;
|
||||
if l.content == l.start || !(size.is_finite() && size > 0.0) {
|
||||
return None;
|
||||
}
|
||||
let para = t.para_at(l.para);
|
||||
let first = if l.para_start { para.first_line_indent } else { 0.0 };
|
||||
let indents = para.laid_out_left_indent() + para.right_indent + first;
|
||||
// Negative indents that take more than the line's width leave the line no room at its
|
||||
// size; the frame is then as wide as the line itself, and Block makes it larger.
|
||||
let room = if width + indents > 1e-9 { width + indents } else { width };
|
||||
// A tenth of a point more size widens the line by 0.1 * width / size.
|
||||
Some((room + 0.04 * width / size, l.start, l.para))
|
||||
})
|
||||
.filter(|r| r.0.is_finite() && r.0 > 1e-9)
|
||||
.reduce(|a, b| if b.0 > a.0 { b } else { a })
|
||||
}
|
||||
|
||||
/// The size a line set at `size` points and `width` wide takes to fill `avail`, its width growing
|
||||
/// by `slope` a point of size (`width / size` when the whole line follows its size): rounded down
|
||||
/// to tenths and kept within [`SIZES`]. A line that fits and would not fit a tenth larger keeps its
|
||||
@@ -152,7 +185,7 @@ pub(crate) fn block_fit(t: &mut TextObject) -> bool {
|
||||
let width = widths.get(&l.start).copied().filter(|w| w.is_finite() && *w > 1e-9)?;
|
||||
let para = t.para_at(l.para);
|
||||
let first = if l.para_start { para.first_line_indent } else { 0.0 };
|
||||
let avail = cell - para.left_indent - para.right_indent - first;
|
||||
let avail = cell - para.laid_out_left_indent() - para.right_indent - first;
|
||||
let size = size * points;
|
||||
if !(size.is_finite() && size > 0.0) {
|
||||
return None;
|
||||
|
||||
@@ -188,13 +188,25 @@ fn bad_params_and_other_type_are_refused() {
|
||||
assert!(!text(&s, id).dynamic_text);
|
||||
let point = s.execute("text.create", &json!({"x": 10, "y": 500, "text": "point"})).unwrap()["id"].as_u64().unwrap();
|
||||
s.execute("select.set", &json!({"ids": [point]})).unwrap();
|
||||
// Off leaves point type as it is, with no undo step, and a gap needs it turned on.
|
||||
let n = undo_len(&s);
|
||||
assert_eq!(s.execute("type.dynamicText", &json!({"on": false})).unwrap()["ids"], json!([point]));
|
||||
let e = s.execute("type.dynamicText", &json!({"on": false, "wordGap": 200})).unwrap_err().to_string();
|
||||
assert!(e.contains("need area type"), "{e}");
|
||||
assert_eq!(undo_len(&s), n);
|
||||
assert!(matches!(text(&s, point).kind, TextKind::Point));
|
||||
let shape = s.execute("shape.rectangle", &json!({"x": 500, "y": 500, "width": 20, "height": 20})).unwrap()["id"].as_u64().unwrap();
|
||||
s.execute("select.set", &json!({"ids": [shape]})).unwrap();
|
||||
let e = s.execute("type.dynamicText", &json!({})).unwrap_err().to_string();
|
||||
assert!(e.contains("select type on a path or area type"), "{e}");
|
||||
// With point type selected too, only the area type changes.
|
||||
// With point type selected too, both turn on, the point type as area type with Block on.
|
||||
s.execute("select.set", &json!({"ids": [id, point]})).unwrap();
|
||||
let n = undo_len(&s);
|
||||
let r = s.execute("type.dynamicText", &json!({})).unwrap();
|
||||
assert_eq!(r["ids"], json!([id]));
|
||||
assert!(text(&s, id).dynamic_text && !text(&s, point).dynamic_text);
|
||||
assert_eq!(r["ids"], json!([id, point]));
|
||||
assert!(text(&s, id).dynamic_text && text(&s, point).dynamic_text);
|
||||
assert!(matches!(text(&s, point).kind, TextKind::Area { .. }));
|
||||
assert_eq!(undo_len(&s), n + 1);
|
||||
// A gap with only type on a path selected is an error, and nothing changes.
|
||||
let path = s.execute("path.create", &json!({"d": "M60 550 L340 550"})).unwrap()["id"].as_u64().unwrap();
|
||||
let along = s.execute("text.createInPath", &json!({"path": path, "mode": "onPath", "text": "Along"})).unwrap()["id"].as_u64().unwrap();
|
||||
@@ -438,6 +450,228 @@ fn threaded_frames_are_refused_and_threading_turns_block_off() {
|
||||
assert!(matches!(text(&s, a).kind, TextKind::Area { .. }));
|
||||
}
|
||||
|
||||
/// 12 pt point type at (300, 100) with `extra` params for `text.create`, selected.
|
||||
fn point(s: &mut Session, text: &str, extra: Value) -> u64 {
|
||||
let mut p = json!({"x": 300, "y": 100, "size": 12, "text": text});
|
||||
if let (Some(p), Some(extra)) = (p.as_object_mut(), extra.as_object()) {
|
||||
p.extend(extra.clone());
|
||||
}
|
||||
let id = s.execute("text.create", &p).unwrap()["id"].as_u64().unwrap();
|
||||
s.execute("select.set", &json!({"ids": [id]})).unwrap();
|
||||
id
|
||||
}
|
||||
|
||||
/// Where the first glyph of the laid-out line starting at byte `start` is in the document.
|
||||
fn line_start(t: &TextObject, start: usize) -> vectorcraft_geom::Point {
|
||||
let lay = vectorcraft_text::layout(vectorcraft_text::FontDb::global(), t);
|
||||
let l = lay.lines.iter().find(|l| l.start == start).expect("a line starts there");
|
||||
t.xf * lay.glyphs[l.glyph_start].origin
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn point_type_becomes_area_type_with_block_at_its_widest_line() {
|
||||
for justify in ["left", "center", "right"] {
|
||||
let mut s = session();
|
||||
let id = point(&mut s, "Short\nA much longer line", json!({}));
|
||||
s.execute("text.setStyle", &json!({"ids": [id], "justify": justify})).unwrap();
|
||||
let before = line_start(&text(&s, id), 6);
|
||||
let n = undo_len(&s);
|
||||
let r = s.execute("type.dynamicText", &json!({})).unwrap();
|
||||
assert_eq!((r["on"].clone(), r["ids"].clone()), (json!(true), json!([id])));
|
||||
assert_eq!((r["wordGap"].clone(), r["lineGap"].clone()), (json!(100.0), json!(0.0)), "the reply gives the new frame's gaps");
|
||||
assert_eq!(undo_len(&s), n + 1, "{justify}: one undo step");
|
||||
assert_eq!(s.doc().unwrap().history.undo.last().unwrap().label, "Dynamic Text");
|
||||
let t = text(&s, id);
|
||||
assert!(t.dynamic_text && matches!(t.kind, TextKind::Area { .. }), "{justify}");
|
||||
assert_eq!(t.area.fit, AreaFit::None);
|
||||
assert_block(&t);
|
||||
assert_eq!(size_at(&t, 6), 12.0, "{justify}: the widest line keeps its size");
|
||||
assert!(size_at(&t, 0) > 24.0, "{justify}: the short line grows to the frame's width: {}", size_at(&t, 0));
|
||||
// Across, the widest line stays where it was; the short line above it grew, so it moved down.
|
||||
let after = line_start(&t, 6);
|
||||
assert!((after.x - before.x).abs() < 0.05, "{justify}: the widest line stays where it was across: {before:?} → {after:?}");
|
||||
assert!(after.y > before.y + 12.0, "{justify}");
|
||||
assert!(!vectorcraft_text::layout(vectorcraft_text::FontDb::global(), &t).overflow, "{justify}: the frame holds every line");
|
||||
// Edits fit it again as area type; undo brings the point type back as it was.
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
let back = text(&s, id);
|
||||
assert!(matches!(back.kind, TextKind::Point) && !back.dynamic_text && size_at(&back, 0) == 12.0, "{justify}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn point_type_with_gaps_indents_and_vertical_type_turns_into_block() {
|
||||
let mut s = session();
|
||||
// The gaps apply to the frame point type becomes.
|
||||
let id = point(&mut s, "one\ntwo words here", json!({}));
|
||||
let r = s.execute("type.dynamicText", &json!({"wordGap": 300, "lineGap": 4})).unwrap();
|
||||
assert_eq!((r["wordGap"].clone(), r["lineGap"].clone()), (json!(300.0), json!(4.0)));
|
||||
let t = text(&s, id);
|
||||
assert!(t.dynamic_text && (t.area.word_gap, t.area.line_gap) == (300.0, 4.0));
|
||||
assert_block(&t);
|
||||
assert!(!vectorcraft_text::layout(vectorcraft_text::FontDb::global(), &t).overflow);
|
||||
// Indents: the frame takes them in, so the widest line keeps its size.
|
||||
let id = point(&mut s, "Tiny\nThe widest line of all", json!({"y": 300}));
|
||||
s.execute("text.setFormat", &json!({"ids": [id], "leftIndent": 20, "firstLineIndent": 10})).unwrap();
|
||||
let before = line_start(&text(&s, id), 5);
|
||||
s.execute("type.dynamicText", &json!({"on": true})).unwrap();
|
||||
let t = text(&s, id);
|
||||
assert_block(&t);
|
||||
assert_eq!(size_at(&t, 5), 12.0);
|
||||
assert!((line_start(&t, 5).x - before.x).abs() < 0.05, "{before:?} → {:?}", line_start(&t, 5));
|
||||
// A nested list item: the frame takes in the level's indent too.
|
||||
let id = point(&mut s, "Tiny\nThe widest item of all", json!({"y": 200}));
|
||||
s.execute("text.setFormat", &json!({"ids": [id], "list": "bullet", "listLevel": 3})).unwrap();
|
||||
s.execute("type.dynamicText", &json!({"on": true})).unwrap();
|
||||
let t = text(&s, id);
|
||||
assert_eq!(size_at(&t, 5), 12.0, "nested list");
|
||||
assert!(size_at(&t, 0) > 12.0);
|
||||
assert!(!vectorcraft_text::layout(vectorcraft_text::FontDb::global(), &t).overflow);
|
||||
// Mixed alignments: the widest line keeps its size and its place.
|
||||
let id = point(&mut s, "Left\nThe widest line, right aligned", json!({"y": 400}));
|
||||
let doc = std::sync::Arc::make_mut(&mut s.doc_mut().unwrap().doc);
|
||||
if let Some(NodeKind::Text(t)) = doc.node_mut(NodeId(id)).map(|n| &mut n.kind) {
|
||||
let right = vectorcraft_doc::ParaStyle { justify: vectorcraft_doc::Justify::Right, ..t.para.clone() };
|
||||
t.set_paragraph_styles(vec![t.para.clone(), right]);
|
||||
}
|
||||
let before = line_start(&text(&s, id), 5);
|
||||
s.execute("type.dynamicText", &json!({"on": true})).unwrap();
|
||||
let t = text(&s, id);
|
||||
assert_eq!(size_at(&t, 5), 12.0, "mixed alignments");
|
||||
assert!((line_start(&t, 5).x - before.x).abs() < 0.05, "{before:?} → {:?}", line_start(&t, 5));
|
||||
// A right-to-left list, whose indents the layout mirrors.
|
||||
let id = point(&mut s, "קצר\nשורה ארוכה בהרבה מהראשונה", json!({"y": 450}));
|
||||
s.execute("text.setFormat", &json!({"ids": [id], "list": "bullet", "rightIndent": 9})).unwrap();
|
||||
let widest = "קצר\n".len();
|
||||
let before = line_start(&text(&s, id), widest);
|
||||
s.execute("type.dynamicText", &json!({"on": true})).unwrap();
|
||||
let t = text(&s, id);
|
||||
assert_eq!(size_at(&t, widest), 12.0, "right-to-left list");
|
||||
assert!((line_start(&t, widest).x - before.x).abs() < 0.05, "{before:?} → {:?}", line_start(&t, widest));
|
||||
// Point type that was area type with its own options: the new frame takes the defaults but the
|
||||
// gaps.
|
||||
let id = point(&mut s, "Was\nonce a frame of two columns", json!({"y": 500}));
|
||||
s.execute("type.convertToAreaType", &json!({"ids": [id]})).unwrap();
|
||||
s.execute(
|
||||
"text.areaOptions",
|
||||
&json!({"columns": 2, "gutter": 12, "inset": 6, "verticalAlign": "bottom", "firstBaselineMin": 40, "wordGap": 150}),
|
||||
)
|
||||
.unwrap();
|
||||
s.execute("type.convertToPointType", &json!({"ids": [id]})).unwrap();
|
||||
let before = line_start(&text(&s, id), 4);
|
||||
s.execute("type.dynamicText", &json!({"on": true})).unwrap();
|
||||
let t = text(&s, id);
|
||||
let defaults = vectorcraft_doc::AreaOptions { word_gap: 150.0, ..Default::default() };
|
||||
assert_eq!(t.area, defaults);
|
||||
assert_eq!(size_at(&t, 4), 12.0, "options from an earlier frame");
|
||||
assert!((line_start(&t, 4).x - before.x).abs() < 0.05, "{before:?} → {:?}", line_start(&t, 4));
|
||||
assert!(!vectorcraft_text::layout(vectorcraft_text::FontDb::global(), &t).overflow);
|
||||
// Vertical type: the frame is as tall as the longest column and grows to the left.
|
||||
let id = point(&mut s, "縦書き\nもっと長い縦書きの行", json!({"x": 600, "y": 40, "vertical": true}));
|
||||
let before = line_start(&text(&s, id), "縦書き\n".len());
|
||||
s.execute("type.dynamicText", &json!({"on": true})).unwrap();
|
||||
let t = text(&s, id);
|
||||
assert!(t.vertical && t.dynamic_text && matches!(t.kind, TextKind::Area { .. }));
|
||||
assert_block(&t);
|
||||
assert_eq!(size_at(&t, "縦書き\n".len()), 12.0);
|
||||
let after = line_start(&t, "縦書き\n".len());
|
||||
assert!((after.y - before.y).abs() < 0.05, "the longest column stays where it was down the page: {before:?} → {after:?}");
|
||||
assert!(after.x < before.x - 12.0, "the short column before it grew, so it moved left: {before:?} → {after:?}");
|
||||
assert!(!vectorcraft_text::layout(vectorcraft_text::FontDb::global(), &t).overflow);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn point_type_with_negative_indents_wider_than_its_line_turns_into_block() {
|
||||
// Block makes the line larger; it keeps its start, center or end by its alignment.
|
||||
let ends = |t: &TextObject| {
|
||||
let lay = vectorcraft_text::layout(vectorcraft_text::FontDb::global(), t);
|
||||
let l = &lay.lines[0];
|
||||
((t.xf * vectorcraft_geom::Point::new(l.x0, l.baseline)).x, (t.xf * vectorcraft_geom::Point::new(l.x1 - l.trailing, l.baseline)).x)
|
||||
};
|
||||
for justify in ["left", "center", "right"] {
|
||||
let mut s = session();
|
||||
let id = point(&mut s, "Short", json!({}));
|
||||
s.execute("text.setStyle", &json!({"ids": [id], "justify": justify})).unwrap();
|
||||
s.execute("text.setFormat", &json!({"ids": [id], "leftIndent": -200})).unwrap();
|
||||
let (b0, b1) = ends(&text(&s, id));
|
||||
let n = undo_len(&s);
|
||||
s.execute("type.dynamicText", &json!({"on": true})).unwrap();
|
||||
assert_eq!(undo_len(&s), n + 1, "{justify}");
|
||||
let t = text(&s, id);
|
||||
assert!(t.dynamic_text && matches!(t.kind, TextKind::Area { .. }), "{justify}: the frame is as wide as the line");
|
||||
assert!(!vectorcraft_text::layout(vectorcraft_text::FontDb::global(), &t).overflow, "{justify}");
|
||||
assert!(size_at(&t, 0) > 12.0, "{justify}: Block makes the line larger");
|
||||
let (a0, a1) = ends(&t);
|
||||
let kept = match justify {
|
||||
"left" => (a0, b0),
|
||||
"center" => ((a0 + a1) / 2.0, (b0 + b1) / 2.0),
|
||||
_ => (a1, b1),
|
||||
};
|
||||
assert!((kept.0 - kept.1).abs() < 0.05, "{justify}: the line keeps its aligned point: {b0},{b1} → {a0},{a1}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn point_type_under_a_text_wrap_object_turns_into_block_that_holds_every_line() {
|
||||
let mut s = session();
|
||||
let id = point(&mut s, "One\nTwo\nThree\nA much longer line than the rest", json!({}));
|
||||
// A wrap object above the type, across the top of the frame it becomes.
|
||||
let r = s.execute("shape.rectangle", &json!({"x": 250, "y": 80, "width": 400, "height": 40})).unwrap()["id"].as_u64().unwrap();
|
||||
s.execute("select.set", &json!({"ids": [r]})).unwrap();
|
||||
s.execute("object.textWrap.make", &json!({})).unwrap();
|
||||
s.execute("select.set", &json!({"ids": [id]})).unwrap();
|
||||
s.execute("type.dynamicText", &json!({"on": true})).unwrap();
|
||||
let t = text(&s, id);
|
||||
assert!(t.dynamic_text && matches!(t.kind, TextKind::Area { .. }));
|
||||
assert!(!t.wrap.is_empty(), "the new area type takes the wrap object");
|
||||
assert!(!vectorcraft_text::layout(vectorcraft_text::FontDb::global(), &t).overflow, "the frame holds every line around the wrap");
|
||||
// A wrap object deeper than any size Block sets, across the whole frame: the lines go below it.
|
||||
let id = point(&mut s, "Below", json!({"x": 1000, "y": 100}));
|
||||
let r = s.execute("shape.rectangle", &json!({"x": 900, "y": 80, "width": 400, "height": 9000})).unwrap()["id"].as_u64().unwrap();
|
||||
s.execute("select.set", &json!({"ids": [r]})).unwrap();
|
||||
s.execute("object.textWrap.make", &json!({})).unwrap();
|
||||
s.execute("select.set", &json!({"ids": [id]})).unwrap();
|
||||
s.execute("type.dynamicText", &json!({"on": true})).unwrap();
|
||||
let t = text(&s, id);
|
||||
assert!(t.dynamic_text && !t.wrap.is_empty());
|
||||
assert!(!vectorcraft_text::layout(vectorcraft_text::FontDb::global(), &t).overflow, "the frame reaches past a deep wrap object");
|
||||
// An inverted wrap object far below the type holds the lines inside it: the frame reaches it.
|
||||
let id = point(&mut s, "Inside", json!({"x": 1600, "y": 100}));
|
||||
let r = s.execute("shape.rectangle", &json!({"x": 1550, "y": 9000, "width": 400, "height": 100})).unwrap()["id"].as_u64().unwrap();
|
||||
s.execute("select.set", &json!({"ids": [r]})).unwrap();
|
||||
s.execute("object.textWrap.make", &json!({})).unwrap();
|
||||
s.execute("object.textWrap.options", &json!({"invert": true})).unwrap();
|
||||
s.execute("select.set", &json!({"ids": [id]})).unwrap();
|
||||
s.execute("type.dynamicText", &json!({"on": true})).unwrap();
|
||||
let t = text(&s, id);
|
||||
assert!(t.dynamic_text && !t.wrap.is_empty());
|
||||
assert!(!vectorcraft_text::layout(vectorcraft_text::FontDb::global(), &t).overflow, "the frame reaches a distant inverted wrap object");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn point_type_with_nothing_to_fit_is_left_as_it_is() {
|
||||
let mut s = session();
|
||||
for blank in ["", " ", "\n\n"] {
|
||||
let id = point(&mut s, blank, json!({}));
|
||||
let before = text(&s, id);
|
||||
let n = undo_len(&s);
|
||||
let r = s.execute("type.dynamicText", &json!({})).unwrap();
|
||||
assert_eq!(r["on"], json!(true));
|
||||
assert_eq!(undo_len(&s), n, "{blank:?}: no undo step");
|
||||
assert_eq!(text(&s, id), before, "{blank:?}");
|
||||
// A gap needs type it can apply to.
|
||||
let e = s.execute("type.dynamicText", &json!({"wordGap": 200})).unwrap_err().to_string();
|
||||
assert!(e.contains("need area type"), "{blank:?}: {e}");
|
||||
}
|
||||
// A line far shorter than the widest one stops at the size limit, and the frame still holds it.
|
||||
let id = point(&mut s, &format!(".\n{}", "w".repeat(400)), json!({"size": 6}));
|
||||
s.execute("type.dynamicText", &json!({})).unwrap();
|
||||
let t = text(&s, id);
|
||||
assert!(t.dynamic_text);
|
||||
assert_eq!(size_at(&t, 0), 1296.0);
|
||||
assert!(!vectorcraft_text::layout(vectorcraft_text::FontDb::global(), &t).overflow);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn long_text_fits_in_a_bounded_number_of_layouts() {
|
||||
let mut s = session();
|
||||
@@ -451,4 +685,16 @@ fn long_text_fits_in_a_bounded_number_of_layouts() {
|
||||
// pass and the caches), plus the reply's own.
|
||||
let bound = 2 * (2 + 2 * super::block::BLOCK_PASSES as u64) + 4;
|
||||
assert!(used <= bound, "{used} layouts");
|
||||
// Long point type: measured, fitted in its frame and fitted again at its depth, with the
|
||||
// layouts that place and measure the frame.
|
||||
let id = point(&mut s, &story, json!({}));
|
||||
let before = vectorcraft_text::test_fonts::layouts_on_this_thread();
|
||||
s.execute("type.dynamicText", &json!({"on": true})).unwrap();
|
||||
let used = vectorcraft_text::test_fonts::layouts_on_this_thread() - before;
|
||||
let t = text(&s, id);
|
||||
assert!(t.dynamic_text && matches!(t.kind, TextKind::Area { .. }));
|
||||
assert_block(&t);
|
||||
assert!(!vectorcraft_text::layout(vectorcraft_text::FontDb::global(), &t).overflow);
|
||||
let bound = 2 * (2 + 2 * super::block::BLOCK_PASSES as u64) + 8;
|
||||
assert!(used <= bound, "point type: {used} layouts");
|
||||
}
|
||||
|
||||
@@ -7,7 +7,7 @@ use vectorcraft_doc::{AreaFit, CharStyle, Document, Justify, LockedArtboards, No
|
||||
use vectorcraft_geom::{Affine, Rect, shapes};
|
||||
|
||||
use super::object::choice;
|
||||
use super::typecmd::{area_text, block_gaps, refresh_bounds};
|
||||
use super::typecmd::{area_text, block_gaps, needed_room, refresh_bounds};
|
||||
use super::{
|
||||
CommandSpec, EngineError, Result, Session, bad, checked_ids_param, cmd, find_command, has_clipboard, has_doc, has_selection, number_or,
|
||||
switch_or, targets, text_param,
|
||||
@@ -60,12 +60,30 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
has_selection,
|
||||
path_options
|
||||
),
|
||||
cmd!(
|
||||
"type.pathCenter.top",
|
||||
"Center on Top",
|
||||
["Type", "Type on a Path"],
|
||||
None,
|
||||
"{ids?|id?} slide the selected type on a closed path along it so that the middle of the span between its start and end brackets lands on the path's topmost point as the object is drawn (after its transform, so a rotated, scaled or skewed path is measured as it appears; on a level stretch such as a rectangle's top edge, its middle; with several separate topmost points, the middle of the shortest part of the path that holds them all), keeping the span's length (a full turn stays a full turn), and set the first paragraph's alignment to Center unless it is Center or Justify Center, so the type is centered there. Point type and area type in the selection are left as they are. Type on an open or compound path, type in a thread, and type on a path with no length or no height are refused with an error that names them, and nothing changes. When every object is already centered there, nothing changes and no undo step is recorded → {ids, start, end} (the first object's brackets)",
|
||||
has_closed_path_type,
|
||||
path_center_top
|
||||
),
|
||||
cmd!(
|
||||
"type.pathCenter.bottom",
|
||||
"Center on Bottom",
|
||||
["Type", "Type on a Path"],
|
||||
None,
|
||||
"{ids?|id?} as type.pathCenter.top, on the path's bottommost point as drawn. The type is not flipped: on the bottom of a path that runs clockwise it reads upside down until Type on a Path Options › Flip turns it → {ids, start, end} (the first object's brackets)",
|
||||
has_closed_path_type,
|
||||
path_center_bottom
|
||||
),
|
||||
cmd!(
|
||||
"type.dynamicText",
|
||||
"Dynamic Text",
|
||||
["Type"],
|
||||
None,
|
||||
"{ids?|id?, on?: bool, wordGap?: % of a word space 0..1000 (100), lineGap?: pt -1296..1296 (0)} turn Dynamic Text on or off for the selected type on a path and area type (on omitted: the opposite of the first object's setting, or on when a gap is given). On type on a path, the layout scales the text by one factor (every character's size, leading and baseline shift; Type on a Path Options › Spacing stays) so that it fills the span between its start and end brackets, larger or smaller, and fits it again after every edit to the text, the path or the brackets; the first character's size moves in steps of 0.1 pt within 0.1 to 1,296 pt, and at 0.1 pt text that still runs past the end bracket overflows. The stored sizes keep their proportions. On area type it is Block: each line as typed (each paragraph, and each forced line break in one; lines are not broken again) is set to the frame's width, less the inset, the gutters and the paragraph's indents, by changing its font size only: its first character's size, in steps of 0.1 pt within 0.1 to 1,296 pt, the line's other characters keeping their proportions to it, with baseline shifts following. Every character's leading is its size plus lineGap, and the layout sets every paragraph with wordGap as its Desired word spacing, its Minimum or Maximum moving out to wordGap when wordGap lies outside it (the paragraphs keep their own Justification… values, which apply again when Block is off; the gaps are area type's, and are kept while Block is off; a gap with no area type in the selection is an error). The sizes are written into the characters and fitted again after every edit to the text or the frame, in the same undo step; at a size limit a line can be narrower than the frame or wrap. An empty line keeps its size. Block replaces Auto Size and Shrink Text to Fit (Area Type Options › Block is the same setting). Off, the text keeps the sizes it was drawn at. Point type in the selection is left as it is; a selection holding type in a thread is refused (threading type turns Dynamic Text off, keeping its drawn sizes). When nothing changes, no undo step is recorded → {on, ids, fitScale (the first object's factor; 1 when off and for area type), wordGap?, lineGap? (the first area type's; left out without area type)}",
|
||||
"{ids?|id?, on?: bool, wordGap?: % of a word space 0..1000 (100), lineGap?: pt -1296..1296 (0)} turn Dynamic Text on or off for the selected type on a path and area type, or turn it on for point type (on omitted: the opposite of the first object's setting, point type counting as off, or on when a gap is given). On type on a path, the layout scales the text by one factor (every character's size, leading and baseline shift; Type on a Path Options › Spacing stays) so that it fills the span between its start and end brackets, larger or smaller, and fits it again after every edit to the text, the path or the brackets; the first character's size moves in steps of 0.1 pt within 0.1 to 1,296 pt, and at 0.1 pt text that still runs past the end bracket overflows. The stored sizes keep their proportions. On area type it is Block: each line as typed (each paragraph, and each forced line break in one; lines are not broken again) is set to the frame's width, less the inset, the gutters and the paragraph's indents, by changing its font size only: its first character's size, in steps of 0.1 pt within 0.1 to 1,296 pt, the line's other characters keeping their proportions to it, with baseline shifts following. Every character's leading is its size plus lineGap, and the layout sets every paragraph with wordGap as its Desired word spacing, its Minimum or Maximum moving out to wordGap when wordGap lies outside it (the paragraphs keep their own Justification… values, which apply again when Block is off; the gaps are area type's, and are kept while Block is off; a gap with no area type in the selection, and no point type it turns into area type, is an error). The sizes are written into the characters and fitted again after every edit to the text or the frame, in the same undo step; at a size limit a line can be narrower than the frame or wrap. An empty line keeps its size. Block replaces Auto Size and Shrink Text to Fit (Area Type Options › Block is the same setting). Off, the text keeps the sizes it was drawn at. Turning it on turns point type into area type with Block on, with the default Area Type Options but the gaps (options kept from an earlier frame do not carry over): the frame is as wide (along the lines) as the widest line as typed with its paragraph's indents, so that line keeps its size and sits where it was (by its own alignment), the other lines are set to that width (when negative indents take more than that line's width, the frame is as wide as the line, and Block makes it larger), and the frame then grows in depth (down, or to the left for vertical type) to hold every fitted line, around any Text Wrap objects above it; the gaps apply to it as to area type. Point type with no line to fit (empty, or only spaces and breaks) is left as it is, and so is point type when it is turned off; a selection holding type in a thread is refused (threading type turns Dynamic Text off, keeping its drawn sizes). When nothing changes, no undo step is recorded → {on, ids, fitScale (the first object's factor; 1 when off and for area type), wordGap?, lineGap? (the first area type's; left out without area type)}",
|
||||
has_dynamic_type,
|
||||
dynamic_text
|
||||
),
|
||||
@@ -328,13 +346,7 @@ fn to_area(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
// A little slack on the right so the text doesn't rewrap.
|
||||
let top = lay.lines.first().map(|l| l.baseline - l.ascent).unwrap_or(b.y0).min(b.y0);
|
||||
let slack = size * 0.5 + trailing;
|
||||
// Slack goes where the alignment leaves room, so the text doesn't move or rewrap.
|
||||
let (sl, sr) = match t.para_at(0).justify {
|
||||
Justify::Center | Justify::JustifyCenter => (slack * 0.5, slack * 0.5),
|
||||
Justify::Right | Justify::JustifyRight => (slack, 0.0),
|
||||
Justify::Auto if lay.lines.first().is_some_and(|l| l.rtl) => (slack, 0.0),
|
||||
_ => (0.0, slack),
|
||||
};
|
||||
let (sl, sr) = slack_sides(t.para_at(0).justify, lay.lines.first().is_some_and(|l| l.rtl), slack);
|
||||
let frame = lay.line_xf.transform_rect_bbox(Rect::new(b.x0 - sl, top, b.x1 + sr, b.y1.max(top + 1.0)));
|
||||
t.kind = TextKind::Area { frame: shapes::rectangle(frame) };
|
||||
refresh_bounds(t);
|
||||
@@ -344,6 +356,107 @@ fn to_area(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
Ok(ids_json(&ids))
|
||||
}
|
||||
|
||||
/// How `slack` added to point type's lines in a frame is shared before and after them: where the
|
||||
/// alignment leaves room, so the text doesn't move or rewrap.
|
||||
fn slack_sides(justify: Justify, rtl: bool, slack: f64) -> (f64, f64) {
|
||||
match justify {
|
||||
Justify::Center | Justify::JustifyCenter => (slack * 0.5, slack * 0.5),
|
||||
Justify::Right | Justify::JustifyRight => (slack, 0.0),
|
||||
Justify::Auto if rtl => (slack, 0.0),
|
||||
_ => (0.0, slack),
|
||||
}
|
||||
}
|
||||
|
||||
/// Point type `t` turned into area type with Dynamic Text's Block on, with the default Area Type
|
||||
/// Options but its gaps: the frame takes the room along the lines that keeps the widest line as
|
||||
/// typed at its size ([`needed_room`]), placed so that line stays where it is by its own
|
||||
/// alignment, and after the fit it takes the depth that holds every line, so nothing overflows.
|
||||
/// Lines run in line space (for vertical type, down the page), and the frame is mapped to the
|
||||
/// type's own space by the layout's `line_xf`. `wrap` is the Text Wrap shapes the new area type
|
||||
/// takes, in the type's own space, which the fit and the depth follow. When negative indents take
|
||||
/// more than the widest line's width, the frame is as wide as that line and placed so that the
|
||||
/// line keeps its aligned point. None when no line has a
|
||||
/// width (empty text, or only spaces and breaks), which Block leaves at its size.
|
||||
fn point_to_block(t: &TextObject, wrap: &[vectorcraft_doc::WrapShape]) -> Option<TextObject> {
|
||||
let db = vectorcraft_text::FontDb::global();
|
||||
// Options point type kept from a frame it had before (columns, inset, alignment, first
|
||||
// baseline) do not carry over to the new frame.
|
||||
let mut t = t.clone();
|
||||
let (word_gap, line_gap) = (t.area.word_gap, t.area.line_gap);
|
||||
t.area = vectorcraft_doc::AreaOptions { word_gap, line_gap, ..Default::default() };
|
||||
t.wrap = wrap.to_vec();
|
||||
let t = &t;
|
||||
// The room is measured as Block sets the lines in a frame, with the word gap.
|
||||
let as_block = TextObject { dynamic_text: true, kind: TextKind::Area { frame: shapes::rectangle(Rect::new(0.0, 0.0, 1.0, 1.0)) }, ..t.clone() };
|
||||
let (room, widest, para_index) = needed_room(&as_block)?;
|
||||
let lay = vectorcraft_text::layout(db, t);
|
||||
let first = lay.lines.first()?;
|
||||
let line = lay.lines.iter().find(|l| l.start == widest)?;
|
||||
// Across, the widest line as it is aligned, with its paragraph's indents, which the frame holds.
|
||||
let para = t.para_at(para_index);
|
||||
let starts = widest == 0 || t.plain_text().as_bytes().get(widest - 1) == Some(&b'\n');
|
||||
let start_indent = para.laid_out_left_indent() + if starts { para.first_line_indent } else { 0.0 };
|
||||
// A horizontal right-to-left list paragraph has its indents mirrored, as the layout sets them.
|
||||
let mirror = line.rtl && !t.vertical && para.list.is_some();
|
||||
let (left, right) = if mirror { (para.right_indent, start_indent) } else { (start_indent, para.right_indent) };
|
||||
let (lo, hi) = (line.x0 - left, line.x1 - line.trailing + right);
|
||||
let top = lay.lines.iter().map(|l| l.baseline - l.ascent).fold(first.baseline - first.ascent, f64::min);
|
||||
let bottom = lay.lines.iter().map(|l| l.baseline + l.descent).fold(top, f64::max);
|
||||
if ![lo, hi, top, bottom, room].iter().all(|v| v.is_finite()) {
|
||||
return None;
|
||||
}
|
||||
// Negative indents can take more than the line's width (hi below lo); the frame is then wider
|
||||
// than the line with its indents, and the same split of the difference keeps the line's
|
||||
// aligned point (its start, center or end) where it was when Block makes the line fill it.
|
||||
let (sl, _) = slack_sides(para.justify, line.rtl, room - (hi - lo));
|
||||
let frame = |depth: f64| lay.line_xf.transform_rect_bbox(Rect::new(lo - sl, top, lo - sl + room, top + depth.max(1.0)));
|
||||
let start = frame(bottom - top);
|
||||
let mut out = t.clone();
|
||||
out.kind = TextKind::Area { frame: shapes::rectangle(start) };
|
||||
out.dynamic_text = true;
|
||||
refresh_bounds(&mut out);
|
||||
// The lines fitted in one cell from the frame's top deep enough for all of them (every line at
|
||||
// the largest size Block sets), around any wrap shapes, give the depth the frame takes.
|
||||
let mut probe = out.clone();
|
||||
let points = t.style_scale().map_or(1.0, |s| s.points).max(1e-12);
|
||||
let deep = (bottom - top) + (lay.lines.len() as f64 + 1.0) * 3.0 * (1296.0 + out.area.line_gap.abs()) / points;
|
||||
// Wrap shapes push lines on past them (an inverted one holds them inside it), so the cell also
|
||||
// reaches as far again as the farthest one reaches past the frame's top.
|
||||
let inv = lay.line_xf.inverse();
|
||||
let deep = deep
|
||||
+ wrap
|
||||
.iter()
|
||||
.filter_map(|w| w.path.bounds().map(|r| inv.transform_rect_bbox(r)).map(|r| (r.y1 - top).max(0.0) + 2.0 * w.wrap.offset.abs()))
|
||||
.filter(|d| d.is_finite())
|
||||
.fold(0.0, f64::max);
|
||||
probe.kind = TextKind::Area { frame: shapes::rectangle(frame(deep)) };
|
||||
if !wrap.is_empty() {
|
||||
refresh_bounds(&mut probe);
|
||||
}
|
||||
let laid = vectorcraft_text::layout(db, &probe);
|
||||
if laid.overflow {
|
||||
return Some(out);
|
||||
}
|
||||
let mut grown = start;
|
||||
for l in &laid.lines {
|
||||
for y in [l.baseline - l.ascent, l.baseline + l.descent] {
|
||||
grown = grown.union_pt(laid.line_xf * vectorcraft_geom::Point::new(l.x0, y));
|
||||
}
|
||||
}
|
||||
// Only the depth grows (for vertical type, the columns run on to the left), with a hundredth
|
||||
// of a point to spare.
|
||||
if t.vertical {
|
||||
(grown.y0, grown.y1, grown.x0) = (start.y0, start.y1, grown.x0 - 0.01);
|
||||
} else {
|
||||
(grown.x0, grown.x1, grown.y1) = (start.x0, start.x1, grown.y1 + 0.01);
|
||||
}
|
||||
if grown.is_finite() && grown != start {
|
||||
out.kind = TextKind::Area { frame: shapes::rectangle(grown) };
|
||||
refresh_bounds(&mut out);
|
||||
}
|
||||
Some(out)
|
||||
}
|
||||
|
||||
/// (run, byte within the run) for a byte offset into the plain text.
|
||||
fn locate(runs: &[TextRun], byte: usize) -> Option<(usize, usize)> {
|
||||
let mut off = 0;
|
||||
@@ -430,6 +543,136 @@ fn to_point(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
Ok(ids_json(&ids))
|
||||
}
|
||||
|
||||
/// Whether type on a path `t` runs along one closed path (no open or compound path).
|
||||
fn on_one_closed_path(t: &TextObject) -> bool {
|
||||
matches!(&t.kind, TextKind::OnPath { path, .. } if path.subpaths.len() == 1 && path.is_closed())
|
||||
}
|
||||
|
||||
/// Why `type.pathCenter.top` and `.bottom` refuse type on a path `ids`: those on an open or
|
||||
/// compound path, then those in a thread; None when they take every one.
|
||||
fn path_center_refusal(d: &Document, ids: &[NodeId]) -> Option<String> {
|
||||
let open = ids.iter().filter(|id| !path_text(d.node(**id)).is_some_and(on_one_closed_path)).map(|id| id.0.to_string()).collect::<Vec<_>>();
|
||||
if !open.is_empty() {
|
||||
let open = open.join(", ");
|
||||
return Some(format!("type on an open or compound path cannot be centered on its top or bottom: {open}"));
|
||||
}
|
||||
let frames = threaded_ids(d, ids);
|
||||
(!frames.is_empty()).then(|| format!("type on a path in a thread cannot be centered on its top or bottom: {frames}"))
|
||||
}
|
||||
|
||||
/// `type.pathCenter.top` and `.bottom` are enabled for a selection holding type on a path, all of
|
||||
/// it on one closed path and none of it in a thread.
|
||||
fn has_closed_path_type(s: &Session) -> std::result::Result<(), String> {
|
||||
has_selection(s)?;
|
||||
let st = s.active().ok_or("no document open")?;
|
||||
let d = &st.doc;
|
||||
let selected: Vec<NodeId> = st.selection.objects.to_vec();
|
||||
let paths: Vec<NodeId> = text_ids(d, &selected).into_iter().filter(|id| path_text(d.node(*id)).is_some()).collect();
|
||||
if paths.is_empty() {
|
||||
return Err("select type on a closed path".into());
|
||||
}
|
||||
path_center_refusal(d, &paths).map_or(Ok(()), Err)
|
||||
}
|
||||
|
||||
/// The brackets that center type on a path `t` on its path's topmost point (`bottom`: its
|
||||
/// bottommost) as `t.xf` draws it, its span keeping its length; None for a path with no length or
|
||||
/// no height.
|
||||
fn centered_brackets(t: &TextObject, bottom: bool) -> Option<(f64, Option<f64>)> {
|
||||
let TextKind::OnPath { path, end, .. } = &t.kind else { return None };
|
||||
let (from, to) = t.kind.path_span()?;
|
||||
let arc = vectorcraft_geom::ArcPath::new(path);
|
||||
if arc.is_empty() {
|
||||
return None;
|
||||
}
|
||||
let flip = if bottom { Affine::FLIP_Y } else { Affine::IDENTITY };
|
||||
let middle = arc.topmost(flip * t.xf)? / arc.len();
|
||||
let run = to - from;
|
||||
let start = (middle - run * 0.5).rem_euclid(1.0).clamp(0.0, 1.0);
|
||||
// Without an end bracket the type runs once round; a full turn's end stays on its start.
|
||||
let end = end.map(|_| if run >= 1.0 - 1e-12 { start } else { (start + run).rem_euclid(1.0).clamp(0.0, 1.0) });
|
||||
Some((start, end))
|
||||
}
|
||||
|
||||
/// Whether brackets `a` and `b`, fractions of a closed path's length, are at one place within
|
||||
/// rounding (0 and 1 are one place).
|
||||
fn same_bracket(a: f64, b: f64) -> bool {
|
||||
let d = (a - b).rem_euclid(1.0);
|
||||
d.min(1.0 - d) <= 1e-9
|
||||
}
|
||||
|
||||
fn path_center_top(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
path_center(s, p, false)
|
||||
}
|
||||
|
||||
fn path_center_bottom(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
path_center(s, p, true)
|
||||
}
|
||||
|
||||
/// Type › Type on a Path › Center on Top and Center on Bottom: slide the selected type on closed
|
||||
/// paths along them so the middle of each span lands on the path's top (`bottom`: its bottom) as
|
||||
/// drawn, and center the first paragraph, in one undo step; none when nothing changes.
|
||||
fn path_center(s: &mut Session, p: &Value, bottom: bool) -> Result<Value> {
|
||||
let c = if bottom { "type.pathCenter.bottom" } else { "type.pathCenter.top" };
|
||||
let centered = |j: Justify| matches!(j, Justify::Center | Justify::JustifyCenter);
|
||||
// Each object the command changes, with its new brackets when they move and whether its first
|
||||
// paragraph is set to Center.
|
||||
let (ids, changes) = {
|
||||
let d = &s.doc()?.doc;
|
||||
let ids: Vec<NodeId> = texts(s, p, c)?.into_iter().filter(|i| path_text(d.node(*i)).is_some()).collect();
|
||||
if ids.is_empty() {
|
||||
return Err(bad(c, "select type on a closed path"));
|
||||
}
|
||||
if let Some(why) = path_center_refusal(d, &ids) {
|
||||
return Err(bad(c, why));
|
||||
}
|
||||
let (mut changes, mut flat) = (vec![], vec![]);
|
||||
for id in &ids {
|
||||
let Some(t) = path_text(d.node(*id)) else { continue };
|
||||
let TextKind::OnPath { start, end, .. } = &t.kind else { continue };
|
||||
let Some((to_start, to_end)) = centered_brackets(t, bottom) else {
|
||||
flat.push(id.0.to_string());
|
||||
continue;
|
||||
};
|
||||
let stays = same_bracket(*start, to_start)
|
||||
&& match (end, to_end) {
|
||||
(None, None) => true,
|
||||
(Some(a), Some(b)) => same_bracket(*a, b),
|
||||
_ => false,
|
||||
};
|
||||
let align = !centered(t.para_at(0).justify);
|
||||
if !stays || align {
|
||||
changes.push((*id, (!stays).then_some((to_start, to_end)), align));
|
||||
}
|
||||
}
|
||||
if !flat.is_empty() {
|
||||
let flat = flat.join(", ");
|
||||
return Err(bad(c, format!("type on a path with no length or no height cannot be centered: {flat}")));
|
||||
}
|
||||
(ids, changes)
|
||||
};
|
||||
if !changes.is_empty() {
|
||||
s.edit(if bottom { "Center on Bottom" } else { "Center on Top" }, |d, _| {
|
||||
for (id, brackets, align) in &changes {
|
||||
let Some(NodeKind::Text(t)) = d.node_mut(*id).map(|n| &mut n.kind) else { continue };
|
||||
if let (Some(b), TextKind::OnPath { start, end, .. }) = (brackets, &mut t.kind) {
|
||||
(*start, *end) = *b;
|
||||
}
|
||||
if *align {
|
||||
t.edit_paras(Some(0..1), |para| para.justify = Justify::Center);
|
||||
}
|
||||
refresh_bounds(t);
|
||||
}
|
||||
Ok(())
|
||||
})?;
|
||||
}
|
||||
let d = &s.doc()?.doc;
|
||||
let (start, end) = ids.first().and_then(|id| path_text(d.node(*id))).map_or((0.0, None), |t| match &t.kind {
|
||||
TextKind::OnPath { start, end, .. } => (*start, *end),
|
||||
_ => (0.0, None),
|
||||
});
|
||||
Ok(json!({"ids": ids.iter().map(|i| i.0).collect::<Vec<_>>(), "start": start, "end": end}))
|
||||
}
|
||||
|
||||
/// The options `type.pathOptions` sets; with none of them given it queries.
|
||||
const PATH_OPTIONS: [&str; 6] = ["start", "end", "flip", "effect", "alignToPath", "spacing"];
|
||||
|
||||
@@ -550,14 +793,23 @@ fn dynamic_type(n: Option<&Node>) -> Option<&TextObject> {
|
||||
path_text(n).or_else(|| area_text(n))
|
||||
}
|
||||
|
||||
/// `type.dynamicText` is enabled for a selection holding type on a path or area type, none of it
|
||||
/// in a thread.
|
||||
/// Type `type.dynamicText` takes: type on a path, area type, and point type, which it turns into
|
||||
/// area type with Block on.
|
||||
fn dynamic_target(n: Option<&Node>) -> Option<&TextObject> {
|
||||
match n.map(|n| &n.kind) {
|
||||
Some(NodeKind::Text(t)) if matches!(t.kind, TextKind::Point) => Some(t),
|
||||
_ => dynamic_type(n),
|
||||
}
|
||||
}
|
||||
|
||||
/// `type.dynamicText` is enabled for a selection holding type on a path, area type or point type,
|
||||
/// none of it in a thread.
|
||||
fn has_dynamic_type(s: &Session) -> std::result::Result<(), String> {
|
||||
has_selection(s)?;
|
||||
let st = s.active().ok_or("no document open")?;
|
||||
let d = &st.doc;
|
||||
let selected: Vec<NodeId> = st.selection.objects.to_vec();
|
||||
let types: Vec<NodeId> = text_ids(d, &selected).into_iter().filter(|id| dynamic_type(d.node(*id)).is_some()).collect();
|
||||
let types: Vec<NodeId> = text_ids(d, &selected).into_iter().filter(|id| dynamic_target(d.node(*id)).is_some()).collect();
|
||||
if types.is_empty() {
|
||||
return Err("select type on a path or area type".into());
|
||||
}
|
||||
@@ -583,32 +835,46 @@ fn threaded_ids(d: &Document, ids: &[NodeId]) -> String {
|
||||
|
||||
/// Type › Dynamic Text: set `on` (without it, the opposite of the first object's setting, or on
|
||||
/// when a gap is given) on the selected type on a path and area type (Block, with its gaps), in
|
||||
/// one undo step, and none when nothing changes. Turning it off keeps the sizes the text was drawn
|
||||
/// at ([`bake_dynamic_text`]).
|
||||
/// one undo step, and none when nothing changes. Turning it on also turns the selected point type
|
||||
/// into area type with Block on ([`point_to_block`]). Turning it off keeps the sizes the text was
|
||||
/// drawn at ([`bake_dynamic_text`]).
|
||||
fn dynamic_text(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const C: &str = "type.dynamicText";
|
||||
let (word_gap, line_gap) = block_gaps(p, C)?;
|
||||
let (ids, on, planned) = {
|
||||
let (ids, on, planned, converted) = {
|
||||
let d = &s.doc()?.doc;
|
||||
let ids: Vec<NodeId> = texts(s, p, C)?.into_iter().filter(|i| dynamic_type(d.node(*i)).is_some()).collect();
|
||||
let ids: Vec<NodeId> = texts(s, p, C)?.into_iter().filter(|i| dynamic_target(d.node(*i)).is_some()).collect();
|
||||
let first = *ids.first().ok_or_else(|| bad(C, "select type on a path or area type"))?;
|
||||
if let Some(why) = threaded_refusal(d, &ids) {
|
||||
return Err(bad(C, why));
|
||||
}
|
||||
let gap_given = word_gap.is_some() || line_gap.is_some();
|
||||
if gap_given && !ids.iter().any(|id| area_text(d.node(*id)).is_some()) {
|
||||
let on = switch_or(p, "on", gap_given || !dynamic_type(d.node(first)).is_some_and(|t| t.dynamic_text), C)?;
|
||||
// Point type with a line to fit, which turning it on makes area type.
|
||||
let converts = |id: &NodeId| on && dynamic_target(d.node(*id)).is_some_and(|t| matches!(t.kind, TextKind::Point) && needed_room(t).is_some());
|
||||
if gap_given && !ids.iter().any(|id| area_text(d.node(*id)).is_some() || converts(id)) {
|
||||
return Err(bad(C, "wordGap and lineGap need area type in the selection"));
|
||||
}
|
||||
let on = switch_or(p, "on", gap_given || !dynamic_type(d.node(first)).is_some_and(|t| t.dynamic_text), C)?;
|
||||
// Each object as the command leaves it, and only those it changes.
|
||||
let mut planned: Vec<(NodeId, TextObject)> = vec![];
|
||||
// The point type that becomes area type, with the gaps applied, by id.
|
||||
let mut converted: Vec<(NodeId, TextObject)> = vec![];
|
||||
for id in &ids {
|
||||
let Some(t) = dynamic_type(d.node(*id)) else { continue };
|
||||
let Some(t) = dynamic_target(d.node(*id)) else { continue };
|
||||
let mut after = t.clone();
|
||||
if matches!(t.kind, TextKind::Area { .. }) {
|
||||
if matches!(t.kind, TextKind::Area { .. } | TextKind::Point) {
|
||||
after.area.word_gap = word_gap.unwrap_or(t.area.word_gap);
|
||||
after.area.line_gap = line_gap.unwrap_or(t.area.line_gap);
|
||||
}
|
||||
if matches!(t.kind, TextKind::Point) {
|
||||
// Point type has no Dynamic Text of its own: on, it becomes area type with Block
|
||||
// on; off, or with no line to fit, it stays as it is.
|
||||
if let Some(block) = on.then(|| point_to_block(&after, &[])).flatten() {
|
||||
planned.push((*id, block));
|
||||
converted.push((*id, after));
|
||||
}
|
||||
continue;
|
||||
}
|
||||
match (on, t.dynamic_text) {
|
||||
(true, true) => refresh_bounds(&mut after),
|
||||
(true, false) => {
|
||||
@@ -628,7 +894,7 @@ fn dynamic_text(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
planned.push((*id, after));
|
||||
}
|
||||
}
|
||||
(ids, on, planned)
|
||||
(ids, on, planned, converted)
|
||||
};
|
||||
if !planned.is_empty() {
|
||||
s.edit("Dynamic Text", |d, _| {
|
||||
@@ -637,6 +903,19 @@ fn dynamic_text(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
**t = after.clone();
|
||||
}
|
||||
}
|
||||
if converted.is_empty() {
|
||||
return Ok(());
|
||||
}
|
||||
// The new area type takes the Text Wrap shapes above it; one that has any is fitted
|
||||
// again around them, with the depth that holds every line.
|
||||
for id in vectorcraft_doc::text::resolve_wrap(None, d) {
|
||||
let Some(NodeKind::Text(t)) = d.node_mut(id).map(|n| &mut n.kind) else { continue };
|
||||
let block = converted.iter().find(|(c, _)| *c == id).and_then(|(_, src)| point_to_block(src, &t.wrap));
|
||||
match block {
|
||||
Some(block) => **t = block,
|
||||
None => refresh_bounds(t),
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
})?;
|
||||
}
|
||||
|
||||
@@ -3,6 +3,9 @@ use super::{NodeId, cancel};
|
||||
/// The text of [`EngineError::Busy`], which ends every error a frontend reports for it.
|
||||
pub const SCRIPT_BUSY: &str = "a script is running: stop it or wait for it to finish";
|
||||
|
||||
/// Stable prefix for a save refused by the filesystem: choose another destination.
|
||||
pub const READ_ONLY_LOCATION: &str = "READ_ONLY_LOCATION";
|
||||
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum EngineError {
|
||||
#[error("unknown command `{0}`")]
|
||||
@@ -20,6 +23,10 @@ pub enum EngineError {
|
||||
NoNode(NodeId),
|
||||
#[error("{0}")]
|
||||
Other(String),
|
||||
/// The save's terminal filesystem error refuses writes here. The reason keeps the path and
|
||||
/// system message. This never means an automation-root refusal or a recovery-copy failure.
|
||||
#[error("{code}: {0}", code = READ_ONLY_LOCATION)]
|
||||
ReadOnlyLocation(String),
|
||||
/// The caller asked for the command to stop ([`cancel`]). It changed nothing.
|
||||
#[error("cancelled; nothing was changed")]
|
||||
Cancelled,
|
||||
|
||||
@@ -382,7 +382,7 @@ pub(crate) use command_sets::SELECTS_GIVEN_IDS;
|
||||
use command_sets::given_ids;
|
||||
pub use command_sets::{FIT_ACTIVE_ARTBOARD, ON_ACTIVE_ARTBOARD, waits_for_tool_edit};
|
||||
pub use doc_state::{DocState, OpenRows, RecalledSelection};
|
||||
pub use error::{EngineError, Result, SCRIPT_BUSY};
|
||||
pub use error::{EngineError, READ_ONLY_LOCATION, Result, SCRIPT_BUSY};
|
||||
#[cfg(test)]
|
||||
use history::history_budget_for;
|
||||
pub use history::{History, HistoryEntry, NamedStep, default_history_budget};
|
||||
|
||||
@@ -2116,6 +2116,27 @@ fn line_to(s: &mut Session, host: &Host, entry: console::Entry, cut: bool) {
|
||||
host.console.borrow_mut().push(line, cut);
|
||||
}
|
||||
|
||||
/// The error of the failed run `report` describes, as its console lines give it; `name` is the
|
||||
/// run's name in those lines ([`console::line_name`]).
|
||||
fn error_text(name: &str, report: &Value) -> String {
|
||||
let (line, column) = (report["line"].as_u64().unwrap_or(0), report["column"].as_u64().unwrap_or(0));
|
||||
let message = report["message"].as_str().unwrap_or_default();
|
||||
match report["file"].as_str() {
|
||||
Some(file) if line > 0 => format!("{name}: {file}:{line}:{column}: {message}"),
|
||||
_ if line > 0 => format!("{name}:{line}:{column}: {message}"),
|
||||
_ => format!("{name}: location unavailable: {message}"),
|
||||
}
|
||||
}
|
||||
|
||||
/// The console lines of the error of the failed run `report` describes, each as the console keeps
|
||||
/// it, cut to its limit.
|
||||
pub(crate) fn error_lines(report: &Value) -> Vec<String> {
|
||||
let name = console::line_name(report["name"].as_str().unwrap_or_default());
|
||||
let mut lines = vec![];
|
||||
each_line(&error_text(&name, report), |line, _| lines.push(line));
|
||||
lines
|
||||
}
|
||||
|
||||
/// Ends the console lines of `host`'s run: the line `$.write` left unended, then for a failed run
|
||||
/// (`report["ok"]` false) its error as `name:line:column: message`, then an end line and a report
|
||||
/// line in the session's console. Adds the run's time, host calls by kind, slowest commands,
|
||||
@@ -2130,14 +2151,7 @@ fn close_run(s: &mut Session, host: &Host, report: &mut Value, ending: console::
|
||||
}
|
||||
}
|
||||
if report["ok"] == false {
|
||||
let (line, column) = (report["line"].as_u64().unwrap_or(0), report["column"].as_u64().unwrap_or(0));
|
||||
let message = report["message"].as_str().unwrap_or_default();
|
||||
let text = match report["file"].as_str() {
|
||||
Some(file) if line > 0 => format!("{}: {file}:{line}:{column}: {message}", host.name),
|
||||
_ if line > 0 => format!("{}:{line}:{column}: {message}", host.name),
|
||||
_ => format!("{}: location unavailable: {message}", host.name),
|
||||
};
|
||||
each_line(&text, |line, cut| line_to(s, host, console::Entry::Error(line), cut));
|
||||
each_line(&error_text(&host.name, report), |line, cut| line_to(s, host, console::Entry::Error(line), cut));
|
||||
}
|
||||
let timing = clock().is_some();
|
||||
let ms = millis(host.run_nanos.get());
|
||||
|
||||
@@ -206,6 +206,7 @@ mod typescale;
|
||||
mod typeundo;
|
||||
mod units;
|
||||
mod variables;
|
||||
mod warp_images;
|
||||
mod webexport;
|
||||
mod widthpoints;
|
||||
mod widthprofiles;
|
||||
|
||||
@@ -104,7 +104,10 @@ fn the_builtin_libraries_are_original_brushes_generated_in_code() {
|
||||
let list = run(&mut s, "brush.libraries", json!({}));
|
||||
let libs = list["libraries"].as_array().unwrap();
|
||||
let ids: Vec<&str> = libs.iter().map(|l| l["id"].as_str().unwrap()).collect();
|
||||
assert_eq!(ids, ["art-ribbons", "calligraphic-nibs", "bristle-brushes", "pattern-borders", "scatter-shapes", "game-trails", "energy-beams"]);
|
||||
assert_eq!(
|
||||
ids,
|
||||
["art-ribbons", "calligraphic-nibs", "bristle-brushes", "pattern-borders", "scatter-shapes", "game-trails", "energy-beams", "dry-media"]
|
||||
);
|
||||
assert!(libs.iter().all(|l| l["category"] == "builtIn"));
|
||||
let lib = run(&mut s, "brush.library", json!({"library": "art ribbons"}));
|
||||
assert_eq!(
|
||||
@@ -124,6 +127,12 @@ fn the_builtin_libraries_are_original_brushes_generated_in_code() {
|
||||
["Laser Bolt", "Plasma Arc", "Charge Wave", "Spark Trail"]
|
||||
);
|
||||
assert_eq!(libs[6]["count"].as_u64(), Some(4));
|
||||
let dry = run(&mut s, "brush.library", json!({"library": "Dry Media"}));
|
||||
assert_eq!(
|
||||
dry["brushes"].as_array().unwrap().iter().map(|b| (b["name"].as_str().unwrap(), b["type"].as_str().unwrap())).collect::<Vec<_>>(),
|
||||
[("Chalk Grain", "art"), ("Graphite Pencil", "art"), ("Wax Crayon", "art"), ("Charcoal Dust", "art")]
|
||||
);
|
||||
assert_eq!((libs[7]["name"].as_str(), libs[7]["count"].as_u64()), (Some("Dry Media"), Some(4)));
|
||||
run(&mut s, "file.new", json!({"width": 200, "height": 200}));
|
||||
let id = run(&mut s, "shape.rectangle", json!({"x": 10, "y": 20, "width": 40, "height": 8}))["id"].as_u64().unwrap();
|
||||
let added = run(&mut s, "brush.addFromLibrary", json!({"library": "energy-beams", "name": "Laser Bolt", "apply": true, "ids": [id]}));
|
||||
@@ -137,6 +146,7 @@ fn the_builtin_libraries_are_original_brushes_generated_in_code() {
|
||||
("scatter-shapes", "scatter"),
|
||||
("game-trails", "art"),
|
||||
("energy-beams", "art"),
|
||||
("dry-media", "art"),
|
||||
] {
|
||||
let (_, lib) = cmd::brushlib::library(&s, id).unwrap();
|
||||
assert!(lib.len() >= 4, "{id}");
|
||||
@@ -166,6 +176,137 @@ fn the_builtin_libraries_are_original_brushes_generated_in_code() {
|
||||
assert!(s.execute("brush.library", &json!({"library": "nope"})).is_err());
|
||||
}
|
||||
|
||||
/// Whether `p` lies inside the polygon through `pts` (even-odd ray cast).
|
||||
fn inside(p: vectorcraft_geom::Point, pts: &[vectorcraft_geom::Point]) -> bool {
|
||||
let mut odd = false;
|
||||
for (i, a) in pts.iter().enumerate() {
|
||||
let b = pts[(i + 1) % pts.len()];
|
||||
if (a.y > p.y) != (b.y > p.y) && p.x < a.x + (p.y - a.y) * (b.x - a.x) / (b.y - a.y) {
|
||||
odd = !odd;
|
||||
}
|
||||
}
|
||||
odd
|
||||
}
|
||||
|
||||
/// Every anchor of the paths in `nodes` and their descendants.
|
||||
fn anchors(nodes: &[&vectorcraft_doc::Node]) -> Vec<vectorcraft_geom::Point> {
|
||||
let mut out = vec![];
|
||||
for n in nodes {
|
||||
n.walk(&mut |c| out.extend(c.path_data().into_iter().flat_map(|p| p.subpaths.iter().flat_map(|sp| sp.anchors.iter().map(|a| a.p)))));
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Dry Media: four art brushes of grainy dry marks (chalk, pencil, crayon, charcoal) with names
|
||||
/// of their own. Each one's art is a bounded set of filled vector paths 100 pt long that takes the
|
||||
/// stroke color as tints, with real even-odd gaps in the chalk and the crayon; it draws finite art
|
||||
/// on curved and short paths, is the same every time it is made, and goes into a document in one
|
||||
/// undo step, once, applied, and through a saved library unchanged.
|
||||
#[test]
|
||||
fn the_dry_media_library_holds_grainy_art_brushes_with_bounded_art() {
|
||||
use vectorcraft_brush::{ArtScale, Colorization};
|
||||
use vectorcraft_geom::FillRule;
|
||||
let mut s = session();
|
||||
let (info, lib) = cmd::brushlib::library(&s, "dry-media").unwrap();
|
||||
assert_eq!((info.name.as_str(), info.category, lib.name.as_str()), ("Dry Media", "builtIn", "Dry Media"));
|
||||
let names: Vec<String> = lib.brushes.iter().map(|b| b.name.clone()).collect();
|
||||
assert_eq!(names, ["Chalk Grain", "Graphite Pencil", "Wax Crayon", "Charcoal Dust"]);
|
||||
let mut total = 0;
|
||||
for (b, (height, gaps)) in lib.brushes.iter().zip([(8.0, true), (2.5, false), (6.0, true), (7.0, false)]) {
|
||||
let BrushKind::Art(a) = &b.kind else { panic!("{} is not an art brush", b.name) };
|
||||
assert_eq!((a.colorization, a.scale, a.width), (Colorization::Tints, ArtScale::Stretch, 100.0), "{}", b.name);
|
||||
let (mut paths, mut holes) = (0, 0);
|
||||
a.art.walk(&mut |n| match &n.kind {
|
||||
NodeKind::Path { path, .. } => {
|
||||
paths += 1;
|
||||
assert!(path.subpaths.iter().all(|sp| sp.closed && sp.anchors.len() >= 4), "{}", b.name);
|
||||
}
|
||||
NodeKind::Compound { children, rule } => {
|
||||
assert_eq!(*rule, FillRule::EvenOdd, "{}", b.name);
|
||||
// The gaps lie inside the band and apart from each other, so each one is a hole.
|
||||
let body = anchors(&[&*children[0]]);
|
||||
let boxes: Vec<vectorcraft_geom::Rect> = children[1..].iter().map(|c| c.path_data().unwrap().bounds().unwrap()).collect();
|
||||
for (i, c) in children[1..].iter().enumerate() {
|
||||
assert!(anchors(&[&**c]).iter().all(|p| inside(*p, &body)), "{}: gap {i} leaves the band", b.name);
|
||||
let r = boxes[i];
|
||||
assert!(boxes[..i].iter().all(|o| r.x0 >= o.x1 || o.x0 >= r.x1 || r.y0 >= o.y1 || o.y0 >= r.y1), "{}: gap {i} overlaps", b.name);
|
||||
}
|
||||
holes += children.len() - 1;
|
||||
}
|
||||
NodeKind::Group { .. } => {}
|
||||
other => panic!("{}: unexpected {other:?}", b.name),
|
||||
});
|
||||
let pts = anchors(&[&a.art]);
|
||||
assert!(pts.iter().all(|p| p.x.is_finite() && p.y.is_finite()));
|
||||
assert!(paths <= 80 && pts.len() <= 500, "{}: {paths} paths, {} anchors", b.name, pts.len());
|
||||
assert_eq!(holes > 0, gaps, "{}: {holes} gaps", b.name);
|
||||
let r = vectorcraft_geom::Rect::from_points(pts[0], pts[0]);
|
||||
let r = pts.iter().fold(r, |r, p| r.union_pt(*p));
|
||||
assert!(r.x0 >= 0.0 && r.x0 < 0.5 && r.x1 <= 100.0 && r.x1 > 99.5, "{}: {r:?}", b.name);
|
||||
assert!((r.height() - height).abs() <= height * 0.25 && r.y0 < 0.0 && r.y1 > 0.0, "{}: {r:?}", b.name);
|
||||
total += pts.len();
|
||||
// Finite art on a curve and on a 2 pt path.
|
||||
for (line, width) in [(vec![(0.0, 0.0), (60.0, -40.0), (140.0, 40.0), (200.0, 0.0)], 1.0), (vec![(0.0, 0.0), (2.0, 0.0)], 3.0)] {
|
||||
let mut bp = vectorcraft_geom::BezPath::new();
|
||||
bp.move_to(line[0]);
|
||||
if line.len() == 4 {
|
||||
bp.curve_to(line[1], line[2], line[3]);
|
||||
} else {
|
||||
bp.line_to(line[1]);
|
||||
}
|
||||
let stroke = vectorcraft_doc::StrokeLayer::new(vectorcraft_color::Paint::solid(vectorcraft_color::Color::BLACK), width);
|
||||
let pieces = vectorcraft_brush::stroke_pieces(&lib.doc, b, &bp, &stroke);
|
||||
let drawn = anchors(&pieces.iter().collect::<Vec<_>>());
|
||||
assert!(!drawn.is_empty() && drawn.iter().all(|p| p.x.is_finite() && p.y.is_finite()), "{} on {line:?}", b.name);
|
||||
}
|
||||
}
|
||||
assert!(total <= 1200, "{total} anchors");
|
||||
// Made again, the same bytes; the checksum pins them across runs and platforms.
|
||||
let bytes = serde_json::to_string(&cmd::brushlib::dry_media().brushes).unwrap();
|
||||
assert_eq!(bytes, serde_json::to_string(&lib.brushes).unwrap());
|
||||
let fnv = bytes.bytes().fold(0xcbf2_9ce4_8422_2325_u64, |h, b| (h ^ u64::from(b)).wrapping_mul(0x0100_0000_01b3));
|
||||
assert_eq!(fnv, 12_859_086_488_513_909_464, "the Dry Media art changed");
|
||||
// Names of their own: no default brush's, no other built-in library's.
|
||||
let defaults: Vec<&str> = vectorcraft_brush::defaults().iter().map(|b| b.name.as_str()).collect();
|
||||
let others: Vec<String> = cmd::brushlib::BUILTIN
|
||||
.iter()
|
||||
.filter(|b| b.id != "dry-media")
|
||||
.flat_map(|b| cmd::brushlib::library(&s, b.id).unwrap().1.brushes.iter().map(|x| x.name.clone()).collect::<Vec<_>>())
|
||||
.collect();
|
||||
assert!(names.iter().all(|n| !defaults.contains(&n.as_str()) && !others.contains(n)), "{names:?}");
|
||||
|
||||
// Added and applied to a path in one undo step, which undo takes back.
|
||||
let path = run(&mut s, "shape.line", json!({"x1": 10, "y1": 10, "x2": 150, "y2": 60}))["id"].as_u64().unwrap();
|
||||
let stroke_brush = |s: &Session| doc(s).node(NodeId(path)).unwrap().appearance.stroke().and_then(|st| st.brush.clone());
|
||||
let before = (undo_len(&s), brushes(&s));
|
||||
let r = run(&mut s, "brush.addFromLibrary", json!({"library": "Dry Media", "name": "Wax Crayon", "apply": true, "ids": [path]}));
|
||||
assert_eq!((strs(&r["added"]), r["applied"].clone()), (vec!["Wax Crayon"], json!([path])));
|
||||
assert_eq!((undo_len(&s), stroke_brush(&s).as_deref()), (before.0 + 1, Some("Wax Crayon")));
|
||||
assert_eq!(&brush(&s, "Wax Crayon"), lib.brush("Wax Crayon").unwrap());
|
||||
run(&mut s, "edit.undo", json!({}));
|
||||
assert_eq!((brushes(&s), stroke_brush(&s)), (before.1.clone(), None));
|
||||
run(&mut s, "edit.redo", json!({}));
|
||||
// The rest in one more step; then nothing new to add.
|
||||
let r = run(&mut s, "brush.addFromLibrary", json!({"library": "dry-media"}));
|
||||
assert_eq!((strs(&r["added"]), strs(&r["existing"])), (vec!["Chalk Grain", "Graphite Pencil", "Charcoal Dust"], vec!["Wax Crayon"]));
|
||||
assert_eq!(undo_len(&s), before.0 + 2);
|
||||
assert_eq!(
|
||||
brushes(&s),
|
||||
[before.1.clone(), vec!["Wax Crayon".into(), "Chalk Grain".into(), "Graphite Pencil".into(), "Charcoal Dust".into()]].concat()
|
||||
);
|
||||
let r = run(&mut s, "brush.addFromLibrary", json!({"library": "dry-media"}));
|
||||
assert_eq!((strs(&r["added"]).len(), undo_len(&s)), (0, before.0 + 2));
|
||||
// Saved as a library and loaded back: the same brushes, in the document's order.
|
||||
let saved = run(&mut s, "brush.saveLibrary", json!({ "names": names, "name": "Dry" }));
|
||||
let b64 = saved["dataBase64"].as_str().unwrap().to_string();
|
||||
let r = run(&mut s, "brush.loadLibrary", json!({"dataBase64": b64, "name": "Dry.vectorcraft"}));
|
||||
let (_, reopened) = cmd::brushlib::library(&s, r["library"].as_str().unwrap()).unwrap();
|
||||
assert_eq!(reopened.len(), lib.len());
|
||||
for b in &lib.brushes {
|
||||
assert_eq!(reopened.brush(&b.name), Some(b), "{}", b.name);
|
||||
}
|
||||
}
|
||||
|
||||
/// Bristle Brushes: one brush per tip shape, every value inside the range the brush keeps (a value
|
||||
/// outside would be clamped without notice), names no other library or default brush uses; added
|
||||
/// in one step, not twice, and saved as a library they reopen unchanged.
|
||||
|
||||
@@ -88,19 +88,17 @@ fn dynamic_text_refuses_bad_params_and_other_type() {
|
||||
assert!(err(s.execute("type.dynamicText", &json!({"on": 1}))).contains("on"));
|
||||
assert_eq!(undo_len(&s), before);
|
||||
assert!(!text(&s, id).dynamic_text);
|
||||
// Point type alone: refused (area type takes Block, `typecmd::block_tests`).
|
||||
let point = s.execute("text.create", &json!({"x": 10, "y": 10, "text": "Point"})).unwrap()["id"].as_u64().unwrap();
|
||||
s.execute("select.set", &json!({"ids": [point]})).unwrap();
|
||||
assert!(err(s.execute("type.dynamicText", &json!({}))).contains("select type on a path or area type"));
|
||||
let empty = s.execute("shape.rectangle", &json!({"x": 300, "y": 10, "width": 20, "height": 20})).unwrap()["id"].as_u64().unwrap();
|
||||
s.execute("select.set", &json!({"ids": [empty]})).unwrap();
|
||||
assert!(s.execute("type.dynamicText", &json!({})).is_err());
|
||||
// With point type selected too, only the type on a path changes.
|
||||
// With point type selected too, both change in one undo step: the point type becomes area
|
||||
// type with Block on (`typecmd::block_tests`).
|
||||
let before = undo_len(&s);
|
||||
s.execute("select.set", &json!({"ids": [point, id.0]})).unwrap();
|
||||
let r = s.execute("type.dynamicText", &json!({"on": true})).unwrap();
|
||||
assert_eq!(r["ids"], json!([id.0]));
|
||||
assert!(!text(&s, NodeId(point)).dynamic_text);
|
||||
assert_eq!(r["ids"], json!([point, id.0]));
|
||||
assert!(text(&s, NodeId(point)).dynamic_text && text(&s, id).dynamic_text);
|
||||
assert_eq!(undo_len(&s), before + 1);
|
||||
}
|
||||
|
||||
|
||||
@@ -147,3 +147,37 @@ fn show_all_hidden_attributes() {
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
assert!(node(&s, id).appearance.has_hidden());
|
||||
}
|
||||
|
||||
/// A Warp applied now takes its style's Bézier net (`version` 1) unless a map is given, an edit
|
||||
/// keeps the map the effect has (one saved before the version existed keeps the earlier map), and
|
||||
/// a map this build doesn't draw is refused.
|
||||
#[test]
|
||||
fn warps_keep_the_map_their_version_names() {
|
||||
let (mut s, id) = session_with_rect();
|
||||
let version = |s: &Session| node(s, id).appearance.effects[0].params.get("version").cloned();
|
||||
run(&mut s, "effect.apply", json!({"effect": "warp.flag", "params": {"bend": 40}}));
|
||||
assert_eq!(version(&s), Some(json!(vectorcraft_doc::live::WARP_NET)));
|
||||
run(&mut s, "effect.setParams", json!({"index": 0, "params": {"bend": 20}, "warpStyle": "arc"}));
|
||||
assert_eq!(version(&s), Some(json!(vectorcraft_doc::live::WARP_NET)));
|
||||
// As a file saved before the version existed gives it.
|
||||
let doc = std::sync::Arc::make_mut(&mut s.doc_mut().unwrap().doc);
|
||||
if let Some(Value::Object(p)) = doc.node_mut(id).and_then(|n| n.appearance.effects.first_mut()).map(|e| &mut e.params) {
|
||||
p.remove("version");
|
||||
}
|
||||
run(&mut s, "effect.setParams", json!({"index": 0, "params": {"bend": 10, "horizontal": 20}}));
|
||||
assert_eq!(version(&s), None);
|
||||
run(&mut s, "effect.setParams", json!({"index": 0, "params": {"version": 1}}));
|
||||
assert_eq!(version(&s), Some(json!(1)));
|
||||
run(&mut s, "effect.apply", json!({"effect": "warp.arc", "params": {"version": 0}}));
|
||||
assert_eq!(node(&s, id).appearance.effects[1].params["version"], json!(0));
|
||||
// A whole number given as a float is that version.
|
||||
run(&mut s, "effect.setParams", json!({"index": 1, "params": {"version": 1.0}}));
|
||||
assert_eq!(node(&s, id).appearance.effects[1].params["version"], json!(1));
|
||||
run(&mut s, "effect.setParams", json!({"index": 1, "params": {"version": 0.0}}));
|
||||
assert_eq!(node(&s, id).appearance.effects[1].params["version"], json!(0));
|
||||
for bad in [json!(2), json!(-1), json!(0.5), json!("1")] {
|
||||
assert!(s.execute("effect.apply", &json!({"effect": "warp.arc", "params": {"version": bad}})).is_err(), "{bad}");
|
||||
assert!(s.execute("effect.setParams", &json!({"index": 0, "params": {"version": bad}})).is_err(), "{bad}");
|
||||
}
|
||||
assert_eq!(node(&s, id).appearance.effects.len(), 2);
|
||||
}
|
||||
|
||||
@@ -123,6 +123,51 @@ fn reset_with_warp_and_mesh_switch_the_kind_and_keep_the_content() {
|
||||
assert!(s.execute("object.envelope.resetWithMesh", &json!({})).is_err());
|
||||
}
|
||||
|
||||
/// A new warp envelope bends with its style's Bézier net (`version` [`live::WARP_NET`]) and keeps
|
||||
/// it through Reset with Warp. One saved before the version existed keeps the earlier map when its
|
||||
/// warp or its options change.
|
||||
#[test]
|
||||
fn new_warp_envelopes_take_the_net_and_saved_ones_keep_their_map() {
|
||||
// The surface point a quarter of the way along the top edge.
|
||||
let quarter = |s: &Session, e| live::EnvelopeMap::of(&node(s, e)).unwrap().surface_mesh(4, 4, vectorcraft_color::Color::BLACK).points[1].p;
|
||||
let at = |x, y| vectorcraft_geom::Point::new(x, y);
|
||||
let mut s = session();
|
||||
let (_, e) = enveloped(&mut s, "object.envelope.makeWithWarp", json!({"style": "flag", "bend": 40}));
|
||||
assert!(matches!(kind(&s, e), EnvelopeKind::Warp { version: live::WARP_NET, .. }), "{:?}", kind(&s, e));
|
||||
assert_eq!(s.execute("object.envelope.info", &json!({})).unwrap()["version"], json!(live::WARP_NET));
|
||||
// Flag moves the top edge 6 × bend × height × u(1 - u)(1 - 2u) down at u: 22.5 at u = 1/4.
|
||||
assert!(quarter(&s, e).distance(at(150.0, 122.5)) < 1e-9, "{:?}", quarter(&s, e));
|
||||
s.execute("object.envelope.resetWithWarp", &json!({"bend": -40})).unwrap();
|
||||
assert!(matches!(kind(&s, e), EnvelopeKind::Warp { version: live::WARP_NET, .. }), "{:?}", kind(&s, e));
|
||||
assert!(quarter(&s, e).distance(at(150.0, 77.5)) < 1e-9, "{:?}", quarter(&s, e));
|
||||
// As a file saved before the version existed gives it.
|
||||
let doc = std::sync::Arc::make_mut(&mut s.doc_mut().unwrap().doc);
|
||||
if let Some(NodeKind::Envelope { kind, .. }) = doc.node_mut(e).map(|n| &mut n.kind)
|
||||
&& let EnvelopeKind::Warp { version, .. } = &mut **kind
|
||||
{
|
||||
*version = 0;
|
||||
}
|
||||
s.execute("object.envelope.options", &json!({"fidelity": 75})).unwrap();
|
||||
s.execute("object.envelope.resetWithWarp", &json!({"bend": 40})).unwrap();
|
||||
assert!(matches!(kind(&s, e), EnvelopeKind::Warp { version: 0, .. }), "{:?}", kind(&s, e));
|
||||
// The earlier map's flag: half the bend times sin(π x), x = -1/2 here, in half heights.
|
||||
assert!(quarter(&s, e).distance(at(150.0, 90.0)) < 1e-9, "{:?}", quarter(&s, e));
|
||||
// `version` moves a warp to a map, and a new warp can take the earlier one.
|
||||
s.execute("object.envelope.options", &json!({"version": 1.0})).unwrap();
|
||||
assert!(matches!(kind(&s, e), EnvelopeKind::Warp { version: live::WARP_NET, .. }), "{:?}", kind(&s, e));
|
||||
assert!(quarter(&s, e).distance(at(150.0, 122.5)) < 1e-9, "{:?}", quarter(&s, e));
|
||||
s.execute("object.envelope.resetWithWarp", &json!({"version": 0})).unwrap();
|
||||
assert!(matches!(kind(&s, e), EnvelopeKind::Warp { version: 0, .. }), "{:?}", kind(&s, e));
|
||||
let (_, old) = enveloped(&mut s, "object.envelope.makeWithWarp", json!({"style": "flag", "bend": 40, "version": 0}));
|
||||
assert!(matches!(kind(&s, old), EnvelopeKind::Warp { version: 0, .. }), "{:?}", kind(&s, old));
|
||||
for bad in [json!(2), json!(-1), json!(0.5), json!("1"), json!(null)] {
|
||||
for cmd in ["object.envelope.makeWithWarp", "object.envelope.resetWithWarp", "object.envelope.options"] {
|
||||
let r = s.execute(cmd, &json!({"version": bad}));
|
||||
assert!(r.as_ref().is_err_and(|e| e.to_string().contains("`version` must be 0 or 1")), "{cmd} {bad}: {r:?}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn release_gives_back_the_warp_shape_as_a_mesh_and_keeps_the_transparency() {
|
||||
let mut s = session();
|
||||
@@ -141,9 +186,9 @@ fn release_gives_back_the_warp_shape_as_a_mesh_and_keeps_the_transparency() {
|
||||
let content = node(&s, a);
|
||||
assert_eq!((content.opacity, content.blend, content.extras().mask.is_some()), (0.4, BlendMode::Multiply, true), "the content keeps the look");
|
||||
let NodeKind::Mesh(m) = node(&s, ids[1]).kind else { panic!("the warp's shape comes back as a mesh") };
|
||||
// The mesh follows the arc: its outline reaches below the content's bottom edge at the sides.
|
||||
// The mesh follows the arc: its top corners swing out past the content's sides and up.
|
||||
let b = m.outline().bounds().unwrap();
|
||||
assert!(b.y1 > 201.0, "{b:?}");
|
||||
assert!(b.x0 < 99.0 && b.x1 > 301.0 && b.y0 < 99.0, "{b:?}");
|
||||
assert_eq!(s.doc().unwrap().selection.objects, ids);
|
||||
}
|
||||
|
||||
|
||||
@@ -154,6 +154,35 @@ fn images_bend_as_a_raster_mesh_warp() {
|
||||
assert_eq!(svg.matches("<image").count(), 1, "the pieces share the image's pixels");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_colorized_image_keeps_its_color_in_an_envelope() {
|
||||
let mut s = session();
|
||||
let img = image::GrayImage::from_pixel(4, 4, image::Luma([0]));
|
||||
let mut png = vec![];
|
||||
image::DynamicImage::ImageLuma8(img).write_to(&mut std::io::Cursor::new(&mut png), image::ImageFormat::Png).unwrap();
|
||||
let im = s
|
||||
.edit("Place", |d, sel| {
|
||||
d.images.insert("gray".into(), vectorcraft_doc::ImageBlob::png(png));
|
||||
let id = d.alloc_id();
|
||||
let xf = Affine::translate((100.0, 100.0)) * Affine::scale(10.0);
|
||||
let im =
|
||||
vectorcraft_doc::ImageObject { key: "gray".into(), width: 4, height: 4, xf, link: None, placement: Default::default(), pixels: None };
|
||||
d.insert(d.default_layer(), 0, Node::new(id, NodeKind::Image(Box::new(im))))?;
|
||||
sel.set([id]);
|
||||
Ok(id)
|
||||
})
|
||||
.unwrap();
|
||||
s.execute("paint.setFill", &json!({"color": "#ff0000", "ids": [im.0]})).unwrap();
|
||||
sel(&mut s, &[im]);
|
||||
arch(&mut s, 80.0, json!({}));
|
||||
let img = vectorcraft_render::Renderer::new().render_region(&s.doc().unwrap().doc, Rect::new(0.0, 0.0, 200.0, 200.0), 1.0, true);
|
||||
// The black pixels draw in the fill color, in the bent shape.
|
||||
let mid = img.pixel(120, 97);
|
||||
assert!(mid[0] > 200 && mid[1] < 60 && mid[2] < 60, "the middle rose, red: {mid:?}");
|
||||
let inside = img.pixel(120, 120);
|
||||
assert!(inside[0] > 200 && inside[1] < 60 && inside[2] < 60, "red inside: {inside:?}");
|
||||
}
|
||||
|
||||
/// A selected embedded 2×2 image, 80×80 pt at (100, 100): a red column left of a blue one.
|
||||
fn two_color_image(s: &mut Session) -> NodeId {
|
||||
let img = image::RgbaImage::from_fn(2, 2, |x, _| if x == 0 { image::Rgba([255, 0, 0, 255]) } else { image::Rgba([0, 0, 255, 255]) });
|
||||
@@ -286,17 +315,18 @@ fn symbols_bend_with_the_envelope() {
|
||||
let e = arch(&mut s, 60.0, json!({}));
|
||||
let pts = anchors(&evaluated(&s, e));
|
||||
assert!(pts.iter().any(|p| p.y < 90.0), "the symbol's art rose in the middle");
|
||||
// Editing the symbol redraws the envelope (the renderer's cache follows the symbol's art).
|
||||
// Editing the symbol redraws the envelope (the renderer's cache follows the symbol's art). The
|
||||
// arch lifts the middle of the 100..200 box by about 51, so (200, 120) is inside it.
|
||||
let mut r = vectorcraft_render::Renderer::new();
|
||||
let region = Rect::new(0.0, 0.0, 400.0, 300.0);
|
||||
assert!(r.render_region(&s.doc().unwrap().doc, region, 1.0, true).pixel(200, 150)[1] < 60);
|
||||
assert!(r.render_region(&s.doc().unwrap().doc, region, 1.0, true).pixel(200, 120)[1] < 60);
|
||||
s.edit("edit symbol", |d, _| {
|
||||
let art = Node::path(NodeId(0), shapes::rectangle(Rect::new(-10.0, -10.0, -8.0, -8.0)), Default::default());
|
||||
d.symbols[0].art = Arc::new(art);
|
||||
Ok(())
|
||||
})
|
||||
.unwrap();
|
||||
assert!(r.render_region(&s.doc().unwrap().doc, region, 1.0, true).pixel(200, 150)[1] > 200);
|
||||
assert!(r.render_region(&s.doc().unwrap().doc, region, 1.0, true).pixel(200, 120)[1] > 200);
|
||||
}
|
||||
|
||||
fn strokes(nodes: &[Node]) -> usize {
|
||||
|
||||
@@ -194,6 +194,43 @@ fn a_search_finds_a_missing_fonts_file_by_its_names() {
|
||||
assert!(matches!(r["state"].as_str(), Some("stopped" | "done")), "{r}");
|
||||
}
|
||||
|
||||
/// A caller confined by read roots sees, in the replies to a search another caller started, only
|
||||
/// the files found inside its read roots, and the folder searched and a failed search's path only
|
||||
/// when they are inside them.
|
||||
#[test]
|
||||
fn a_search_shows_a_confined_caller_only_paths_inside_its_read_roots() {
|
||||
let root = folder("read-roots");
|
||||
let font = vectorcraft_testkit::fonts::renamed("Findme Sans 3");
|
||||
let (home, wide) = (root.join("home"), root.join("lrf-wide"));
|
||||
for dir in [&home, &wide] {
|
||||
std::fs::create_dir_all(dir).unwrap();
|
||||
std::fs::write(dir.join("Findme.otf"), &font).unwrap();
|
||||
}
|
||||
let mut s = session_for(&root);
|
||||
let roots = |read: &[&Path]| crate::file_access::AutomationRoots::new(read, &[]).unwrap().unwrap();
|
||||
let (narrow, broad) = (roots(&[&home]), roots(&[&root]));
|
||||
let ask = |s: &mut Session, r: &Arc<crate::file_access::AutomationRoots>, p: Value| {
|
||||
crate::file_access::confine(Some(r), || s.execute("text.findFontFiles", &p).unwrap())
|
||||
};
|
||||
// A font no file has keeps the search going through every folder.
|
||||
let fonts = json!([{"family": "Findme Sans 3"}, {"family": "No Such Sans"}]);
|
||||
ask(&mut s, &broad, json!({"folder": root, "fonts": fonts}));
|
||||
finished(&mut s);
|
||||
let all = ask(&mut s, &broad, json!({}));
|
||||
assert_eq!((&all["fonts"][0]["files"], &all["folder"]), (&json!([home.join("Findme.otf"), wide.join("Findme.otf")]), &json!(root)), "{all}");
|
||||
for r in [ask(&mut s, &narrow, json!({})), ask(&mut s, &narrow, json!({"stop": true}))] {
|
||||
assert!(!r.to_string().contains("lrf-wide") && r.get("folder").is_none(), "{r}");
|
||||
assert_eq!((&r["fonts"][0]["files"], &r["fonts"][0]["family"]), (&json!([home.join("Findme.otf")]), &json!("Findme Sans 3")), "{r}");
|
||||
assert_eq!((&r["state"], &r["searched"], &r["id"]), (&all["state"], &all["searched"], &all["id"]), "{r}");
|
||||
}
|
||||
// A search that failed: the reason shows without the path to a caller outside its folder.
|
||||
ask(&mut s, &broad, json!({"folder": wide.join("gone"), "fonts": fonts}));
|
||||
assert!(finished(&mut s)["error"].as_str().is_some_and(|e| e.contains("lrf-wide")));
|
||||
let r = ask(&mut s, &narrow, json!({}));
|
||||
assert_eq!((r["state"].as_str(), r["error"].as_str(), r.get("folder")), (Some("failed"), Some("the folder can't be read"), None), "{r}");
|
||||
let _ = std::fs::remove_dir_all(&root);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn junk_search_params_never_fail_internally() {
|
||||
let root = folder("junk");
|
||||
|
||||
@@ -271,7 +271,7 @@ fn stop_ends_only_the_search_it_names() {
|
||||
let (tx, rx) = mpsc::channel();
|
||||
let search = findfiles::start(root.clone(), s.search_rules.clone(), Limits::default(), 1, 2, Arc::new(Blocked(Mutex::new(rx)))).unwrap();
|
||||
let links = vec![Sought { name: "a.png".into(), path: "/gone/a.png".into(), ids: vec![], size: None, hash: None }];
|
||||
s.link_search = Some(LinkSearch { id: 7, folder: root.to_string_lossy().into_owned(), links, sizes: Arc::default(), search });
|
||||
s.link_search = Some(LinkSearch { id: 7, folder: root.to_string_lossy().into_owned(), doc: None, links, sizes: Arc::default(), search });
|
||||
let until = Instant::now() + WAIT;
|
||||
while s.execute("links.findFiles", &json!({})).unwrap()["searched"]["read"] != 1 && Instant::now() < until {
|
||||
std::thread::sleep(Duration::from_millis(2));
|
||||
@@ -284,6 +284,116 @@ fn stop_ends_only_the_search_it_names() {
|
||||
drop(tx);
|
||||
}
|
||||
|
||||
/// A caller confined by read roots sees, in the replies to a search another caller started and to
|
||||
/// its own, only the paths inside its read roots: the files found outside them, a link's saved path
|
||||
/// outside them and the folder searched are left out, and the rows, ids and counts stay. Without
|
||||
/// roots, the replies are as before.
|
||||
#[test]
|
||||
fn a_search_shows_a_confined_caller_only_paths_inside_its_read_roots() {
|
||||
let root = folder("read-roots");
|
||||
let mut s = session_for(&root);
|
||||
let photo = png(40, 30, RED);
|
||||
write(&root.join("lrf-away/photo.png"), &photo);
|
||||
let id = placed(&mut s, &root.join("lrf-away/photo.png"));
|
||||
std::fs::remove_file(root.join("lrf-away/photo.png")).unwrap();
|
||||
// Files of the name with other contents: the search reads every folder.
|
||||
let other = png(40, 30, BLUE);
|
||||
let (home, wide) = (root.join("home"), root.join("lrf-wide"));
|
||||
write(&home.join("photo.png"), &other);
|
||||
write(&wide.join("photo.png"), &other);
|
||||
let roots = |read: &[&Path], write: &[&Path]| crate::file_access::AutomationRoots::new(read, write).unwrap().unwrap();
|
||||
let (narrow, write_only, broad) = (roots(&[&home], &[]), roots(&[], &[&root]), roots(&[&root], &[]));
|
||||
let ask = |s: &mut Session, r: &Arc<crate::file_access::AutomationRoots>, p: Value| {
|
||||
crate::file_access::confine(Some(r), || s.execute("links.findFiles", &p).unwrap())
|
||||
};
|
||||
let hidden = |r: &Value| {
|
||||
let text = r.to_string();
|
||||
assert!(!text.contains("lrf-away") && !text.contains("lrf-wide") && r.get("folder").is_none(), "{r}");
|
||||
let row = &r["links"][0];
|
||||
assert_eq!((row["name"].as_str(), &row["ids"], row.get("path")), (Some("photo.png"), &json!([id]), None), "{r}");
|
||||
};
|
||||
// Started with the broad roots, over the whole folder.
|
||||
let r = ask(&mut s, &broad, json!({"folder": root}));
|
||||
assert_eq!(r["folder"], json!(root), "{r}");
|
||||
finished(&mut s);
|
||||
let all = ask(&mut s, &broad, json!({}));
|
||||
assert_eq!(all["links"][0]["files"].as_array().map(Vec::len), Some(2), "{all}");
|
||||
for r in [ask(&mut s, &narrow, json!({})), ask(&mut s, &narrow, json!({"stop": true}))] {
|
||||
hidden(&r);
|
||||
assert_eq!(r["links"][0]["files"], json!([{"path": home.join("photo.png"), "size": other.len(), "match": "name"}]), "{r}");
|
||||
assert_eq!((&r["state"], &r["searched"], &r["id"]), (&all["state"], &all["searched"], &all["id"]), "{r}");
|
||||
}
|
||||
let r = ask(&mut s, &write_only, json!({}));
|
||||
hidden(&r);
|
||||
assert_eq!(r["links"][0]["files"], json!([]), "{r}");
|
||||
// Without roots, every path shows.
|
||||
let r = s.execute("links.findFiles", &json!({})).unwrap();
|
||||
assert_eq!((&r["folder"], &r["links"][0]["path"]), (&json!(root), &json!(root.join("lrf-away/photo.png"))), "{r}");
|
||||
assert_eq!(r["links"][0]["files"].as_array().map(Vec::len), Some(2), "{r}");
|
||||
// The narrow caller's own search shows the folder it gave.
|
||||
let r = ask(&mut s, &narrow, json!({"folder": home}));
|
||||
assert_eq!(r["folder"], json!(home), "{r}");
|
||||
finished(&mut s);
|
||||
let r = ask(&mut s, &narrow, json!({}));
|
||||
assert_eq!((&r["folder"], r["links"][0].get("path")), (&json!(home), None), "{r}");
|
||||
// A search that failed: the reason shows without the path to a caller outside its folder.
|
||||
ask(&mut s, &broad, json!({"folder": root.join("lrf-wide/gone")}));
|
||||
let r = finished(&mut s);
|
||||
assert!(r["error"].as_str().is_some_and(|e| e.contains("lrf-wide")), "{r}");
|
||||
let r = ask(&mut s, &narrow, json!({}));
|
||||
assert_eq!((r["state"].as_str(), r["error"].as_str()), (Some("failed"), Some("the folder can't be read")), "{r}");
|
||||
hidden(&r);
|
||||
let _ = std::fs::remove_dir_all(&root);
|
||||
}
|
||||
|
||||
/// A link's saved path relative to the document is shown to a confined caller when it leads to a
|
||||
/// file inside its read roots, and left out when `..` leads out of them.
|
||||
#[test]
|
||||
fn a_relative_link_path_is_judged_from_the_documents_folder() {
|
||||
let root = folder("read-roots-relative");
|
||||
let mut s = session_for(&root);
|
||||
let home = root.join("home");
|
||||
write(&root.join("lrf-away/photo.png"), &png(40, 30, RED));
|
||||
let id = placed(&mut s, &root.join("lrf-away/photo.png"));
|
||||
std::fs::create_dir_all(&home).unwrap();
|
||||
s.execute("document.save", &json!({"path": home.join("poster.vectorcraft")})).unwrap();
|
||||
let narrow = crate::file_access::AutomationRoots::new(&[&home], &[]).unwrap().unwrap();
|
||||
for (saved, shown) in [("sub/photo.png", true), ("../lrf-away/photo.png", false)] {
|
||||
s.edit("relative path", |d, _| {
|
||||
if let NodeKind::Image(im) = &mut d.node_mut(NodeId(id)).unwrap().kind {
|
||||
im.link.as_mut().unwrap().path = saved.into();
|
||||
}
|
||||
Ok(())
|
||||
})
|
||||
.unwrap();
|
||||
let r = crate::file_access::confine(Some(&narrow), || s.execute("links.findFiles", &json!({"folder": home, "ids": [id]})).unwrap());
|
||||
assert_eq!(r["links"][0].get("path"), shown.then(|| json!(saved)).as_ref(), "{saved}: {r}");
|
||||
finished(&mut s);
|
||||
}
|
||||
let _ = std::fs::remove_dir_all(&root);
|
||||
}
|
||||
|
||||
/// `image.info` leaves a linked file outside the confined caller's read roots unnamed (`link`
|
||||
/// null), as `links.info` leaves it out, and keeps the other fields.
|
||||
#[test]
|
||||
fn image_info_leaves_out_a_link_outside_the_read_roots() {
|
||||
let root = folder("read-roots-info");
|
||||
let mut s = session_for(&root);
|
||||
let pic = root.join("lrf-away/photo.png");
|
||||
write(&pic, &png(40, 30, RED));
|
||||
let id = placed(&mut s, &pic);
|
||||
std::fs::create_dir_all(root.join("home")).unwrap();
|
||||
let narrow = crate::file_access::AutomationRoots::new(&[&root.join("home")], &[]).unwrap().unwrap();
|
||||
let ask = |s: &mut Session, cmd: &str| crate::file_access::confine(Some(&narrow), || s.execute(cmd, &json!({"id": id})).unwrap());
|
||||
let r = ask(&mut s, "image.info");
|
||||
assert_eq!((&r["link"], &r["linked"], &r["pixelWidth"], &r["id"]), (&Value::Null, &json!(true), &json!(40), &json!(id)), "{r}");
|
||||
assert!(!r.to_string().contains("lrf-away"), "{r}");
|
||||
let info = ask(&mut s, "links.info");
|
||||
assert!(info.get("link").is_none() && !info.to_string().contains("lrf-away"), "{info}");
|
||||
assert_eq!(s.execute("image.info", &json!({"id": id})).unwrap()["link"], json!(pic));
|
||||
let _ = std::fs::remove_dir_all(&root);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn relinking_with_copy_puts_the_file_in_the_documents_folder() {
|
||||
let root = folder("copy");
|
||||
|
||||
@@ -570,8 +570,8 @@ fn envelope_warp_make_and_content_stays_inside_bounds() {
|
||||
let pts = all_anchors(&out);
|
||||
assert!(pts.len() > 4, "curves are subdivided");
|
||||
assert!(pts.iter().all(|p| b.contains(*p)), "{b:?}");
|
||||
// It really is bent: the sides moved down.
|
||||
assert!(pts.iter().any(|p| p.y > 201.0));
|
||||
// It really is bent: the top corners swung out past the sides.
|
||||
assert!(pts.iter().any(|p| p.x < 99.0) && pts.iter().any(|p| p.x > 301.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -531,6 +531,139 @@ fn a_space_that_is_not_usable_is_refused_and_one_too_large_is_kept_to_the_path()
|
||||
assert_eq!(space(&s, g), (Some(100.0), 0.0));
|
||||
}
|
||||
|
||||
/// The ids of group `g`'s objects in their order along the path, and of its children in the
|
||||
/// Layers panel's order.
|
||||
fn orders(s: &Session, g: NodeId) -> (Vec<NodeId>, Vec<NodeId>) {
|
||||
let n = s.doc().unwrap().doc.node(g).unwrap();
|
||||
let path = n.extras().objects_on_path.as_deref().unwrap().members.iter().map(|m| m.id).collect();
|
||||
(path, n.children().unwrap().iter().map(|c| c.id).collect())
|
||||
}
|
||||
|
||||
/// Three objects of different widths (20, 40 and 10 pt) attached to a 300 pt line from (50, 300),
|
||||
/// in that order, with a Space of 100 pt from 0, Pivot 4 and Rotate 0.
|
||||
fn three_on_a_line(s: &mut Session) -> (NodeId, [NodeId; 3], NodeId) {
|
||||
let a = rect(s, 0.0, 0.0, 20.0, 20.0);
|
||||
let b = rect(s, 100.0, 0.0, 40.0, 20.0);
|
||||
let c = rect(s, 200.0, 0.0, 10.0, 20.0);
|
||||
let path = line(s, (50.0, 300.0), (350.0, 300.0));
|
||||
let g = attach(s, path, &[a, b, c], json!({}));
|
||||
s.execute("object.objectsOnPath.options", &json!({"spacing": 100})).unwrap();
|
||||
(g, [a, b, c], path)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn move_member_takes_the_target_s_place_and_shifts_the_objects_between() {
|
||||
let mut s = session();
|
||||
let (g, [a, b, c], path) = three_on_a_line(&mut s);
|
||||
let (_, layers) = orders(&s, g);
|
||||
let before = s.doc().unwrap().doc.node(g).unwrap().extras().objects_on_path.as_deref().cloned().unwrap();
|
||||
let steps = s.doc().unwrap().history.undo.len();
|
||||
let v = s.execute("object.objectsOnPath.moveMember", &json!({"member": a.0, "target": c.0})).unwrap();
|
||||
assert_eq!(v, json!({"group": g.0, "member": a.0, "index": 2}));
|
||||
assert_eq!(orders(&s, g), (vec![b, c, a], layers.clone()), "the order along the path changes, the Layers order does not");
|
||||
assert_eq!(s.doc().unwrap().history.undo.len(), steps + 1, "one undo step");
|
||||
assert_eq!(center(&s, b), Point::new(50.0, 300.0));
|
||||
assert_eq!(center(&s, c), Point::new(150.0, 300.0));
|
||||
assert_eq!(center(&s, a), Point::new(250.0, 300.0));
|
||||
let after = s.doc().unwrap().doc.node(g).unwrap().extras().objects_on_path.as_deref().cloned().unwrap();
|
||||
assert_eq!(
|
||||
(after.path, after.pivot, after.rotate, after.spacing, after.start),
|
||||
(path, before.pivot, before.rotate, before.spacing, before.start)
|
||||
);
|
||||
let w = bounds(&s, a);
|
||||
assert!((w.width() - 20.0).abs() < 1e-9, "the object keeps its art: {w:?}");
|
||||
// The last one back to the first place, with its group named.
|
||||
let v = s.execute("object.objectsOnPath.moveMember", &json!({"group": g.0, "member": a.0, "target": b.0})).unwrap();
|
||||
assert_eq!(v["index"], 0);
|
||||
assert_eq!(orders(&s, g).0, [a, b, c]);
|
||||
assert_eq!(center(&s, a), Point::new(50.0, 300.0));
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
assert_eq!(orders(&s, g).0, [b, c, a]);
|
||||
assert_eq!(center(&s, a), Point::new(250.0, 300.0));
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
assert_eq!(orders(&s, g).0, [a, b, c]);
|
||||
assert_eq!(center(&s, c), Point::new(250.0, 300.0));
|
||||
s.execute("edit.redo", &json!({})).unwrap();
|
||||
assert_eq!(orders(&s, g).0, [b, c, a]);
|
||||
// Onto its own place: nothing changes and no step is recorded.
|
||||
let steps = s.doc().unwrap().history.undo.len();
|
||||
let v = s.execute("object.objectsOnPath.moveMember", &json!({"member": c.0, "target": c.0})).unwrap();
|
||||
assert_eq!(v["index"], 1);
|
||||
assert_eq!(s.doc().unwrap().history.undo.len(), steps);
|
||||
// The order saves and reopens.
|
||||
let data = s.execute("document.export", &json!({"format": "vectorcraft"})).unwrap()["dataBase64"].clone();
|
||||
let mut o = Session::new();
|
||||
o.execute("document.open", &json!({"name": "objects.vectorcraft", "dataBase64": data})).unwrap();
|
||||
assert_eq!(orders(&o, g), (vec![b, c, a], layers));
|
||||
assert_eq!(center(&o, a), Point::new(250.0, 300.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn move_member_refuses_what_is_not_an_object_on_the_same_path() {
|
||||
let mut s = session();
|
||||
let (g, [a, b, c], path) = three_on_a_line(&mut s);
|
||||
let loose = rect(&mut s, 300.0, 20.0, 10.0, 10.0);
|
||||
let x = rect(&mut s, 0.0, 200.0, 10.0, 10.0);
|
||||
let other_line = line(&mut s, (50.0, 380.0), (350.0, 380.0));
|
||||
let h = attach(&mut s, other_line, &[x], json!({}));
|
||||
let err = |s: &mut Session, p: Value| s.execute("object.objectsOnPath.moveMember", &p).unwrap_err().to_string();
|
||||
assert!(err(&mut s, json!({"member": path.0, "target": a.0})).contains("`member` must name an object attached"), "the path");
|
||||
assert!(err(&mut s, json!({"member": a.0, "target": path.0})).contains("`target` must name"), "the path as the target");
|
||||
assert!(err(&mut s, json!({"member": loose.0, "target": a.0})).contains("`member` must name"), "an object on no path");
|
||||
assert!(err(&mut s, json!({"member": g.0, "target": a.0})).contains("`member` must name"), "the group itself");
|
||||
assert!(err(&mut s, json!({"member": a.0, "target": x.0})).contains("same Objects on Path group"), "another group's object");
|
||||
assert!(err(&mut s, json!({"member": a.0, "target": b.0, "group": h.0})).contains("`group` must be"), "another group named");
|
||||
assert!(err(&mut s, json!({"target": b.0})).contains("missing `member`"));
|
||||
assert!(err(&mut s, json!({"member": a.0})).contains("missing `target`"));
|
||||
assert!(err(&mut s, json!({"member": 99999, "target": b.0})).contains("no such object"));
|
||||
assert!(err(&mut s, json!({"member": "a", "target": b.0})).contains("invalid object id"));
|
||||
// A locked object neither moves nor gives its place, and is not chosen.
|
||||
select(&mut s, &[c]);
|
||||
s.execute("object.lock", &json!({})).unwrap();
|
||||
assert!(err(&mut s, json!({"member": c.0, "target": a.0})).contains("`member` names an object that is hidden or locked"));
|
||||
assert!(err(&mut s, json!({"member": a.0, "target": c.0})).contains("`target` names an object that is hidden or locked"));
|
||||
assert!(s.execute("object.objectsOnPath.selectMember", &json!({"member": c.0})).is_err());
|
||||
s.execute("object.unlockAll", &json!({})).unwrap();
|
||||
// A locked object between the two keeps its place, and the others pass over it.
|
||||
select(&mut s, &[b]);
|
||||
s.execute("object.lock", &json!({})).unwrap();
|
||||
let v = s.execute("object.objectsOnPath.moveMember", &json!({"member": a.0, "target": c.0})).unwrap();
|
||||
assert_eq!(v["index"], 2);
|
||||
assert_eq!(orders(&s, g).0, [c, b, a]);
|
||||
assert_eq!(center(&s, b), Point::new(150.0, 300.0), "the locked object stays where it was");
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
s.execute("object.unlockAll", &json!({})).unwrap();
|
||||
select(&mut s, &[g]);
|
||||
s.execute("object.lock", &json!({})).unwrap();
|
||||
assert!(s.doc().unwrap().doc.node(g).unwrap().locked);
|
||||
let e = err(&mut s, json!({"member": a.0, "target": b.0}));
|
||||
assert!(e.contains("hidden or locked"), "a locked group: {e}");
|
||||
assert!(s.execute("object.objectsOnPath.selectMember", &json!({"member": a.0})).is_err(), "a locked group's object is not chosen");
|
||||
assert_eq!(orders(&s, g).0, [a, b, c], "nothing refused changed the order");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn select_member_selects_the_group_with_the_object_chosen() {
|
||||
let mut s = session();
|
||||
let (g, [a, b, _], path) = three_on_a_line(&mut s);
|
||||
let v = s.execute("object.objectsOnPath.selectMember", &json!({"member": b.0})).unwrap();
|
||||
assert_eq!(v, json!({"group": g.0, "member": b.0}));
|
||||
let sel = &s.doc().unwrap().selection;
|
||||
assert_eq!((sel.objects.as_slice(), sel.on_path_member), (&[g][..], Some(b)));
|
||||
// Moving the chosen object keeps it chosen.
|
||||
s.execute("object.objectsOnPath.moveMember", &json!({"member": b.0, "target": a.0})).unwrap();
|
||||
assert_eq!(s.doc().unwrap().selection.on_path_member, Some(b));
|
||||
assert!(s.execute("object.objectsOnPath.selectMember", &json!({"member": path.0})).is_err());
|
||||
select(&mut s, &[g]);
|
||||
assert_eq!(s.doc().unwrap().selection.on_path_member, None, "another selection clears it");
|
||||
// Both take their objects without a selection.
|
||||
select(&mut s, &[]);
|
||||
s.execute("object.objectsOnPath.moveMember", &json!({"member": a.0, "target": b.0})).unwrap();
|
||||
assert_eq!(orders(&s, g).0[..2], [a, b]);
|
||||
s.execute("object.objectsOnPath.selectMember", &json!({"member": a.0})).unwrap();
|
||||
assert_eq!(s.doc().unwrap().selection.on_path_member, Some(a));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_space_saves_and_reopens_and_goes_with_a_copy() {
|
||||
let mut s = session();
|
||||
|
||||
@@ -184,6 +184,184 @@ fn a_flip_round_a_closed_path_keeps_the_type_in_place() {
|
||||
assert!(origins(&s, id)[0].distance(first) < 8.0);
|
||||
}
|
||||
|
||||
/// A circle of radius 100 round (200, 200), starting at 9 o'clock and running clockwise, with
|
||||
/// type on it from its start.
|
||||
fn on_circle(s: &mut Session, text: &str) -> NodeId {
|
||||
path_type(s, "M100 200 A100 100 0 1 1 300 200 A100 100 0 1 1 100 200 Z", [90.0, 200.0], text)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn center_on_top_and_bottom_of_a_circle() {
|
||||
let mut s = session();
|
||||
let id = on_circle(&mut s, "OOOO");
|
||||
assert!(near(brackets(&s, id).0, 0.0), "{:?}", brackets(&s, id));
|
||||
let steps = undo_len(&s);
|
||||
let r = s.execute("type.pathCenter.top", &json!({})).unwrap();
|
||||
// Once round, its middle at 12 o'clock, a quarter of the way round: the type sits on the top, centered.
|
||||
let (start, end) = brackets(&s, id);
|
||||
assert!(near(start, 0.75) && end.is_none(), "{start} {end:?}");
|
||||
assert_eq!((r["ids"].clone(), r["end"].clone()), (json!([id.0]), Value::Null));
|
||||
assert_eq!(text(&s, id).para_at(0).justify, vectorcraft_doc::Justify::Center);
|
||||
let top = ink(&s, id);
|
||||
assert!((top.center().x - 200.0).abs() < 0.5 && top.y1 < 101.0, "{top:?}");
|
||||
assert_eq!((undo_len(&s), last_step(&s)), (steps + 1, "Center on Top".to_string()));
|
||||
// Again: nothing moves, and no undo step is recorded.
|
||||
s.execute("type.pathCenter.top", &json!({})).unwrap();
|
||||
assert_eq!(undo_len(&s), steps + 1);
|
||||
// The bottom: 6 o'clock, outside the circle (the type is not flipped).
|
||||
s.execute("type.pathCenter.bottom", &json!({})).unwrap();
|
||||
let (start, end) = brackets(&s, id);
|
||||
assert!(near(start, 0.25) && end.is_none(), "{start} {end:?}");
|
||||
let bottom = ink(&s, id);
|
||||
assert!((bottom.center().x - 200.0).abs() < 0.5 && bottom.y0 > 299.0, "{bottom:?}");
|
||||
assert_eq!((undo_len(&s), last_step(&s)), (steps + 2, "Center on Bottom".to_string()));
|
||||
// Justify Center stays as it is.
|
||||
s.execute("text.setStyle", &json!({"justify": "justifyCenter"})).unwrap();
|
||||
s.execute("type.pathCenter.top", &json!({})).unwrap();
|
||||
assert_eq!(text(&s, id).para_at(0).justify, vectorcraft_doc::Justify::JustifyCenter);
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
assert!(near(brackets(&s, id).0, 0.25));
|
||||
// Brackets already in place with another alignment: only the alignment changes, in one step.
|
||||
s.execute("text.setStyle", &json!({"justify": "left"})).unwrap();
|
||||
let (steps, before) = (undo_len(&s), brackets(&s, id));
|
||||
s.execute("type.pathCenter.bottom", &json!({})).unwrap();
|
||||
assert_eq!((undo_len(&s), brackets(&s, id)), (steps + 1, before));
|
||||
assert_eq!(text(&s, id).para_at(0).justify, vectorcraft_doc::Justify::Center);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn center_on_top_twice_records_one_step_for_any_span() {
|
||||
let mut s = session();
|
||||
let id = on_circle(&mut s, "OOOO");
|
||||
for (start, end) in [(0.0, 0.2), (0.13, 0.41), (0.37, 0.52), (0.61, 0.97), (0.89, 0.07), (0.3, 0.3001)] {
|
||||
s.execute("type.pathOptions", &json!({"start": start, "end": end})).unwrap();
|
||||
for c in ["type.pathCenter.top", "type.pathCenter.bottom"] {
|
||||
s.execute(c, &json!({})).unwrap();
|
||||
let (steps, placed) = (undo_len(&s), brackets(&s, id));
|
||||
// Again: the brackets stay exactly where they are, and no undo step is recorded.
|
||||
s.execute(c, &json!({})).unwrap();
|
||||
assert_eq!((undo_len(&s), brackets(&s, id)), (steps, placed), "{c} {start} {end}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn center_on_top_keeps_the_span_and_measures_the_path_as_drawn() {
|
||||
let mut s = session();
|
||||
let id = on_circle(&mut s, "OOOO");
|
||||
// A span of a fifth of the path keeps its length.
|
||||
s.execute("type.pathOptions", &json!({"end": 0.2})).unwrap();
|
||||
s.execute("type.pathCenter.top", &json!({})).unwrap();
|
||||
let (start, end) = brackets(&s, id);
|
||||
assert!(near(start, 0.15) && end.is_some_and(|e| near(e, 0.35)), "{start} {end:?}");
|
||||
// Turned a quarter turn counter-clockwise, its 3 o'clock point, halfway round, is at the top.
|
||||
s.execute("type.pathOptions", &json!({"start": 0, "end": null})).unwrap();
|
||||
s.execute("object.rotate", &json!({"angle": 90})).unwrap();
|
||||
s.execute("type.pathCenter.top", &json!({})).unwrap();
|
||||
let (start, _) = brackets(&s, id);
|
||||
assert!(near(start.rem_euclid(1.0), 0.0) || near(start, 1.0), "{start}");
|
||||
let bounds = text(&s, id).bounds().unwrap();
|
||||
let top = ink(&s, id);
|
||||
assert!((top.center().x - bounds.center().x).abs() < 0.5 && top.y1 < bounds.y0 + 1.0, "{top:?} {bounds:?}");
|
||||
// A rectangle: the middle of its top edge.
|
||||
let id = path_type(&mut s, "M100 100 L300 100 L300 300 L100 300 Z", [90.0, 100.0], "OOOO");
|
||||
s.execute("type.pathCenter.top", &json!({})).unwrap();
|
||||
let (start, end) = brackets(&s, id);
|
||||
assert!(near(start, 0.625) && end.is_none(), "{start} {end:?}");
|
||||
assert!((ink(&s, id).center().x - 200.0).abs() < 0.5);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn center_on_top_moves_every_selected_type_on_a_path_in_one_step() {
|
||||
let mut s = session();
|
||||
let a = on_circle(&mut s, "OOOO");
|
||||
let b = path_type(&mut s, "M100 100 L300 100 L300 300 L100 300 Z", [90.0, 100.0], "OOOO");
|
||||
let p = s.execute("text.create", &json!({"x": 40, "y": 40, "text": "Point"})).unwrap()["id"].as_u64().unwrap();
|
||||
s.execute("select.set", &json!({"ids": [a.0, b.0, p]})).unwrap();
|
||||
let steps = undo_len(&s);
|
||||
let r = s.execute("type.pathCenter.top", &json!({})).unwrap();
|
||||
assert_eq!(r["ids"], json!([a.0, b.0]));
|
||||
assert!(near(brackets(&s, a).0, 0.75) && near(brackets(&s, b).0, 0.625));
|
||||
assert_eq!(undo_len(&s), steps + 1);
|
||||
// Dynamic Text stays on, and the layout fits the text to the span where it now runs: a fifth of
|
||||
// the circle, at its bottom.
|
||||
s.execute("select.set", &json!({"ids": [a.0]})).unwrap();
|
||||
s.execute("type.pathOptions", &json!({"start": 0, "end": 0.2})).unwrap();
|
||||
s.execute("type.dynamicText", &json!({"on": true})).unwrap();
|
||||
s.execute("type.pathCenter.bottom", &json!({})).unwrap();
|
||||
assert!(text(&s, a).dynamic_text && near(brackets(&s, a).0, 0.65), "{:?}", brackets(&s, a));
|
||||
let lay = vectorcraft_text::layout(vectorcraft_text::FontDb::global(), &text(&s, a));
|
||||
assert!(lay.fit_scale > 1.5 && !lay.overflow, "{}", lay.fit_scale);
|
||||
let bottom = ink(&s, a);
|
||||
assert!((bottom.center().x - 200.0).abs() < 1.0 && bottom.y1 > 300.0, "{bottom:?}");
|
||||
// Vertical type on a path moves the same way.
|
||||
s.execute("type.pathOptions", &json!({"start": 0, "end": null})).unwrap();
|
||||
s.execute("type.orientation.vertical", &json!({})).unwrap();
|
||||
s.execute("type.pathCenter.top", &json!({})).unwrap();
|
||||
assert!(near(brackets(&s, a).0, 0.75));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn center_on_top_refuses_open_compound_and_threaded_type() {
|
||||
let mut s = session();
|
||||
let circle = on_circle(&mut s, "OOOO");
|
||||
let line = on_line(&mut s);
|
||||
s.execute("select.set", &json!({"ids": [circle.0, line.0]})).unwrap();
|
||||
let steps = undo_len(&s);
|
||||
let before = brackets(&s, circle);
|
||||
let e = s.execute("type.pathCenter.top", &json!({})).unwrap_err().to_string();
|
||||
assert!(e.contains("type on an open or compound path cannot be centered on its top or bottom") && e.ends_with(&line.0.to_string()), "{e}");
|
||||
// Nothing changes, the type on the circle included, and the same holds for an explicit list.
|
||||
assert_eq!((undo_len(&s), brackets(&s, circle)), (steps, before));
|
||||
let e = s.execute("type.pathCenter.bottom", &json!({"ids": [circle.0, line.0]})).unwrap_err().to_string();
|
||||
assert!(e.contains("open or compound path") && e.ends_with(&line.0.to_string()), "{e}");
|
||||
s.execute("select.set", &json!({"ids": [circle.0]})).unwrap();
|
||||
for bad in [json!({"ids": "all"}), json!({"ids": [999_999]})] {
|
||||
assert!(s.execute("type.pathCenter.top", &bad).is_err(), "{bad}");
|
||||
}
|
||||
assert_eq!(undo_len(&s), steps);
|
||||
// Two circles side by side in one path: a compound path.
|
||||
let pair = path_type(
|
||||
&mut s,
|
||||
"M100 450 A40 40 0 1 1 180 450 A40 40 0 1 1 100 450 Z M300 450 A40 40 0 1 1 380 450 A40 40 0 1 1 300 450 Z",
|
||||
[90.0, 450.0],
|
||||
"OOOO",
|
||||
);
|
||||
let e = s.execute("type.pathCenter.top", &json!({})).unwrap_err().to_string();
|
||||
assert!(e.contains("open or compound path") && e.ends_with(&pair.0.to_string()), "{e}");
|
||||
// A closed path that is level all round has no height.
|
||||
let flat = path_type(&mut s, "M100 400 L300 400 Z", [90.0, 400.0], "OOOO");
|
||||
let e = s.execute("type.pathCenter.top", &json!({})).unwrap_err().to_string();
|
||||
assert!(e.contains("type on a path with no length or no height cannot be centered") && e.ends_with(&flat.0.to_string()), "{e}");
|
||||
// A frame of a thread is refused, alone or with other type on a closed path.
|
||||
let threaded = on_circle(&mut s, "Threaded story");
|
||||
let frame = s.execute("shape.rectangle", &json!({"x": 400, "y": 100, "width": 200, "height": 100})).unwrap()["id"].as_u64().unwrap();
|
||||
s.execute("select.set", &json!({"ids": [threaded.0, frame]})).unwrap();
|
||||
s.execute("text.thread.create", &json!({})).unwrap();
|
||||
s.execute("select.set", &json!({"ids": [threaded.0]})).unwrap();
|
||||
let before = brackets(&s, threaded);
|
||||
for ids in [json!([threaded.0]), json!([circle.0, threaded.0])] {
|
||||
let steps = undo_len(&s);
|
||||
let e = s.execute("type.pathCenter.top", &json!({ "ids": ids })).unwrap_err().to_string();
|
||||
assert!(e.contains("type on a path in a thread cannot be centered on its top or bottom") && e.ends_with(&threaded.0.to_string()), "{e}");
|
||||
assert_eq!((undo_len(&s), brackets(&s, threaded)), (steps, before));
|
||||
}
|
||||
// Without type on one closed path selected, the commands are not available.
|
||||
let p = s.execute("text.create", &json!({"x": 40, "y": 40, "text": "Point"})).unwrap()["id"].as_u64().unwrap();
|
||||
for (ids, why) in [
|
||||
(json!([p]), "select type on a closed path"),
|
||||
(json!([line.0]), "open or compound path"),
|
||||
(json!([circle.0, line.0]), "open or compound path"),
|
||||
(json!([threaded.0]), "in a thread"),
|
||||
] {
|
||||
s.execute("select.set", &json!({ "ids": ids })).unwrap();
|
||||
for c in ["type.pathCenter.top", "type.pathCenter.bottom"] {
|
||||
let e = s.execute(c, &json!({})).unwrap_err().to_string();
|
||||
assert!(e.contains(why), "{c} {ids}: {e}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn align_to_path_and_spacing() {
|
||||
let mut s = session();
|
||||
|
||||
@@ -959,3 +959,202 @@ fn not_aligned_to_pixel_grid_leaves_out_what_cant_be_selected() {
|
||||
assert_eq!(s.execute("select.object.notAlignedToPixelGrid", &json!({})).unwrap()["count"], 1);
|
||||
assert_eq!(selected_ids(&s), vec![inside]);
|
||||
}
|
||||
|
||||
/// Run Not Aligned to Pixel Grid with `before` selected, check that it makes no undo step and that
|
||||
/// its count is the number of objects it selected → the selected ids, sorted.
|
||||
fn not_aligned(s: &mut Session, before: &[u64]) -> Vec<u64> {
|
||||
s.execute("select.set", &json!({ "ids": before })).unwrap();
|
||||
let steps = undo_len(s);
|
||||
let r = s.execute("select.object.notAlignedToPixelGrid", &json!({})).unwrap();
|
||||
let ids = selected_ids(s);
|
||||
assert_eq!(r["count"], json!(ids.len()), "{r}");
|
||||
assert_eq!(undo_len(s), steps);
|
||||
ids
|
||||
}
|
||||
|
||||
fn sorted(mut ids: Vec<u64>) -> Vec<u64> {
|
||||
ids.sort_unstable();
|
||||
ids
|
||||
}
|
||||
|
||||
/// What Select All selects, sorted.
|
||||
fn all_ids(s: &mut Session) -> Vec<u64> {
|
||||
s.execute("select.all", &json!({})).unwrap();
|
||||
selected_ids(s)
|
||||
}
|
||||
|
||||
fn kind(s: &Session, id: u64) -> NodeKind {
|
||||
s.doc().unwrap().doc.node(NodeId(id)).unwrap().kind.clone()
|
||||
}
|
||||
|
||||
/// Images, symbol instances, point type, area type and type on a path cannot take the property, so
|
||||
/// Not Aligned to Pixel Grid leaves them out though Select All selects them; the path a symbol was
|
||||
/// made from is the symbol's art, not an object of the document.
|
||||
#[test]
|
||||
fn not_aligned_to_pixel_grid_leaves_out_images_symbol_instances_and_type() {
|
||||
let mut s = new_doc();
|
||||
let p = path(&mut s, "M10.3 20.6 L40.7 20.6 L40.7 30.3 L10.3 30.3 Z", 1.0);
|
||||
let png = crate::tests_links::png(4, 4, [255, 0, 0]);
|
||||
s.execute("file.place", &json!({"name": "red.png", "dataBase64": vectorcraft_format::base64_encode(&png), "link": false})).unwrap();
|
||||
let image = s.doc().unwrap().selection.objects[0].0;
|
||||
let art = path(&mut s, "M60.5 60 L80 60 L80 80 L60.5 80 Z", 1.0);
|
||||
let made = s.execute("symbol.new", &json!({"name": "Mark", "ids": [art]})).unwrap()["id"].as_u64().unwrap();
|
||||
let placed = s.execute("symbol.place", &json!({"name": "Mark", "x": 200.3, "y": 200.3})).unwrap()["id"].as_u64().unwrap();
|
||||
let point = s.execute("text.create", &json!({"x": 100, "y": 100, "text": "point"})).unwrap()["id"].as_u64().unwrap();
|
||||
let frame = json!({"width": 80.5, "height": 40.5});
|
||||
let area = s.execute("text.create", &json!({"x": 100, "y": 150, "text": "area", "area": frame})).unwrap()["id"].as_u64().unwrap();
|
||||
let on_path = path(&mut s, "M10.5 300 C100 250 200 350 300.5 300", 1.0);
|
||||
let on_path = s.execute("text.createInPath", &json!({"path": on_path, "mode": "onPath", "text": "on a path"})).unwrap()["id"].as_u64().unwrap();
|
||||
let in_path = path(&mut s, "M250.5 20.5 L350 20.5 L350 90 L250.5 90 Z", 1.0);
|
||||
let in_path = s.execute("text.createInPath", &json!({"path": in_path, "mode": "area", "text": "in a path"})).unwrap()["id"].as_u64().unwrap();
|
||||
assert!(matches!(kind(&s, image), NodeKind::Image(_)));
|
||||
for id in [made, placed] {
|
||||
assert!(matches!(kind(&s, id), NodeKind::SymbolInstance { .. }), "{id}");
|
||||
}
|
||||
for id in [point, area, on_path, in_path] {
|
||||
assert!(matches!(kind(&s, id), NodeKind::Text(_)), "{id}");
|
||||
}
|
||||
assert!(s.doc().unwrap().doc.node(NodeId(art)).is_none());
|
||||
assert_eq!(all_ids(&mut s), sorted(vec![p, image, made, placed, point, area, on_path, in_path]));
|
||||
assert_eq!(not_aligned(&mut s, &[image, made, point]), vec![p]);
|
||||
// With the path aligned, nothing is left to select.
|
||||
s.execute("object.setPixelAligned", &json!({"ids": [p]})).unwrap();
|
||||
assert_eq!(not_aligned(&mut s, &[placed]), Vec::<u64>::new());
|
||||
}
|
||||
|
||||
/// Paths on a template layer, in a hidden or locked sublayer or on a locked artboard are left out,
|
||||
/// as Select All leaves them out; paths in an unlocked visible sublayer are selected.
|
||||
#[test]
|
||||
fn not_aligned_to_pixel_grid_leaves_out_template_layers_locked_sublayers_and_locked_artboards() {
|
||||
let mut s = new_doc();
|
||||
s.execute("artboard.new", &json!({"x": 500, "y": 0, "width": 100, "height": 100})).unwrap();
|
||||
let main = s.doc().unwrap().doc.layers[0].id.0;
|
||||
let free = path(&mut s, "M10 10 L20 10 L20 20 L10 20 Z", 1.0);
|
||||
let on_locked_board = path(&mut s, "M510 10 L520 10 L520 20 L510 20 Z", 1.0);
|
||||
let template = s.execute("layer.new", &json!({"name": "Trace"})).unwrap()["id"].as_u64().unwrap();
|
||||
let traced = path(&mut s, "M30 10 L40 10 L40 20 L30 20 Z", 1.0);
|
||||
s.execute("layer.setProps", &json!({"id": template, "template": true})).unwrap();
|
||||
let sub = |s: &mut Session, name: &str, d: &str| {
|
||||
let l = s.execute("layer.newSublayer", &json!({"parent": main, "name": name})).unwrap()["id"].as_u64().unwrap();
|
||||
s.execute("layer.setCurrent", &json!({"id": l})).unwrap();
|
||||
(l, path(s, d, 1.0))
|
||||
};
|
||||
let (_, in_sublayer) = sub(&mut s, "Open", "M50 10 L60 10 L60 20 L50 20 Z");
|
||||
let (hidden, in_hidden) = sub(&mut s, "Hidden", "M70 10 L80 10 L80 20 L70 20 Z");
|
||||
let (locked, in_locked) = sub(&mut s, "Locked", "M90 10 L100 10 L100 20 L90 20 Z");
|
||||
s.execute("layer.setProps", &json!({"id": hidden, "visible": false})).unwrap();
|
||||
s.execute("layer.setProps", &json!({"id": locked, "locked": true})).unwrap();
|
||||
s.execute("artboard.setProps", &json!({"index": 1, "locked": true})).unwrap();
|
||||
let all = all_ids(&mut s);
|
||||
for id in [traced, in_hidden, in_locked, on_locked_board] {
|
||||
assert!(!all.contains(&id), "{id}");
|
||||
}
|
||||
assert_eq!(not_aligned(&mut s, &[free]), sorted(vec![free, in_sublayer]));
|
||||
// Unlocked, the artboard's path is selected again.
|
||||
s.execute("artboard.setProps", &json!({"index": 1, "locked": false})).unwrap();
|
||||
assert_eq!(not_aligned(&mut s, &[free]), sorted(vec![free, in_sublayer, on_locked_board]));
|
||||
}
|
||||
|
||||
/// A clipping path and the paths it clips are selected, not their clip group; a path with a drop
|
||||
/// shadow or a Gaussian Blur is selected as the path it is, and its effects are not objects.
|
||||
#[test]
|
||||
fn not_aligned_to_pixel_grid_selects_clipping_paths_and_paths_with_effects() {
|
||||
let mut s = new_doc();
|
||||
let clipped = path(&mut s, "M10.5 10 L60 10 L60 60 L10.5 60 Z", 1.0);
|
||||
let clip = path(&mut s, "M20.5 20 L50 20 L50 50 L20.5 50 Z", 1.0);
|
||||
s.execute("select.set", &json!({"ids": [clipped, clip]})).unwrap();
|
||||
let group = s.execute("object.clippingMask.make", &json!({})).unwrap()["id"].as_u64().unwrap();
|
||||
let shadowed = path(&mut s, "M100.5 10 L150 10 L150 60 L100.5 60 Z", 1.0);
|
||||
s.execute("effect.apply", &json!({"effect": "stylize.dropShadow", "ids": [shadowed]})).unwrap();
|
||||
let blurred = path(&mut s, "M200.5 10 L250 10 L250 60 L200.5 60 Z", 1.0);
|
||||
s.execute("effect.apply", &json!({"effect": "blur.gaussian", "params": {"radius": 3}, "ids": [blurred]})).unwrap();
|
||||
assert!(matches!(kind(&s, group), NodeKind::Group { .. }));
|
||||
for id in [shadowed, blurred] {
|
||||
assert!(!s.doc().unwrap().doc.node(NodeId(id)).unwrap().appearance.effects.is_empty(), "{id}");
|
||||
}
|
||||
let want = sorted(vec![clipped, clip, shadowed, blurred]);
|
||||
assert_eq!(not_aligned(&mut s, &[group]), want);
|
||||
// Object › Align to Pixel Grid takes each object selected; then nothing is left to select.
|
||||
s.execute("object.setPixelAligned", &json!({"ids": want})).unwrap();
|
||||
assert_eq!(not_aligned(&mut s, &[group]), Vec::<u64>::new());
|
||||
}
|
||||
|
||||
/// The filter looks into blends, envelopes, repeats and Intertwine groups and selects the paths
|
||||
/// they are made of, not the objects that hold them; Align to Pixel Grid takes that selection, and
|
||||
/// then nothing is left to select.
|
||||
#[test]
|
||||
fn not_aligned_to_pixel_grid_selects_the_paths_of_blends_envelopes_repeats_and_intertwines() {
|
||||
let mut s = new_doc();
|
||||
let b1 = path(&mut s, "M10.5 10 L30 10 L30 30 L10.5 30 Z", 1.0);
|
||||
let b2 = path(&mut s, "M80.5 10 L100 10 L100 30 L80.5 30 Z", 1.0);
|
||||
s.execute("select.set", &json!({"ids": [b1, b2]})).unwrap();
|
||||
let blend = s.execute("object.blend.make", &json!({})).unwrap()["id"].as_u64().unwrap();
|
||||
let warped = path(&mut s, "M10.5 100 L100 100 L100 150 L10.5 150 Z", 1.0);
|
||||
let envelope = s.execute("object.envelope.makeWithWarp", &json!({"ids": [warped]})).unwrap()["id"].as_u64().unwrap();
|
||||
let tile = path(&mut s, "M200.5 10 L220 10 L220 30 L200.5 30 Z", 1.0);
|
||||
let repeat = s.execute("object.repeat.grid", &json!({"ids": [tile], "rows": 2, "cols": 2})).unwrap()["id"].as_u64().unwrap();
|
||||
let i1 = path(&mut s, "M200.5 200 L260 200 L260 230 L200.5 230 Z", 1.0);
|
||||
let i2 = path(&mut s, "M220.5 180 L240 180 L240 250 L220.5 250 Z", 1.0);
|
||||
let intertwine = s.execute("object.intertwine.make", &json!({"ids": [i1, i2]})).unwrap()["id"].as_u64().unwrap();
|
||||
assert!(matches!(kind(&s, blend), NodeKind::Blend { .. }));
|
||||
assert!(matches!(kind(&s, envelope), NodeKind::Envelope { .. }));
|
||||
assert!(matches!(kind(&s, repeat), NodeKind::Repeat(_)));
|
||||
assert!(matches!(kind(&s, intertwine), NodeKind::Intertwine { .. }));
|
||||
let want = sorted(vec![b1, b2, warped, tile, i1, i2]);
|
||||
assert_eq!(not_aligned(&mut s, &[blend, envelope]), want);
|
||||
s.execute("object.setPixelAligned", &json!({})).unwrap();
|
||||
for id in &want {
|
||||
assert!(aligned(&s, *id), "{id}");
|
||||
}
|
||||
assert_eq!(not_aligned(&mut s, &[repeat, intertwine]), Vec::<u64>::new());
|
||||
}
|
||||
|
||||
/// Gradient meshes, placed documents and ruler guides are left out; a live shape is a path and is
|
||||
/// selected.
|
||||
#[test]
|
||||
fn not_aligned_to_pixel_grid_leaves_out_meshes_placed_documents_and_guides_but_takes_live_shapes() {
|
||||
let mut s = new_doc();
|
||||
let filled = path(&mut s, "M10.5 10 L60 10 L60 60 L10.5 60 Z", 1.0);
|
||||
s.execute("paint.setFill", &json!({"color": "#3366cc"})).unwrap();
|
||||
let mesh = s.execute("object.mesh.create", &json!({"ids": [filled], "rows": 2, "cols": 2})).unwrap()["ids"][0].as_u64().unwrap();
|
||||
let dir = crate::tests_links::Folder::new("pixelalign-placed");
|
||||
let file = dir.file("placed.vectorcraft");
|
||||
let mut d = vectorcraft_doc::Document::new(50.0, 50.0);
|
||||
let layer = d.layers[0].id;
|
||||
let id = d.alloc_id();
|
||||
let rect = vectorcraft_geom::shapes::rectangle(vectorcraft_geom::Rect::new(0.5, 0.5, 40.0, 40.0));
|
||||
let paint = vectorcraft_color::Paint::solid(vectorcraft_color::Color::rgb8(20, 20, 230));
|
||||
let look = vectorcraft_doc::Appearance::basic(paint, vectorcraft_color::Paint::None, 0.0);
|
||||
d.insert(Some(layer), 0, vectorcraft_doc::Node::path(id, rect, look)).unwrap();
|
||||
crate::tests_links::write(&file, &vectorcraft_format::save_file(&d));
|
||||
let placed = s.execute("file.place", &json!({"path": file, "at": [200, 200]})).unwrap()["ids"][0].as_u64().unwrap();
|
||||
let guide = path(&mut s, "M0 300.5 L400 300.5", 1.0);
|
||||
s.execute("view.guides.make", &json!({})).unwrap();
|
||||
let live = s.execute("shape.rectangle", &json!({"x": 100.3, "y": 20.6, "width": 30.4, "height": 9.7})).unwrap()["id"].as_u64().unwrap();
|
||||
assert!(matches!(kind(&s, mesh), NodeKind::Mesh(_)));
|
||||
assert!(matches!(kind(&s, placed), NodeKind::PlacedDocument(_)));
|
||||
assert!(matches!(kind(&s, guide), NodeKind::Path { guide: true, .. }));
|
||||
assert!(s.doc().unwrap().doc.node(NodeId(live)).unwrap().live_shape().is_some());
|
||||
let all = all_ids(&mut s);
|
||||
assert!(all.contains(&mesh) && all.contains(&placed), "{all:?}");
|
||||
assert_eq!(not_aligned(&mut s, &[mesh, placed]), vec![live]);
|
||||
}
|
||||
|
||||
/// A compound path goes by its own property, whatever the property of the paths in it (a file can
|
||||
/// hold paths with the property on inside a compound path): off, it is selected as one object and
|
||||
/// the paths in it are not; on, it is not selected, though a path in it has the property off.
|
||||
#[test]
|
||||
fn not_aligned_to_pixel_grid_takes_a_compound_path_by_its_own_property() {
|
||||
let mut s = new_doc();
|
||||
let on = path(&mut s, "M10.5 10 L30 10 L30 30 L10.5 30 Z", 1.0);
|
||||
let off = path(&mut s, "M50.5 10 L70 10 L70 30 L50.5 30 Z", 1.0);
|
||||
s.execute("select.set", &json!({"ids": [on, off]})).unwrap();
|
||||
let compound = s.execute("object.compoundPath.make", &json!({})).unwrap()["id"].as_u64().unwrap();
|
||||
std::sync::Arc::make_mut(&mut s.doc_mut().unwrap().doc).node_mut(NodeId(on)).unwrap().extras_mut().pixel_aligned = true;
|
||||
assert!(aligned(&s, on) && !aligned(&s, off) && !aligned(&s, compound));
|
||||
let other = path(&mut s, "M100 100 L110 100 L110 110 L100 110 Z", 1.0);
|
||||
assert_eq!(not_aligned(&mut s, &[other]), sorted(vec![compound, other]));
|
||||
s.execute("object.setPixelAligned", &json!({"ids": [compound]})).unwrap();
|
||||
assert!(!aligned(&s, off));
|
||||
assert_eq!(not_aligned(&mut s, &[compound]), vec![other]);
|
||||
}
|
||||
|
||||
@@ -1433,6 +1433,92 @@ fn plugins_folder_set_again_after_clearing_installs_its_plugins() {
|
||||
run(&mut s, "plugin.remove", json!({"id": id}));
|
||||
}
|
||||
|
||||
/// Under automation read roots, `plugin.list` names the plug-in folder last loaded, its failed
|
||||
/// files and a plug-in's source file only inside them: a folder outside is `null`, and a file
|
||||
/// outside is named by its file name.
|
||||
#[test]
|
||||
fn plugin_list_leaves_out_paths_outside_the_read_root() {
|
||||
use crate::file_access::{AutomationRoots, confine};
|
||||
let _folder = PREFS_FOLDER.lock().unwrap_or_else(|e| e.into_inner());
|
||||
let base = crate::tests_links::Folder::new("plugin-list-read-root");
|
||||
let (inside, outside) = (base.0.join("in"), base.0.join("out"));
|
||||
std::fs::create_dir_all(&inside).unwrap();
|
||||
std::fs::create_dir_all(&outside).unwrap();
|
||||
let id = "test.engine.listroots";
|
||||
let src = tpl::returning(&tpl::manifest(id, "filter", "{}"), r#"{"objects":[]}"#);
|
||||
std::fs::write(outside.join("hidden.wasm"), wat::parse_str(src).unwrap()).unwrap();
|
||||
std::fs::write(outside.join("broken.wasm"), b"\0asm?").unwrap();
|
||||
let folder = outside.to_string_lossy().to_string();
|
||||
let mut s = session();
|
||||
let r = run(&mut s, "plugin.reload", json!({"path": folder}));
|
||||
assert_eq!(r["loaded"], json!([id]), "{r}");
|
||||
let broken = outside.join("broken.wasm").to_string_lossy().to_string();
|
||||
let source = outside.join("hidden.wasm").to_string_lossy().to_string();
|
||||
let entry = |list: &Value| list["plugins"].as_array().unwrap().iter().find(|p| p["id"] == id).cloned().unwrap();
|
||||
|
||||
// The person sees the paths.
|
||||
let person = run(&mut s, "plugin.list", json!({}));
|
||||
assert_eq!((&person["folder"]["folder"], &person["folder"]["failed"][0]["file"]), (&json!(folder), &json!(broken)), "{person}");
|
||||
assert_eq!(entry(&person)["source"], json!(source));
|
||||
|
||||
// The client sees their names.
|
||||
let roots = AutomationRoots::new(&[&inside], &[]).unwrap();
|
||||
let listed = confine(roots.as_ref(), || s.execute("plugin.list", &json!({}))).unwrap();
|
||||
let report = &listed["folder"];
|
||||
assert_eq!((&report["folder"], &report["loaded"], &report["failed"][0]["file"]), (&Value::Null, &json!([id]), &json!("broken.wasm")), "{listed}");
|
||||
assert_eq!(entry(&listed)["source"], "hidden.wasm");
|
||||
let info = confine(roots.as_ref(), || s.execute("plugin.info", &json!({"id": id}))).unwrap();
|
||||
assert_eq!(info["source"], "hidden.wasm");
|
||||
let outside_text = json!(folder).to_string();
|
||||
for reply in [&listed, &info] {
|
||||
assert!(!reply.to_string().contains(outside_text.trim_matches('"')), "no path outside the root: {reply}");
|
||||
}
|
||||
|
||||
// A read root holding the folder shows it whole.
|
||||
let wider = AutomationRoots::new(&[&outside], &[]).unwrap();
|
||||
let listed = confine(wider.as_ref(), || s.execute("plugin.list", &json!({}))).unwrap();
|
||||
assert_eq!(listed["folder"]["folder"], json!(folder), "{listed}");
|
||||
run(&mut s, "plugin.remove", json!({"id": id}));
|
||||
}
|
||||
|
||||
/// Under automation read roots, a plug-in turned off for the next launch is listed with a file
|
||||
/// outside them named by its file name, and `plugin.setEnabled` turns it on by that name.
|
||||
#[test]
|
||||
fn disabled_plugins_name_files_outside_the_read_root_by_their_names() {
|
||||
if !in_own_process("disabled_plugins_name_files_outside_the_read_root_by_their_names") {
|
||||
return;
|
||||
}
|
||||
use crate::file_access::{AutomationRoots, confine};
|
||||
let base = crate::tests_links::Folder::new("plugin-disabled-read-root");
|
||||
let (inside, outside) = (base.0.join("in"), base.0.join("out"));
|
||||
std::fs::create_dir_all(&inside).unwrap();
|
||||
std::fs::create_dir_all(&outside).unwrap();
|
||||
let id = "test.engine.disabledroots";
|
||||
let src = tpl::returning(&tpl::manifest(id, "filter", "{}"), r#"{"objects":[]}"#);
|
||||
let file = outside.join("off.wasm");
|
||||
std::fs::write(&file, wat::parse_str(src).unwrap()).unwrap();
|
||||
let file = file.to_string_lossy().to_string();
|
||||
let mut s = session();
|
||||
run(&mut s, "plugin.install", json!({"path": file}));
|
||||
let roots = AutomationRoots::new(&[&inside], &[]).unwrap();
|
||||
// The client turns it off by the source plugin.list shows.
|
||||
let listed = confine(roots.as_ref(), || s.execute("plugin.list", &json!({}))).unwrap();
|
||||
let source = listed["plugins"].as_array().unwrap().iter().find(|p| p["id"] == id).unwrap()["source"].clone();
|
||||
assert_eq!(source, "off.wasm", "{listed}");
|
||||
confine(roots.as_ref(), || s.execute("plugin.setEnabled", &json!({"file": source, "enabled": false}))).unwrap();
|
||||
assert_eq!(run(&mut s, "plugin.safeMode", json!({}))["disabled"][0]["file"], json!(file), "the person sees the path");
|
||||
|
||||
let outside_text = json!(outside.to_string_lossy()).to_string();
|
||||
for cmd in ["plugin.list", "plugin.safeMode"] {
|
||||
let r = confine(roots.as_ref(), || s.execute(cmd, &json!({}))).unwrap();
|
||||
assert_eq!(r["disabled"][0]["file"], "off.wasm", "{cmd}: {r}");
|
||||
assert!(!r.to_string().contains(outside_text.trim_matches('"')), "{cmd}: no path outside the root: {r}");
|
||||
}
|
||||
let r = confine(roots.as_ref(), || s.execute("plugin.setEnabled", &json!({"file": "off.wasm", "enabled": true}))).unwrap();
|
||||
assert_eq!((&r["changed"], &r["disabled"]), (&json!(true), &json!([])), "{r}");
|
||||
run(&mut s, "plugin.remove", json!({"id": id}));
|
||||
}
|
||||
|
||||
/// #2541: a plug-in installed in a session with a plug-in store is installed again in the next
|
||||
/// session that attaches the store, and Remove Plug-in takes it out of the store.
|
||||
#[test]
|
||||
|
||||
@@ -1545,6 +1545,71 @@ impl RecoveryStore for DiesAfterDocument {
|
||||
}
|
||||
}
|
||||
|
||||
/// Under automation read roots, `file.recovery.save`, `file.recovery.list` and
|
||||
/// `file.recovery.restore` name a document's file and the recovery folder only inside them.
|
||||
#[test]
|
||||
fn recovery_replies_leave_out_paths_outside_the_read_root() {
|
||||
use crate::file_access::{AutomationRoots, confine};
|
||||
use serde_json::Value;
|
||||
let base = crate::tests_links::Folder::new("recovery-read-root");
|
||||
let (inside, outside) = (base.0.join("in"), base.0.join("out"));
|
||||
std::fs::create_dir_all(&inside).unwrap();
|
||||
std::fs::create_dir_all(&outside).unwrap();
|
||||
let store: Arc<dyn RecoveryStore> = Arc::new(FolderStore::new(outside.join("recovery")));
|
||||
let location = outside.join("recovery").to_string_lossy().to_string();
|
||||
let kept = inside.join("kept.vectorcraft").to_string_lossy().to_string();
|
||||
let hidden = outside.join("hidden.vectorcraft").to_string_lossy().to_string();
|
||||
let mut s = Session::new();
|
||||
s.recovery.set_store(store.clone());
|
||||
for path in [&kept, &hidden] {
|
||||
s.execute("file.new", &json!({"width": 200, "height": 100})).unwrap();
|
||||
s.execute("file.saveAs", &json!({"path": path})).unwrap();
|
||||
s.execute("shape.rectangle", &json!({"x": 10, "y": 10, "width": 50, "height": 20})).unwrap();
|
||||
}
|
||||
let roots = AutomationRoots::new(&[&inside], &[]).unwrap();
|
||||
let saved = confine(roots.as_ref(), || s.execute("file.recovery.save", &json!({}))).unwrap();
|
||||
assert_eq!(saved["location"], Value::Null, "{saved}");
|
||||
s.execute("shape.ellipse", &json!({"x": 80, "y": 20, "width": 20, "height": 20})).unwrap();
|
||||
assert_eq!(s.execute("file.recovery.save", &json!({})).unwrap()["location"], json!(location), "the person sees the folder");
|
||||
|
||||
let sorted = |v: &Value, key: &str| -> Vec<String> {
|
||||
let mut out: Vec<String> = v[key].as_array().unwrap().iter().map(|c| c["path"].to_string()).collect();
|
||||
out.sort();
|
||||
out
|
||||
};
|
||||
let both = |first: Value| {
|
||||
let mut out = vec![first.to_string(), json!(kept).to_string()];
|
||||
out.sort();
|
||||
out
|
||||
};
|
||||
assert_eq!(sorted(&s.execute("file.recovery.list", &json!({})).unwrap(), "copies"), both(json!(hidden)));
|
||||
let listed = confine(roots.as_ref(), || s.execute("file.recovery.list", &json!({}))).unwrap();
|
||||
assert_eq!((sorted(&listed, "copies"), &listed["location"]), (both(Value::Null), &Value::Null), "{listed}");
|
||||
let outside_text = json!(outside.to_string_lossy()).to_string();
|
||||
assert!(!listed.to_string().contains(outside_text.trim_matches('"')), "no path outside the root: {listed}");
|
||||
// A relative recovery folder is checked as the file system reads it; a store that is no folder
|
||||
// keeps its name.
|
||||
let mut relative = Session::new();
|
||||
relative.recovery.set_store(Arc::new(FolderStore::new("vc-recovery-relative-folder")));
|
||||
let l = confine(roots.as_ref(), || relative.execute("file.recovery.list", &json!({}))).unwrap();
|
||||
assert_eq!(l["location"], Value::Null, "{l}");
|
||||
let (mut memory, _) = session();
|
||||
let l = confine(roots.as_ref(), || memory.execute("file.recovery.list", &json!({}))).unwrap();
|
||||
assert_eq!(l["location"], "memory", "{l}");
|
||||
|
||||
// After a crash, the copies restored.
|
||||
drop(s);
|
||||
let mut s = Session::new();
|
||||
s.recovery.set_store(store.clone());
|
||||
let r = confine(roots.as_ref(), || s.execute("file.recovery.restore", &json!({}))).unwrap();
|
||||
assert_eq!(sorted(&r, "restored"), both(Value::Null), "{r}");
|
||||
let mut opened: Vec<Option<String>> = s.documents().iter().map(|d| d.path.clone()).collect();
|
||||
opened.sort();
|
||||
let mut files = vec![Some(hidden), Some(kept)];
|
||||
files.sort();
|
||||
assert_eq!(opened, files, "the documents keep their files");
|
||||
}
|
||||
|
||||
/// A crash between writing a copy's document and its details restores the new document with
|
||||
/// every image, the one placed since the last copy too (and the first copy likewise).
|
||||
#[test]
|
||||
|
||||
@@ -265,3 +265,31 @@ fn the_slowest_commands_are_the_longest_in_all_then_by_id() {
|
||||
assert_eq!(ids, ["b.two", "a.one", "c.three", "f", "e"]);
|
||||
assert_eq!((slow[0].calls, slow[0].total_ms, slow[0].max_ms), (3, 9, 4));
|
||||
}
|
||||
|
||||
/// Under automation read roots, the error lines of a failed run name the included file it stopped
|
||||
/// in by its file name also when the console cut a line inside the file's path.
|
||||
#[test]
|
||||
fn a_cut_error_line_leaves_out_an_included_files_folder_outside_the_read_root() {
|
||||
use crate::file_access::{AutomationRoots, confine};
|
||||
let base = crate::tests_links::Folder::new("script-report-cut-line");
|
||||
let inside = base.0.join("in");
|
||||
std::fs::create_dir_all(&inside).unwrap();
|
||||
// A path longer than a console line, as the report of a failed run names it.
|
||||
let file = base.0.join("out").join("d".repeat(MAX_LINE_BYTES)).join("bad.jsxinc").to_string_lossy().to_string();
|
||||
let mut report = json!({"ok": false, "name": "t.js", "file": file, "line": 2, "column": 7, "message": format!("Error: stopped\nin {file}")});
|
||||
let error = script::error_lines(&report);
|
||||
assert!(error.len() == 2 && error.iter().all(|l| l.len() <= MAX_LINE_BYTES && l.ends_with(CUT_MARK)), "both lines are cut: {error:?}");
|
||||
let written: Vec<String> = std::iter::once("written by the script".to_string()).chain(error).collect();
|
||||
report["console"] = json!({"lines": written, "droppedLines": 0, "truncatedLines": 2});
|
||||
let mut s = Session::new();
|
||||
s.script_report = Some(report.clone());
|
||||
|
||||
let roots = AutomationRoots::new(&[&inside], &[]).unwrap();
|
||||
let shown = confine(roots.as_ref(), || s.shown_script_report()).unwrap();
|
||||
assert_eq!((&shown["file"], &shown["message"]), (&json!("bad.jsxinc"), &json!("Error: stopped\nin bad.jsxinc")), "{shown}");
|
||||
assert_eq!(shown["console"]["lines"], json!(["written by the script", "t.js: bad.jsxinc:2:7: Error: stopped", "in bad.jsxinc"]), "{shown}");
|
||||
let outside = json!(base.0.join("out").to_string_lossy()).to_string();
|
||||
assert!(!shown.to_string().contains(outside.trim_matches('"')), "no path outside the root: {shown}");
|
||||
// The person sees the report as it is.
|
||||
assert_eq!(s.shown_script_report(), Some(report));
|
||||
}
|
||||
|
||||
@@ -230,7 +230,7 @@ fn script_file_link_escape_refused() {
|
||||
/// not matter, and outside it where case matters, whether or not the file is there.
|
||||
#[test]
|
||||
fn script_file_case_variant_folder() {
|
||||
let base = folder("case");
|
||||
let base = crate::cmd::findfiles::plain(folder("case").canonicalize().unwrap());
|
||||
write(&base.join("main/own.txt"), b"own");
|
||||
let upper = base.join("MAIN");
|
||||
let source = |name: &str| {
|
||||
|
||||
@@ -234,3 +234,160 @@ fn a_marquee_that_touches_only_a_drop_shadow_does_not_select_its_object() {
|
||||
// From above B, over B and the shadow: B only.
|
||||
assert_eq!(press(&mut s, "selection", (105.0, 5.0), (115.0, 30.0), Mods::default()), [b]);
|
||||
}
|
||||
|
||||
/// The selection after a drag with `tool` from `from` to `to`.
|
||||
fn marquee(s: &mut Session, tool: &str, from: (f64, f64), to: (f64, f64)) -> vectorcraft_doc::Selection {
|
||||
press(s, tool, from, to, Mods::default());
|
||||
s.doc().unwrap().selection.clone()
|
||||
}
|
||||
|
||||
/// The point of anchor `(si, ai)` of path `id`.
|
||||
fn anchor_point(s: &Session, id: NodeId, (si, ai): vectorcraft_doc::AnchorRef) -> Point {
|
||||
let path = s.doc().unwrap().doc.node(id).and_then(|n| n.path_data()).unwrap();
|
||||
path.anchors().find(|(s, i, _)| (*s, *i) == (si, ai)).map(|(.., a)| a.p).unwrap()
|
||||
}
|
||||
|
||||
/// The anchors a marquee or a click picked on `id`, as points.
|
||||
fn picked_points(s: &Session, id: NodeId) -> Vec<Point> {
|
||||
let refs: Vec<_> = s.doc().unwrap().selection.partial(id).into_iter().flatten().copied().collect();
|
||||
refs.into_iter().map(|r| anchor_point(s, id, r)).collect()
|
||||
}
|
||||
|
||||
/// The anchors the tool marks as hovered.
|
||||
fn anchor_marks(s: &mut Session) -> Vec<Point> {
|
||||
s.overlays(ViewInfo::default())
|
||||
.into_iter()
|
||||
.filter_map(|o| if let vectorcraft_tools::Overlay::Anchor { p, .. } = o { Some(p) } else { None })
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn hover(s: &mut Session, x: f64, y: f64) -> Cursor {
|
||||
let v = ViewInfo::default();
|
||||
s.pointer(&PointerEvent::new(PointerKind::Move, x, y), v).unwrap();
|
||||
s.cursor(Point::new(x, y), Mods::default(), v)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_direct_selection_marquee_leaves_out_a_drop_shadow() {
|
||||
let mut s = session();
|
||||
let (a, b) = shadow_over_b(&mut s);
|
||||
let (a_before, b_before) = (bounds(&s, a), bounds(&s, b));
|
||||
assert_ne!(pixel(&s, 90, 50), WHITE);
|
||||
run(&mut s, "select.none", json!({}));
|
||||
// Over the shadow alone: no object, no anchor, no segment.
|
||||
let sel = marquee(&mut s, "directSelection", (85.0, 40.0), (95.0, 60.0));
|
||||
assert!(sel.objects.is_empty() && sel.anchors.is_empty() && sel.segments.is_empty(), "{sel:?}");
|
||||
// Over the shadow and B's upper left corner: that anchor of B, and nothing of A.
|
||||
let sel = marquee(&mut s, "directSelection", (90.0, 5.0), (105.0, 30.0));
|
||||
assert_eq!(sel.objects, [b]);
|
||||
assert_eq!(picked_points(&s, b), [Point::new(100.0, 20.0)]);
|
||||
// Across B's top edge under the shadow, with neither of its anchors inside: that segment.
|
||||
run(&mut s, "select.none", json!({}));
|
||||
assert_ne!(pixel(&s, 112, 25), BLUE);
|
||||
let sel = marquee(&mut s, "directSelection", (110.0, 5.0), (115.0, 30.0));
|
||||
assert_eq!(sel.objects, [b]);
|
||||
assert!(sel.segments.contains_key(&b) && !sel.segments.contains_key(&a), "{sel:?}");
|
||||
assert_eq!((bounds(&s, a), bounds(&s, b)), (a_before, b_before));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn direct_selection_hover_leaves_out_a_drop_shadow_and_marks_the_anchor_under_it() {
|
||||
let mut s = session();
|
||||
let (_, b) = shadow_over_b(&mut s);
|
||||
// B's upper left corner lies under the shadow.
|
||||
assert_ne!(pixel(&s, 101, 21), BLUE);
|
||||
run(&mut s, "select.none", json!({}));
|
||||
s.select_tool("directSelection", ViewInfo::default()).unwrap();
|
||||
for (x, y) in [(90.0, 50.0), (110.0, 50.0)] {
|
||||
assert_eq!(hover(&mut s, x, y), Cursor::ArrowHollow, "({x}, {y})");
|
||||
assert!(anchor_marks(&mut s).is_empty(), "({x}, {y})");
|
||||
}
|
||||
assert_eq!(hover(&mut s, 101.0, 21.0), Cursor::DirectAnchor);
|
||||
assert_eq!(anchor_marks(&mut s), [Point::new(100.0, 20.0)]);
|
||||
// A press there picks that anchor of B.
|
||||
assert_eq!(press(&mut s, "directSelection", (101.0, 21.0), (101.0, 21.0), Mods::default()), [b]);
|
||||
assert_eq!(picked_points(&s, b), [Point::new(100.0, 20.0)]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_group_selection_click_on_a_drop_shadow_selects_the_object_under_it() {
|
||||
let mut s = session();
|
||||
let (a, b) = shadow_over_b(&mut s);
|
||||
run(&mut s, "select.none", json!({}));
|
||||
assert_eq!(press(&mut s, "groupSelection", (110.0, 50.0), (110.0, 50.0), Mods::default()), [b]);
|
||||
assert!(press(&mut s, "groupSelection", (90.0, 50.0), (90.0, 50.0), Mods::default()).is_empty());
|
||||
assert_eq!(press(&mut s, "groupSelection", (50.0, 50.0), (50.0, 50.0), Mods::default()), [a]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn group_selection_clicks_on_a_drop_shadow_climb_the_groups_of_the_object_under_it() {
|
||||
let mut s = session();
|
||||
// B and C in one group, A and D in another above it, and A's shadow over B.
|
||||
let b = rect(&mut s, 100.0, 20.0, 60.0, 60.0, "#0000ff");
|
||||
let c = rect(&mut s, 170.0, 20.0, 20.0, 20.0, "#0000ff");
|
||||
run(&mut s, "select.set", json!({"ids": [b.0, c.0]}));
|
||||
let gb = id_of(&run(&mut s, "object.group", json!({})));
|
||||
let a = rect(&mut s, 20.0, 20.0, 60.0, 60.0, "#ff0000");
|
||||
let d = rect(&mut s, 20.0, 120.0, 20.0, 20.0, "#ff0000");
|
||||
run(&mut s, "select.set", json!({"ids": [a.0, d.0]}));
|
||||
let ga = id_of(&run(&mut s, "object.group", json!({})));
|
||||
shadow(&mut s, a, 40.0);
|
||||
assert_ne!(pixel(&s, 110, 50), BLUE);
|
||||
assert_ne!(pixel(&s, 90, 50), WHITE);
|
||||
run(&mut s, "select.none", json!({}));
|
||||
let click = |s: &mut Session, x, y| {
|
||||
let mut sel = press(s, "groupSelection", (x, y), (x, y), Mods::default());
|
||||
sel.sort_by_key(|n| n.0);
|
||||
sel
|
||||
};
|
||||
assert_eq!(click(&mut s, 110.0, 50.0), [b]);
|
||||
let mut both = vec![b, gb];
|
||||
both.sort_by_key(|n| n.0);
|
||||
assert_eq!(click(&mut s, 110.0, 50.0), both);
|
||||
// With A selected, a click on its shadow over the canvas does not add A's group.
|
||||
run(&mut s, "select.set", json!({"ids": [a.0]}));
|
||||
assert!(click(&mut s, 90.0, 50.0).is_empty());
|
||||
// On A itself the second click adds it.
|
||||
assert_eq!(click(&mut s, 50.0, 50.0), [a]);
|
||||
let mut both = vec![a, ga];
|
||||
both.sort_by_key(|n| n.0);
|
||||
assert_eq!(click(&mut s, 50.0, 50.0), both);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_group_selection_marquee_leaves_out_a_drop_shadow() {
|
||||
let mut s = session();
|
||||
let (a, b) = shadow_over_b(&mut s);
|
||||
run(&mut s, "select.none", json!({}));
|
||||
let sel = marquee(&mut s, "groupSelection", (85.0, 40.0), (95.0, 60.0));
|
||||
assert!(sel.objects.is_empty() && sel.anchors.is_empty(), "{sel:?}");
|
||||
// Over the shadow and B's upper left corner: that anchor of B.
|
||||
let sel = marquee(&mut s, "groupSelection", (90.0, 5.0), (105.0, 30.0));
|
||||
assert_eq!(sel.objects, [b]);
|
||||
assert_eq!(picked_points(&s, b), [Point::new(100.0, 20.0)]);
|
||||
// With E, a marquee around B that crosses A's shadow selects B whole, and not A.
|
||||
run(&mut s, "select.none", json!({}));
|
||||
let v = ViewInfo::default();
|
||||
s.select_tool("groupSelection", v).unwrap();
|
||||
s.pointer(&PointerEvent::new(PointerKind::Down, 95.0, 10.0), v).unwrap();
|
||||
s.pointer(&PointerEvent::new(PointerKind::Drag, 130.0, 50.0), v).unwrap();
|
||||
s.tool_key(vectorcraft_tools::ToolKey::Letter('e'), Mods::default(), v).unwrap();
|
||||
s.pointer(&PointerEvent::new(PointerKind::Drag, 165.0, 90.0), v).unwrap();
|
||||
s.pointer(&PointerEvent::new(PointerKind::Up, 165.0, 90.0), v).unwrap();
|
||||
let sel = s.doc().unwrap().selection.clone();
|
||||
assert_eq!(sel.objects, [b]);
|
||||
assert!(sel.partial(b).is_none() && sel.partial(a).is_none(), "{sel:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn group_selection_hover_over_a_drop_shadow_marks_nothing() {
|
||||
let mut s = session();
|
||||
let (_, b) = shadow_over_b(&mut s);
|
||||
run(&mut s, "select.none", json!({}));
|
||||
s.select_tool("groupSelection", ViewInfo::default()).unwrap();
|
||||
for (x, y) in [(90.0, 50.0), (110.0, 50.0), (101.0, 21.0)] {
|
||||
assert_eq!(hover(&mut s, x, y), Cursor::GroupSelection, "({x}, {y})");
|
||||
assert!(anchor_marks(&mut s).is_empty(), "({x}, {y})");
|
||||
}
|
||||
assert_eq!(press(&mut s, "groupSelection", (110.0, 50.0), (110.0, 50.0), Mods::default()), [b]);
|
||||
}
|
||||
|
||||
@@ -844,7 +844,7 @@ fn a_grit_texture_adds_once_undoes_and_saves() {
|
||||
let list = run(&mut s, "swatch.library.list", json!({}));
|
||||
let ids: Vec<&str> =
|
||||
list["libraries"].as_array().unwrap().iter().filter(|l| l["category"] == "patterns").map(|l| l["id"].as_str().unwrap()).collect();
|
||||
assert_eq!(ids, ["geometric-patterns", "grit-textures"]);
|
||||
assert_eq!(ids, ["geometric-patterns", "grit-textures", "optical-effects"]);
|
||||
let got = run(&mut s, "swatch.library.get", json!({"library": "Grit Textures"}));
|
||||
let names: Vec<&str> = got["swatches"].as_array().unwrap().iter().map(|w| w["name"].as_str().unwrap()).collect();
|
||||
assert_eq!(names, ["Fine Grain", "Coarse Grit", "Sand", "Speckle", "Stipple", "Scratches", "Sparse Flecks", "Dense Flecks"]);
|
||||
@@ -869,6 +869,49 @@ fn a_grit_texture_adds_once_undoes_and_saves() {
|
||||
assert_eq!(back.swatch("Stipple").map(|w| &w.paint), doc(&s).swatch("Stipple").map(|w| &w.paint));
|
||||
}
|
||||
|
||||
/// Optical Effects is listed after Grit Textures, and reading it adds nothing. Clicking one of
|
||||
/// its patterns brings it with its swatch and fills the selection with it, once, as one undo step
|
||||
/// that also takes both away, and both stay after a save and reopen.
|
||||
#[test]
|
||||
fn an_optical_effect_adds_once_fills_undoes_and_saves() {
|
||||
let mut s = session();
|
||||
let counts = |d: &vectorcraft_doc::Document| (d.patterns.len(), d.swatches_iter().count());
|
||||
let empty = counts(doc(&s));
|
||||
let list = run(&mut s, "swatch.library.list", json!({}));
|
||||
let ids: Vec<&str> =
|
||||
list["libraries"].as_array().unwrap().iter().filter(|l| l["category"] == "patterns").map(|l| l["id"].as_str().unwrap()).collect();
|
||||
assert_eq!(ids, ["geometric-patterns", "grit-textures", "optical-effects"]);
|
||||
let got = run(&mut s, "swatch.library.get", json!({"library": "Optical Effects"}));
|
||||
let names: Vec<&str> = got["swatches"].as_array().unwrap().iter().map(|w| w["name"].as_str().unwrap()).collect();
|
||||
assert_eq!(
|
||||
names,
|
||||
["Sine Bands", "Ripple Lines", "Interference Rings", "Offset Rings", "Warped Checks", "Pinched Stripes", "Chevron Pulse", "Radiating Lenses"]
|
||||
);
|
||||
assert_eq!(counts(doc(&s)), empty, "reading a library adds no pattern or swatch");
|
||||
let id = vectorcraft_doc::NodeId(run(&mut s, "shape.rectangle", json!({"x": 0, "y": 0, "width": 50, "height": 50}))["id"].as_u64().unwrap());
|
||||
let undo = undo_len(&s);
|
||||
let r = run(&mut s, "swatch.library.add", json!({"library": "optical-effects", "names": ["Warped Checks"], "apply": "fill"}));
|
||||
assert_eq!((r["added"].clone(), r["applied"].clone()), (json!(["Warped Checks"]), json!("Warped Checks")));
|
||||
assert_eq!((counts(doc(&s)), undo_len(&s)), ((empty.0 + 1, empty.1 + 1), undo + 1));
|
||||
let def = doc(&s).pattern("Warped Checks").expect("the pattern comes with its swatch").clone();
|
||||
assert_eq!(def.art.len(), 32, "the black cells of an 8 × 8 checkerboard");
|
||||
assert!(matches!(doc(&s).swatch("Warped Checks").map(|w| &w.paint), Some(Paint::Pattern { pattern, .. }) if pattern == "Warped Checks"));
|
||||
let filled = |d: &vectorcraft_doc::Document| matches!(d.node(id).unwrap().appearance.fill_paint(), Paint::Pattern { pattern, .. } if pattern == "Warped Checks");
|
||||
assert!(filled(doc(&s)));
|
||||
let r = run(&mut s, "swatch.library.add", json!({"library": "optical-effects", "names": ["Warped Checks"]}));
|
||||
assert_eq!((r["added"].clone(), r["existing"].clone()), (json!([]), json!(["Warped Checks"])));
|
||||
assert_eq!((counts(doc(&s)), undo_len(&s)), ((empty.0 + 1, empty.1 + 1), undo + 1), "added once");
|
||||
run(&mut s, "edit.undo", json!({}));
|
||||
assert_eq!(counts(doc(&s)), empty);
|
||||
assert!(doc(&s).pattern("Warped Checks").is_none() && doc(&s).swatch("Warped Checks").is_none() && !filled(doc(&s)));
|
||||
run(&mut s, "edit.redo", json!({}));
|
||||
assert!(filled(doc(&s)));
|
||||
let back = vectorcraft_format::load(&vectorcraft_format::save(doc(&s), false)).unwrap();
|
||||
assert_eq!(back.pattern("Warped Checks"), Some(&def));
|
||||
assert_eq!(back.swatch("Warped Checks").map(|w| &w.paint), doc(&s).swatch("Warped Checks").map(|w| &w.paint));
|
||||
assert!(filled(&back));
|
||||
}
|
||||
|
||||
/// A device name is saved with a `-` after it, which can be another library's own name: neither
|
||||
/// library replaces the other, and saving either again replaces only itself.
|
||||
#[test]
|
||||
|
||||
@@ -0,0 +1,293 @@
|
||||
//! Effect › Warp on placed images: the pixels bend with the frame, in the canvas and in exports.
|
||||
|
||||
use serde_json::{Value, json};
|
||||
use vectorcraft_doc::live::{WarpNet, WarpStyle, uses_net, warp_version};
|
||||
use vectorcraft_doc::{Node, NodeKind};
|
||||
use vectorcraft_geom::{Affine, Point, Rect};
|
||||
|
||||
use crate::*;
|
||||
|
||||
fn session() -> Session {
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", &json!({"width": 800, "height": 600})).unwrap();
|
||||
s
|
||||
}
|
||||
|
||||
/// The box of the image [`quarters`] places.
|
||||
const FRAME: Rect = Rect::new(100.0, 100.0, 180.0, 180.0);
|
||||
|
||||
/// The color of the quarter of the image [`quarters`] places that holds (u, v) of its box: red top
|
||||
/// left, green top right, blue bottom left, yellow bottom right.
|
||||
fn quarter(u: f64, v: f64) -> [u8; 3] {
|
||||
match (u < 0.5, v < 0.5) {
|
||||
(true, true) => [255, 0, 0],
|
||||
(false, true) => [0, 255, 0],
|
||||
(true, false) => [0, 0, 255],
|
||||
(false, false) => [255, 255, 0],
|
||||
}
|
||||
}
|
||||
|
||||
/// A selected embedded 40×40 px image filling [`FRAME`], each quarter one color ([`quarter`]).
|
||||
fn quarters(s: &mut Session) -> NodeId {
|
||||
let img = image::RgbaImage::from_fn(40, 40, |x, y| {
|
||||
let [r, g, b] = quarter(x as f64 / 40.0, y as f64 / 40.0);
|
||||
image::Rgba([r, g, b, 255])
|
||||
});
|
||||
let mut png = vec![];
|
||||
img.write_to(&mut std::io::Cursor::new(&mut png), image::ImageFormat::Png).unwrap();
|
||||
s.edit("Place", |d, sel| {
|
||||
d.images.insert("quarters".into(), vectorcraft_doc::ImageBlob::png(png));
|
||||
let id = d.alloc_id();
|
||||
let xf = Affine::translate((FRAME.x0, FRAME.y0)) * Affine::scale(2.0);
|
||||
let im = vectorcraft_doc::ImageObject {
|
||||
key: "quarters".into(),
|
||||
width: 40,
|
||||
height: 40,
|
||||
xf,
|
||||
link: None,
|
||||
placement: Default::default(),
|
||||
pixels: None,
|
||||
};
|
||||
d.insert(d.default_layer(), 0, Node::new(id, NodeKind::Image(Box::new(im))))?;
|
||||
sel.set([id]);
|
||||
Ok(id)
|
||||
})
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
/// Where an Arc warp bending `bend` % takes the point at (u, v) of [`FRAME`].
|
||||
fn arced(bend: f64, u: f64, v: f64) -> Point {
|
||||
let net = WarpNet::new(WarpStyle::Arc, bend / 100.0, 0.0, 0.0, FRAME.width(), FRAME.height(), false);
|
||||
FRAME.origin() + net.at(u, v).to_vec2()
|
||||
}
|
||||
|
||||
/// Points of the image's box: near each quarter's middle and on both sides of the line between
|
||||
/// the top and bottom quarters, where a warp that moves the pixels up or down changes the color.
|
||||
const SAMPLES: [(f64, f64); 10] =
|
||||
[(0.2, 0.2), (0.8, 0.2), (0.2, 0.8), (0.8, 0.8), (0.4, 0.4), (0.6, 0.4), (0.4, 0.6), (0.6, 0.6), (0.3, 0.62), (0.7, 0.62)];
|
||||
|
||||
/// The document drawn over (0, 0)–(400, 400) at one pixel a point, on white.
|
||||
fn drawn(s: &Session) -> vectorcraft_render::Rendered {
|
||||
vectorcraft_render::Renderer::new().render_region(&s.doc().unwrap().doc, Rect::new(0.0, 0.0, 400.0, 400.0), 1.0, true)
|
||||
}
|
||||
|
||||
/// The sample points whose color in `img` at `at(u, v)` is not the image's color at (u, v).
|
||||
fn wrong(img: &vectorcraft_render::Rendered, at: &dyn Fn(f64, f64) -> Point) -> Vec<(f64, f64, [u8; 4])> {
|
||||
SAMPLES
|
||||
.iter()
|
||||
.filter_map(|&(u, v)| {
|
||||
let p = at(u, v);
|
||||
let px = img.pixel(p.x.floor() as u32, p.y.floor() as u32);
|
||||
let want = quarter(u, v);
|
||||
let near = (0..3).all(|i| (px[i] as i32 - want[i] as i32).abs() <= 40);
|
||||
(!near).then_some((u, v, px))
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// `quarters` with Effect › Warp › Arc at 50 %: the warp the effect holds, checked to be the
|
||||
/// Bézier patch [`arced`] follows.
|
||||
fn arc_image(s: &mut Session) -> NodeId {
|
||||
let id = quarters(s);
|
||||
s.execute("effect.apply", &json!({"effect": "warp.arc", "params": {"bend": 50}})).unwrap();
|
||||
let n = s.doc().unwrap().doc.node(id).unwrap().clone();
|
||||
let e = &n.appearance.effects[0];
|
||||
assert!(warp_version(&e.params["version"]).is_some_and(uses_net), "{}", e.params);
|
||||
id
|
||||
}
|
||||
|
||||
fn decoded(v: &Value) -> Vec<u8> {
|
||||
vectorcraft_format::base64_decode(v["dataBase64"].as_str().unwrap()).unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_images_pixels_bend_with_effect_warp() {
|
||||
let mut s = session();
|
||||
let id = arc_image(&mut s);
|
||||
// The arc lifts the middle of the image: the line between the quarters rises there.
|
||||
assert!(arced(50.0, 0.5, 0.6).y < FRAME.y0 + 0.5 * FRAME.height(), "{:?}", arced(50.0, 0.5, 0.6));
|
||||
let bent = drawn(&s);
|
||||
assert_eq!(wrong(&bent, &|u, v| arced(50.0, u, v)), vec![], "each pixel is drawn where the warp takes it");
|
||||
// Drawn as pieces of the one image, each placed by the warp.
|
||||
let d = &s.doc().unwrap().doc;
|
||||
let art = vectorcraft_render::effects::reshape(Some(d), d.node(id).unwrap(), None).unwrap();
|
||||
let mut pieces = 0;
|
||||
art.walk(&mut |c| pieces += matches!(c.kind, NodeKind::Image(_)) as usize);
|
||||
assert!(pieces >= 100, "{pieces} pieces");
|
||||
// Removing the warp draws the image as it was, in one undo step.
|
||||
let steps = s.doc().unwrap().history.undo.len();
|
||||
s.execute("effect.remove", &json!({"index": 0})).unwrap();
|
||||
assert_eq!(s.doc().unwrap().history.undo.len(), steps + 1);
|
||||
let plain = |u: f64, v: f64| Point::new(FRAME.x0 + u * FRAME.width(), FRAME.y0 + v * FRAME.height());
|
||||
assert_eq!(wrong(&drawn(&s), &plain), vec![]);
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
assert_eq!(wrong(&drawn(&s), &|u, v| arced(50.0, u, v)), vec![], "undo brings the bend back");
|
||||
}
|
||||
|
||||
/// `out`, an export of [`arc_image`]'s document named `name`, opened again: it draws the image
|
||||
/// bent.
|
||||
fn opens_bent(name: &str, out: &Value) {
|
||||
let mut back = session();
|
||||
back.execute("document.open", &json!({"name": name, "dataBase64": out["dataBase64"]})).unwrap();
|
||||
assert_eq!(wrong(&drawn(&back), &|u, v| arced(50.0, u, v)), vec![], "{name}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_warped_image_keeps_its_bend_in_svg() {
|
||||
let mut s = session();
|
||||
arc_image(&mut s);
|
||||
let svg = s.execute("document.export", &json!({"format": "svg"})).unwrap();
|
||||
let text = String::from_utf8(decoded(&svg)).unwrap();
|
||||
assert_eq!(text.matches("<image").count(), 1, "the pieces share the image's pixels");
|
||||
opens_bent("warped.svg", &svg);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_warped_image_keeps_its_bend_in_pdf() {
|
||||
let mut s = session();
|
||||
arc_image(&mut s);
|
||||
opens_bent("warped.pdf", &s.execute("document.export", &json!({"format": "pdf", "preserveEditing": false})).unwrap());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_warped_image_keeps_its_bend_in_ai() {
|
||||
let mut s = session();
|
||||
arc_image(&mut s);
|
||||
opens_bent("warped.ai", &s.execute("file.saveCopy", &json!({"format": "ai"})).unwrap());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_warped_image_keeps_its_bend_in_eps() {
|
||||
let mut s = session();
|
||||
arc_image(&mut s);
|
||||
opens_bent("warped.eps", &s.execute("document.exportEps", &json!({"previewFormat": "none", "useArtboards": false})).unwrap());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_warped_image_keeps_its_bend_in_png() {
|
||||
let mut s = session();
|
||||
arc_image(&mut s);
|
||||
let out = s.execute("document.export", &json!({"format": "png"})).unwrap();
|
||||
let png = image::load_from_memory(&decoded(&out)).unwrap().to_rgba8();
|
||||
// The artboard starts at (0, 0) and the export draws one pixel a point.
|
||||
assert_eq!((png.width(), png.height()), (800, 600));
|
||||
for &(u, v) in &SAMPLES {
|
||||
let p = arced(50.0, u, v);
|
||||
let px = png.get_pixel(p.x.floor() as u32, p.y.floor() as u32).0;
|
||||
let want = quarter(u, v);
|
||||
assert!((0..3).all(|i| (px[i] as i32 - want[i] as i32).abs() <= 40), "({u}, {v}) at {p:?}: {px:?}");
|
||||
}
|
||||
}
|
||||
|
||||
/// A group the user made of two clip groups of the same image, each clipped by a rectangle, writes
|
||||
/// the image once for each: only the pieces of a warp share one.
|
||||
#[test]
|
||||
fn svg_writes_an_image_clipped_twice_in_a_users_group_twice() {
|
||||
let mut s = session();
|
||||
let im = quarters(&mut s);
|
||||
s.edit("Clip twice", |d, _| {
|
||||
let image = d.node(im).cloned().unwrap();
|
||||
let mut clipped = vec![];
|
||||
for x in [FRAME.x0, FRAME.x0 + 40.0] {
|
||||
let mut clip =
|
||||
Node::path(d.alloc_id(), vectorcraft_geom::shapes::rectangle(Rect::new(x, FRAME.y0, x + 30.0, FRAME.y0 + 30.0)), Default::default());
|
||||
if let NodeKind::Path { clipping, .. } = &mut clip.kind {
|
||||
*clipping = true;
|
||||
}
|
||||
let image = Node { id: d.alloc_id(), ..image.clone() };
|
||||
let children = vec![std::sync::Arc::new(clip), std::sync::Arc::new(image)];
|
||||
clipped.push(std::sync::Arc::new(Node::new(d.alloc_id(), NodeKind::Group { children, clip: true })));
|
||||
}
|
||||
let group = Node::group(d.alloc_id(), clipped);
|
||||
d.remove(im)?;
|
||||
d.insert(d.default_layer(), 0, group)?;
|
||||
Ok(())
|
||||
})
|
||||
.unwrap();
|
||||
let svg = s.execute("document.export", &json!({"format": "svg"})).unwrap();
|
||||
let text = String::from_utf8(decoded(&svg)).unwrap();
|
||||
assert_eq!(text.matches("<image").count(), 2, "{text}");
|
||||
}
|
||||
|
||||
/// Expand Appearance of a warped image gives objects that draw as the canvas did, with ids of
|
||||
/// their own, in one undo step.
|
||||
#[test]
|
||||
fn expand_appearance_of_a_warped_image_draws_as_the_canvas_did() {
|
||||
let mut s = session();
|
||||
let id = arc_image(&mut s);
|
||||
let steps = s.doc().unwrap().history.undo.len();
|
||||
s.execute("effect.expandAppearance", &json!({})).unwrap();
|
||||
assert_eq!(s.doc().unwrap().history.undo.len(), steps + 1);
|
||||
let d = &s.doc().unwrap().doc;
|
||||
let mut ids = std::collections::HashSet::new();
|
||||
let mut images = 0;
|
||||
for layer in &d.layers {
|
||||
layer.walk(&mut |c| {
|
||||
assert!(ids.insert(c.id), "{:?} twice", c.id);
|
||||
images += matches!(c.kind, NodeKind::Image(_)) as usize;
|
||||
assert!(c.appearance.effects.iter().all(|e| !e.id.starts_with("warp.")));
|
||||
});
|
||||
}
|
||||
assert!(images >= 100, "{images} pieces");
|
||||
assert_eq!(wrong(&drawn(&s), &|u, v| arced(50.0, u, v)), vec![]);
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
let n = s.doc().unwrap().doc.node(id).unwrap();
|
||||
assert!(matches!(n.kind, NodeKind::Image(_)) && n.appearance.effects.len() == 1);
|
||||
assert_eq!(wrong(&drawn(&s), &|u, v| arced(50.0, u, v)), vec![]);
|
||||
}
|
||||
|
||||
/// A selected embedded grayscale 40×40 px image filling [`FRAME`], black above white, colorized
|
||||
/// red by its fill color: red above, nothing below.
|
||||
fn red_on_clear(s: &mut Session) -> NodeId {
|
||||
let img = image::GrayImage::from_fn(40, 40, |_, y| image::Luma([if y < 20 { 0 } else { 255 }]));
|
||||
let mut png = vec![];
|
||||
image::DynamicImage::ImageLuma8(img).write_to(&mut std::io::Cursor::new(&mut png), image::ImageFormat::Png).unwrap();
|
||||
let id = s
|
||||
.edit("Place", |d, sel| {
|
||||
d.images.insert("gray".into(), vectorcraft_doc::ImageBlob::png(png));
|
||||
let id = d.alloc_id();
|
||||
let xf = Affine::translate((FRAME.x0, FRAME.y0)) * Affine::scale(2.0);
|
||||
let im = vectorcraft_doc::ImageObject {
|
||||
key: "gray".into(),
|
||||
width: 40,
|
||||
height: 40,
|
||||
xf,
|
||||
link: None,
|
||||
placement: Default::default(),
|
||||
pixels: None,
|
||||
};
|
||||
d.insert(d.default_layer(), 0, Node::new(id, NodeKind::Image(Box::new(im))))?;
|
||||
sel.set([id]);
|
||||
Ok(id)
|
||||
})
|
||||
.unwrap();
|
||||
s.execute("paint.setFill", &json!({"color": "#ff0000", "ids": [id.0]})).unwrap();
|
||||
id
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_colorized_image_keeps_its_color_when_warped() {
|
||||
let mut s = session();
|
||||
red_on_clear(&mut s);
|
||||
s.execute("effect.apply", &json!({"effect": "warp.arc", "params": {"bend": 50}})).unwrap();
|
||||
let img = drawn(&s);
|
||||
let (red, white) = ([255u8, 0, 0], [255u8, 255, 255]);
|
||||
for (u, v, want) in [(0.5, 0.35, red), (0.2, 0.2, red), (0.5, 0.65, white), (0.8, 0.8, white)] {
|
||||
let p = arced(50.0, u, v);
|
||||
let px = img.pixel(p.x.floor() as u32, p.y.floor() as u32);
|
||||
assert!((0..3).all(|i| (px[i] as i32 - want[i] as i32).abs() <= 40), "({u}, {v}) at {p:?}: {px:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_warp_on_a_group_bends_the_image_in_it() {
|
||||
let mut s = session();
|
||||
let im = quarters(&mut s);
|
||||
// A group of the image and a line below it as wide as the image: the group's box is the image's.
|
||||
let line = s.execute("shape.line", &json!({"x1": FRAME.x0, "y1": FRAME.y1, "x2": FRAME.x1, "y2": FRAME.y1})).unwrap();
|
||||
let line = NodeId(line["id"].as_u64().unwrap());
|
||||
s.execute("select.set", &json!({"ids": [im.0, line.0]})).unwrap();
|
||||
s.execute("object.group", &json!({})).unwrap();
|
||||
s.execute("effect.apply", &json!({"effect": "warp.arc", "params": {"bend": 50}})).unwrap();
|
||||
assert_eq!(wrong(&drawn(&s), &|u, v| arced(50.0, u, v)), vec![]);
|
||||
}
|
||||
Reference in New Issue
Block a user