diff --git a/ROADMAP.md b/ROADMAP.md index dabed3eb94..283d7d7dff 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -129,6 +129,7 @@ Ranked; detail in [docs/roadmap.md](docs/roadmap.md) and [docs/gaps.md](docs/gap - 2026-11-05: Window › Toolbars › Keep Tools Beside the Canvas, off by default, docks the Tools panel between left-side panels and the canvas instead of at the window's left edge, and the canvas's left edge then also docks a floating Tools panel (`window.toolsBesideCanvas`, #5395). Workspaces keep the setting; workspaces saved without it keep Tools at the window's edge. An optional VectorCraft layout choice; headline numbers unchanged. - 2026-11-12: Stencil Bridges in the Cutting Tools add-on (Object › Plug-ins › Cutting Tools): strips of a set width are cut out of the selected art so that every island of a stencil cut from it (the inside of an O, a hole in a shape) stays attached to the sheet, by the shortest crossings first, vertical, horizontal or the shortest among 12 directions 15° apart, with up to 16 bridges per island; type is measured by its glyph outlines and stays type, and the original goes to a hidden layer or is replaced. One undo step (`addon.cutting.bridges`). A VectorCraft addition: headline numbers unchanged. - 2026-11-13: Banner Hems and Grommets in the Cutting Tools add-on (Object › Plug-ins › Cutting Tools): on the chosen sides of the selected art's bounds, or of each artboard, a hem line at a set depth inside the edge and grommet marks (a circle with a cross) at a set inset, one at each end of a side and the rest spread evenly at most a set spacing apart, stroked in a chosen swatch; a dialog with Preview, one undo step (`addon.cutting.banner`). A VectorCraft addition: headline numbers unchanged. +- 2026-11-14: Nest Shapes in the Cutting Tools add-on (Object › Plug-ins › Cutting Tools): the selected objects move close together onto an artboard, a sheet of a set size or a roll of a set width, packed by their bounding boxes with a margin inside the edges and a spacing between them, optionally turning objects 90° to fit; objects that do not fit go in a column beside the sheet; a dialog with Preview, one undo step (`addon.cutting.nest`). A VectorCraft addition: headline numbers unchanged. - 2026-11-05: Release Type on a Path joins the Type Tools add-on (Object › Plug-ins › Type Tools): each selected type on a path splits into point type with the same text and attributes, on one straight line starting where its first character sat, and the plain path it was set on, with its exact shape and place in the stacking order and no paint; or keeps only the path or only the text, optionally leaving the original. A dialog with Preview, one undo step (`addon.type.releasePath`, #5532). A VectorCraft addition: headline numbers unchanged. - 2026-11-04: New Document: the units chosen in Preset Details now belong to the chosen document, as the pane's options are listed for the selected preset; the chosen card shows them, every other Recent and Saved card keeps the units its document or preset was made in, and the built-in tabs still follow the chosen units, so switching between inches and millimetres no longer restyles every recent template. Scores and estimates unchanged. - 2026-11-02: Mouse wheel over a closed font-family field in Character, Properties or Control changes selected text through the family list using saved font filters (excluding search text), one undo step per notch, stopping at the ends (#5253). Scores and estimates unchanged. diff --git a/crates/doc/src/graph.rs b/crates/doc/src/graph.rs index 2746390b86..0b726cd0c1 100644 --- a/crates/doc/src/graph.rs +++ b/crates/doc/src/graph.rs @@ -527,6 +527,10 @@ pub struct GraphSpec { /// Bounds of the art when it was last generated (detects moves/scales of the graph group). #[serde(skip_serializing_if = "Option::is_none")] pub placed: Option, + /// Bounds of the art other than type when it was last generated ([`art_bounds`]). Moves and scales of the graph + /// group are measured on these when present; graphs saved without them are measured on [`Self::placed`]. + #[serde(skip_serializing_if = "Option::is_none")] + pub placed_art: Option, } fn at_most_one(n: &usize) -> bool { @@ -610,6 +614,32 @@ impl GraphSpec { } } +/// The geometric bounds of a graph's art `children` leaving out their type: the plot, axes, tick marks and legend +/// swatches without the labels. None when there is nothing else. [`GraphSpec::placed_art`] keeps these. +pub fn art_bounds(children: &[std::sync::Arc]) -> Option { + children.iter().filter_map(|c| art_bounds_of(c).0).reduce(|a, b| a.union(b)) +} + +/// The geometric bounds of `n` leaving out the type in it, and whether there was type to leave out. A group without type +/// gives its own bounds, so a clipping group gives the bounds of its clip. +fn art_bounds_of(n: &crate::Node) -> (Option, bool) { + if matches!(n.kind, crate::NodeKind::Text(_)) { + return (None, true); + } + let Some(children) = n.children() else { return (n.geometric_bounds(), false) }; + let mut bounds: Option = None; + let mut had_type = false; + for c in children { + let (b, t) = art_bounds_of(c); + had_type |= t; + bounds = match (bounds, b) { + (Some(a), Some(b)) => Some(a.union(b)), + (a, b) => a.or(b), + }; + } + if had_type { (bounds, true) } else { (n.geometric_bounds(), false) } +} + impl Default for GraphSpec { fn default() -> Self { Self { @@ -659,6 +689,7 @@ impl Default for GraphSpec { connect_points: true, edge_to_edge: false, placed: None, + placed_art: None, } } } diff --git a/crates/doc/src/lib.rs b/crates/doc/src/lib.rs index c286492236..8507b50754 100644 --- a/crates/doc/src/lib.rs +++ b/crates/doc/src/lib.rs @@ -55,6 +55,7 @@ mod pixels; pub use pixels::{GraySource, MAX_RASTER_ALLOC, PixelPick}; pub mod placed_document; pub mod plugin_data; +pub mod ppi; pub mod profiles; pub mod puppet; pub mod range; diff --git a/crates/doc/src/ppi.rs b/crates/doc/src/ppi.rs new file mode 100644 index 0000000000..5c3bd24b53 --- /dev/null +++ b/crates/doc/src/ppi.rs @@ -0,0 +1,287 @@ +//! Image resolution metadata (pixels per inch) in the formats the image decoder reads: PNG `pHYs`, +//! JPEG JFIF density or EXIF, TIFF and WebP EXIF resolution tags, and BMP pixels per metre. An image +//! converted to PNG keeps it as a `pHYs` chunk ([`with_png_resolution`]). + +const INCH_M: f64 = 0.0254; +const INCH_CM: f64 = 2.54; + +/// Is `bytes` a PNG, JPEG, TIFF, WebP or BMP file, the formats [`resolution`] reads? +pub fn reads(bytes: &[u8]) -> bool { + bytes.starts_with(b"\x89PNG\r\n\x1a\n") + || bytes.starts_with(&[0xff, 0xd8]) + || bytes.starts_with(b"II*\0") + || bytes.starts_with(b"MM\0*") + || (bytes.len() >= 12 && &bytes[..4] == b"RIFF" && &bytes[8..12] == b"WEBP") + || bytes.starts_with(b"BM") +} + +/// The resolution a PNG, JPEG, TIFF, WebP or BMP file declares as (horizontal, vertical) ppi, each +/// from 1 to 100,000; `None` when it declares none (or only an aspect ratio), and for other files. +pub fn resolution(bytes: &[u8]) -> Option<(f64, f64)> { + let r = if bytes.starts_with(b"\x89PNG\r\n\x1a\n") { + png(bytes) + } else if bytes.starts_with(&[0xff, 0xd8]) { + jpeg(bytes) + } else if bytes.starts_with(b"II*\0") || bytes.starts_with(b"MM\0*") { + tiff(bytes) + } else if bytes.len() >= 12 && &bytes[..4] == b"RIFF" && &bytes[8..12] == b"WEBP" { + webp(bytes) + } else if bytes.starts_with(b"BM") { + bmp(bytes) + } else { + None + }; + r.filter(|(x, y)| [*x, *y].iter().all(|v| v.is_finite() && (1.0..=100_000.0).contains(v))) +} + +/// `n` bytes at `at` (`None` past the end; offsets come from the file, so no overflow either). +fn slice(b: &[u8], at: usize, n: usize) -> Option<&[u8]> { + b.get(at..at.checked_add(n)?) +} +fn bytes(b: &[u8], at: usize) -> Option<[u8; N]> { + slice(b, at, N)?.try_into().ok() +} +fn be16(b: &[u8], at: usize) -> Option { + bytes(b, at).map(u16::from_be_bytes) +} +fn be32(b: &[u8], at: usize) -> Option { + bytes(b, at).map(u32::from_be_bytes) +} +fn le32(b: &[u8], at: usize) -> Option { + bytes(b, at).map(u32::from_le_bytes) +} + +/// Pixels per inch from the whole pixels per metre PNG and BMP store: a whole number of ppi when +/// the metre value is that rounded (300 ppi is stored as 11811 per metre, 299.9994 ppi). +fn per_metre(ppm: u32) -> f64 { + let v = ppm as f64 * INCH_M; + let whole = v.round(); + if (v - whole).abs() <= INCH_M / 2.0 + 1e-9 { whole } else { v } +} + +/// PNG chunks after the signature: (type, data). +pub fn png_chunks(b: &[u8]) -> impl Iterator { + let mut at = 8; + std::iter::from_fn(move || { + let len = be32(b, at)? as usize; + let ty = slice(b, at + 4, 4)?; + let data = slice(b, at + 8, len)?; + at = at.checked_add(12 + len)?; + Some((ty, data)) + }) +} + +fn png(b: &[u8]) -> Option<(f64, f64)> { + let (_, d) = png_chunks(b).take_while(|(ty, _)| *ty != b"IDAT").find(|(ty, _)| *ty == b"pHYs")?; + // Unit 1 = metre; 0 only gives the pixel aspect ratio. + (d.get(8) == Some(&1)).then(|| (per_metre(be32(d, 0).unwrap_or(0)), per_metre(be32(d, 4).unwrap_or(0)))) +} + +fn jpeg(b: &[u8]) -> Option<(f64, f64)> { + let (mut jfif, mut exif) = (None, None); + let mut at = 2; + while at + 4 <= b.len() && b[at] == 0xff { + let marker = b[at + 1]; + // 0xff fill bytes pad between segments. + if marker == 0xff { + at += 1; + continue; + } + if marker == 0xd8 || marker == 0x01 || (0xd0..=0xd7).contains(&marker) { + at += 2; + continue; + } + // Start of scan or end of image: no more metadata. + if marker == 0xda || marker == 0xd9 { + break; + } + let len = be16(b, at + 2)? as usize; + let seg = slice(b, at + 4, len.checked_sub(2)?)?; + match marker { + 0xe0 if seg.starts_with(b"JFIF\0") && seg.len() >= 12 => { + let (x, y) = (be16(seg, 8)? as f64, be16(seg, 10)? as f64); + jfif = match seg[7] { + 1 => Some((x, y)), + 2 => Some((x * INCH_CM, y * INCH_CM)), + _ => None, + }; + } + 0xe1 if seg.starts_with(b"Exif\0\0") => exif = tiff(&seg[6..]), + _ => {} + } + at += 2 + len; + } + jfif.or(exif) +} + +/// TIFF (or EXIF) IFD0 XResolution / YResolution / ResolutionUnit, as (horizontal, vertical) ppi +/// in the stored pixels' axes, unchecked; `None` without an absolute unit. +pub fn tiff(b: &[u8]) -> Option<(f64, f64)> { + let le = b.starts_with(b"II"); + let u16_at = |at: usize| bytes(b, at).map(if le { u16::from_le_bytes } else { u16::from_be_bytes }); + let u32_at = |at: usize| bytes(b, at).map(if le { u32::from_le_bytes } else { u32::from_be_bytes }); + let ifd = u32_at(4)? as usize; + let (mut x, mut y, mut unit) = (None, None, 2); + for i in 0..u16_at(ifd)? as usize { + let e = ifd.checked_add(2 + i * 12)?; + let rational = || -> Option { + let at = u32_at(e + 8)? as usize; + let (n, d) = (u32_at(at)?, u32_at(at.checked_add(4)?)?); + (d != 0).then(|| n as f64 / d as f64) + }; + match u16_at(e)? { + 282 => x = rational(), + 283 => y = rational(), + 296 => unit = u16_at(e + 8)?, + _ => {} + } + } + let k = match unit { + 2 => 1.0, + 3 => INCH_CM, + _ => return None, + }; + let x = x?; + Some((x * k, y.unwrap_or(x) * k)) +} + +fn webp(b: &[u8]) -> Option<(f64, f64)> { + let mut at = 12; + while at + 8 <= b.len() { + let len = le32(b, at + 4)? as usize; + let data = slice(b, at + 8, len)?; + if &b[at..at + 4] == b"EXIF" { + return tiff(data.strip_prefix(b"Exif\0\0").unwrap_or(data)); + } + at += 8 + len + (len & 1); + } + None +} + +fn bmp(b: &[u8]) -> Option<(f64, f64)> { + // BITMAPINFOHEADER (40 bytes or longer) at 14: pixels per metre at +24 and +28. + if le32(b, 14)? < 40 { + return None; + } + let ppm = |at| le32(b, at).filter(|v| (1..=i32::MAX as u32).contains(v)).map(per_metre); + Some((ppm(38)?, ppm(42)?)) +} + +/// Pixels per metre for a resolution in pixels per inch (the unit `pHYs` stores). +pub fn pixels_per_metre(ppi: f64) -> u32 { + (ppi / INCH_M).round().clamp(1.0, u32::MAX as f64) as u32 +} + +/// PNG CRC-32 (the polynomial ZIP uses) of `parts` read one after another. +fn crc32(parts: &[&[u8]]) -> u32 { + let mut crc = !0u32; + for &b in parts.iter().flat_map(|p| p.iter()) { + crc ^= u32::from(b); + for _ in 0..8 { + crc = (crc >> 1) ^ (0xEDB8_8320 & u32::from(crc & 1 != 0).wrapping_neg()); + } + } + !crc +} + +/// A whole `pHYs` chunk (length, type, data, CRC) declaring `x` × `y` pixels per inch. +pub fn phys_chunk(x: f64, y: f64) -> Vec { + let [x, y] = [x, y].map(|v| pixels_per_metre(v).to_be_bytes()); + // x, y, unit 1 = metre. + let data = [x.as_slice(), y.as_slice(), &[1]].concat(); + let mut out = Vec::with_capacity(21); + out.extend_from_slice(&9u32.to_be_bytes()); + out.extend_from_slice(b"pHYs"); + out.extend_from_slice(&data); + out.extend_from_slice(&crc32(&[b"pHYs", &data]).to_be_bytes()); + out +} + +/// `png` with a `pHYs` chunk declaring `ppi` right after the header (any existing one is replaced). +pub fn with_png_resolution(png: &[u8], (x, y): (f64, f64)) -> Vec { + let Some(head) = be32(png, 8).and_then(|len| slice(png, 0, 20usize.checked_add(len as usize)?)) else { return png.to_vec() }; + let chunk = phys_chunk(x, y); + let mut out = Vec::with_capacity(png.len() + chunk.len()); + out.extend_from_slice(head); + out.extend_from_slice(&chunk); + let mut at = head.len(); + while let Some(chunk) = be32(png, at).and_then(|len| slice(png, at, 12usize.checked_add(len as usize)?)) { + if chunk.get(4..8) != Some(b"pHYs") { + out.extend_from_slice(chunk); + } + at += chunk.len(); + } + out.extend_from_slice(png.get(at..).unwrap_or_default()); + out +} + +#[cfg(test)] +mod tests { + use super::*; + + fn png_of(w: u32, h: u32) -> Vec { + let mut out = Vec::new(); + image::RgbaImage::new(w, h).write_to(&mut std::io::Cursor::new(&mut out), image::ImageFormat::Png).unwrap(); + out + } + + /// The `pHYs` chunk carries the CRC a PNG reader checks, and replaces the one already there. + #[test] + fn a_png_takes_its_resolution_and_still_decodes() { + let png = png_of(4, 3); + assert_eq!(resolution(&png), None); + let tagged = with_png_resolution(&png, (300.0, 150.0)); + assert_eq!(resolution(&tagged), Some((300.0, 150.0))); + let again = with_png_resolution(&tagged, (72.0, 72.0)); + assert_eq!(resolution(&again), Some((72.0, 72.0)), "replaced, not duplicated"); + assert_eq!(again.len(), tagged.len()); + let img = image::load_from_memory(&again).unwrap(); + assert_eq!((img.width(), img.height()), (4, 3)); + // Every PNG ends with this IEND chunk CRC. + assert_eq!(crc32(&[b"IEND"]), 0xAE42_6082); + } + + /// RIFF chunk `ty` holding `data`, padded to an even length. + fn riff_chunk(ty: &[u8; 4], len: u32, data: &[u8]) -> Vec { + let mut out = [ty.as_slice(), &len.to_le_bytes(), data].concat(); + if data.len() % 2 == 1 { + out.push(0); + } + out + } + + fn webp_of(chunks: &[Vec]) -> Vec { + let body = chunks.concat(); + let size = u32::try_from(body.len() + 4).unwrap(); + [b"RIFF".as_slice(), &size.to_le_bytes(), b"WEBP", &body].concat() + } + + /// A WebP's EXIF chunk is found past an odd-length chunk and its padding byte, and a chunk whose + /// length runs past the end of the file (up to `u32::MAX`, more than a 32-bit `usize` can add to + /// an offset) gives no resolution. + #[test] + fn a_webp_reads_its_exif_and_rejects_a_chunk_past_the_end() { + // Little-endian TIFF: two IFD0 entries, XResolution 300/1 at offset 38 and ResolutionUnit inch. + let mut tiff = b"II*\0".to_vec(); + tiff.extend_from_slice(&8u32.to_le_bytes()); + tiff.extend_from_slice(&2u16.to_le_bytes()); + for (tag, ty, value) in [(282u16, 5u16, 38u32), (296, 3, 2)] { + tiff.extend_from_slice(&tag.to_le_bytes()); + tiff.extend_from_slice(&ty.to_le_bytes()); + tiff.extend_from_slice(&1u32.to_le_bytes()); + tiff.extend_from_slice(&value.to_le_bytes()); + } + tiff.extend_from_slice(&0u32.to_le_bytes()); + tiff.extend_from_slice(&300u32.to_le_bytes()); + tiff.extend_from_slice(&1u32.to_le_bytes()); + let exif = [b"Exif\0\0".as_slice(), &tiff].concat(); + let exif_len = u32::try_from(exif.len()).unwrap(); + let ok = webp_of(&[riff_chunk(b"VP8X", 3, &[0, 0, 0]), riff_chunk(b"EXIF", exif_len, &exif)]); + assert_eq!(resolution(&ok), Some((300.0, 300.0))); + + for len in [u32::MAX, u32::MAX - 7, 1_000] { + let bad = webp_of(&[riff_chunk(b"VP8X", len, &[0; 10])]); + assert_eq!(resolution(&bad), None, "chunk length {len}"); + } + } +} diff --git a/crates/effects/src/solids.rs b/crates/effects/src/solids.rs index 9338d74393..5bba1ea432 100644 --- a/crates/effects/src/solids.rs +++ b/crates/effects/src/solids.rs @@ -1502,31 +1502,31 @@ fn masks_surface(paint: &Paint, colored: bool) -> bool { colored && crate::lighting::opaque_paint(paint).1.is_some() } +/// A graphic of [`placed_graphics`]: the index of the first surface of the solids that share it, +/// the index of the surface it is drawn right after, its face and its art. +type Placement = (usize, usize, PlanarFace, Arc); + /// The graphics of the solids `filled` ([`graphic_art`]), each with the index of the surface it is /// drawn right after (the last of those solids holding its face) and its face. Solids in a run -/// with the same opacity and blending mode share their graphics. With `per_member` -/// (a member's opacity mask or blending mode applies to its surfaces) each member's solids get -/// graphics of their own, clipped to that member's caps, so they stay in that member's scopes. -/// All the groups share one limit on the path elements drawn: once it is reached, later groups -/// draw no graphics. -fn placed_graphics( - doc: Option<&Document>, - id: NodeId, - effect: (&str, &Value), - bounds: Rect, - filled: &[Filled], - per_member: bool, -) -> Vec<(usize, PlanarFace, Arc)> { +/// with the same opacity and blending mode share their graphics, one on each face. With +/// `per_member` (a member's opacity mask or blending mode applies to its surfaces) each member's +/// solids get graphics of their own, clipped to that member's caps, so they stay in that member's +/// scopes. All the groups share one limit on the path elements drawn: once it is reached, later +/// groups draw no graphics. +fn placed_graphics(doc: Option<&Document>, id: NodeId, effect: (&str, &Value), bounds: Rect, filled: &[Filled], per_member: bool) -> Vec { let mut groups: Vec> = vec![]; for f in filled { // Solids whose fills or strokes differ in opacity or blending mode get graphics of their - // own, each drawn as its fill or stroke is. A solid whose surface carries an opacity mask - // gets its own too, which takes that mask ([`with_graphics`]). A solid joins only the - // group of the solid before it, so a graphic is never drawn past a solid painted between. - let joins = |first: &Filled| { - (!per_member || first.member == f.member) && first.opacity == f.opacity && first.blend == f.blend && !first.masked && !f.masked + // own, each drawn as its fill or stroke is. One member's solids share their graphics + // whether or not their surfaces carry an opacity mask, and each graphic takes the mask of + // the surface it is drawn after ([`with_graphics`]). Solids of different members share + // them only when neither surface carries a mask. A solid joins only the group of the solid + // before it, so a graphic is never drawn past a solid painted between. + let joins = |g: &&Filled| { + let member = g.member == f.member; + (member || !per_member && !g.masked && !f.masked) && g.opacity == f.opacity && g.blend == f.blend }; - match groups.last_mut().filter(|g| g.first().is_some_and(|first| joins(first))) { + match groups.last_mut().filter(|g| g.iter().all(joins)) { Some(g) => g.push(f), None => groups.push(vec![f]), } @@ -1535,12 +1535,13 @@ fn placed_graphics( let mut elements = 0usize; for group in groups { let caps: Vec<&Facet> = group.iter().flat_map(|f| &f.caps).collect(); + let first = group.first().map_or(0, |f| f.at); let opacity = group.first().map_or(1.0, |f| f.opacity); let blend = group.first().map_or(BlendMode::Normal, |f| f.blend); for (face, mut art) in graphic_art(doc, id, effect, bounds, &caps, opacity, &mut elements) { if let Some(f) = group.iter().rev().find(|f| f.caps.iter().any(|c| c.end == Some(face))) { Arc::make_mut(&mut art).blend = blend; - out.push((f.at, face, art)); + out.push((first, f.at, face, art)); } } } @@ -1552,23 +1553,19 @@ fn placed_graphics( type BuiltSurface<'s> = (Vec, Node, &'s [Scope], Vec>); /// [`with_graphics`] of built surfaces, each drawn whole. -fn whole_surfaces<'s>(surfaces: Vec>, arts: &[(usize, PlanarFace, Arc)]) -> Vec<(&'s [Scope], Arc)> { +fn whole_surfaces<'s>(surfaces: Vec>, arts: &[Placement]) -> Vec<(&'s [Scope], Arc)> { with_graphics(surfaces.into_iter().map(|(_, s, scopes, _)| (scopes, Arc::new(s))).collect(), 0, arts) } /// `surfaces` (those from index `first`) drawn whole, each with its scopes, and the graphics `arts` /// ([`placed_graphics`]) of those surfaces each right after its surface, with that surface's scopes /// and its opacity mask (a gradient's transparency), so a graphic is hidden where its cap is. -fn with_graphics<'s>( - surfaces: Vec<(&'s [Scope], Arc)>, - first: usize, - arts: &[(usize, PlanarFace, Arc)], -) -> Vec<(&'s [Scope], Arc)> { +fn with_graphics<'s>(surfaces: Vec<(&'s [Scope], Arc)>, first: usize, arts: &[Placement]) -> Vec<(&'s [Scope], Arc)> { let mut out = Vec::with_capacity(surfaces.len() + arts.len()); for (k, (scopes, s)) in surfaces.into_iter().enumerate() { let mask = s.extras().mask.clone(); out.push((scopes, s)); - out.extend(arts.iter().filter(|(at, ..)| *at == first + k).map(|(.., art)| { + out.extend(arts.iter().filter(|(_, at, ..)| *at == first + k).map(|(.., art)| { let mut art = art.clone(); if let Some(mask) = &mask { Arc::make_mut(&mut art).extras_mut().mask = Some(mask.clone()); @@ -1793,16 +1790,9 @@ fn underlaid_surface(id: NodeId, surface: Node, seal: bool) -> Node { /// its graphics carries the mask too ([`with_graphics`]), so a graphic is hidden where its cap is. /// The faces of the surfaces that `seals` marks lie over underlays of themselves /// ([`underlaid_runs`], [`underlaid_surface`]). -fn in_one_order( - id: NodeId, - surfaces: Vec>, - first: usize, - arts: &[(usize, PlanarFace, Arc)], - sealed: bool, - seals: &[bool], -) -> Vec> { +fn in_one_order(id: NodeId, surfaces: Vec>, first: usize, arts: &[Placement], sealed: bool, seals: &[bool]) -> Vec> { let masks: Vec>> = surfaces.iter().map(|(_, s, _, _)| s.extras().mask.clone()).collect(); - let arts: Vec<&(usize, PlanarFace, Arc)> = arts.iter().filter(|(at, ..)| (first..first + surfaces.len()).contains(at)).collect(); + let arts: Vec<&Placement> = arts.iter().filter(|(_, at, ..)| (first..first + surfaces.len()).contains(at)).collect(); let (mut placed, mut faces, mut ends) = (vec![], vec![], vec![]); for (surface, (depths, s, _, e)) in surfaces.into_iter().enumerate() { if let NodeKind::Group { children, .. } = s.kind { @@ -1813,24 +1803,18 @@ fn in_one_order( } let order = draw_order(&placed, sealed); // Each graphic goes right after the last of the faces it is drawn on, among the surfaces that - // share it ([`placed_graphics`]): a masked surface's own, or else the run of surfaces without a - // mask that ends at its surface. + // share it ([`placed_graphics`]): those from the first of them to its own. let last: Vec> = arts .iter() - .map(|(at, face, _)| { - let at = at - first; - let from = if masks.get(at).is_some_and(Option::is_some) { - at - } else { - masks.get(..at).and_then(|before| before.iter().rposition(Option::is_some)).map_or(0, |k| k + 1) - }; + .map(|(from, at, face, _)| { + let (from, at) = (from.saturating_sub(first), at - first); order.iter().rposition(|k| ends.get(*k).copied().flatten().is_some_and(|(surface, end)| end == *face && (from..=at).contains(&surface))) }) .collect(); let mut drawn: Vec<(usize, Arc)> = Vec::with_capacity(order.len() + arts.len()); for (at, k) in order.into_iter().enumerate() { drawn.extend(faces.get_mut(k).and_then(Option::take)); - drawn.extend(last.iter().zip(&arts).filter(|(l, _)| **l == Some(at)).map(|(_, (surface, .., art))| { + drawn.extend(last.iter().zip(&arts).filter(|(l, _)| **l == Some(at)).map(|(_, (_, surface, _, art))| { let mut art = art.clone(); if let Some(mask) = masks.get(surface - first).cloned().flatten() { Arc::make_mut(&mut art).extras_mut().mask = Some(mask); diff --git a/crates/effects/src/solids/tests_graphics.rs b/crates/effects/src/solids/tests_graphics.rs index e0935a43e7..f0da6b992b 100644 --- a/crates/effects/src/solids/tests_graphics.rs +++ b/crates/effects/src/solids/tests_graphics.rs @@ -617,10 +617,11 @@ fn a_solid_in_a_transparent_gradient_keeps_its_graphic() { }; // The graphic carries the cap's mask, so it is hidden where the gradient is transparent. assert_eq!(masks(&art), vec![true]); - // With an opaque stroke drawn after the fill, the fill's cap keeps a masked graphic of its own - // and the stroke's outline gets an unmasked one. + // With an opaque stroke drawn after the fill, the fill and the stroke share one graphic, drawn + // after the stroke's cap as for a fill without a mask, and it takes the stroke's surface, + // which carries no mask. n.appearance.items.push(AppearanceItem::Stroke(StrokeLayer::new(Paint::solid(Color::rgb(0.0, 0.0, 1.0)), 4.0))); - assert_eq!(masks(&solid_art_in(Some(&d), &n).unwrap()), vec![true, false]); + assert_eq!(masks(&solid_art_in(Some(&d), &n).unwrap()), vec![false]); } /// The paths of `art`'s faces in drawing order (a face lit by a colored light is a clip group: its @@ -649,23 +650,56 @@ fn face_paths(art: &Node) -> Vec { out } +/// The graphics in `art` (clip groups of solid-color paths), in drawing order: for each, how many +/// faces ([`face_paths`]) are drawn before it, whether it carries an opacity mask, its opacity and +/// its blending mode. +fn graphic_places(art: &Node) -> Vec<(usize, bool, f32, BlendMode)> { + fn walk(n: &Node, faces: &mut usize, out: &mut Vec<(usize, bool, f32, BlendMode)>) { + match &n.kind { + NodeKind::Group { children, clip: true } => { + let graphic = children.get(1).is_some_and(|inner| match &inner.kind { + NodeKind::Group { children: paths, clip: false } => paths.iter().all(|p| { + p.path_data().is_some() + && p.appearance.items.iter().any(|i| matches!(i, AppearanceItem::Fill(f) if f.paint.color().is_some())) + }), + _ => false, + }); + if graphic { + out.push((*faces, n.extras().mask.is_some(), n.opacity, n.blend)); + } else if children.first().and_then(|c| c.path_data()).is_some() { + *faces += 1; + } + } + NodeKind::Group { children, .. } => children.iter().for_each(|c| walk(c, faces, out)), + _ => *faces += usize::from(n.path_data().is_some()), + } + } + let mut out = vec![]; + walk(art, &mut 0, &mut out); + out +} + /// A turned solid in a gradient with transparent stops under a colored light, with a dashed stroke /// and a graphic, draws its fill's and its stroke's faces in the one order it draws them in without -/// the graphic, and the graphic keeps the cap's mask and goes after the fill's cap, not the stroke's. +/// the graphic, and draws the graphic once, where it goes in a gradient without transparent stops: +/// after the stroke's cap, without a mask. #[test] fn a_transparent_gradient_with_a_graphic_keeps_the_shared_face_order() { use vectorcraft_doc::color::{Gradient, GradientKind, GradientPaint, GradientStop}; let d = document(); - let mut stop = GradientStop::new(1.0, red()); - stop.opacity = 0.25; - let gradient = GradientPaint::new(Gradient::new(GradientKind::Linear, vec![GradientStop::new(0.0, red()), stop])); - let solid = |graphics: Value| { + // A red gradient whose last stop has opacity `last`. + let gradient = |last: f32| { + let mut stop = GradientStop::new(1.0, red()); + stop.opacity = last; + GradientPaint::new(Gradient::new(GradientKind::Linear, vec![GradientStop::new(0.0, red()), stop])) + }; + let solid = |fill: &GradientPaint, graphics: Value| { let mut n = object( EXTRUDE, json!({"rotationX": -20.0, "rotationY": -40.0, "depth": 60.0, "shade": true, "lights": [{"color": "#ff8080"}], "graphics": graphics}), ); if let Some(AppearanceItem::Fill(f)) = n.appearance.items.first_mut() { - f.paint = Paint::Gradient(Box::new(gradient.clone())); + f.paint = Paint::Gradient(Box::new(fill.clone())); } // A gradient stroke: a solid color under the colored light would be one composite. let blues = Gradient::new( @@ -677,54 +711,76 @@ fn a_transparent_gradient_with_a_graphic_keeps_the_shared_face_order() { n.appearance.items.push(AppearanceItem::Stroke(stroke)); solid_art_in(Some(&d), &n).unwrap() }; - let with = solid(json!([{"symbol": "Mark"}])); - let without = solid(json!([])); + let masked = gradient(0.25); + let with = solid(&masked, json!([{"symbol": "Mark"}])); + let without = solid(&masked, json!([])); assert!(face_paths(&without).len() > 20, "the faces are found"); assert_eq!(face_paths(&with), face_paths(&without), "the faces in one order"); - // The graphic is drawn on the fill's cap and on the stroke's (the clip groups holding the - // symbol's red square); the fill's, whose surface carries the mask, carries it too. - let mut masked = vec![]; - with.walk(&mut |c| { - if let NodeKind::Group { children, clip: true } = &c.kind - && let [_, inner] = children.as_slice() - && let NodeKind::Group { children: paths, clip: false } = &inner.kind - && paths.iter().any(|p| { - p.appearance.items.iter().any(|i| match i { - AppearanceItem::Fill(f) => f.paint.color().is_some_and(|c| c.to_rgb()[1] < 0.1 && c.to_rgb()[0] > 0.3), - AppearanceItem::Stroke(_) => false, - }) - }) - { - masked.push(c.extras().mask.is_some()); - } - }); - masked.sort_unstable(); - assert_eq!(masked, [false, true], "the fill's graphic carries the cap's mask"); - // The fill's graphic goes right after the fill's cap, so the dashes on the cap's edge, drawn - // after it, cover it. Drawn in order: the fill's graphic (the masked clip group), and each face, - // the fill's or the stroke's by its gradient's first stop (red or blue), with its bounds. - let mut drawn: Vec<(&str, Rect)> = vec![]; - with.walk(&mut |c| { - if let NodeKind::Group { children, clip: true } = &c.kind - && c.extras().mask.is_some() - && let Some(bounds) = children.first().and_then(|clip| clip.path_data()).and_then(PathData::bounds) - { - drawn.push(("graphic", bounds)); - } - let first_stop = c.appearance.items.iter().find_map(|i| match i { - AppearanceItem::Fill(f) => match &f.paint { - Paint::Gradient(g) => g.gradient.stops.first().map(|s| s.color.to_rgb()), - _ => None, - }, - AppearanceItem::Stroke(_) => None, - }); - if let (Some(rgb), Some(bounds)) = (first_stop, c.path_data().and_then(PathData::bounds)) { - drawn.push((if rgb[0] > rgb[2] { "fill" } else { "stroke" }, bounds)); - } - }); - let graphic = drawn.iter().position(|(kind, _)| *kind == "graphic").unwrap(); - let covering = |(kind, b): &(&str, Rect)| *kind == "stroke" && b.intersect(drawn[graphic].1).area() > 0.0; - assert!(drawn[graphic..].iter().any(covering), "a dash on the cap's edge is drawn over the fill's graphic"); + let mut mask = false; + with.walk(&mut |c| mask |= c.extras().mask.is_some()); + assert!(mask, "the fill's faces carry the gradient's mask"); + let opaque = solid(&gradient(1.0), json!([{"symbol": "Mark"}])); + assert_eq!(face_paths(&opaque), face_paths(&with)); + let places = graphic_places(&with); + assert_eq!(places.iter().map(|(_, mask, opacity, blend)| (*mask, *opacity, *blend)).collect::>(), [(false, 1.0, BlendMode::Normal)]); + assert_eq!(places, graphic_places(&opaque), "drawn where a fill without a mask draws it"); +} + +/// A plane in a gradient with transparent stops under a colored light draws its graphic with the +/// fill's mask, and with a dashed stroke draws it once, after the stroke, without a mask. +#[test] +fn a_plane_in_a_transparent_gradient_with_a_stroke_draws_its_graphic_once() { + use vectorcraft_doc::color::{Gradient, GradientKind, GradientPaint, GradientStop}; + let d = document(); + let mut stop = GradientStop::new(1.0, red()); + stop.opacity = 0.25; + let gradient = GradientPaint::new(Gradient::new(GradientKind::Linear, vec![GradientStop::new(0.0, red()), stop])); + let mut n = object(ROTATE, json!({"shade": true, "lights": [{"color": "#ff8080"}], "graphics": [{"symbol": "Mark"}]})); + if let Some(AppearanceItem::Fill(f)) = n.appearance.items.first_mut() { + f.paint = Paint::Gradient(Box::new(gradient)); + } + let places = graphic_places(&solid_art_in(Some(&d), &n).unwrap()); + assert_eq!(places.iter().map(|(_, mask, ..)| *mask).collect::>(), [true]); + let mut stroke = StrokeLayer::new(Paint::solid(Color::rgb(0.0, 0.0, 1.0)), 6.0); + stroke.dash = Some(vectorcraft_doc::Dash { pattern: vec![12.0, 12.0], offset: 0.0, align_corners: false }); + n.appearance.items.push(AppearanceItem::Stroke(stroke)); + let art = solid_art_in(Some(&d), &n).unwrap(); + let places = graphic_places(&art); + assert_eq!(places, [(face_paths(&art).len(), false, 1.0, BlendMode::Normal)], "one graphic, drawn after every face"); +} + +/// Members of a group share a graphic only when neither surface carries an opacity mask, while +/// one member's fill and stroke share theirs. +#[test] +fn members_in_transparent_gradients_keep_graphics_of_their_own() { + use vectorcraft_doc::color::{Gradient, GradientKind, GradientPaint, GradientStop}; + let d = document(); + let mut stop = GradientStop::new(1.0, red()); + stop.opacity = 0.25; + let gradient = Paint::Gradient(Box::new(GradientPaint::new(Gradient::new(GradientKind::Linear, vec![GradientStop::new(0.0, red()), stop])))); + let gray = Paint::solid(Color::rgb(0.5, 0.5, 0.5)); + let (left, right) = (Rect::new(0.0, 0.0, 50.0, 100.0), Rect::new(50.0, 0.0, 100.0, 100.0)); + // Whether each graphic drawn on the group of `members` carries an opacity mask, in order. + let masks = |members: Vec| { + let mut group = Node::group(NodeId(3), members.into_iter().map(Arc::new).collect()); + let p = json!({"rotationX": 0.0, "rotationY": 0.0, "rotationZ": 0.0, "shade": true, "lights": [{"color": "#ff8080"}], "graphics": [{"symbol": "Mark", "scale": 1000.0}]}); + group.appearance.effects.push(crate::new_effect(EXTRUDE, &p).unwrap()); + let mut masks: Vec = graphic_places(&solid_art_in(Some(&d), &group).unwrap()).iter().map(|(_, mask, ..)| *mask).collect(); + masks.sort_unstable(); + masks + }; + assert_eq!(masks(vec![square(1, left, gradient.clone()), square(2, right, gradient.clone())]), [true, true]); + // With a dashed stroke, the first member's fill and stroke share one graphic, without a mask. + let mut stroked = square(1, left, gradient.clone()); + let mut stroke = StrokeLayer::new(Paint::solid(Color::rgb(0.0, 0.0, 1.0)), 6.0); + stroke.dash = Some(vectorcraft_doc::Dash { pattern: vec![12.0, 12.0], offset: 0.0, align_corners: false }); + stroked.appearance.items.push(AppearanceItem::Stroke(stroke)); + assert_eq!(masks(vec![stroked, square(2, right, gradient.clone())]), [false, true]); + // A member with a gray stroke listed before its fill in the gradient does not share its + // graphic with the gray member after it. + let mut layered = square(1, left, gradient); + layered.appearance.items.insert(0, AppearanceItem::Stroke(StrokeLayer::new(gray.clone(), 6.0))); + assert_eq!(masks(vec![layered, square(2, right, gray)]), [false, true]); } #[test] diff --git a/crates/engine/src/cmd/addons/cutting.rs b/crates/engine/src/cmd/addons/cutting.rs index 37e9f2fa79..6ad2af043b 100644 --- a/crates/engine/src/cmd/addons/cutting.rs +++ b/crates/engine/src/cmd/addons/cutting.rs @@ -2,9 +2,11 @@ //! Bridges cuts bridges out of the selected art so that a stencil cut from it holds together //! ([`stencil`]), and Weed Box and Lines adds a weed box and weeding lines around the selected art //! for vinyl cutting ([`weed`]). Banner Hems and Grommets marks the hems and grommets of a banner -//! ([`banner`]). Its submenu (Object › Plug-ins › Cutting Tools) and dialogs are the UI's. +//! ([`banner`]). Nest Shapes packs the selected art onto a sheet or a roll of material ([`nest`]). +//! Its submenu (Object › Plug-ins › Cutting Tools) and dialogs are the UI's. mod banner; +mod nest; mod stencil; mod weed; @@ -14,5 +16,6 @@ pub fn specs() -> Vec { let mut v = stencil::specs(); v.extend(weed::specs()); v.extend(banner::specs()); + v.extend(nest::specs()); v } diff --git a/crates/engine/src/cmd/addons/cutting/nest.rs b/crates/engine/src/cmd/addons/cutting/nest.rs new file mode 100644 index 0000000000..18d4dd70f2 --- /dev/null +++ b/crates/engine/src/cmd/addons/cutting/nest.rs @@ -0,0 +1,403 @@ +//! Nest Shapes (`addon.cutting.nest`), a command of the built-in Cutting Tools add-on that packs +//! the selected objects onto a sheet or a roll of material by their bounding boxes +//! (). The sheet is an +//! artboard, or a set size from the artboard's top-left corner; a roll is as wide as the sheet +//! and has no end. The objects are moved, and with `rotate` turned a quarter turn when that places +//! them better; they are never scaled or changed otherwise. + +#[cfg(test)] +mod tests; + +use std::collections::VecDeque; + +use serde_json::{Value, json}; +use vectorcraft_doc::{Document, Node, NodeId, NodeKind, Scaling}; +use vectorcraft_geom::{Affine, Point, Rect, Vec2}; +use vectorcraft_render::effects; + +use super::super::super::edit::selected_roots_to_transform; +use super::super::super::object::{keep_compound_aligned, transform_node, transform_patterns}; +use super::super::super::pathops::length_given; +use super::super::super::{CommandSpec, bad, check_switches, cmd, has_selection, switch_or}; +use crate::{Result, Session}; + +/// Nest Shapes' command. +pub const NEST: &str = "addon.cutting.nest"; +/// The largest sheet width or height, in points. +const MAX_SIZE: f64 = 1_000_000.0; +/// The largest margin or spacing, in points. +const MAX_GAP: f64 = 100_000.0; +/// The most objects one run nests. +const MAX_SHAPES: usize = 10_000; +/// How far a box may reach past the sheet's edge and still fit (rounding). +const EPS: f64 = 1e-9; + +pub fn specs() -> Vec { + vec![cmd!( + "addon.cutting.nest", + "Nest Shapes…", + ["Object", "Plug-ins", "Cutting Tools"], + None, + "{roll?: bool (false: a sheet `width` × `height`; true: a roll `width` wide with no end), width?: pt 0..1000000 (0: the artboard's width), height?: pt 0..1000000 (0: the artboard's height; not used with roll), margin?: pt 0..100000 (18: kept clear inside each edge of the sheet; on a roll, the sides and the start), spacing?: pt 0..100000 (9: kept between two objects), rotate?: bool (false: objects keep their orientation; true: an object may turn 90° clockwise where that puts its bottom edge higher, or where only the turned object fits), usePreviewBounds?: bool (true: objects are measured with their strokes and effects, the reach of shadows, glows and blurs included; false: by their geometry), artboard?: index (0-based; the sheet's artboard; default: the artboard under the center of the selection, else the first), patterns?: bool (Transform Patterns: pattern fills and strokes move with the art; default: prefs transformPatternTiles)} move the selected objects onto the sheet or roll, which starts at the artboard's top-left corner, packed by their bounding boxes: the tallest first (with rotate, the longest side first), each where its bottom edge is highest and then furthest left, without resizing them. An object on the pixel grid moves by whole points and takes 1 pt more room across and down; a path of a pixel-aligned compound path is not turned. Objects that do not fit on the sheet go in a column to the right of it, `spacing` from its edge and from each other, from its top, in stacking order (back to front). Objects without bounds (an empty group) are not moved. One undo step; nothing changes when no object moves → {placed: count, unplaced: [ids of the objects put beside the sheet], rotated: [ids], skipped: [ids of the objects without bounds], length: pt (from the sheet's top edge to the bottom margin below the lowest object placed)}", + has_selection, + nest + )] +} + +/// The length `p` gives for `key`, from 0 to `max`, or `default`. +fn length(p: &Value, key: &str, default: f64, max: f64) -> Result { + match length_given(p, key, NEST)? { + None => Ok(default), + Some(v) if (0.0..=max).contains(&v) => Ok(v), + Some(_) => Err(bad(NEST, format!("`{key}` must be from 0 to {max} pt"))), + } +} + +/// One stretch of a skyline: from `x`, `w` wide, filled down to `y`. +#[derive(Clone, Copy, Debug, PartialEq)] +struct Span { + x: f64, + w: f64, + y: f64, +} + +/// Bottom-left packing of boxes on a skyline: a strip `width` wide and `height` deep +/// (`f64::INFINITY` for a roll), whose filled part is the spans' outline along its top. +struct Skyline { + spans: Vec, + width: f64, + height: f64, +} + +impl Skyline { + fn new(width: f64, height: f64) -> Self { + Skyline { spans: vec![Span { x: 0.0, w: width, y: 0.0 }], width, height } + } + + /// Where a box `w` × `h` goes: at the left edge of a span, as high as the spans under it + /// allow, choosing the place where its bottom edge is highest and then furthest left. `None` + /// when it fits nowhere. The spans under the box at each span are a window whose two ends only + /// move right, and `window` holds the indices in it whose depth no later span in it reaches, so + /// its front is the deepest: one call takes time in proportion to the number of spans. + fn find(&self, w: f64, h: f64) -> Option<(f64, f64)> { + let mut best: Option<(f64, f64)> = None; + let mut window: VecDeque = VecDeque::new(); + let mut end = 0; + for (i, s) in self.spans.iter().enumerate() { + if s.x + w > self.width + EPS { + break; + } + while window.front().is_some_and(|&j| j < i) { + window.pop_front(); + } + end = end.max(i); + while let Some(t) = self.spans.get(end) + && (end == i || t.x < s.x + w - EPS) + { + while window.back().and_then(|&j| self.spans.get(j)).is_some_and(|b| b.y <= t.y) { + window.pop_back(); + } + window.push_back(end); + end += 1; + } + let y = window.front().and_then(|&j| self.spans.get(j)).map_or(0.0, |t| t.y.max(0.0)); + if y + h > self.height + EPS { + continue; + } + // The box is `h` tall at every place, so the place with the highest bottom edge is the + // place with the highest top edge. + if best.is_none_or(|(bx, by)| y < by - EPS || ((y - by).abs() <= EPS && s.x < bx)) { + best = Some((s.x, y)); + } + } + best + } + + /// Fills the skyline down to `y + h` from `x` across `w`. + fn place(&mut self, x: f64, y: f64, w: f64, h: f64) { + if w <= 0.0 { + return; + } + let end = x + w; + let mut out = Vec::with_capacity(self.spans.len() + 2); + let mut added = false; + for s in &self.spans { + let s_end = s.x + s.w; + if s_end <= x || s.x >= end { + out.push(*s); + continue; + } + if s.x < x { + out.push(Span { x: s.x, w: x - s.x, y: s.y }); + } + if !added { + out.push(Span { x, w, y: y + h }); + added = true; + } + if s_end > end { + out.push(Span { x: end, w: s_end - end, y: s.y }); + } + } + // Neighbors filled to the same depth are one span. + let mut merged: Vec = Vec::with_capacity(out.len()); + for s in out { + match merged.last_mut() { + Some(l) if l.y == s.y => l.w = s.x + s.w - l.x, + _ => merged.push(s), + } + } + self.spans = merged; + } +} + +/// One box to pack: its width and height as it stands, and when it may turn, as it is turned. +type Sizes = ((f64, f64), Option<(f64, f64)>); + +/// The side a box is ordered by: its height, or its longest side when it may turn. +fn lead(b: &Sizes) -> (f64, f64) { + let ((w, h), turned) = *b; + if turned.is_some() { (w.max(h), w.min(h)) } else { (h, w) } +} + +/// Packs `boxes` onto a strip `width` wide and `height` deep, the tallest first (a box that may +/// turn by its longest side; then the widest, then in their order), each where [`Skyline::find`] +/// puts it, turned when that puts its bottom edge higher (then further left), or when only the +/// turned box fits. Gives each box's top-left corner and whether it is turned, or `None` for a box +/// that does not fit. +fn pack(boxes: &[Sizes], width: f64, height: f64) -> Vec> { + let mut order: Vec = (0..boxes.len()).collect(); + order.sort_by(|&a, &b| { + let (la, lb) = (lead(&boxes[a]), lead(&boxes[b])); + lb.0.total_cmp(&la.0).then(lb.1.total_cmp(&la.1)) + }); + let mut sky = Skyline::new(width, height); + let mut at = vec![None; boxes.len()]; + for i in order { + let Some(&((w, h), turned)) = boxes.get(i) else { continue }; + let mut best = sky.find(w, h).map(|(x, y)| (x, y, false, w, h)); + if let Some((tw, th)) = turned + && let Some((x, y)) = sky.find(tw, th) + { + let better = best.is_none_or(|(bx, by, _, _, bh)| { + let (was, now) = (by + bh, y + th); + now < was - EPS || ((now - was).abs() <= EPS && x < bx - EPS) + }); + if better { + best = Some((x, y, true, tw, th)); + } + } + if let Some((x, y, t, bw, bh)) = best { + sky.place(x, y, bw, bh); + if let Some(slot) = at.get_mut(i) { + *slot = Some((x, y, t)); + } + } + } + at +} + +/// A quarter turn clockwise on the page about `c`. +fn quarter_turn(c: Point) -> Affine { + Affine::translate(c.to_vec2()) * vectorcraft_geom::rotate_deg(90.0) * Affine::translate(-c.to_vec2()) +} + +fn finite(r: &Rect) -> bool { + [r.x0, r.y0, r.x1, r.y1].iter().all(|v| v.is_finite()) +} + +/// The box `id` is packed by: with `preview`, its visual bounds with geometry effects +/// ([`effects::preview::bounds_of`]) and the reach of its raster effects, such as shadows, glows +/// and blurs ([`vectorcraft_render::drawn_bounds`]); else its geometric bounds. +fn measure(d: &vectorcraft_doc::Document, id: NodeId, preview: bool) -> Option { + let b = effects::preview::bounds_of(d, &[id], preview).filter(finite)?; + if !preview { + return Some(b); + } + match d.node(id).and_then(|n| vectorcraft_render::drawn_bounds(d, n)).filter(finite) { + Some(r) => Some(b.union(r)), + None => Some(b), + } +} + +/// Whether `id` is a path of a pixel-aligned compound path. +fn in_aligned_compound(d: &Document, id: NodeId) -> bool { + d.parent_of(id).and_then(|p| d.node(p)).is_some_and(|n| matches!(n.kind, NodeKind::Compound { .. }) && n.extras().pixel_aligned) +} + +/// Whether moving `id` snaps it, or an object in it, to the pixel grid: it, an object it holds or +/// the art of a linked opacity mask in it is pixel-aligned, or it is a path of a pixel-aligned +/// compound path. +fn snaps(d: &Document, id: NodeId) -> bool { + if in_aligned_compound(d, id) { + return true; + } + let mut stack: Vec<&Node> = d.node(id).into_iter().collect(); + while let Some(n) = stack.pop() { + if n.extras().pixel_aligned { + return true; + } + stack.extend(n.children().map_or(&[][..], Vec::as_slice).iter().map(|c| &**c)); + if let Some(m) = n.extras().mask.as_ref() + && m.linked + { + stack.push(&*m.art); + } + } + false +} + +fn nest(s: &mut Session, p: &Value) -> Result { + check_switches(p, &["patterns"], NEST)?; + let roll = switch_or(p, "roll", false, NEST)?; + let width = length(p, "width", 0.0, MAX_SIZE)?; + let height = length(p, "height", 0.0, MAX_SIZE)?; + let margin = length(p, "margin", 18.0, MAX_GAP)?; + let spacing = length(p, "spacing", 9.0, MAX_GAP)?; + let turn = switch_or(p, "rotate", false, NEST)?; + let preview = switch_or(p, "usePreviewBounds", true, NEST)?; + let named = match p.get("artboard") { + None | Some(Value::Null) => None, + Some(v) => Some(v.as_u64().and_then(|i| usize::try_from(i).ok()).ok_or_else(|| bad(NEST, "`artboard` must be an artboard's index"))?), + }; + // A path of a compound path selected on its own moves without the rest of the compound path. + let ids = selected_roots_to_transform(s)?; + if ids.len() > MAX_SHAPES { + return Err(bad(NEST, format!("select at most {MAX_SHAPES} objects to nest"))); + } + // Objects without a finite box (an empty group, empty type) are left where they are and + // listed as `skipped`. + let mut skipped = vec![]; + let (items, board): (Vec<(NodeId, Rect)>, Rect) = { + let d = &s.doc()?.doc; + let mut items: Vec<(NodeId, Rect)> = Vec::with_capacity(ids.len()); + for id in &ids { + match measure(d, *id, preview) { + Some(r) => items.push((*id, r)), + None => skipped.push(id.0), + } + } + let board = match named { + Some(i) => d.artboards.get(i).ok_or_else(|| bad(NEST, format!("there is no artboard {i}")))?.rect, + None => { + let all = items.iter().map(|(_, r)| *r).reduce(|a, b| a.union(b)); + let i = all.and_then(|r| d.artboard_at(r.center())).unwrap_or(0); + d.artboards.get(i).ok_or_else(|| bad(NEST, "there is no artboard for the sheet"))?.rect + } + }; + (items, board) + }; + if items.is_empty() { + return Err(bad(NEST, "the selection has nothing to nest")); + } + let sheet_w = if width > 0.0 { width } else { board.width() }; + let sheet_h = if roll { + f64::INFINITY + } else if height > 0.0 { + height + } else { + board.height() + }; + // Every box is `spacing` wider and taller, and the strip is `spacing` wider and deeper than + // the room inside the margins, so the spacing falls between boxes and not at the far edges. + let room_w = sheet_w - 2.0 * margin; + let room_h = if roll { f64::INFINITY } else { sheet_h - 2.0 * margin }; + if room_w <= 0.0 || room_h <= 0.0 { + return Err(bad(NEST, "the margin leaves no room on the sheet")); + } + let all: Vec = items.iter().map(|(id, _)| *id).collect(); + let grid: Vec = { + let d = &s.doc()?.doc; + all.iter().map(|id| snaps(d, *id)).collect() + }; + let sc = Scaling { patterns: transform_patterns(s, p, &all)?, ..Scaling::default() }; + // With `rotate`, each object is turned in a copy of the document and measured there, so the + // box packed for a turned object is the box its turned strokes and effects take. A path of a + // pixel-aligned compound path keeps its orientation: turning it snaps the whole compound path + // again, which would move the compound's other paths away from where they were measured. + let turned: Vec> = if turn { + let mut t = vectorcraft_doc::Document::clone(&s.doc()?.doc); + let xfs: Vec> = items.iter().map(|(id, r)| (!in_aligned_compound(&t, *id)).then(|| quarter_turn(r.center()))).collect(); + for ((id, _), xf) in items.iter().zip(&xfs) { + if let (Some(xf), Some(n)) = (xf, t.node_mut(*id)) { + transform_node(n, *xf, sc, true); + } + } + items.iter().zip(xfs).map(|((id, _), xf)| xf.and_then(|xf| measure(&t, *id, preview).map(|r| (xf, r)))).collect() + } else { + vec![None; items.len()] + }; + // An object that snaps to the pixel grid moves by whole points, so it stays on the grid and the + // snap after the move does not shift it; its box is 1 pt wider and taller to hold the rounding. + let slack = |g: bool| if g { 1.0 } else { 0.0 }; + let boxes: Vec = items + .iter() + .zip(&turned) + .zip(&grid) + .map(|(((_, r), t), g)| { + let pad = spacing + slack(*g); + ((r.width() + pad, r.height() + pad), t.map(|(_, q)| (q.width() + pad, q.height() + pad))) + }) + .collect(); + let at = pack(&boxes, room_w + spacing, room_h + spacing); + let origin = Vec2::new(board.x0 + margin, board.y0 + margin); + // The move that puts the top-left corner of box `b` at `to`: rounded up to whole points for an + // object on the pixel grid, so it moves at most 1 pt further right and down, into its slack. + let shift = |b: &Rect, to: Vec2, g: bool| { + let v = Vec2::new(to.x - b.x0, to.y - b.y0); + if g { Vec2::new(v.x.ceil(), v.y.ceil()) } else { v } + }; + // Each move: the quarter turn first, when the object turns, then the shift. + let mut moves: Vec<(NodeId, Option, Vec2)> = vec![]; + let mut aside: Vec<(NodeId, Option, Vec2)> = vec![]; + let mut unplaced = vec![]; + let mut rotated = vec![]; + let mut bottom = 0.0_f64; + // Objects that do not fit go in a column to the right of the sheet, `spacing` from its edge + // and from each other, so that no object placed on the sheet covers them. + let aside_x = board.x0 + sheet_w + spacing; + let mut aside_y = board.y0; + for ((((id, r), spot), t), g) in items.iter().zip(&at).zip(&turned).zip(&grid) { + match (spot, t) { + (Some((x, y, true)), Some((xf, q))) => { + let dv = shift(q, Vec2::new(origin.x + x, origin.y + y), *g); + moves.push((*id, Some(*xf), dv)); + rotated.push(id.0); + bottom = bottom.max(q.y1 + dv.y - origin.y); + } + (Some((x, y, _)), _) => { + let dv = shift(r, Vec2::new(origin.x + x, origin.y + y), *g); + moves.push((*id, None, dv)); + bottom = bottom.max(r.y1 + dv.y - origin.y); + } + (None, _) => { + aside.push((*id, None, shift(r, Vec2::new(aside_x, aside_y), *g))); + aside_y += r.height() + spacing + slack(*g); + unplaced.push(id.0); + } + } + } + if moves.is_empty() { + return Err(bad(NEST, "no selected object fits on the sheet")); + } + moves.extend(aside); + let length = 2.0 * margin + bottom; + let placed = moves.len() - unplaced.len(); + let result = json!({ "placed": placed, "unplaced": unplaced, "rotated": rotated, "skipped": skipped, "length": length }); + if moves.iter().all(|(_, xf, dv)| xf.is_none() && dv.x.abs() <= EPS && dv.y.abs() <= EPS) { + return Ok(result); + } + s.edit("Nest Shapes", |d, _| { + for (id, xf, dv) in &moves { + // The turn is the one the object was measured with in the copy, and the shift after it + // is whole points for an object on the pixel grid. + for step in xf.iter().copied().chain([Affine::translate(*dv)]) { + if let Some(n) = d.node_mut(*id) { + transform_node(n, step, sc, true); + } + keep_compound_aligned(d, *id, sc); + } + } + Ok(()) + })?; + Ok(result) +} diff --git a/crates/engine/src/cmd/addons/cutting/nest/tests.rs b/crates/engine/src/cmd/addons/cutting/nest/tests.rs new file mode 100644 index 0000000000..6ca6548a19 --- /dev/null +++ b/crates/engine/src/cmd/addons/cutting/nest/tests.rs @@ -0,0 +1,433 @@ +//! Tests for `addon.cutting.nest`: packing the selected objects onto a sheet or a roll. + +use serde_json::{Value, json}; +use vectorcraft_geom::Rect; + +use super::{Skyline, pack}; +use crate::*; + +fn session() -> Session { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 800, "height": 600})).unwrap(); + s +} + +fn rect(s: &mut Session, x: f64, y: f64, w: f64, h: f64) -> NodeId { + NodeId(s.execute("shape.rectangle", &json!({"x": x, "y": y, "width": w, "height": h})).unwrap()["id"].as_u64().unwrap()) +} + +fn bounds(s: &Session, id: NodeId) -> Rect { + s.doc().unwrap().doc.node(id).unwrap().geometric_bounds().unwrap() +} + +fn select(s: &mut Session, ids: &[NodeId]) { + s.execute("select.set", &json!({"ids": ids.iter().map(|i| i.0).collect::>()})).unwrap(); +} + +fn steps(s: &Session) -> usize { + s.doc().unwrap().history.undo.len() +} + +fn overlap(a: Rect, b: Rect) -> bool { + a.x0 < b.x1 - 1e-9 && b.x0 < a.x1 - 1e-9 && a.y0 < b.y1 - 1e-9 && b.y0 < a.y1 - 1e-9 +} + +#[test] +fn boxes_pack_tallest_first_from_the_top_left() { + // 100 wide: the 60 × 50 box goes first at the left and the 40 × 30 one beside it. The 50 × 20 + // one is too wide for the 40 pt beside the first box, so it goes below the first box. + let at = pack(&fixed(&[(40.0, 30.0), (60.0, 50.0), (50.0, 20.0)]), 100.0, f64::INFINITY); + assert_eq!(at, vec![Some((60.0, 0.0, false)), Some((0.0, 0.0, false)), Some((0.0, 50.0, false))]); +} + +#[test] +fn a_box_that_does_not_fit_is_left_out() { + let at = pack(&fixed(&[(80.0, 80.0), (80.0, 80.0), (120.0, 10.0)]), 100.0, 100.0); + assert_eq!(at, vec![Some((0.0, 0.0, false)), None, None]); +} + +/// Boxes that keep their orientation. +fn fixed(b: &[(f64, f64)]) -> Vec { + b.iter().map(|&s| (s, None)).collect() +} + +/// Boxes that may turn a quarter. +fn turning(b: &[(f64, f64)]) -> Vec { + b.iter().map(|&(w, h)| ((w, h), Some((h, w)))).collect() +} + +#[test] +fn a_box_turns_where_its_bottom_edge_ends_higher_or_only_turned_it_fits() { + // A 20 × 90 box lies down as 90 × 20 at the top; a 120 × 10 box only fits the 100 pt strip + // turned, as 10 × 120. + let at = pack(&turning(&[(20.0, 90.0), (120.0, 10.0)]), 100.0, f64::INFINITY); + assert_eq!(at, vec![Some((10.0, 0.0, true)), Some((0.0, 0.0, true))]); + // Without turning, the 120 pt wide box is left out and the other stands up. + let at = pack(&fixed(&[(20.0, 90.0), (120.0, 10.0)]), 100.0, f64::INFINITY); + assert_eq!(at, vec![Some((0.0, 0.0, false)), None]); + // A square box is never turned: turned, it is no better. + let at = pack(&turning(&[(30.0, 30.0)]), 100.0, 100.0); + assert_eq!(at, vec![Some((0.0, 0.0, false))]); +} + +#[test] +fn the_skyline_fills_and_merges_spans() { + let mut sky = Skyline::new(100.0, f64::INFINITY); + sky.place(0.0, 0.0, 30.0, 10.0); + sky.place(30.0, 0.0, 30.0, 10.0); + assert_eq!(sky.spans.len(), 2, "two spans at the same depth are one: {:?}", sky.spans); + assert_eq!(sky.find(50.0, 5.0), Some((0.0, 10.0)), "50 wide does not fit in the 40 left at the top"); + assert_eq!(sky.find(40.0, 5.0), Some((60.0, 0.0))); +} + +#[test] +fn selected_objects_are_nested_on_the_artboard_as_one_undo_step() { + let mut s = session(); + let ids: Vec = (0..6).map(|i| rect(&mut s, 900.0 + 300.0 * i as f64, 700.0, 150.0, 100.0 + 10.0 * i as f64)).collect(); + select(&mut s, &ids); + let n = steps(&s); + let r = s.execute("addon.cutting.nest", &json!({"margin": 10, "spacing": 5, "usePreviewBounds": false})).unwrap(); + assert_eq!(steps(&s), n + 1, "one undo step"); + assert_eq!(r["placed"], json!(6)); + assert_eq!(r["unplaced"], json!([])); + let boxes: Vec = ids.iter().map(|id| bounds(&s, *id)).collect(); + for (i, a) in boxes.iter().enumerate() { + assert!(a.x0 >= 10.0 - 1e-9 && a.y0 >= 10.0 - 1e-9 && a.x1 <= 790.0 + 1e-9 && a.y1 <= 590.0 + 1e-9, "{a:?} inside the margins"); + assert!((a.width(), a.height()) == (150.0, 100.0 + 10.0 * i as f64), "moved, not resized"); + for b in &boxes[i + 1..] { + assert!(!overlap(a.inflate(2.5, 2.5), b.inflate(2.5, 2.5)), "{a:?} and {b:?} are 5 pt apart"); + } + } + // The tallest (150 pt) is at the top-left corner inside the margin, and five 150 pt wide + // boxes with 5 pt between them fill the 780 pt inside the margins. The shortest goes 5 pt below + // the 110 pt tall box at the right, where its bottom edge is highest. + assert_eq!((boxes[5].x0, boxes[5].y0), (10.0, 10.0)); + assert_eq!(boxes.iter().filter(|b| b.y0 == 10.0).count(), 5); + assert_eq!((boxes[0].x0, boxes[0].y0), (630.0, 125.0)); + assert_eq!(r["length"], json!(10.0 + 115.0 + 100.0 + 10.0)); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(bounds(&s, ids[0]), Rect::new(900.0, 700.0, 1050.0, 800.0)); +} + +#[test] +fn objects_that_do_not_fit_go_beside_the_sheet() { + let mut s = session(); + let a = rect(&mut s, 900.0, 0.0, 500.0, 400.0); + let b = rect(&mut s, 1500.0, 0.0, 500.0, 400.0); + let big = rect(&mut s, 2100.0, 0.0, 900.0, 100.0); + select(&mut s, &[a, b, big]); + let r = s.execute("addon.cutting.nest", &json!({"margin": 0, "spacing": 5, "usePreviewBounds": false})).unwrap(); + assert_eq!(r["placed"], json!(1)); + assert_eq!(r["unplaced"], json!([b.0, big.0])); + assert_eq!(bounds(&s, a), Rect::new(0.0, 0.0, 500.0, 400.0)); + // A column 5 pt to the right of the 800 pt sheet, in stacking order, 5 pt apart. + assert_eq!(bounds(&s, b), Rect::new(805.0, 0.0, 1305.0, 400.0)); + assert_eq!(bounds(&s, big), Rect::new(805.0, 405.0, 1705.0, 505.0)); +} + +#[test] +fn the_column_beside_the_sheet_is_in_stacking_order() { + let mut s = session(); + let back = rect(&mut s, 900.0, 0.0, 900.0, 100.0); + let front = rect(&mut s, 900.0, 200.0, 900.0, 50.0); + let small = rect(&mut s, 900.0, 400.0, 50.0, 50.0); + // Selected front first: the column still starts with the object at the back. + select(&mut s, &[front, small, back]); + let r = s.execute("addon.cutting.nest", &json!({"margin": 0, "spacing": 5, "usePreviewBounds": false})).unwrap(); + assert_eq!(r["unplaced"], json!([back.0, front.0])); + assert_eq!(bounds(&s, back), Rect::new(805.0, 0.0, 1705.0, 100.0)); + assert_eq!(bounds(&s, front), Rect::new(805.0, 105.0, 1705.0, 155.0)); +} + +#[test] +fn objects_without_bounds_are_skipped_and_listed() { + let mut s = session(); + let a = rect(&mut s, 900.0, 0.0, 50.0, 50.0); + let layer = s.doc().unwrap().doc.parent_of(a); + let empty = s + .edit("empty group", |d, _| { + let g = vectorcraft_doc::Node::new(d.alloc_id(), vectorcraft_doc::NodeKind::Group { children: vec![], clip: false }); + Ok(d.insert(layer, usize::MAX, g).unwrap()) + }) + .unwrap(); + select(&mut s, &[a, empty]); + let r = s.execute("addon.cutting.nest", &json!({"margin": 0, "usePreviewBounds": false})).unwrap(); + assert_eq!((r["placed"].clone(), r["unplaced"].clone(), r["skipped"].clone()), (json!(1), json!([]), json!([empty.0]))); + assert_eq!(bounds(&s, a), Rect::new(0.0, 0.0, 50.0, 50.0)); +} + +#[test] +fn the_sheet_is_the_artboard_under_the_center_of_the_selection() { + let mut s = session(); + s.execute("artboard.new", &json!({"x": 1000, "y": 0, "width": 300, "height": 300})).unwrap(); + let a = rect(&mut s, 1100.0, 100.0, 50.0, 50.0); + let b = rect(&mut s, 1200.0, 200.0, 40.0, 40.0); + select(&mut s, &[a, b]); + s.execute("addon.cutting.nest", &json!({"margin": 0, "spacing": 0, "usePreviewBounds": false})).unwrap(); + assert_eq!(bounds(&s, a), Rect::new(1000.0, 0.0, 1050.0, 50.0), "on the second artboard, not the first"); + assert_eq!(bounds(&s, b), Rect::new(1050.0, 0.0, 1090.0, 40.0)); +} + +#[test] +fn rotate_turns_clockwise() { + let mut s = session(); + // A 400 × 100 group with a 20 pt mark at its top-left corner. Turned clockwise on the page, the + // top-left corner goes to the top-right. + let long = rect(&mut s, 900.0, 0.0, 400.0, 100.0); + let mark = rect(&mut s, 900.0, 0.0, 20.0, 20.0); + select(&mut s, &[long, mark]); + s.execute("object.group", &json!({})).unwrap(); + let r = s + .execute("addon.cutting.nest", &json!({"width": 300, "height": 500, "margin": 0, "spacing": 0, "rotate": true, "usePreviewBounds": false})) + .unwrap(); + assert_eq!(r["rotated"].as_array().map(Vec::len), Some(1)); + assert_eq!(bounds(&s, long), Rect::new(0.0, 0.0, 100.0, 400.0)); + assert_eq!(bounds(&s, mark), Rect::new(80.0, 0.0, 100.0, 20.0), "the mark is at the top-right"); +} + +#[test] +fn a_path_of_a_pixel_aligned_compound_path_is_not_turned() { + let mut s = session(); + let outer = rect(&mut s, 900.0, 100.0, 500.0, 200.0); + let hole = rect(&mut s, 950.0, 150.0, 400.0, 100.0); + select(&mut s, &[outer, hole]); + s.execute("object.compoundPath.make", &json!({})).unwrap(); + s.execute("object.setPixelAligned", &json!({})).unwrap(); + select(&mut s, &[hole]); + // The 400 pt wide path fits the 300 pt sheet only turned, and it keeps its orientation. + let r = + s.execute("addon.cutting.nest", &json!({"width": 300, "height": 500, "margin": 0, "spacing": 0, "rotate": true, "usePreviewBounds": false})); + let e = r.unwrap_err().to_string(); + assert!(e.contains("no selected object fits"), "{e}"); +} + +/// [`Skyline::find`] as a search of every span and every span under the box. +fn find_by_search(sky: &Skyline, w: f64, h: f64) -> Option<(f64, f64)> { + let mut best: Option<(f64, f64)> = None; + for (i, s) in sky.spans.iter().enumerate() { + if s.x + w > sky.width + 1e-9 { + break; + } + let y = sky.spans[i..].iter().take_while(|t| t.x == s.x || t.x < s.x + w - 1e-9).fold(0.0_f64, |m, t| m.max(t.y)); + if y + h > sky.height + 1e-9 { + continue; + } + if best.is_none_or(|(bx, by)| y < by - 1e-9 || ((y - by).abs() <= 1e-9 && s.x < bx)) { + best = Some((s.x, y)); + } + } + best +} + +#[test] +fn find_matches_a_search_of_every_span() { + // A fixed pseudo-random sequence of boxes, each placed where `find` puts it. + let mut seed: u64 = 0x2545_f491_4f6c_dd1d; + let mut next = |n: u64| { + seed ^= seed << 13; + seed ^= seed >> 7; + seed ^= seed << 17; + (seed % n) as f64 + 1.0 + }; + let mut sky = Skyline::new(200.0, 400.0); + for _ in 0..400 { + let (w, h) = (next(60), next(30)); + let got = sky.find(w, h); + assert_eq!(got, find_by_search(&sky, w, h), "{w} × {h} on {:?}", sky.spans); + if let Some((x, y)) = got { + sky.place(x, y, w, h); + } + } +} + +#[test] +fn an_object_that_does_not_fit_is_not_covered_by_the_others() { + let mut s = session(); + // The wide object sits on the sheet and does not fit; the small one must not land on it. + let wide = rect(&mut s, 0.0, 0.0, 900.0, 100.0); + let small = rect(&mut s, 300.0, 300.0, 50.0, 50.0); + select(&mut s, &[wide, small]); + s.execute("addon.cutting.nest", &json!({"margin": 0, "spacing": 0, "usePreviewBounds": false})).unwrap(); + assert!(!overlap(bounds(&s, wide), bounds(&s, small)), "{:?} {:?}", bounds(&s, wide), bounds(&s, small)); +} + +#[test] +fn objects_on_the_pixel_grid_keep_the_margin_and_spacing() { + let mut s = session(); + let a = rect(&mut s, 900.0, 0.0, 100.0, 100.0); + let b = rect(&mut s, 1100.0, 0.0, 100.0, 90.0); + select(&mut s, &[a, b]); + s.execute("object.setPixelAligned", &json!({})).unwrap(); + let on_grid = |r: Rect| [r.x0, r.y0, r.x1, r.y1].iter().all(|v| (v * 2.0).fract() == 0.0); + assert!(on_grid(bounds(&s, a)) && on_grid(bounds(&s, b))); + s.execute("addon.cutting.nest", &json!({"margin": 0.6, "spacing": 0.6, "usePreviewBounds": false})).unwrap(); + let (ba, bb) = (bounds(&s, a), bounds(&s, b)); + assert!(on_grid(ba) && on_grid(bb), "still on the grid: {ba:?} {bb:?}"); + assert!(ba.x0 >= 0.6 && ba.y0 >= 0.6 && bb.y0 >= 0.6, "inside the margin: {ba:?} {bb:?}"); + assert!(bb.x0 >= ba.x1 + 0.6, "0.6 pt apart: {ba:?} {bb:?}"); + assert!(ba.x1.max(bb.x1) <= 800.0 - 0.6, "inside the margin: {ba:?} {bb:?}"); +} + +#[test] +fn preview_bounds_take_in_a_drop_shadow() { + let mut s = session(); + let a = rect(&mut s, 900.0, 0.0, 100.0, 100.0); + let b = rect(&mut s, 1100.0, 0.0, 100.0, 100.0); + s.execute("effect.apply", &json!({"effect": "stylize.dropShadow", "ids": [a.0], "params": {"x": 20, "y": 0, "blur": 0}})).unwrap(); + select(&mut s, &[a, b]); + s.execute("addon.cutting.nest", &json!({"margin": 0, "spacing": 0})).unwrap(); + let (ba, bb) = (bounds(&s, a), bounds(&s, b)); + let shadow = vectorcraft_render::drawn_bounds(&s.doc().unwrap().doc, s.doc().unwrap().doc.node(a).unwrap()).unwrap(); + assert!(shadow.x1 >= ba.x1 + 20.0 - 1e-9, "the shadow reaches 20 pt right: {shadow:?}"); + let shadow_b = vectorcraft_render::drawn_bounds(&s.doc().unwrap().doc, s.doc().unwrap().doc.node(b).unwrap()).unwrap_or(bb); + assert!(!overlap(shadow, shadow_b.union(bb)), "the shadow {shadow:?} does not reach {bb:?}"); +} + +#[test] +fn a_roll_has_no_end() { + let mut s = session(); + let ids: Vec = (0..5).map(|i| rect(&mut s, 900.0, 300.0 * i as f64, 500.0, 400.0)).collect(); + select(&mut s, &ids); + let r = s.execute("addon.cutting.nest", &json!({"roll": true, "margin": 0, "spacing": 10, "usePreviewBounds": false})).unwrap(); + assert_eq!((r["placed"].clone(), r["unplaced"].clone()), (json!(5), json!([]))); + let mut tops: Vec = ids.iter().map(|id| bounds(&s, *id).y0).collect(); + tops.sort_by(f64::total_cmp); + assert_eq!(tops, vec![0.0, 410.0, 820.0, 1230.0, 1640.0], "one 500 pt wide shape per row on the 800 pt roll"); + assert_eq!(r["length"], json!(2040.0)); +} + +#[test] +fn width_and_height_set_the_sheet_from_the_artboard_corner() { + let mut s = session(); + let ids: Vec = (0..3).map(|i| rect(&mut s, 50.0 * i as f64, 300.0, 40.0, 40.0)).collect(); + select(&mut s, &ids); + let r = s.execute("addon.cutting.nest", &json!({"width": "1 in", "height": 200, "margin": 0, "spacing": 0, "usePreviewBounds": false})).unwrap(); + assert_eq!(r["placed"], json!(3)); + for id in &ids { + let b = bounds(&s, *id); + assert!(b.x0 == 0.0 && b.x1 <= 72.0, "one 40 pt shape per row on a 72 pt sheet: {b:?}"); + } +} + +#[test] +fn preview_bounds_keep_strokes_apart() { + let mut s = session(); + let a = rect(&mut s, 900.0, 0.0, 100.0, 100.0); + let b = rect(&mut s, 1100.0, 0.0, 100.0, 100.0); + select(&mut s, &[a, b]); + s.execute("stroke.set", &json!({"weight": 10})).unwrap(); + s.execute("addon.cutting.nest", &json!({"margin": 0, "spacing": 0})).unwrap(); + let (ba, bb) = (bounds(&s, a), bounds(&s, b)); + assert_eq!((ba.x0, ba.y0), (5.0, 5.0), "the stroke's outer half is inside the sheet"); + assert_eq!((bb.x0 - ba.x1), 10.0, "the strokes touch and do not overlap"); +} + +#[test] +fn a_selection_already_nested_adds_no_undo_step() { + let mut s = session(); + let a = rect(&mut s, 18.0, 18.0, 100.0, 100.0); + select(&mut s, &[a]); + let n = steps(&s); + let r = s.execute("addon.cutting.nest", &json!({"usePreviewBounds": false})).unwrap(); + assert_eq!(r["placed"], json!(1)); + assert_eq!(steps(&s), n, "nothing moved"); +} + +#[test] +fn bad_params_are_refused_and_change_nothing() { + let mut s = session(); + let a = rect(&mut s, 900.0, 0.0, 100.0, 100.0); + select(&mut s, &[a]); + let n = steps(&s); + let bad: [(Value, &str); 10] = [ + (json!({"rotate": 1}), "`rotate` must be true or false"), + (json!({"width": -1}), "`width` must be from 0"), + (json!({"height": 2e6}), "`height` must be from 0"), + (json!({"margin": "x"}), "`margin` must be a number"), + (json!({"spacing": 1e6}), "`spacing` must be from 0"), + (json!({"roll": "yes"}), "`roll` must be true or false"), + (json!({"usePreviewBounds": 1}), "`usePreviewBounds` must be true or false"), + (json!({"artboard": 3}), "there is no artboard 3"), + (json!({"artboard": -1}), "`artboard` must be"), + (json!({"margin": 400}), "the margin leaves no room"), + ]; + for (p, want) in bad { + let e = s.execute("addon.cutting.nest", &p).expect_err(&p.to_string()).to_string(); + assert!(e.contains(want), "{p}: {e}"); + } + let e = s.execute("addon.cutting.nest", &json!({"width": 50, "margin": 0})).unwrap_err().to_string(); + assert!(e.contains("no selected object fits"), "{e}"); + let e = s.execute("addon.cutting.nest", &json!({"patterns": "no"})).unwrap_err().to_string(); + assert!(e.contains("`patterns` must be true or false"), "{e}"); + assert_eq!(steps(&s), n, "a refused run changes nothing"); + assert_eq!(bounds(&s, a), Rect::new(900.0, 0.0, 1000.0, 100.0)); +} + +#[test] +fn the_named_artboard_is_the_sheet() { + let mut s = session(); + s.execute("artboard.new", &json!({"x": 1000, "y": 0, "width": 300, "height": 300})).unwrap(); + let a = rect(&mut s, 100.0, 100.0, 50.0, 50.0); + select(&mut s, &[a]); + s.execute("addon.cutting.nest", &json!({"artboard": 1, "margin": 0, "usePreviewBounds": false})).unwrap(); + assert_eq!(bounds(&s, a), Rect::new(1000.0, 0.0, 1050.0, 50.0)); +} + +#[test] +fn rotate_turns_objects_a_quarter_to_fit_the_sheet() { + let mut s = session(); + // On a 300 × 500 sheet with no margin, a 400 × 100 object only fits standing up. + let long = rect(&mut s, 900.0, 0.0, 400.0, 100.0); + let small = rect(&mut s, 900.0, 300.0, 50.0, 40.0); + select(&mut s, &[long, small]); + let p = json!({"width": 300, "height": 500, "margin": 0, "spacing": 0, "usePreviewBounds": false}); + let r = s.execute("addon.cutting.nest", &p).unwrap(); + assert_eq!((r["unplaced"].clone(), r["rotated"].clone()), (json!([long.0]), json!([])), "without rotate it does not fit"); + s.execute("edit.undo", &json!({})).unwrap(); + let mut p = p; + p["rotate"] = json!(true); + let n = steps(&s); + let r = s.execute("addon.cutting.nest", &p).unwrap(); + assert_eq!(steps(&s), n + 1, "one undo step"); + assert_eq!((r["placed"].clone(), r["unplaced"].clone(), r["rotated"].clone()), (json!(2), json!([]), json!([long.0]))); + assert_eq!(bounds(&s, long), Rect::new(0.0, 0.0, 100.0, 400.0), "turned and at the corner"); + // The 50 × 40 object stays as it is: turned, its bottom edge would be lower. + assert_eq!(bounds(&s, small), Rect::new(100.0, 0.0, 150.0, 40.0)); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(bounds(&s, long), Rect::new(900.0, 0.0, 1300.0, 100.0), "undo turns it back"); +} + +#[test] +fn rotate_measures_the_turned_preview_bounds() { + let mut s = session(); + let a = rect(&mut s, 900.0, 0.0, 400.0, 100.0); + select(&mut s, &[a]); + s.execute("stroke.set", &json!({"weight": 10})).unwrap(); + let r = s.execute("addon.cutting.nest", &json!({"width": 300, "height": 500, "margin": 0, "spacing": 0, "rotate": true})).unwrap(); + assert_eq!(r["rotated"], json!([a.0])); + assert_eq!(bounds(&s, a), Rect::new(5.0, 5.0, 105.0, 405.0), "the turned stroke's outer half is inside the sheet"); +} + +#[test] +fn a_compound_paths_path_selected_alone_moves_alone() { + let mut s = session(); + let outer = rect(&mut s, 900.0, 100.0, 40.0, 40.0); + let hole = rect(&mut s, 910.0, 110.0, 10.0, 10.0); + select(&mut s, &[outer, hole]); + s.execute("object.compoundPath.make", &json!({})).unwrap(); + select(&mut s, &[hole]); + s.execute("addon.cutting.nest", &json!({"margin": 0, "usePreviewBounds": false})).unwrap(); + assert_eq!(bounds(&s, hole), Rect::new(0.0, 0.0, 10.0, 10.0), "the path moved"); + assert_eq!(bounds(&s, outer), Rect::new(900.0, 100.0, 940.0, 140.0), "the rest of the compound path stayed"); +} + +#[test] +fn a_group_is_nested_as_one_object() { + let mut s = session(); + let a = rect(&mut s, 900.0, 0.0, 50.0, 50.0); + let b = rect(&mut s, 1000.0, 100.0, 50.0, 50.0); + select(&mut s, &[a, b]); + s.execute("object.group", &json!({})).unwrap(); + s.execute("addon.cutting.nest", &json!({"margin": 0, "usePreviewBounds": false})).unwrap(); + assert_eq!((bounds(&s, a), bounds(&s, b)), (Rect::new(0.0, 0.0, 50.0, 50.0), Rect::new(100.0, 100.0, 150.0, 150.0))); +} diff --git a/crates/engine/src/cmd/assets.rs b/crates/engine/src/cmd/assets.rs index e30e6efcdc..cb990e4857 100644 --- a/crates/engine/src/cmd/assets.rs +++ b/crates/engine/src/cmd/assets.rs @@ -89,7 +89,7 @@ pub fn specs() -> Vec { "Asset Export Settings", [], None, - "{exportPreset?: (a preset of document.exportPresets.list: its formats or preset, its format settings over the saved ones, its prefix (none when it has none), its folders (or subfolders: rows or none) with subfolders on for rows, and antiAlias; the other keys go over it), formats?, preset?: mobile|density|\"\" (none), settings?, prefix?, subfolders?, antiAlias?} change the export settings the Asset Export panel and Export for Screens share (document.exportSettings; the values of document.exportForScreens; null removes one): formats replace a preset, a preset replaces the formats → {settings}. Not an undo step", + "{exportPreset?: (a preset of document.exportPresets.list: its formats or preset, its format settings over the saved ones, its prefix (none when it has none), its folders (or subfolders: rows or none) with subfolders on for rows, and antiAlias; the other keys go over it), formats?, preset?: mobile|density|\"\" (none), settings?, prefix?, subfolders?: true|false (Create Sub-folders: true sorts the files into a folder per row; false puts assets.export's files in the folder itself and, given without exportPreset, sets a remembered folder per format or item to none as well), antiAlias?, openLocation?: true|false (Open Location after Export: remembered in document.exportSettings, not in presets or the reply; given alone to a document without settings, Create Sub-folders stays on)} change the export settings the Asset Export panel and Export for Screens share (document.exportSettings; the values of document.exportForScreens; null removes one): formats replace a preset, a preset replaces the formats → {settings}. Not an undo step", has_doc, settings_set ), @@ -435,19 +435,35 @@ fn export(s: &mut Session, p: &Value) -> Result { fn settings_set(s: &mut Session, p: &Value) -> Result { const C: &str = "assets.settings.set"; let o = p.as_object().ok_or_else(|| bad(C, "give the settings to change, e.g. {\"formats\": [{\"format\": \"png\", \"scale\": \"2x\"}]}"))?; - if let Some(k) = o.keys().find(|k| !SCREEN_SETTINGS_KEYS.contains(&k.as_str()) && k.as_str() != "exportPreset") { - return Err(bad(C, format!("`{k}` isn't an export setting: {}", SCREEN_SETTINGS_KEYS.join(", ")))); + if let Some(k) = o.keys().find(|k| !SCREEN_SETTINGS_KEYS.contains(&k.as_str()) && !matches!(k.as_str(), "exportPreset" | "openLocation")) { + return Err(bad(C, format!("`{k}` isn't an export setting: {}, openLocation", SCREEN_SETTINGS_KEYS.join(", ")))); + } + for k in ["subfolders", "openLocation"] { + if o.get(k).is_some_and(|v| !v.is_boolean() && !v.is_null()) { + return Err(bad(C, format!("`{k}` is true or false"))); + } } let mut settings = s.doc()?.doc.export_settings.clone(); let mut given = o.clone(); + // Create Sub-folders is on for a document without settings (Export for Screens opens so), and + // Open Location after Export set on its own leaves it on. + if settings.is_empty() && o.len() == 1 && o.get("openLocation").is_some_and(Value::is_boolean) { + given.insert("subfolders".into(), json!(true)); + } if let Some(name) = given.remove("exportPreset") { let name = name.as_str().ok_or_else(|| bad(C, "`exportPreset` is the name of a preset of document.exportPresets.list"))?; with_screen_export_preset(s, C, &mut settings, name)?; } // Create Sub-folders is Export for Screens' folder per row: the dialog opens on the choice. + // Turned off on its own (the panel menu), it also ends a remembered folder per format or item; + // with an export preset, that preset's folder per format or item stays. + let folders = settings.get("folders").and_then(Value::as_str).map(str::to_string); + let sorted = folders.as_deref().is_some_and(|f| f != "none"); match given.get("subfolders").and_then(Value::as_bool) { Some(true) => _ = settings.insert("folders".into(), json!("rows")), - Some(false) if settings.get("folders").and_then(Value::as_str) == Some("rows") => _ = settings.insert("folders".into(), json!("none")), + Some(false) if folders.as_deref() == Some("rows") || (sorted && !o.contains_key("exportPreset")) => { + _ = settings.insert("folders".into(), json!("none")) + } _ => {} } overlay(&mut settings, &given); diff --git a/crates/engine/src/cmd/fileio/ppi.rs b/crates/engine/src/cmd/fileio/ppi.rs index 6998d86b28..119201bb87 100644 --- a/crates/engine/src/cmd/fileio/ppi.rs +++ b/crates/engine/src/cmd/fileio/ppi.rs @@ -3,26 +3,22 @@ //! header's HDpi and VDpi, JP2 capture and display resolution boxes, or a HEIF image's EXIF //! resolution tags. Place //! sizes an image by it (72 ppi when the file has none); images converted to PNG on import keep it -//! as a `pHYs` chunk. +//! as a `pHYs` chunk. The formats the image decoder reads are read by [`vectorcraft_doc::ppi`]. + +pub(super) use vectorcraft_doc::ppi::png_chunks; +use vectorcraft_doc::ppi::tiff; +pub use vectorcraft_doc::ppi::with_png_resolution; const INCH_M: f64 = 0.0254; -const INCH_CM: f64 = 2.54; /// The resolution a file declares as (horizontal, vertical) ppi; `None` when it declares none (or /// only an aspect ratio). pub fn resolution(bytes: &[u8]) -> Option<(f64, f64)> { - let r = if bytes.starts_with(b"\x89PNG\r\n\x1a\n") { - png(bytes) - } else if bytes.starts_with(&[0xff, 0xd8]) { - jpeg(bytes) - } else if bytes.starts_with(b"II*\0") || bytes.starts_with(b"MM\0*") { - tiff(bytes) - } else if super::jxlread::is_jxl(bytes) { + if vectorcraft_doc::ppi::reads(bytes) { + return vectorcraft_doc::ppi::resolution(bytes); + } + let r = if super::jxlread::is_jxl(bytes) { jxl(bytes) - } else if bytes.len() >= 12 && &bytes[..4] == b"RIFF" && &bytes[8..12] == b"WEBP" { - webp(bytes) - } else if bytes.starts_with(b"BM") { - bmp(bytes) } else if super::psdread::is_psd(bytes) { super::psdread::resolution(bytes) } else if super::pcxread::is_pcx(bytes) { @@ -82,121 +78,9 @@ fn slice(b: &[u8], at: usize, n: usize) -> Option<&[u8]> { fn bytes(b: &[u8], at: usize) -> Option<[u8; N]> { slice(b, at, N)?.try_into().ok() } -fn be16(b: &[u8], at: usize) -> Option { - bytes(b, at).map(u16::from_be_bytes) -} fn be32(b: &[u8], at: usize) -> Option { bytes(b, at).map(u32::from_be_bytes) } -fn le32(b: &[u8], at: usize) -> Option { - bytes(b, at).map(u32::from_le_bytes) -} - -/// Pixels per inch from the whole pixels per metre PNG and BMP store: a whole number of ppi when -/// the metre value is that rounded (300 ppi is stored as 11811 per metre, 299.9994 ppi). -fn per_metre(ppm: u32) -> f64 { - let v = ppm as f64 * INCH_M; - let whole = v.round(); - if (v - whole).abs() <= INCH_M / 2.0 + 1e-9 { whole } else { v } -} - -/// PNG chunks after the signature: (type, data). -pub(super) fn png_chunks(b: &[u8]) -> impl Iterator { - let mut at = 8; - std::iter::from_fn(move || { - let len = be32(b, at)? as usize; - let ty = slice(b, at + 4, 4)?; - let data = slice(b, at + 8, len)?; - at = at.checked_add(12 + len)?; - Some((ty, data)) - }) -} - -fn png(b: &[u8]) -> Option<(f64, f64)> { - let (_, d) = png_chunks(b).take_while(|(ty, _)| *ty != b"IDAT").find(|(ty, _)| *ty == b"pHYs")?; - // Unit 1 = metre; 0 only gives the pixel aspect ratio. - (d.get(8) == Some(&1)).then(|| (per_metre(be32(d, 0).unwrap_or(0)), per_metre(be32(d, 4).unwrap_or(0)))) -} - -fn jpeg(b: &[u8]) -> Option<(f64, f64)> { - let (mut jfif, mut exif) = (None, None); - let mut at = 2; - while at + 4 <= b.len() && b[at] == 0xff { - let marker = b[at + 1]; - // 0xff fill bytes pad between segments. - if marker == 0xff { - at += 1; - continue; - } - if marker == 0xd8 || marker == 0x01 || (0xd0..=0xd7).contains(&marker) { - at += 2; - continue; - } - // Start of scan or end of image: no more metadata. - if marker == 0xda || marker == 0xd9 { - break; - } - let len = be16(b, at + 2)? as usize; - let seg = slice(b, at + 4, len.checked_sub(2)?)?; - match marker { - 0xe0 if seg.starts_with(b"JFIF\0") && seg.len() >= 12 => { - let (x, y) = (be16(seg, 8)? as f64, be16(seg, 10)? as f64); - jfif = match seg[7] { - 1 => Some((x, y)), - 2 => Some((x * INCH_CM, y * INCH_CM)), - _ => None, - }; - } - 0xe1 if seg.starts_with(b"Exif\0\0") => exif = tiff(&seg[6..]), - _ => {} - } - at += 2 + len; - } - jfif.or(exif) -} - -/// TIFF (or EXIF) IFD0 XResolution / YResolution / ResolutionUnit. -fn tiff(b: &[u8]) -> Option<(f64, f64)> { - let le = b.starts_with(b"II"); - let u16_at = |at: usize| bytes(b, at).map(if le { u16::from_le_bytes } else { u16::from_be_bytes }); - let u32_at = |at: usize| bytes(b, at).map(if le { u32::from_le_bytes } else { u32::from_be_bytes }); - let ifd = u32_at(4)? as usize; - let (mut x, mut y, mut unit) = (None, None, 2); - for i in 0..u16_at(ifd)? as usize { - let e = ifd.checked_add(2 + i * 12)?; - let rational = || -> Option { - let at = u32_at(e + 8)? as usize; - let (n, d) = (u32_at(at)?, u32_at(at.checked_add(4)?)?); - (d != 0).then(|| n as f64 / d as f64) - }; - match u16_at(e)? { - 282 => x = rational(), - 283 => y = rational(), - 296 => unit = u16_at(e + 8)?, - _ => {} - } - } - let k = match unit { - 2 => 1.0, - 3 => INCH_CM, - _ => return None, - }; - let x = x?; - Some((x * k, y.unwrap_or(x) * k)) -} - -fn webp(b: &[u8]) -> Option<(f64, f64)> { - let mut at = 12; - while at + 8 <= b.len() { - let len = le32(b, at + 4)? as usize; - let data = b.get(at + 8..at + 8 + len)?; - if &b[at..at + 4] == b"EXIF" { - return tiff(data.strip_prefix(b"Exif\0\0").unwrap_or(data)); - } - at += 8 + len + (len & 1); - } - None -} fn jxl(b: &[u8]) -> Option<(f64, f64)> { if !b.starts_with(&[0, 0, 0, 12, b'J', b'X', b'L', b' ', 13, 10, 135, 10]) { @@ -224,33 +108,6 @@ fn jxl(b: &[u8]) -> Option<(f64, f64)> { None } -fn bmp(b: &[u8]) -> Option<(f64, f64)> { - // BITMAPINFOHEADER (40 bytes or longer) at 14: pixels per metre at +24 and +28. - if le32(b, 14)? < 40 { - return None; - } - let ppm = |at| le32(b, at).filter(|v| (1..=i32::MAX as u32).contains(v)).map(per_metre); - Some((ppm(38)?, ppm(42)?)) -} - -/// `png` with a `pHYs` chunk declaring `ppi` right after the header (any existing one is replaced). -pub fn with_png_resolution(png: &[u8], (x, y): (f64, f64)) -> Vec { - let Some(head) = be32(png, 8).and_then(|len| slice(png, 0, 20usize.checked_add(len as usize)?)) else { return png.to_vec() }; - let chunk = vectorcraft_render::encode::png::phys_chunk(x, y); - let mut out = Vec::with_capacity(png.len() + chunk.len()); - out.extend_from_slice(head); - out.extend_from_slice(&chunk); - let mut at = head.len(); - while let Some(chunk) = be32(png, at).and_then(|len| slice(png, at, 12usize.checked_add(len as usize)?)) { - if chunk.get(4..8) != Some(b"pHYs") { - out.extend_from_slice(chunk); - } - at += chunk.len(); - } - out.extend_from_slice(png.get(at..).unwrap_or_default()); - out -} - #[cfg(test)] mod tests { use super::*; diff --git a/crates/engine/src/cmd/graph.rs b/crates/engine/src/cmd/graph.rs index da23bd4f04..9af36db9aa 100644 --- a/crates/engine/src/cmd/graph.rs +++ b/crates/engine/src/cmd/graph.rs @@ -40,6 +40,7 @@ pub use commands::specs; pub(crate) use data::apply_csv; #[cfg(test)] use data::{fmt_value, parse_csv, to_csv}; +pub(crate) use edit::{graphs_holding, keep_placed}; #[cfg(test)] use generate::generate; #[cfg(test)] diff --git a/crates/engine/src/cmd/graph/edit.rs b/crates/engine/src/cmd/graph/edit.rs index c7f6d52ea8..e1c5c336b3 100644 --- a/crates/engine/src/cmd/graph/edit.rs +++ b/crates/engine/src/cmd/graph/edit.rs @@ -1,3 +1,5 @@ +use vectorcraft_doc::graph::art_bounds; + use super::axis::{MAX_TICK_MARKS, count_given, one_line}; use super::data::apply_data; use super::generate::{children_bounds, generate}; @@ -17,11 +19,62 @@ fn bound_param(p: &Value, key: &str, cmd: &str) -> Result>> { } } +/// The bounds a redraw of graph `n` measures a move or scale on, as kept at the last generation and as they are now: +/// those of its art without type ([`GraphSpec::placed_art`]), or those of all its art when the graph keeps no such +/// bounds with a width and a height, or has no art without type now. +fn measured(n: &Node) -> Option<(Rect, Rect)> { + let kept = n.extras().graph.as_deref()?; + let c = n.children()?; + let art = kept.placed_art.filter(|p| p.width() > 1e-9 && p.height() > 1e-9).zip(art_bounds(c)); + art.or_else(|| kept.placed.zip(children_bounds(c))) +} + +/// `r` carried by the change from bounds `from` to bounds `to`: each edge stays at the same place relative to them. +/// `None` when `from` has no width or no height. +fn carried(r: Rect, from: Rect, to: Rect) -> Option { + if from.width() <= 1e-9 || from.height() <= 1e-9 { + return None; + } + let (sx, sy) = (to.width() / from.width(), to.height() / from.height()); + Some(Rect::new(to.x0 + (r.x0 - from.x0) * sx, to.y0 + (r.y0 - from.y0) * sy, to.x0 + (r.x1 - from.x0) * sx, to.y0 + (r.y1 - from.y0) * sy)) +} + +/// The graphs holding objects `ids`, each with the bounds of its art without type and of all its art. Give them to +/// [`keep_placed`] after an edit that changes a graph's art without moving the graph. +pub(crate) fn graphs_holding(d: &Document, ids: &[NodeId]) -> Vec<(NodeId, Option, Option)> { + let mut out: Vec<(NodeId, Option, Option)> = vec![]; + for id in ids { + let Some(chain) = d.ancestry(*id) else { continue }; + let holder = chain.iter().rev().skip(1).copied().find(|a| d.node(*a).is_some_and(|n| n.extras().graph.is_some())); + let Some(gid) = holder.filter(|g| !out.iter().any(|(o, ..)| o == g)) else { continue }; + let children = d.node(gid).and_then(Node::children); + out.push((gid, children.and_then(|c| art_bounds(c)), children.and_then(|c| children_bounds(c)))); + } + out +} + +/// After an edit that changed the art of `graphs` (from [`graphs_holding`]) without moving them, set the bounds each +/// keeps so that its next redraw measures the same move or scale as it would have before the edit. +pub(crate) fn keep_placed(d: &mut Document, graphs: &[(NodeId, Option, Option)]) { + for &(gid, art_was, whole_was) in graphs { + let Some(n) = d.node(gid) else { continue }; + let children = n.children(); + let (art_now, whole_now) = (children.and_then(|c| art_bounds(c)), children.and_then(|c| children_bounds(c))); + let Some(spec) = d.node_mut(gid).and_then(|n| n.extras_if_any().and_then(|e| e.graph.as_deref_mut())) else { continue }; + if let (Some(placed), Some(was), Some(now)) = (spec.placed_art, art_was, art_now) { + spec.placed_art = carried(now, was, placed).or(spec.placed_art); + } + if let (Some(placed), Some(was), Some(now)) = (spec.placed, whole_was, whole_now) { + spec.placed = carried(now, was, placed).or(spec.placed); + } + } +} + /// Regenerate graph `id` from `spec` (adjusting the plot rect for any move/scale since the last -/// generation, which is recorded in the spec's `placed` bounds). +/// generation, which is recorded in the spec's `placed_art` bounds, or `placed` in graphs saved without them). pub(super) fn regenerate(d: &mut Document, id: NodeId, mut spec: GraphSpec) -> Result<()> { let n = d.node(id).ok_or(EngineError::NoNode(id))?; - if let (Some(placed), Some(now)) = (n.extras().graph.as_ref().and_then(|g| g.placed), n.children().and_then(|c| children_bounds(c))) + if let Some((placed, now)) = measured(n) && placed.width() > 1e-9 && placed.height() > 1e-9 && (placed.x0 - now.x0).abs() + (placed.y0 - now.y0).abs() + (placed.x1 - now.x1).abs() + (placed.y1 - now.y1).abs() > 1e-6 @@ -33,6 +86,7 @@ pub(super) fn regenerate(d: &mut Document, id: NodeId, mut spec: GraphSpec) -> R } let children = generate(d, &spec); spec.placed = children_bounds(&children); + spec.placed_art = art_bounds(&children); let n = d.node_mut(id).ok_or(EngineError::NoNode(id))?; if let Some(ch) = n.children_mut() { *ch = children; diff --git a/crates/engine/src/cmd/graph/tests.rs b/crates/engine/src/cmd/graph/tests.rs index 04eff93a6c..c160a78833 100644 --- a/crates/engine/src/cmd/graph/tests.rs +++ b/crates/engine/src/cmd/graph/tests.rs @@ -5691,6 +5691,151 @@ fn formatting_one_label_keeps_that_kind_of_label() { assert!(serde_json::to_value(GraphSpec::default()).unwrap().get("labelStyles").is_none()); } +/// Character and paragraph edits that resize a graph's labels leave the plot where it is when the graph is drawn +/// again, and a move or scale of the graph made after them is still followed. +#[test] +fn restyling_a_graphs_labels_leaves_the_plot_in_place() { + let csv = ",2024,2025\nQ1,3,2\nQ2,5,4\nQ3,-1,6"; + let redraw = |s: &mut Session, id: NodeId| s.execute("graph.setData", &json!({"id": id.0, "csv": csv})).unwrap(); + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 800, "height": 800})).unwrap(); + let id = graph(&mut s, "column"); + let rect = spec(&s, id).rect; + s.execute("select.set", &json!({"ids": [id.0]})).unwrap(); + s.execute("text.setStyle", &json!({"size": 14})).unwrap(); + redraw(&mut s, id); + assert_eq!(spec(&s, id).rect, rect, "after a size change to every label"); + let c = text_id(&s, id, "Axes", "Q2"); + s.execute("text.setFormat", &json!({"ids": [c.0], "leftIndent": 20})).unwrap(); + redraw(&mut s, id); + assert_eq!(spec(&s, id).rect, rect, "after an indent on the category labels"); + let l = text_id(&s, id, "Legend", "2025"); + s.execute("text.setStyle", &json!({"ids": [l.0], "size": 4})).unwrap(); + redraw(&mut s, id); + assert_eq!(spec(&s, id).rect, rect, "after a smaller size on the legend labels"); + // A move and a scale of the whole graph still carry the plot with them. + s.execute("select.set", &json!({"ids": [id.0]})).unwrap(); + s.execute("object.move", &json!({"dx": 40, "dy": -10})).unwrap(); + redraw(&mut s, id); + let moved = spec(&s, id).rect; + assert!((moved.x0 - (rect.x0 + 40.0)).abs() < 1e-6 && (moved.y0 - (rect.y0 - 10.0)).abs() < 1e-6, "{moved:?}"); + assert!((moved.width() - rect.width()).abs() < 1e-6 && (moved.height() - rect.height()).abs() < 1e-6, "{moved:?}"); + s.execute("object.scale", &json!({"sx": 200, "sy": 100, "origin": [moved.x0, moved.y0]})).unwrap(); + redraw(&mut s, id); + let scaled = spec(&s, id).rect; + assert!((scaled.width() - 2.0 * rect.width()).abs() < 1e-6 && (scaled.height() - rect.height()).abs() < 1e-6, "{scaled:?}"); + assert!((scaled.x0 - moved.x0).abs() < 1e-6 && (scaled.y0 - moved.y0).abs() < 1e-6, "{scaled:?}"); +} + +/// Moving one label of a graph on its own leaves the plot where it is when the graph is drawn again. +#[test] +fn moving_one_label_leaves_the_plot_in_place() { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 800, "height": 800})).unwrap(); + let id = graph(&mut s, "column"); + let rect = spec(&s, id).rect; + for (grp, text) in [("Legend", "2025"), ("Axes", "Q2")] { + let l = text_id(&s, id, grp, text); + s.execute("select.set", &json!({"ids": [l.0]})).unwrap(); + s.execute("object.move", &json!({"dx": 120, "dy": 60})).unwrap(); + s.execute("graph.setData", &json!({"id": id.0, "csv": ",2024,2025\nQ1,3,2\nQ2,5,4\nQ3,-1,6"})).unwrap(); + assert_eq!(spec(&s, id).rect, rect, "after moving {text}"); + } +} + +/// Label edits leave the plot in place in graphs whose legend swatches follow the size of the labels at the next +/// drawing: a legend across the top, a legend after a right value axis, and legend labels of several lines. A move of +/// the graph made after them is still followed. +#[test] +fn restyling_the_labels_of_a_graph_whose_legend_follows_them_leaves_the_plot_in_place() { + let csv = ",North|East|Coast,South|West,Inland|Hills|Lakes|Plains\nQ1,3,2,4\nQ2,5,4,1\nQ3,-1,6,2"; + for options in [json!({"legendAcrossTop": true}), json!({"valueAxis": "right"}), json!({"valueAxis": "both"}), json!({})] { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 800, "height": 800})).unwrap(); + let id = graph(&mut s, "column"); + s.execute("select.set", &json!({"ids": [id.0]})).unwrap(); + s.execute("graph.setType", &options).unwrap(); + s.execute("graph.setData", &json!({"id": id.0, "csv": csv})).unwrap(); + let rect = spec(&s, id).rect; + for size in [16, 5] { + s.execute("select.set", &json!({"ids": [id.0]})).unwrap(); + s.execute("text.setStyle", &json!({"size": size})).unwrap(); + for _ in 0..2 { + s.execute("graph.setData", &json!({"id": id.0, "csv": csv})).unwrap(); + assert_eq!(spec(&s, id).rect, rect, "{options}: labels at {size} pt"); + } + } + s.execute("select.set", &json!({"ids": [id.0]})).unwrap(); + s.execute("object.move", &json!({"dx": 30, "dy": 20})).unwrap(); + s.execute("graph.setData", &json!({"id": id.0, "csv": csv})).unwrap(); + let moved = spec(&s, id).rect; + assert!((moved.x0 - (rect.x0 + 30.0)).abs() < 1e-6 && (moved.y0 - (rect.y0 + 20.0)).abs() < 1e-6, "{options}: {moved:?}"); + assert!((moved.width() - rect.width()).abs() < 1e-6 && (moved.height() - rect.height()).abs() < 1e-6, "{options}: {moved:?}"); + } +} + +/// Outlining a graph's labels leaves the plot where it is when the graph is drawn again, and a move of the graph made +/// before them is still followed. +#[test] +fn outlining_a_graphs_labels_leaves_the_plot_in_place() { + let csv = ",2024,2025\nQ1,3,2\nQ2,5,4\nQ3,-1,6"; + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 800, "height": 800})).unwrap(); + let id = graph(&mut s, "column"); + let rect = spec(&s, id).rect; + s.execute("graph.setData", &json!({"id": id.0, "csv": csv})).unwrap(); + s.execute("select.set", &json!({"ids": [text_id(&s, id, "Axes", "Q2").0, text_id(&s, id, "Legend", "2025").0]})).unwrap(); + s.execute("type.createOutlines", &json!({})).unwrap(); + s.execute("graph.setData", &json!({"id": id.0, "csv": csv})).unwrap(); + assert_eq!(spec(&s, id).rect, rect, "after outlining labels"); + s.execute("select.set", &json!({"ids": [id.0]})).unwrap(); + s.execute("object.move", &json!({"dx": 30, "dy": 15})).unwrap(); + s.execute("select.set", &json!({"ids": [text_id(&s, id, "Axes", "Q1").0, text_id(&s, id, "Legend", "2024").0]})).unwrap(); + s.execute("type.createOutlines", &json!({})).unwrap(); + s.execute("graph.setData", &json!({"id": id.0, "csv": csv})).unwrap(); + let moved = spec(&s, id).rect; + assert!((moved.x0 - (rect.x0 + 30.0)).abs() < 1e-6 && (moved.y0 - (rect.y0 + 15.0)).abs() < 1e-6, "{moved:?}"); + assert!((moved.width() - rect.width()).abs() < 1e-6 && (moved.height() - rect.height()).abs() < 1e-6, "{moved:?}"); +} + +/// A graph whose kept bounds of its art without type have no width follows a move with the bounds of all of its art. +#[test] +fn a_graph_whose_art_without_type_kept_no_width_still_follows_a_move() { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 800, "height": 800})).unwrap(); + let id = graph(&mut s, "column"); + let rect = spec(&s, id).rect; + let mut flat = spec(&s, id); + let art = flat.placed_art.unwrap(); + flat.placed_art = Some(vectorcraft_geom::Rect::new(art.x0, art.y0, art.x0, art.y1)); + std::sync::Arc::make_mut(&mut s.doc_mut().unwrap().doc).node_mut(id).unwrap().extras_mut().graph = Some(Box::new(flat)); + s.execute("select.set", &json!({"ids": [id.0]})).unwrap(); + s.execute("object.move", &json!({"dx": 25, "dy": 5})).unwrap(); + s.execute("graph.setData", &json!({"id": id.0, "csv": ",2024,2025\nQ1,3,2\nQ2,5,4\nQ3,-1,6"})).unwrap(); + let moved = spec(&s, id).rect; + assert!((moved.x0 - (rect.x0 + 25.0)).abs() < 1e-6 && (moved.y0 - (rect.y0 + 5.0)).abs() < 1e-6, "{moved:?}"); +} + +/// A graph saved before it kept the bounds of its art without type follows a move made since with its old bounds. +#[test] +fn a_graph_saved_with_only_its_whole_bounds_still_follows_a_move() { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 800, "height": 800})).unwrap(); + let id = graph(&mut s, "column"); + let rect = spec(&s, id).rect; + let mut old = serde_json::to_value(spec(&s, id)).unwrap(); + old.as_object_mut().unwrap().remove("placedArt"); + let old: GraphSpec = serde_json::from_value(old).unwrap(); + assert_eq!(old.placed_art, None); + std::sync::Arc::make_mut(&mut s.doc_mut().unwrap().doc).node_mut(id).unwrap().extras_mut().graph = Some(Box::new(old)); + s.execute("select.set", &json!({"ids": [id.0]})).unwrap(); + s.execute("object.move", &json!({"dx": 25, "dy": 5})).unwrap(); + s.execute("graph.setData", &json!({"id": id.0, "csv": ",2024,2025\nQ1,3,2\nQ2,5,4\nQ3,-1,6"})).unwrap(); + let moved = spec(&s, id).rect; + assert!((moved.x0 - (rect.x0 + 25.0)).abs() < 1e-6 && (moved.y0 - (rect.y0 + 5.0)).abs() < 1e-6, "{moved:?}"); + assert!(spec(&s, id).placed_art.is_some(), "the next drawing keeps the bounds of the art without type"); +} + /// Each Axes and Legend text after the graph is drawn again, with its object rows (fill or stroke, color, opacity, and /// for a stroke its weight) and its object opacity. type RowLooks = Vec<(bool, String, f32, f64)>; @@ -6935,3 +7080,42 @@ fn a_graph_opened_from_ai_keeps_its_data_for_editing() { assert!(notes.iter().any(|w| w.as_str().is_some_and(|w| w.starts_with("graphs are written as their art"))), "{name}: {r}"); } } + +/// A graph opened from a `.ai` keeps the bounds of its art without type, so moving one of its labels on its own +/// leaves the plot where it is at the first redraw. +#[test] +fn a_graph_opened_from_ai_measures_moves_without_its_labels() { + let graph = "%_Gs\n%_100 200 300 100 Gb\n%_5 0 0 14 0 1 0 0 6 44 Gy\n%_10 2 0 0 90 80 0 Gd\n\ + %_1 () Ga\n%_0 13 0 0 0 0 0 () GA\n%_2 () Ga\n%_0 14 0 0 0 0 0 () GA\n%_4 () Ga\n%_0 14 0 0 0 0 0 () GA\n\ + %_2 2 1 1 Gz\n%_() Gc (East) Gc (Q1) Gc 5 Gc GC\n\ + u\nu\n0.6 0 1 0 k\n110 100 m\n110 150 L\n130 150 L\n130 100 L\nf\nU\n%_2 1 0 0 Go\n\ + u\n0 0 0 1 K\n100 200 m\n100 100 L\n300 100 L\nS\nU\n\ + 0 To\n1 0 0 1 100 220 0 Tp\nTP\n0 Tr\n0 0 0 1 k\n/_Helvetica 12 Tf\n(Sales) Tx\nTO\nU\n%_GS\n"; + let text = format!( + "%!PS-Adobe-3.0\n%%Creator: a script\n%%BoundingBox: 0 0 400 400\n%AI5_FileFormat 3\n%AI3_Cropmarks: 0 0 400 400\n%%EndComments\n\ + %%BeginProlog\n%%EndProlog\n%%BeginSetup\n%%EndSetup\n%AI5_BeginLayer\n1 1 1 1 0 0 0 79 128 255 Lb\n(Graph) Ln\n0 A\n{graph}LB\n\ + %AI5_EndLayer--\n%%PageTrailer\n%%Trailer\n%%EOF\n" + ); + let dir = vectorcraft_testkit::temp_dir("graph-ai-label"); + let path = dir.join("graph.ai"); + std::fs::write(&path, text).unwrap(); + let mut s = Session::new(); + s.execute("document.open", &json!({"path": path})).unwrap(); + let (mut graphs, mut labels) = (vec![], vec![]); + s.doc().unwrap().doc.walk(|n| { + if n.extras().graph.is_some() { + graphs.push(n.id); + } + if matches!(&n.kind, NodeKind::Text(t) if t.plain_text() == "Sales") { + labels.push(n.id); + } + }); + assert_eq!((graphs.len(), labels.len()), (1, 1)); + let (id, label) = (graphs[0], labels[0]); + let before = spec(&s, id); + assert!(before.placed_art.is_some()); + s.execute("select.set", &json!({"ids": [label.0]})).unwrap(); + s.execute("object.move", &json!({"dx": 80, "dy": -40})).unwrap(); + s.execute("graph.setData", &json!({"id": id.0, "csv": ",East\nQ1,8\nQ2,2"})).unwrap(); + assert_eq!(spec(&s, id).rect, before.rect); +} diff --git a/crates/engine/src/cmd/plugin_call/reviewed.rs b/crates/engine/src/cmd/plugin_call/reviewed.rs index 5329f23331..fc6659174d 100644 --- a/crates/engine/src/cmd/plugin_call/reviewed.rs +++ b/crates/engine/src/cmd/plugin_call/reviewed.rs @@ -34,6 +34,7 @@ pub(super) const ALLOWED: &[&str] = &[ "addon.contour.set", "addon.cutting.banner", "addon.cutting.bridges", + "addon.cutting.nest", "addon.cutting.weed", "addon.image.applyColorMask", "addon.image.colorMask", diff --git a/crates/engine/src/cmd/typecmd.rs b/crates/engine/src/cmd/typecmd.rs index 05b45cc051..79b835398c 100644 --- a/crates/engine/src/cmd/typecmd.rs +++ b/crates/engine/src/cmd/typecmd.rs @@ -623,6 +623,8 @@ fn create_outlines(s: &mut Session, _: &Value) -> Result { if texts.is_empty() { return Err(EngineError::Other("Create Outlines: select text objects".into())); } + // Outlining a graph's labels does not move or scale its plot at the next redraw. + let graphs = super::graph::graphs_holding(d, &texts); let before = (!super::dimension::reached(d, &roots).is_empty()).then(|| d.clone()); let ended = super::dimension::end_links(d, &roots); let mut new_sel: Vec = roots.iter().copied().filter(|r| !texts.contains(r)).collect(); @@ -750,6 +752,7 @@ fn create_outlines(s: &mut Session, _: &Value) -> Result { } // Outlined frames of threaded text leave their threads with the text they show. super::threads::leave_threads(d, &threaded); + super::graph::keep_placed(d, &graphs); sel.set(new_sel); Ok(out) })?; diff --git a/crates/engine/src/tests/assets.rs b/crates/engine/src/tests/assets.rs index 8719873a94..64ef55d7ab 100644 --- a/crates/engine/src/tests/assets.rs +++ b/crates/engine/src/tests/assets.rs @@ -301,6 +301,42 @@ fn an_export_preset_sets_the_shared_settings() { assert_eq!(run(&mut s, "document.exportSettings", json!({}))["settings"]["prefix"], "p_"); } +/// `openLocation` (Open Location after Export) is remembered in the document's export settings, +/// outside presets; null removes it, and only true or false is taken. +#[test] +fn open_location_is_remembered_in_the_export_settings() { + let mut s = session(); + let open = |s: &mut Session| run(s, "document.exportSettings", json!({}))["settings"]["openLocation"].clone(); + let r = run(&mut s, "assets.settings.set", json!({"openLocation": false})); + assert_eq!(open(&mut s), false); + assert!(r["settings"].get("openLocation").is_none(), "{r}"); + // Create Sub-folders, on for a document without settings, stays on. + assert_eq!(r["settings"]["subfolders"], true, "{r}"); + run(&mut s, "document.exportPresets.save", json!({"name": "Kept", "settings": {"formats": [{"format": "svg"}]}})); + run(&mut s, "assets.settings.set", json!({"exportPreset": "Kept"})); + assert_eq!(open(&mut s), false, "a preset leaves it as it is"); + run(&mut s, "assets.settings.set", json!({"openLocation": null})); + assert_eq!(open(&mut s), Value::Null); + for bad in [json!({"openLocation": "yes"}), json!({"openLocation": 1}), json!({"subfolders": "no"})] { + assert!(s.execute("assets.settings.set", &bad).is_err(), "{bad}"); + } +} + +/// Create Sub-folders turned off on its own ends a remembered folder per format or item, so Export +/// for Screens opens with it off too; given with an export preset, the preset's folders stay. +#[test] +fn create_sub_folders_off_alone_ends_a_folder_per_format_or_item() { + let mut s = session(); + let folders = |s: &mut Session| run(s, "document.exportSettings", json!({}))["settings"]["folders"].clone(); + run(&mut s, "document.exportPresets.save", json!({"name": "Items", "settings": {"formats": [{"format": "svg"}], "folders": "item"}})); + for preset in ["Logo Package", "Items"] { + run(&mut s, "assets.settings.set", json!({"exportPreset": preset, "subfolders": false})); + assert_ne!(folders(&mut s), "none", "{preset}"); + run(&mut s, "assets.settings.set", json!({"subfolders": false})); + assert_eq!(folders(&mut s), "none", "{preset}"); + } +} + /// An export preset applied to the shared settings keeps its folders, with Create Sub-folders on /// for a folder per row; with Create Sub-folders off every file goes into the folder itself, /// mobile and density rows too. diff --git a/crates/engine/tests/command_sweep.rs b/crates/engine/tests/command_sweep.rs index 797ca39f52..f84bc4784a 100644 --- a/crates/engine/tests/command_sweep.rs +++ b/crates/engine/tests/command_sweep.rs @@ -914,6 +914,12 @@ fn structured_junk() { ("addon.cutting.banner", json!({"hem": 0, "grommetInset": 0, "spacing": 0.01, "allArtboards": true})), ("addon.cutting.banner", json!({"spacing": "1 mm", "grommets": false, "swatch": 5, "left": null})), ("addon.cutting.banner", json!({"top": false, "right": false, "bottom": false, "left": false, "allArtboards": false})), + ("addon.cutting.nest", json!({"width": 1e6, "height": 1e6, "margin": 1e5, "spacing": 1e5, "roll": true})), + ("addon.cutting.nest", json!({"width": 1e-300, "height": f64::MAX, "margin": -1, "spacing": "1 mm", "artboard": u64::MAX})), + ("addon.cutting.nest", json!({"width": 0, "height": 0, "margin": 0, "spacing": 0, "usePreviewBounds": "no", "patterns": 1})), + ("addon.cutting.nest", json!({"roll": null, "artboard": -1, "width": "1 in", "height": [1]})), + ("addon.cutting.nest", json!({"rotate": true, "roll": true, "width": 1e-6, "margin": 0, "spacing": 0, "usePreviewBounds": true})), + ("addon.cutting.nest", json!({"rotate": "yes", "width": f64::NAN, "spacing": -0.0})), // Type Tools add-on. ("addon.type.textTweaker", json!({"attribute": "size", "from": 1296, "to": 0.1, "shape": "random", "seed": u64::MAX})), ("addon.type.textTweaker", json!({"attribute": "tracking", "from": f64::MAX, "to": -1e308, "shape": "spike"})), diff --git a/crates/eps/src/ai_editing/tests.rs b/crates/eps/src/ai_editing/tests.rs index 6e963e70e4..6526a5dc2b 100644 --- a/crates/eps/src/ai_editing/tests.rs +++ b/crates/eps/src/ai_editing/tests.rs @@ -1187,6 +1187,32 @@ fn a_symbol_definition_is_written_only_with_room_for_its_instance() { } } +/// A live instance inside a stained instance's art, whose definition has no room left, is left out +/// without upsetting the art around it: with room for any number of objects the export finishes, +/// within that room. +#[test] +fn a_definition_without_room_inside_drawn_art_is_left_out() { + let mut doc = Document::new(200.0, 100.0); + let layer = doc.layers[0].id; + let leaf = square(&mut doc, Rect::new(0.0, 0.0, 10.0, 10.0), LEAF); + doc.symbols.push(Symbol { dynamic: false, name: "Leaf".into(), art: Arc::new(leaf) }); + let inner = instance(&mut doc, "Leaf", Affine::translate((5.0, 5.0))); + let side = square(&mut doc, Rect::new(20.0, 0.0, 30.0, 10.0), Color::gray(0.5)); + let art = group(&mut doc, vec![inner, side], false); + doc.symbols.push(Symbol { dynamic: false, name: "Branch".into(), art: Arc::new(art) }); + let mut stained = instance(&mut doc, "Branch", Affine::translate((50.0, 10.0))); + stained.appearance = Appearance::basic(solid(Color::rgb(1.0, 0.0, 0.0)), Paint::None, 0.0).into(); + doc.insert(Some(layer), usize::MAX, stained).unwrap(); + add_rect(&mut doc, Rect::new(150.0, 10.0, 160.0, 20.0)); + let read = crate::import::objects_read(&export_ai_editing(&doc, &opts()).unwrap().program).unwrap(); + for max in 0..read { + let out = export_within(&doc, &opts(), max).unwrap(); + assert!(out.warnings.iter().any(|w| w == writer::OBJECT_LIMIT), "art is left out with room for {max} of {read} objects"); + let objects = crate::import::objects_read(&out.program).unwrap(); + assert!(objects <= max, "{objects} objects read with room for {max}"); + } +} + /// A symbol definition without room for its group, asked for by an instance in art drawn for an /// object (a stained instance's art), is left out and leaves the writing of that art as it was. #[test] @@ -10695,4 +10721,25 @@ fn a_clipping_path_written_as_its_paint_keeps_one_text_wrap() { assert_eq!(text.matches("(AI11TextWrapOffset)").count(), 1, "{text}"); same_wraps(&wraps(read_back(&doc).layers[0].children().unwrap()), &[(None, 5.0, true)]); } + + // The outer clipping object ends at x = 100 and the clipping path's fill starts at x = 101: the + // fill is cut away and writes nothing, and its 4 pt stroke, which reaches x = 99, keeps the + // options, whether or not the fill's lock state is written before it. + for locked in [false, true] { + let mut doc = Document::new(200.0, 100.0); + let layer = doc.layers[0].id; + let mut cutter = clipping(&mut doc, shapes::rectangle(Rect::new(101.0, 20.0, 150.0, 80.0))); + cutter.appearance = Appearance::basic(solid(Color::gray(0.5)), solid(Color::BLACK), 4.0).into(); + cutter.locked = locked; + let cutter = wrapped(cutter, 5.0, true); + let member = square(&mut doc, Rect::new(110.0, 30.0, 140.0, 60.0), Color::rgb(1.0, 0.0, 0.0)); + let inner = group(&mut doc, vec![cutter, member], true); + let frame = square(&mut doc, Rect::new(0.0, 0.0, 100.0, 100.0), Color::gray(0.9)); + let frame = group(&mut doc, vec![frame], false); + let outer = group(&mut doc, vec![frame, inner], true); + doc.insert(Some(layer), usize::MAX, outer).unwrap(); + let text = program(&doc); + assert_eq!(text.matches("(AI11TextWrapOffset)").count(), 1, "locked {locked}: {text}"); + same_wraps(&wraps(read_back(&doc).layers[0].children().unwrap()), &[(None, 5.0, true)]); + } } diff --git a/crates/eps/src/import/ai/read/graph.rs b/crates/eps/src/import/ai/read/graph.rs index 759eb4e1cd..9fb60408c7 100644 --- a/crates/eps/src/import/ai/read/graph.rs +++ b/crates/eps/src/import/ai/read/graph.rs @@ -631,6 +631,7 @@ impl<'a> Reader<'a> { let Some(paints) = series_paints(art, &tags, spec.kind == GraphKind::Line) else { return false }; spec.series_paints = paints; spec.placed = Some(placed); + spec.placed_art = art.children().and_then(|c| vectorcraft_doc::graph::art_bounds(c)); tag_series(art, &tags); art.extras_mut().graph = Some(Box::new(spec)); true diff --git a/crates/eps/src/import/interp.rs b/crates/eps/src/import/interp.rs index 0ea7017c11..43346988ff 100644 --- a/crates/eps/src/import/interp.rs +++ b/crates/eps/src/import/interp.rs @@ -98,7 +98,7 @@ pub(crate) struct Interp<'a> { /// The font a `kshow` procedure sees as current while type in a CIDFont only named is set in /// a font of its own (see `text`). pub kshow_font: Option, - /// The `cshow` procedure running over type in a CIDFont only named, with the font it sees as + /// The `cshow` procedure running over a composite font's type, with the font it sees as /// current (see `text`). pub cshow: Option, pub g: GState, diff --git a/crates/eps/src/import/tests_cid.rs b/crates/eps/src/import/tests_cid.rs index d7ef0623d9..b44a5410ee 100644 --- a/crates/eps/src/import/tests_cid.rs +++ b/crates/eps/src/import/tests_cid.rs @@ -136,6 +136,31 @@ fn codes_map_by_their_codespace_with_notdef_fallbacks() { width("<01>", 30.0); } +/// `cshow` over a CMap runs its procedure once for each code, whatever its length, with the +/// code's last byte and its width (PLRM 3rd ed., `cshow`). +#[test] +fn cshow_runs_once_for_each_cmap_code() { + let fonts = String::from_utf8(cid_font(true)).unwrap() + CMAP; + let check = |cond: &str| check(&format!("{fonts} /TestFont 50 selectfont {cond}")); + check("[ {pop pop} <418001> cshow ] dup length 2 eq exch aload pop 1 eq exch 65 eq and and"); + check("0 {pop exch pop add} <418001> cshow 55 sub abs 0.01 lt"); + check( + "{pop pop pop currentfont /CIDFontName get /seen exch def rootfont /FontName get /root exch def} <41> cshow seen /TestCID eq root /TestFont eq and", + ); + // With the CIDFont current, the byte the procedure was given shows the glyph of its code's + // CID: <8001>'s last byte draws CID 2, not code 01's CID 1. Another string is an error. + let shown = "{pop pop ( ) dup 0 4 -1 roll put show} <418001> cshow"; + check(&format!("gsave nulldevice 10 20 moveto {shown} currentpoint grestore pop 65 sub abs 0.01 lt")); + check("gsave nulldevice 10 20 moveto {{pop pop pop (B) show} <41> cshow} stopped grestore"); + let r = import(&program(true, &format!("/TestFont 50 selectfont 10 20 moveto {shown}"))).unwrap(); + let want = import(&program(true, "/TestFont 50 selectfont 10 20 moveto <418001> show")).unwrap(); + let (p, q) = (paths(&r), paths(&want)); + assert_eq!(p.len(), 2, "{p:?}"); + for (a, b) in p.iter().zip(&q) { + assert!((a.x0 - b.x0).abs() < 0.01 && (a.width() - b.width()).abs() < 0.01 && (a.height() - b.height()).abs() < 0.01, "{p:?} {q:?}"); + } +} + /// `kshow` over a CMap runs its procedure between each two codes with their values, whatever /// their length, and the next character follows where it moves the point (PLRM 3rd ed., `kshow`). #[test] @@ -188,6 +213,22 @@ fn type2_cidfonts_draw_their_truetype_glyphs_through_cidmap() { } } +/// A `cshow` procedure over a Type 2 CIDFont without a `FontMatrix` sees the CIDFont current at +/// the scale its glyphs are drawn at: an em of the composite font's size, as with the identity. +#[test] +fn cshow_over_a_type2_cidfont_without_a_matrix_sees_it_at_its_drawn_scale() { + let (h, i) = (glyph('H').0, glyph('I').0); + let with = cid2_font(&format!("<0000{h:04x}{i:04x}>")); + let without = with.replace("/FontMatrix [1 0 0 1 0 0] def", ""); + assert_ne!(with, without); + for fonts in [with, without] { + check(&format!( + "{fonts}/T2 /Identity-H [/TestCID2] composefont 50 scalefont setfont \ + {{pop pop pop currentfont /FontMatrix get 0 get /m exch def}} <0001> cshow m 50 sub abs 0.01 lt" + )); + } +} + /// `Identity-V`: each glyph an em below the one before (writing mode 1). #[test] fn identity_v_sets_glyphs_downwards() { @@ -385,6 +426,30 @@ fn cmaps_map_codes_to_base_fonts_codes_and_glyph_names() { }) .collect(); assert_eq!(text, "ABC"); + // A `cshow` procedure that shows the byte it was given, in the base font current while it + // runs, shows the character the code maps to, as `show` does. + let body = format!( + "{plain}{BF_CMAP}/BF /TestBF-H [/Plain42 /Helvetica] composefont 20 scalefont setfont 10 20 moveto \ + {{pop pop ( ) dup 0 4 -1 roll put show}} <001000110012> cshow" + ); + let r = super::tests::read(&body); + let text: String = all(&r.document) + .iter() + .filter_map(|n| match &n.kind { + NodeKind::Text(t) => Some(t.plain_text()), + _ => None, + }) + .collect(); + assert_eq!(text, "ABC"); + let body = format!( + "{plain}{BF_CMAP}/BF /TestBF-H [/Plain42 /Helvetica] composefont 50 scalefont setfont 10 20 moveto \ + {{pop pop ( ) dup 0 4 -1 roll put show}} <000100020003> cshow" + ); + let q = paths(&super::tests::read(&body)); + assert_eq!(q.len(), 3, "{q:?}"); + for (a, b) in p.iter().zip(&q) { + assert!((a.x0 - b.x0).abs() < 0.01 && (a.width() - b.width()).abs() < 0.01, "{p:?} {q:?}"); + } } /// A CID-keyed font over a predefined Unicode CMap the file doesn't define, its CIDFont only diff --git a/crates/eps/src/import/tests_composite.rs b/crates/eps/src/import/tests_composite.rs index 2f4074af97..ff3b3382d3 100644 --- a/crates/eps/src/import/tests_composite.rs +++ b/crates/eps/src/import/tests_composite.rs @@ -119,6 +119,45 @@ fn widthshow_matches_a_composite_character_by_font_number_and_code() { check(format!("{} {} sub abs 0.01 lt", end(2), 10.0 + 100.0 * wh)); } +/// `cshow` runs its procedure once for each character of a composite font, not for each byte, +/// with the last byte of its code and its width, and with the descendant that draws it as the +/// current font while `rootfont` gives the composite font (PLRM 3rd ed., `cshow` and `rootfont`). +#[test] +fn cshow_runs_once_for_each_composite_character_in_its_descendant() { + let (_, wh) = glyph('H'); + let (_, wi) = glyph('I'); + let check = |cond: String| super::tests::check(&format!("{} /Comp findfont 50 scalefont setfont {cond}", fonts())); + check("[ {pop pop} (\\000\\001\\000\\002) cshow ] dup length 2 eq exch aload pop 2 eq exch 1 eq and and".into()); + check(format!("0 {{pop exch pop add}} (\\000\\001\\000\\002) cshow {} sub abs 0.01 lt", 50.0 * (wh + wi))); + check("{pop pop pop currentfont /FontName get /seen exch def rootfont /FontName get /root exch def} (\\000\\001) cshow seen /T42 eq root /Comp eq and".into()); + check("{pop pop pop} (\\000\\001) cshow currentfont /FontName get /Comp eq".into()); + // A character the procedure shows in the descendant is as wide as the composite font sets it. + check(format!( + "gsave nulldevice 10 20 moveto {{pop pop ( ) dup 0 4 -1 roll put show}} (\\000\\001\\000\\002) cshow currentpoint grestore pop {} sub abs 0.01 lt", + 10.0 + 50.0 * (wh + wi) + )); + // A character in font number 1 still gives only the last byte of its code, not 258. + let two = fonts().replace("/Encoding [0] def", "/Encoding [0 0] def"); + super::tests::check(&format!("{two} /Comp findfont 50 scalefont setfont [ {{pop pop}} (\\001\\002) cshow ] dup length 1 eq exch 0 get 2 eq and")); +} + +/// A `cshow` procedure over a Type 42 descendant without a `FontMatrix` sees it current at the +/// scale its characters are drawn at: another character it measures there is as wide as the +/// composite font sets it, as with the identity. +#[test] +fn cshow_over_a_type42_descendant_without_a_matrix_sees_it_at_its_drawn_scale() { + let (_, wi) = glyph('I'); + let with = fonts(); + let without = with.replacen("/FontType 42 def /FontMatrix [1 0 0 1 0 0] def", "/FontType 42 def", 1); + assert_ne!(with, without); + for fonts in [with, without] { + super::tests::check(&format!( + "{fonts} /Comp findfont 50 scalefont setfont {{pop pop pop (\\002) stringwidth pop /w exch def}} (\\000\\001) cshow w {} sub abs 0.01 lt", + 50.0 * wi + )); + } +} + /// `kshow` runs its procedure between each two characters of a composite font, with the values of /// their codes (font number times 256 plus code), and the next character follows where it moves /// the current point (PLRM 3rd ed., `kshow`). diff --git a/crates/eps/src/import/text.rs b/crates/eps/src/import/text.rs index 257fdc2247..3de16d7bbf 100644 --- a/crates/eps/src/import/text.rs +++ b/crates/eps/src/import/text.rs @@ -251,6 +251,15 @@ impl Interp<'_> { self.g.font = root; return r; } + // In a composite font the file carries, once for each character it maps. + if let Some(glyphs) = self.composite_glyphs(&s)? { + let root = self.g.font.clone(); + let before = self.cshow.take(); + let r = self.composite_cshow(&glyphs, &proc, root.clone()); + self.cshow = before; + self.g.font = root; + return r; + } for b in s { let v = self.width_of(&[b])?; self.push(Obj::Int(i64::from(b)))?; @@ -992,6 +1001,7 @@ impl Spacing { /// A character of a composite font: the descendant font that draws it, its code there (its last /// byte for a CID), its CID and the notdef mapping's (`FMapType` 9), the composite font's matrix /// (over the descendant's own) and whether it is set vertically (`WMode` 1). +#[derive(Clone)] struct Mapped { font: DictRef, /// The byte that picked the descendant (the font number). @@ -1006,6 +1016,13 @@ struct Mapped { value: u32, } +impl Mapped { + /// The last byte of the character's code in the shown string (what `cshow` gives its procedure). + fn last(&self) -> u8 { + (self.value & 0xff) as u8 + } +} + /// Type in a CIDFont only named, over a predefined CMap ([`Interp::named_cid_type`]): its codes /// with their characters, the font it is set in, whether it runs down a column, and whether each /// code moves the point one em (down a column, or in a CMap VectorCraft can't read). @@ -1016,13 +1033,15 @@ struct NamedCid { ems: bool, } -/// A `cshow` procedure running over type in a CIDFont only named ([`Interp::named_cshow`]): the -/// CIDFont it sees as current, the composite font `rootfont` gives while that CIDFont is current, -/// and the type of the code it was given. +/// A `cshow` procedure running over a composite font's type ([`Interp::named_cshow`], +/// [`Interp::composite_cshow`]): the descendant it sees as current, the composite font `rootfont` +/// gives while that descendant is current, and the code it was given: over a CIDFont only named, +/// its type; over a composite font the file carries, the character it maps to. pub(super) struct CShowFont { selected: DictRef, root: DictRef, - code: NamedCid, + code: Option, + mapped: Option, } /// What drawing a glyph does with it. @@ -1069,6 +1088,22 @@ impl Interp<'_> { /// codes mapped to a descendant and a CID, §5.11.5); `None` for other fonts and mappings, /// which are set as type in the font of the composite font's name. fn composite_glyphs(&mut self, s: &[u8]) -> Res>> { + // Inside a `cshow` procedure over a composite font the file carries, with the descendant + // it selected current, the one byte the procedure was given stands for the character its + // code maps to: a CMap's CID, base-font code or glyph name. In a CIDFont any other string + // is a `rangecheck` error (PLRM 3rd ed., `cshow`: the glyph of the selected CID); in a + // base font it is shown as it is. + if let Some(c) = self.cshow.as_ref() + && let Some(mapped) = c.mapped.as_ref() + && self.g.font.as_ref().is_some_and(|f| f.same(&c.selected)) + { + if s == [mapped.last()] { + return Ok(Some(vec![mapped.clone()])); + } + if mapped.cid.is_some() { + return ps_err("rangecheck", "a string shown in cshow"); + } + } let Some(font) = self.g.font.clone() else { return Ok(None) }; let map = { let f = font.borrow(); @@ -1220,17 +1255,18 @@ impl Interp<'_> { /// that code when `code` and with none otherwise, while the CIDFont it selected is current. fn cshow_code(&self, code: bool) -> Option { let c = self.cshow.as_ref()?; + let given = c.code.as_ref()?; if !self.g.font.as_ref().is_some_and(|f| f.same(&c.selected)) { return None; } let mut codes = Decoded::default(); if code { - let mut chars = c.code.codes.text.chars(); - for &(value, n) in &c.code.codes.codes { + let mut chars = given.codes.text.chars(); + for &(value, n) in &given.codes.codes { codes.push(value, chars.by_ref().take(n)); } } - Some(NamedCid { codes, font: c.code.font.clone(), vertical: c.code.vertical, ems: c.code.ems }) + Some(NamedCid { codes, font: given.font.clone(), vertical: given.vertical, ems: given.ems }) } /// The CMap's name, the composite font, the CIDFont's name and the writing mode of @@ -1318,7 +1354,7 @@ impl Interp<'_> { self.push(Obj::Int(i64::from(code & 0xff)))?; self.push_num(v.x)?; self.push_num(v.y)?; - self.cshow = Some(CShowFont { selected: selected.clone(), root: composite.clone(), code: one }); + self.cshow = Some(CShowFont { selected: selected.clone(), root: composite.clone(), code: Some(one), mapped: None }); self.g.font = Some(selected.clone()); let go = self.body(proc); self.g.font = root.clone(); @@ -1329,6 +1365,41 @@ impl Interp<'_> { Ok(()) } + /// `cshow`'s procedure `proc` run for each character `glyphs` of composite font `root` maps, + /// with the last byte of its code and its width (PLRM 3rd ed., `cshow`). While it runs the + /// descendant that draws the character, at the size the composite font sets it, is the current + /// font and `rootfont` gives `root`. The current font is restored when the procedure completes. + fn composite_cshow(&mut self, glyphs: &[Mapped], proc: &Obj, root: Option) -> Res { + let Some(composite) = root.clone() else { return Ok(()) }; + for g in glyphs { + let v = self.composite_glyph(g, Point::ZERO, Draw::No)?; + self.push(Obj::Int(i64::from(g.last())))?; + self.push_num(v.x)?; + self.push_num(v.y)?; + // Scaling the descendant copies its dictionary for every character: charged by its + // size, so a huge font dictionary in a loop runs out of the budget. + let entries = g.font.borrow().len(); + self.spend(entries as u64)?; + // Without a matrix, a Type 42 font or a Type 2 CIDFont is in ems, as `composite_glyph` + // and `cid_glyph_at` draw it. + let selected = { + let f = g.font.borrow(); + let default = if units(&f) == 1.0 { 1.0 } else { 0.001 }; + let mut d = f.clone(); + d.insert(Key::name("FontMatrix"), matrix_obj(g.outer * font_matrix(&f, default))); + DictRef::new(d) + }; + self.cshow = Some(CShowFont { selected: selected.clone(), root: composite.clone(), code: None, mapped: Some(g.clone()) }); + self.g.font = Some(selected); + let go = self.body(proc); + self.g.font = root.clone(); + if !go? { + break; + } + } + Ok(()) + } + /// How far codes `codes` of type `named` move the current point (user space) when /// [`Self::show_named_cid`] shows them with no extra spacing. fn named_cid_width(&mut self, named: &NamedCid, codes: &Decoded) -> Res { diff --git a/crates/render/src/encode/png.rs b/crates/render/src/encode/png.rs index 6a62034e10..d393e23fa7 100644 --- a/crates/render/src/encode/png.rs +++ b/crates/render/src/encode/png.rs @@ -28,16 +28,12 @@ const ADAM7: [(u32, u32, u32, u32); 7] = [(0, 0, 8, 8), (4, 0, 8, 8), (0, 4, 4, /// Pixels per metre for a resolution in pixels per inch (the unit `pHYs` stores). pub fn pixels_per_metre(ppi: f64) -> u32 { - (ppi / 0.0254).round().clamp(1.0, u32::MAX as f64) as u32 + vectorcraft_doc::ppi::pixels_per_metre(ppi) } /// A whole `pHYs` chunk (length, type, data, CRC) declaring `x` × `y` pixels per inch. pub fn phys_chunk(x: f64, y: f64) -> Vec { - let [x, y] = [x, y].map(|v| pixels_per_metre(v).to_be_bytes()); - let mut out = Vec::with_capacity(21); - // x, y, unit 1 = metre. - chunk(&mut out, b"pHYs", &[x.as_slice(), y.as_slice(), &[1]].concat()); - out + vectorcraft_doc::ppi::phys_chunk(x, y) } /// Encode straight-alpha RGBA8 pixels (`width`×`height`, row-major) as a PNG file. diff --git a/crates/svg/src/import/files.rs b/crates/svg/src/import/files.rs index d2b5504f9d..bae6bbd0be 100644 --- a/crates/svg/src/import/files.rs +++ b/crates/svg/src/import/files.rs @@ -615,10 +615,11 @@ fn raster(format: image::ImageFormat) -> Option { /// orientation ([`vectorcraft_doc::cmyk::exif_orientation`], read for PNG, JPEG, GIF, WebP and /// TIFF) leaves the pixels as stored. Otherwise the pixels are read ([`decoded`], /// [`tiff_pixels`]), turned or flipped as the orientation says, and written as PNG, so usvg sizes -/// and fits the image as it is shown. A CMYK TIFF also keeps its inks as a CMYK TIFF -/// ([`vectorcraft_doc::cmyk::stored_cmyk_tiff`]), and a CMYK JPEG or TIFF that is turned keeps them -/// turned ([`vectorcraft_doc::cmyk::turned_cmyk`]). `None` when the pixels can't be read, and for -/// a TIFF with more strips or tiles than the decoder can count +/// and fits the image as it is shown, with the resolution the file declares +/// ([`vectorcraft_doc::ppi::resolution`]), its axes swapped by a quarter turn. A CMYK TIFF also +/// keeps its inks as a CMYK TIFF ([`vectorcraft_doc::cmyk::stored_cmyk_tiff`]), and a CMYK JPEG +/// or TIFF that is turned keeps them turned ([`vectorcraft_doc::cmyk::turned_cmyk`]). `None` when +/// the pixels can't be read, and for a TIFF with more strips or tiles than the decoder can count /// ([`vectorcraft_doc::cmyk::oversized_tiff`]), before any decoder opens it. fn shown(bytes: Arc>, format: image::ImageFormat) -> Option { use image::ImageFormat as F; @@ -634,9 +635,13 @@ fn shown(bytes: Arc>, format: image::ImageFormat) -> Option { } let tiff = image::guess_format(&bytes).ok() == Some(F::Tiff); let mut img = if tiff { tiff_pixels(&bytes)? } else { decoded(&bytes, format)? }; + let ppi = vectorcraft_doc::ppi::resolution(&bytes); + // 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) }); let cmyk = match orientation { - Some(o) if matches!(format, F::Jpeg | F::Tiff) => vectorcraft_doc::cmyk::turned_cmyk(&bytes, o, None).map(|(tiff, _)| tiff), - None if tiff => vectorcraft_doc::cmyk::stored_cmyk_tiff(&bytes, None), + Some(o) if matches!(format, F::Jpeg | F::Tiff) => vectorcraft_doc::cmyk::turned_cmyk(&bytes, o, shown_ppi).map(|(tiff, _)| tiff), + None if tiff => vectorcraft_doc::cmyk::stored_cmyk_tiff(&bytes, ppi), _ => None, }; if let Some(orientation) = orientation { @@ -644,6 +649,9 @@ fn shown(bytes: Arc>, format: image::ImageFormat) -> Option { } let mut png = Vec::new(); img.write_to(&mut std::io::Cursor::new(&mut png), F::Png).ok()?; + if let Some(ppi) = shown_ppi { + png = vectorcraft_doc::ppi::with_png_resolution(&png, ppi); + } Some(Shown { kind: ImageKind::PNG, bytes: Arc::new(png), cmyk }) } diff --git a/crates/svg/tests/it/image_orientation.rs b/crates/svg/tests/it/image_orientation.rs index 549a40a3bf..d9de61290b 100644 --- a/crates/svg/tests/it/image_orientation.rs +++ b/crates/svg/tests/it/image_orientation.rs @@ -69,13 +69,21 @@ fn jpeg(orientation: Option) -> Vec { /// an Orientation tag (274) of `orientation`. Its RowsPerStrip (278) value is at byte [`ROWS_AT`] /// and its PlanarConfiguration (284, 1) value at [`PLANAR_AT`]. fn tiff(w: u32, h: u32, samples: u16, data: &[u8], orientation: u16) -> Vec { - let entries: u32 = if samples == 2 { 12 } else { 11 }; + tiff_at(w, h, samples, data, orientation, None) +} + +/// [`tiff`], declaring `ppi` (horizontal, vertical pixels per inch) when given, with XResolution +/// (282), YResolution (283) and ResolutionUnit (296, inches). +fn tiff_at(w: u32, h: u32, samples: u16, data: &[u8], orientation: u16, ppi: Option<(u32, u32)>) -> Vec { + let entries = 11 + u32::from(samples == 2) + 3 * u32::from(ppi.is_some()); let ifd_end = 8 + 2 + entries * 12 + 4; let bits_at = ifd_end; - let data_at = bits_at + if samples > 2 { 2 * u32::from(samples) } else { 0 }; + let ppi_at = bits_at + if samples > 2 { 2 * u32::from(samples) } else { 0 }; + let data_at = ppi_at + if ppi.is_some() { 16 } else { 0 }; let entry = |tag: u16, kind: u16, count: u32, value: u32| { [&tag.to_le_bytes()[..], &kind.to_le_bytes(), &count.to_le_bytes(), &value.to_le_bytes()].concat() }; + let resolution = |tags: [(u16, u16, u32); 2]| ppi.map(|_| tags.map(|(tag, kind, value)| entry(tag, kind, 1, value))).into_iter().flatten(); // Two SHORTs fit in the entry; more are written after the directory. let bits = match samples { 1 => 8, @@ -97,9 +105,11 @@ fn tiff(w: u32, h: u32, samples: u16, data: &[u8], orientation: u16) -> Vec entry(277, 3, 1, u32::from(samples)), entry(278, 4, 1, h), entry(279, 4, 1, u32::try_from(data.len()).unwrap()), - entry(284, 3, 1, 1), ] .into_iter() + .chain(resolution([(282, 5, ppi_at), (283, 5, ppi_at + 8)])) + .chain([entry(284, 3, 1, 1)]) + .chain(ppi.map(|_| entry(296, 3, 1, 2))) .chain(extra) { out.extend(e); @@ -110,6 +120,11 @@ fn tiff(w: u32, h: u32, samples: u16, data: &[u8], orientation: u16) -> Vec out.extend(8u16.to_le_bytes()); } } + if let Some((x, y)) = ppi { + for v in [x, 1, y, 1] { + out.extend(v.to_le_bytes()); + } + } assert_eq!(out.len(), data_at as usize); out.extend(data); out @@ -277,6 +292,76 @@ fn a_turned_jpeg_in_an_svg_image_imports_upright() { assert!(red(px.get_pixel(4, 3)) && !red(px.get_pixel(4, 12))); } +/// [`jpeg`] with its JFIF segment declaring `x` × `y` pixels per inch. +fn jpeg_at(orientation: Option, (x, y): (u16, u16)) -> Vec { + let mut jpg = jpeg(orientation); + let jfif = jpg.windows(5).position(|w| w == b"JFIF\0").unwrap(); + jpg[jfif + 7] = 1; + jpg[jfif + 8..jfif + 10].copy_from_slice(&x.to_be_bytes()); + jpg[jfif + 10..jfif + 12].copy_from_slice(&y.to_be_bytes()); + jpg +} + +/// The resolution the stored image of `im` declares. +fn stored_ppi(d: &Document, im: &ImageObject) -> Option<(f64, f64)> { + vectorcraft_doc::ppi::resolution(&d.images[&im.key].bytes) +} + +/// A raster that import writes again as PNG keeps the resolution its file declares, linked, as a +/// data URL and inside an SVG shown as an image: its axes swapped when the image turns a quarter, +/// in their order when it turns a half or is flipped. +#[test] +fn a_turned_raster_keeps_its_resolution() { + let dir = folder("ppi"); + for (orientation, ppi) in [(6, (150.0, 300.0)), (8, (150.0, 300.0)), (3, (300.0, 150.0)), (2, (300.0, 150.0))] { + let jpg = jpeg_at(Some(orientation), (300, 150)); + std::fs::write(dir.join("photo.jpg"), &jpg).unwrap(); + let data = format!("data:image/jpeg;base64,{}", base64_encode(&jpg)); + let inner = format!(r#""#); + let nested = format!("data:image/svg+xml;base64,{}", base64_encode(inner.as_bytes())); + for href in ["photo.jpg", data.as_str(), nested.as_str()] { + let (d, im) = import_one(&format!(r#""#), Some(&dir)); + let what = format!("orientation {orientation}, {}", &href[..href.len().min(20)]); + assert_eq!(d.images[&im.key].mime, "image/png", "{what}"); + assert_eq!(stored_ppi(&d, &im), Some(ppi), "{what}"); + } + } + let href = format!("data:image/tiff;base64,{}", base64_encode(&tiff_at(4, 2, 1, &[0, 0, 255, 255, 0, 0, 255, 255], 6, Some((300, 150))))); + let (d, im) = import_one(&format!(r#""#), None); + assert_eq!((im.width, im.height), (2, 4)); + assert_eq!(stored_ppi(&d, &im), Some((150.0, 300.0)), "a turned TIFF"); +} + +/// A TIFF that is not turned is written as PNG with the resolution it declares; a raster that +/// declares none is stored without one. +#[test] +fn an_upright_tiff_keeps_its_resolution() { + let gray = [0, 0, 255, 255, 0, 0, 255, 255]; + let href = format!("data:image/tiff;base64,{}", base64_encode(&tiff_at(4, 2, 1, &gray, 1, Some((300, 150))))); + let (d, im) = import_one(&format!(r#""#), None); + assert_eq!(d.images[&im.key].mime, "image/png"); + assert_eq!(stored_ppi(&d, &im), Some((300.0, 150.0))); + for bytes in [tiff(4, 2, 1, &gray, 1), jpeg(Some(6))] { + let href = format!("data:;base64,{}", base64_encode(&bytes)); + let (d, im) = import_one(&format!(r#""#), None); + assert_eq!(stored_ppi(&d, &im), None, "no resolution is made up"); + } +} + +/// A CMYK TIFF stored with its inks keeps its resolution, swapped when it turns a quarter. +#[test] +fn a_cmyk_tiff_keeps_its_resolution_turned_or_not() { + let (c, none) = ([255u8, 0, 0, 0], [0u8; 4]); + let data: Vec = [c, c, none, none, c, c, none, none].concat(); + for (orientation, ppi) in [(1, (300.0, 150.0)), (6, (150.0, 300.0))] { + let href = format!("data:image/tiff;base64,{}", base64_encode(&tiff_at(4, 2, 4, &data, orientation, Some((300, 150))))); + let (d, im) = import_one(&format!(r#""#), None); + assert_eq!(d.images[&im.key].mime, "image/tiff", "orientation {orientation}"); + assert!(d.images[&im.key].cmyk().is_some(), "orientation {orientation}: CMYK inks"); + assert_eq!(stored_ppi(&d, &im), Some(ppi), "orientation {orientation}"); + } +} + /// A turned JPEG that a linked SVG file links to is turned too. #[test] fn a_turned_jpeg_linked_from_a_linked_svg_file_imports_upright() { diff --git a/crates/text/src/layout/breaking.rs b/crates/text/src/layout/breaking.rs index d43e17ad99..873156eaaf 100644 --- a/crates/text/src/layout/breaking.rs +++ b/crates/text/src/layout/breaking.rs @@ -53,6 +53,15 @@ impl Metrics { } } +/// What a line band was placed below: the baseline of the line above it and the em box top that +/// line left for the next one (both `None` for a cell's first band), and the space pending between. +#[derive(Clone, Copy, Default)] +pub(super) struct Above { + pub(super) prev: Option, + pub(super) next_top: Option, + pub(super) pending: f64, +} + /// Where the next line goes: the region being filled and the previous baseline in it. #[derive(Clone)] pub(super) struct Pen<'r> { @@ -64,6 +73,8 @@ pub(super) struct Pen<'r> { pub(super) fb_min: f64, /// Further spans at the current baseline (text wrapping on both sides of an object). pub(super) queued: Vec<(f64, f64, f64)>, + /// What the line band at the current baseline was placed below (see [`Above`]). + pub(super) above: Above, /// Leading measured from em box top to em box top: where the next line's em box top goes. pub(super) model: LeadingModel, pub(super) next_top: Option, @@ -108,16 +119,21 @@ impl Pen<'_> { /// [`Pen::place`], skipping lines of a non-rectangular frame narrower than `min`. fn place_at_least(&mut self, est: Metrics, ind_l: f64, ind_r: f64, min: f64) -> Option<(f64, f64, f64, bool)> { let Some(regions) = self.regions else { - let b = self.prev.map_or(0.0, |b| self.next_baseline(b, est)); + let b = self.prev.map_or(0.0, |b| self.next_baseline(b, self.next_top, self.pending, est)); return Some((b, f64::NEG_INFINITY, f64::INFINITY, false)); }; // A further span was measured across the height of the line set before it on its baseline. // This line gets the part of it that is free across its own height: a taller line can // reach a wrap object the line before it stayed clear of. A span with no such part is - // left empty, and so is a part the line's height made narrower than `min`. + // left empty, and so is a part the line's height made narrower than `min`. A span on a + // baseline nearer the line above the band than this line's own leading sets it is left + // empty too: a taller line would reach into that line. while !self.queued.is_empty() { let (b, x0, x1) = self.queued.remove(0); let Some(r) = regions.get(self.ri) else { return Some((b, x0, x1, true)) }; + if b + EPS < self.band_baseline(r, est) { + continue; + } let mut free = r.spans(b - est.asc, b + est.desc).into_iter().filter_map(|(s, e)| { let (s, e) = (s.max(x0), e.min(x1)); let room = e - s - ind_l - ind_r; @@ -132,11 +148,8 @@ impl Pen<'_> { } loop { let r = regions.get(self.ri)?; - let mut baseline = match self.prev { - None if self.model == LeadingModel::EmBoxTop => r.top() + r.shift + est.top.max(self.fb_min), - None => r.top() + r.shift + est.first_baseline(self.fb, self.fb_min), - Some(b) => self.next_baseline(b, est) + r.gap, - }; + self.above = Above { prev: self.prev, next_top: self.next_top, pending: self.pending }; + let mut baseline = self.band_baseline(r, est); // The baseline moves down the cell a lead at a time, or a `MAX_STEPS`th of the cell's // height when that is more, in at most `MAX_STEPS` steps. let step = est.lead.max(1.0).max(r.cell.height() / MAX_STEPS as f64); @@ -188,13 +201,30 @@ impl Pen<'_> { pub(super) fn bottom(&self) -> f64 { self.regions.and_then(|r| r.get(self.ri)).map_or(f64::INFINITY, |r| r.bottom()) } - /// The baseline of the line after the one at `prev`, for a line of metrics `est`. - fn next_baseline(&self, prev: f64, est: Metrics) -> f64 { - match (self.model, self.next_top) { - (LeadingModel::EmBoxTop, Some(top)) => top + est.top + self.pending, - _ => prev + est.lead + self.pending, + /// The baseline of the line after the one at `prev`, which left the next em box top at + /// `next_top`, for a line of metrics `est` with `pending` space between them. + fn next_baseline(&self, prev: f64, next_top: Option, pending: f64, est: Metrics) -> f64 { + match (self.model, next_top) { + (LeadingModel::EmBoxTop, Some(top)) => top + est.top + pending, + _ => prev + est.lead + pending, } } + /// The highest baseline in cell `r` for a line of metrics `est` in the current line band: as + /// far below the line above the band ([`Pen::above`]) as its own leading sets it, or at the + /// cell's first baseline. + fn band_baseline(&self, r: &Region, est: Metrics) -> f64 { + let Above { prev, next_top, pending } = self.above; + match prev { + None if self.model == LeadingModel::EmBoxTop => r.top() + r.shift + est.top.max(self.fb_min), + None => r.top() + r.shift + est.first_baseline(self.fb, self.fb_min), + Some(b) => self.next_baseline(b, next_top, pending, est) + r.gap, + } + } + /// A line of metrics `m` set in a further span at baseline `b` is no nearer the line above + /// the band than its own leading sets it. + pub(super) fn clears_above(&self, b: f64, m: Metrics) -> bool { + self.regions.and_then(|r| r.get(self.ri)).is_none_or(|r| b + EPS >= self.band_baseline(r, m)) + } /// A line was set at `baseline` with metrics `m`: where the next one goes from. pub(super) fn settled(&mut self, baseline: f64, m: Metrics) { self.prev = Some(baseline); @@ -967,6 +997,7 @@ fn a_pen_out_of_baseline_steps_finds_no_room() { fb: FirstBaseline::Ascent, fb_min: 0.0, queued: vec![], + above: Default::default(), model: LeadingModel::default(), next_top: None, no_room_for: f64::INFINITY, diff --git a/crates/text/src/layout/flow.rs b/crates/text/src/layout/flow.rs index 5cf7a87224..30b615c5fc 100644 --- a/crates/text/src/layout/flow.rs +++ b/crates/text/src/layout/flow.rs @@ -177,6 +177,7 @@ pub(super) fn flow(cx: &mut Ctx<'_>, paras: &[Range], t: &TextObject, reg fb: cx.opts.first_baseline, fb_min: cx.opts.first_baseline_min, queued: vec![], + above: Default::default(), model: t.para_at(0).leading_model, next_top: None, no_room_for: f64::INFINITY, @@ -538,6 +539,11 @@ fn place_flow_line(cx: &mut Ctx<'_>, pen: &mut Pen<'_>, context: &mut PlacementC } } let m = metrics(end); + if same_baseline && !pen.clears_above(baseline, m) { + // A glyph taller than the first one sets the line lower than the span's baseline: the + // span is left empty, and the line goes in the next span or on the next baseline. + continue; + } baseline += if !settle || same_baseline || (pen.regions.is_none() && first_in_region) { 0.0 } else if pen.model == LeadingModel::EmBoxTop { diff --git a/crates/text/src/layout/shaped.rs b/crates/text/src/layout/shaped.rs index 63005364fb..61c28b1465 100644 --- a/crates/text/src/layout/shaped.rs +++ b/crates/text/src/layout/shaped.rs @@ -91,8 +91,9 @@ struct Placed<'r> { /// What a line's placement depends on: its first glyph, whether it is the paragraph's first line, /// the pen's cell, baselines, pending space, narrowest word without room and further spans, the -/// span of the line above and whether the line is past the frame's end. -type Key = (usize, bool, usize, Option, Option, u64, u64, Vec<[u64; 3]>, Option, bool); +/// span of the line above, whether the line is past the frame's end, and what the further spans' +/// band was placed below. +type Key = (usize, bool, usize, Option, Option, u64, u64, Vec<[u64; 3]>, Option, bool, (Option, Option, u64)); fn key(pos: usize, first: bool, pen: &Pen<'_>, span: Option, past: bool) -> Key { ( @@ -106,6 +107,7 @@ fn key(pos: usize, first: bool, pen: &Pen<'_>, span: Option, past: bool) -> pen.queued.iter().map(|&(b, x0, x1)| [b.to_bits(), x0.to_bits(), x1.to_bits()]).collect(), span.map(f64::to_bits), past, + (pen.above.prev.map(f64::to_bits), pen.above.next_top.map(f64::to_bits), pen.above.pending.to_bits()), ) } diff --git a/crates/text/src/layout/wrapping_tests.rs b/crates/text/src/layout/wrapping_tests.rs index 1878ce962d..997c703b29 100644 --- a/crates/text/src/layout/wrapping_tests.rs +++ b/crates/text/src/layout/wrapping_tests.rs @@ -911,7 +911,8 @@ fn a_further_span_is_measured_across_the_height_of_its_line() { wrap: vectorcraft_doc::TextWrap { offset: 0.0, invert: false }, }; let small = breaking::Metrics { asc: 10.0, desc: 3.0, lead: 14.0, cap: 7.0, xh: 5.0, top: 10.0 }; - let tall = breaking::Metrics { asc: 30.0, desc: 9.0, lead: 42.0, cap: 21.0, xh: 15.0, top: 30.0 }; + // The tall line is set at the small line's leading, so it may share the small line's baseline. + let tall = breaking::Metrics { asc: 30.0, desc: 9.0, lead: 14.0, cap: 21.0, xh: 15.0, top: 30.0 }; // An object down the middle of the frame splits every line in two, and `above` lies right of // it over y 20 to 40. A small line after a baseline of 50 is set at 64, over y 54 to 67, clear // of `above`; a tall line in the span right of it on that baseline covers y 34 to 73. @@ -926,6 +927,7 @@ fn a_further_span_is_measured_across_the_height_of_its_line() { fb: FirstBaseline::Ascent, fb_min: 0.0, queued: vec![], + above: Default::default(), model: LeadingModel::RomanBaseline, next_top: None, no_room_for: f64::INFINITY, @@ -946,17 +948,72 @@ fn a_further_span_is_measured_across_the_height_of_its_line() { assert!(near(right, (64.0, 220.0, 300.0, true)), "{right:?}"); // That part is 80 wide: a tall line that starts with a wider word goes on the next baseline. let (_, right) = set(Rect::new(300.0, 20.0, 400.0, 40.0), 100.0); - assert!(near(right, (106.0, 0.0, 180.0, false)), "{right:?}"); + assert!(near(right, (78.0, 0.0, 180.0, false)), "{right:?}"); // `above` covers the whole span: the tall line goes on the next baseline, left of the object. let (left, right) = set(Rect::new(220.0, 20.0, 400.0, 40.0), 0.0); assert!(near(left, (64.0, 0.0, 180.0, false)), "{left:?}"); - assert!(near(right, (106.0, 0.0, 180.0, false)), "{right:?}"); + assert!(near(right, (78.0, 0.0, 180.0, false)), "{right:?}"); // `above` lies below the tall line: the span is as wide for it as for the small line, and it // takes the span whatever its first word, as a further span always did. let (_, right) = set(Rect::new(300.0, 300.0, 400.0, 320.0), 200.0); assert!(near(right, (64.0, 220.0, 400.0, true)), "{right:?}"); } +/// A further span goes only to a line whose own leading sets it no higher than the span's +/// baseline: a taller line leaves the span empty and goes on the next baseline, so its ascent does +/// not reach into the line above the band. In a cell's first band, a taller line leaves a span +/// whose baseline is above its own first baseline in the cell. +#[test] +fn a_further_span_is_left_to_a_line_its_leading_sets_there() { + use std::cell::Cell; + use std::rc::Rc; + let frame = Rect::new(0.0, 0.0, 400.0, 400.0).to_path(0.1); + let wraps = [vectorcraft_doc::WrapShape { + path: PathData::from_bezpath(&Rect::new(180.0, 0.0, 220.0, 400.0).to_path(0.1)), + wrap: vectorcraft_doc::TextWrap { offset: 0.0, invert: false }, + }]; + let regions = region::Region::cells(&frame, &LayoutOptions::default(), &wraps); + let small = breaking::Metrics { asc: 10.0, desc: 3.0, lead: 14.0, cap: 7.0, xh: 5.0, top: 10.0 }; + let tall = breaking::Metrics { asc: 30.0, desc: 9.0, lead: 42.0, cap: 21.0, xh: 15.0, top: 30.0 }; + let set = |prev: Option, model: LeadingModel, next: breaking::Metrics| { + let mut pen = breaking::Pen { + regions: Some(®ions), + ri: 0, + prev, + pending: 0.0, + fb: FirstBaseline::Ascent, + fb_min: 0.0, + queued: vec![], + above: Default::default(), + model, + next_top: prev.map(|b| b - small.top + small.lead), + no_room_for: f64::INFINITY, + steps_left: Rc::new(Cell::new(breaking::MAX_TOTAL_STEPS)), + search: Default::default(), + }; + let left = pen.place(small, 0.0, 0.0, 0.0); + let at = left.map_or(f64::NAN, |l| l.0); + pen.settled(at, small); + let ok = pen.clears_above(at, next); + (left, pen.place(next, 0.0, 0.0, 0.0), ok) + }; + let near = |got: Option<(f64, f64, f64, bool)>, want: (f64, f64, f64, bool)| { + got.is_some_and(|g| (g.0 - want.0).abs() < 0.01 && (g.1 - want.1).abs() < 0.01 && (g.2 - want.2).abs() < 0.01 && g.3 == want.3) + }; + // The next baseline for the tall line after the small one at 64, and after the small one at 10. + for (model, after_64, after_10) in [(LeadingModel::RomanBaseline, 106.0, 52.0), (LeadingModel::EmBoxTop, 98.0, 44.0)] { + // After a line at 50, the small line is set at 64. A small line takes the span right of the + // object on that baseline; a tall line, which its leading sets lower, goes on the next one. + let (left, right, ok) = set(Some(50.0), model, small); + assert!(near(left, (64.0, 0.0, 180.0, false)) && near(right, (64.0, 220.0, 400.0, true)) && ok, "{model:?}: {left:?} {right:?}"); + let (_, right, ok) = set(Some(50.0), model, tall); + assert!(near(right, (after_64, 0.0, 180.0, false)) && !ok, "{model:?}: {right:?}"); + // The cell's first band: the small line at 10, the tall line on the next baseline. + let (left, right, ok) = set(None, model, tall); + assert!(near(left, (10.0, 0.0, 180.0, false)) && near(right, (after_10, 0.0, 180.0, false)) && !ok, "{model:?}: {left:?} {right:?}"); + } +} + /// The every-line composer's searches for the paragraphs of one frame share one budget: each takes /// the lines it tried and the baseline steps it spent from it, the layout's own steps stay as they /// were, and once the budget runs out the next paragraph's search stops (it is broken line by line). @@ -990,6 +1047,7 @@ fn shaped_composition_searches_share_the_frame_budget() { fb: FirstBaseline::Ascent, fb_min: 0.0, queued: vec![], + above: Default::default(), model: LeadingModel::default(), next_top: None, no_room_for: f64::INFINITY, diff --git a/crates/text/src/tests/combos.rs b/crates/text/src/tests/combos.rs index cdb5a31fee..a2d837e286 100644 --- a/crates/text/src/tests/combos.rs +++ b/crates/text/src/tests/combos.rs @@ -443,6 +443,36 @@ fn composer_x_mixed_sizes_around_text_wrap() { assert_eq!(every, greedy); } +#[test] +fn composer_x_taller_line_in_a_further_span() { + // A wrap object down the middle of the frame splits every line in two. The 36 pt word falls in + // the span right of it on the second baseline, after a 12 pt line left of it: the line that + // holds it is set as far below the line above it as its own leading sets it, and its ascent + // stays clear of that line, with either composer. + let w = x_width(); + let object = Rect::new(w, -10.0, w + 40.0, 310.0); + for composer in [Composer::SingleLine, Composer::EveryLine] { + let mut runs = vec![run(&format!("{RAG} Then draw a card for each creature destroyed this way. "), sans(12.0))]; + runs.extend(with_x(sans(36.0))); + let mut t = area(runs, Rect::new(0.0, 0.0, 2.0 * w + 40.0, 300.0)); + t.wrap = vec![vectorcraft_doc::WrapShape { path: PathData::from_bezpath(&object.to_path(0.1)), wrap: Default::default() }]; + t.para.composer = composer; + let l = layout(db(), &t); + assert!(!l.overflow, "{composer:?}: {:?}", l.lines); + let tall: Vec<_> = l.lines.iter().filter(|line| line.ascent > 30.0).collect(); + assert_eq!(tall.len(), 1, "{composer:?}: {:?}", l.lines); + for line in tall { + for above in l.lines.iter().filter(|a| a.baseline < line.baseline - 0.01) { + let beside = above.avail.0 < line.avail.1 && line.avail.0 < above.avail.1; + assert!( + !beside || line.baseline - line.ascent >= above.baseline + above.descent - 0.01, + "{composer:?}: {line:?} reaches into {above:?}" + ); + } + } + } +} + #[test] fn composer_x_mixed_sizes_in_a_frame_that_widens() { // A frame as wide as "Destroy all" down to 20 pt and as wide as the first line of diff --git a/crates/ui-egui/src/addons/cutting.rs b/crates/ui-egui/src/addons/cutting.rs index 15a6790872..314a25fd0c 100644 --- a/crates/ui-egui/src/addons/cutting.rs +++ b/crates/ui-egui/src/addons/cutting.rs @@ -3,6 +3,7 @@ //! of `addon.cutting.bridges`. Weed Box and Lines… opens it on the options of `addon.cutting.weed`, //! with a Preview, and runs the command on OK. A Spacing above 0 is sent instead of the Count. //! Banner Hems and Grommets… opens it on the options of `addon.cutting.banner`, with a Preview. +//! Nest Shapes… opens it on the options of `addon.cutting.nest`, with a Preview. use serde_json::json; @@ -18,6 +19,8 @@ pub const WEED: &str = "addon.cutting.weed"; pub const WEED_DIRECTIONS: &[(&str, &str)] = &[("Horizontal", "horizontal"), ("Vertical", "vertical"), ("Both", "both")]; /// Banner Hems and Grommets' command. pub const BANNER: &str = "addon.cutting.banner"; +/// Nest Shapes' command. +pub const NEST: &str = "addon.cutting.nest"; /// Object › Plug-ins › Cutting Tools. pub fn menu() -> Vec { @@ -36,6 +39,11 @@ pub fn menu() -> Vec { "diameter": 27, "weight": 0.3, "swatch": "[Registration]" }), ), + Item::Cmd( + "Nest Shapes…", + NEST, + json!({"roll": false, "width": 0, "height": 0, "margin": 18, "spacing": 9, "rotate": false, "usePreviewBounds": true}), + ), ] } diff --git a/crates/ui-egui/src/addons/cutting/tests.rs b/crates/ui-egui/src/addons/cutting/tests.rs index 99580b369c..7e2787996f 100644 --- a/crates/ui-egui/src/addons/cutting/tests.rs +++ b/crates/ui-egui/src/addons/cutting/tests.rs @@ -210,3 +210,57 @@ fn banner_hems_and_grommets_previews_cancels_and_runs_on_ok() { // 528 pt along the top and bottom need 6 gaps, 328 pt down the sides 4: 20 grommets. assert_eq!(count(&app), nodes + 1 + 4 + 20 * 4); } + +/// Object › Plug-ins › Cutting Tools › Nest Shapes… opens the command dialog on its defaults with +/// Preview off, previews the packed objects, puts them back on Cancel and keeps them as one step +/// on OK. +#[test] +fn nest_shapes_previews_cancels_and_runs_on_ok() { + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + app.run("file.new", json!({"width": 600, "height": 400})).unwrap(); + let a = app.run("shape.rectangle", json!({"x": 700, "y": 100, "width": 50, "height": 40})).unwrap()["id"].clone(); + let b = app.run("shape.rectangle", json!({"x": 800, "y": 100, "width": 60, "height": 30})).unwrap()["id"].clone(); + app.run("select.set", json!({"ids": [a, b]})).unwrap(); + let steps = |app: &VectorcraftApp| app.session.doc().unwrap().history.undo.len(); + let (start, n) = (app.session.doc().unwrap().doc.clone(), steps(&app)); + let open = |app: &mut VectorcraftApp| { + let entries = menus::menu_entries(app); + let item = entries.iter().find(|e| e.path == ["Object", "Plug-ins", "Cutting Tools"] && e.label == "Nest Shapes…").unwrap(); + assert!(item.enabled); + let (cmd, p) = menus::click_target(&item.label, item.command.as_deref().unwrap(), &item.params); + menus::invoke(app, &cmd, p); + }; + + open(&mut app); + let d = app.ui.dialog.as_ref().expect("a dialog opens"); + assert_eq!((d.str("__command"), d.str("__label")), (super::NEST.to_string(), "Nest Shapes".to_string())); + assert_eq!((d.bool("roll"), d.bool("rotate"), d.bool("usePreviewBounds"), d.bool("preview")), (false, false, true, false)); + let shown = painted_text(&mut app, |app, ui| crate::dialogs::show(app, ui.ctx())); + for s in ["Nest Shapes", "Roll", "Width", "Height", "Margin", "Spacing", "Rotate", "Use Preview Bounds", "Preview"] { + assert!(shown.contains(s), "{s} in {shown}"); + } + crate::i18n::set_current(crate::i18n::Lang::from_code("de").expect("de registered")); + let german = painted_text(&mut app, |app, ui| crate::dialogs::show(app, ui.ctx())); + crate::i18n::set_current(crate::i18n::Lang::EN); + for s in ["Formen verschachteln", "Rolle"] { + assert!(german.contains(s), "{s} in {german}"); + } + assert_eq!(app.session.doc().unwrap().doc, start, "no preview without the checkbox"); + + crate::dialogs::set_field(app.ui.dialog.as_mut().unwrap(), "preview".into(), json!(true)); + painted_text(&mut app, |app, ui| crate::dialogs::show(app, ui.ctx())); + assert_ne!(app.session.doc().unwrap().doc, start, "previewed"); + crate::dialogs::cancel(&mut app); + assert_eq!(app.session.doc().unwrap().doc, start, "Cancel puts the objects back"); + assert_eq!(steps(&app), n, "and adds no undo step"); + + open(&mut app); + crate::dialogs::set_field(app.ui.dialog.as_mut().unwrap(), "margin".into(), json!("0 pt")); + crate::dialogs::confirm(&mut app).unwrap(); + assert_eq!(steps(&app), n + 1, "one undo step"); + let doc = &app.session.doc().unwrap().doc; + let left = |id: &serde_json::Value| doc.node(vectorcraft_doc::NodeId(id.as_u64().unwrap())).unwrap().geometric_bounds().unwrap().x0; + // The taller object goes first, its 1 pt stroke at the artboard's corner, and the other 9 pt to + // the right of its stroke. + assert_eq!((left(&a), left(&b)), (0.5, 0.5 + 51.0 + 9.0)); +} diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index 5afad21b0d..13975cf1f9 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -159,7 +159,7 @@ pub use export_for_screens::open as open_export_for_screens; pub(crate) use export_for_screens::{ KIND as EXPORT_FOR_SCREENS, PresetPick as ScreenPresetPick, export_preset as screen_export_preset, formats as screen_formats, import_presets as screen_import_presets, open_assets as open_export_for_screens_assets, preset_list as screen_preset_list, - saved_options as screen_saved_options, saved_rows as screen_saved_rows, + saved_options as screen_saved_options, saved_rows as screen_saved_rows, settings_format as screen_settings_format, }; pub(crate) use form::{humanize, number_list_text, plugin_color_button, read_number_list, unit_suffix}; pub use new_document::{create_without_dialog as new_document_without_dialog, open as open_new_document, preset_card}; diff --git a/crates/ui-egui/src/dialogs/command.rs b/crates/ui-egui/src/dialogs/command.rs index 6656b45bf5..fb28afaeac 100644 --- a/crates/ui-egui/src/dialogs/command.rs +++ b/crates/ui-egui/src/dialogs/command.rs @@ -65,8 +65,8 @@ pub(super) const SPEC: DialogSpec = DialogSpec { /// grid options lists one, and the radial and mirror pages do not. Type on a Path Options does /// too (#5360), as public tutorials of the dialog show, and Type Tools › Release Type on a Path /// works the same way (#5532), and so do Image Tools › Remove Background and Color Mask and -/// Cutting Tools › Weed Box and Lines and Banner Hems and Grommets. (Cancel rolls a preview back -/// whichever dialog started it.) +/// Cutting Tools › Weed Box and Lines, Banner Hems and Grommets and Nest Shapes. (Cancel rolls a +/// preview back whichever dialog started it.) fn previews(d: &Dialog) -> bool { match d.str("__command").as_str() { "object.repeat.options" => d.fields.contains_key("gridType"), @@ -75,7 +75,8 @@ fn previews(d: &Dialog) -> bool { | crate::addons::image::REMOVE_BACKGROUND | crate::addons::image::COLOR_MASK | crate::addons::cutting::WEED - | crate::addons::cutting::BANNER => true, + | crate::addons::cutting::BANNER + | crate::addons::cutting::NEST => true, _ => false, } } @@ -123,6 +124,7 @@ fn lengths(command: &str) -> &'static [&'static str] { "perspective.grid.set" => &["cell", "distance"], crate::addons::cutting::WEED => &["margin", "spacing", "gap", "weight"], crate::addons::cutting::BANNER => &["hem", "spacing", "grommetInset", "diameter", "weight"], + crate::addons::cutting::NEST => &["width", "height", "margin", "spacing"], "object.repeat.options" => &["radius", "hSpacing", "vSpacing"], "text.areaOptions" => &["width", "height", "gutter", "rowGutter", "inset", "firstBaselineMin", "lineGap"], "type.pathOptions" => &["spacing"], @@ -150,6 +152,7 @@ fn order(command: &str) -> &'static [&'static str] { crate::addons::cutting::BANNER => { &["hem", "top", "right", "bottom", "left", "grommets", "spacing", "grommetInset", "diameter", "weight", "swatch"] } + crate::addons::cutting::NEST => &["roll", "width", "height", "margin", "spacing", "rotate", "usePreviewBounds"], // The public Trap options' order. "object.pathfinder.trap" => &["thickness", "heightWidth", "tintReduction", "trapsWithProcessColor", "reverseTraps"], "addon.color.printFixes" => &["inkLimit", "excludePureBlack", "removeWhiteOverprint", "overprintBlack", "textOnly", "fill", "stroke"], diff --git a/crates/ui-egui/src/dialogs/export_for_screens.rs b/crates/ui-egui/src/dialogs/export_for_screens.rs index 54c98052bf..ecd9ca6996 100644 --- a/crates/ui-egui/src/dialogs/export_for_screens.rs +++ b/crates/ui-egui/src/dialogs/export_for_screens.rs @@ -156,7 +156,8 @@ pub fn open(app: &mut VectorcraftApp) { // `folders: "format"`, any other sorting a folder per scale. let folders = get("folders").and_then(Value::as_str); let subfolders = match folders { - Some("none") => false, + // An export reads empty folders as none. + Some("none" | "") => false, Some(_) => true, None => flag("subfolders", saved.is_empty() || preset.is_some()), }; @@ -955,14 +956,7 @@ pub(crate) fn formats(ui: &mut egui::Ui, d: &mut Dialog, mut menu: Option &'static str { + rows.first() + .and_then(|r| r["format"].as_str()) + .and_then(fileio::format) + .map(|f| f.id) + .filter(|f| SETTINGS.iter().any(|(id, _)| id == f)) + .unwrap_or("png") +} + /// Format Settings: the options of format `id` that apply to every row of it, then Done. fn format_settings(ui: &mut egui::Ui, d: &mut Dialog, id: &str) { let t = Tokens::get(ui.ctx()); diff --git a/crates/ui-egui/src/i18n.rs b/crates/ui-egui/src/i18n.rs index 8db6de2f96..377df81a72 100644 --- a/crates/ui-egui/src/i18n.rs +++ b/crates/ui-egui/src/i18n.rs @@ -317,7 +317,7 @@ pub static LANGUAGES: [LangInfo; 29] = [ complete_menus: true, catalog: OnceLock::new(), }, - // Danish; every `da-*` locale (`da-DK`, `da_DK.UTF-8`…) resolves here. + // Danish: the whole interface and the status and error messages; every `da-*` locale (`da-DK`, `da_DK.UTF-8`…) resolves here. LangInfo { code: "da", name: "Dansk", diff --git a/crates/ui-egui/src/i18n/bg.tsv b/crates/ui-egui/src/i18n/bg.tsv index 1e801cff32..d0a770b2b1 100644 --- a/crates/ui-egui/src/i18n/bg.tsv +++ b/crates/ui-egui/src/i18n/bg.tsv @@ -1287,6 +1287,7 @@ font-technology Variable Променливи Format Формат Format Options Опции за формат Format Settings Настройки на формата + Format Settings… Настройки на формата… Format: Формат: Formats: Формати: Fractional Widths Дробни ширини @@ -6899,3 +6900,6 @@ breakText Glyphs Глифи Grommets Капси Grommet Inset Отстъп на капсите от ръба Diameter Диаметър + Nest Shapes… Подреждане на форми… + Nest Shapes Подреждане на форми + Roll Ролка diff --git a/crates/ui-egui/src/i18n/bs.tsv b/crates/ui-egui/src/i18n/bs.tsv index ec09c33484..5fca937f98 100644 --- a/crates/ui-egui/src/i18n/bs.tsv +++ b/crates/ui-egui/src/i18n/bs.tsv @@ -2503,6 +2503,7 @@ digits Malayalam Malajalam Export Asset Izvezi resurs Export to: Izvezi u: Format Settings Postavke formata + Format Settings… Postavke formata… Formats: Formati: Full Document Cijeli dokument Include Bleed Uključi napust @@ -6952,3 +6953,6 @@ shape Open Otvoren Grommets Ušice Grommet Inset Udaljenost ušica od ruba Diameter Prečnik + Nest Shapes… Slaganje oblika… + Nest Shapes Slaganje oblika + Roll Rolna diff --git a/crates/ui-egui/src/i18n/ca.tsv b/crates/ui-egui/src/i18n/ca.tsv index 583ea386c8..b6fdc2a6a6 100644 --- a/crates/ui-egui/src/i18n/ca.tsv +++ b/crates/ui-egui/src/i18n/ca.tsv @@ -1797,6 +1797,7 @@ digits Malayalam Malaiàlam Format Format Format Options Opcions de format Format Settings Configuració de format + Format Settings… Configuració de format… Format: Format: Formats: Formats: Fractional Widths Amplades fraccionàries @@ -7494,6 +7495,9 @@ breakText Glyphs Glifs Grommets Ullets Grommet Inset Distància dels ullets a la vora Diameter Diàmetre + Nest Shapes… Encaixa les formes… + Nest Shapes Encaixa les formes + Roll Rotlle # Status and error messages of image export, color profiles and libraries, 3D effects, path operations and scripts. @msg 3D effects require finite coordinates within 100 million points Els efectes 3D requereixen coordenades finites dins de 100 milions de punts @msg 3D effects require one to ten lights Els efectes 3D requereixen d’un a deu llums diff --git a/crates/ui-egui/src/i18n/cs.tsv b/crates/ui-egui/src/i18n/cs.tsv index 54082ffcfd..47ca092cc0 100644 --- a/crates/ui-egui/src/i18n/cs.tsv +++ b/crates/ui-egui/src/i18n/cs.tsv @@ -2536,6 +2536,7 @@ digits Malayalam Malajálamské Export Asset Exportovat prvek Export to: Exportovat do: Format Settings Nastavení formátu + Format Settings… Nastavení formátu… Formats: Formáty: Full Document Celý dokument Include Bleed Zahrnout spadávku @@ -7629,6 +7630,9 @@ breakText Glyphs Glyfy Grommets Průchodky Grommet Inset Odstup průchodek od okraje Diameter Průměr + Nest Shapes… Skládat tvary… + Nest Shapes Skládat tvary + Roll Role @msg 'caller', 'callee' and 'arguments' cannot be used on strict mode functions 'caller', 'callee' a 'arguments' nelze použít u funkcí ve striktním režimu @msg A profile supports one Revolve effect. Edit the existing effect's options instead. Profil podporuje jeden efekt Rotační těleso. Upravte místo toho volby stávajícího efektu. @msg Add Points at Crossings: a path went missing Přidat body v křížení: jedna cesta se ztratila diff --git a/crates/ui-egui/src/i18n/da.tsv b/crates/ui-egui/src/i18n/da.tsv index dd5aba4de6..ca27bdd2f9 100644 --- a/crates/ui-egui/src/i18n/da.tsv +++ b/crates/ui-egui/src/i18n/da.tsv @@ -25,6 +25,615 @@ @msg {key} must be [x, y] with finite coordinates {key} skal være [x, y] med endelige koordinater @msg ids must contain 1–10000 object ids ids skal indeholde 1–10000 objekt-id’er +# Status and error messages: 3D assets, type on a path, scripts and their File and $ objects, presets, +# PDF page sizes, Objects on Path, plug-ins, file readers and data sets. Names in backticks, command ids, +# double-quoted values and bare true, false and null stay as the English writes them; names the user +# gave are quoted »…«, and numbers use a period between thousands (2.048). +@msg {what} can't be exported in 3D: {e} {what} kan ikke eksporteres i 3D: {e} +@msg asset {id} isn't a 3D asset aktiv {id} er ikke et 3D-aktiv +@msg asset {id} is a 3D asset: assets.export3D writes it aktiv {id} er et 3D-aktiv: assets.export3D skriver det +@msg no 3D assets yet: collect an object with a 3D effect first (assets.add3D) ingen 3D-aktiver endnu: saml først et objekt med en 3D-effekt (assets.add3D) +@msg select an object with a 3D effect (or give its ids) markér et objekt med en 3D-effekt (eller angiv dets id’er) +@msg select point type or type on a path markér punkttekst eller tekst på en kurve +@msg type on a path in a thread cannot use Dynamic Text: {frames} tekst på en kurve i en tekstkæde kan ikke bruge dynamisk tekst: {frames} +@msg type on an open or compound path cannot be centered on its top or bottom: {open} tekst på en åben eller sammensat kurve kan ikke centreres på dens top eller bund: {open} +@msg type on a path with no length or no height cannot be centered: {flat} tekst på en kurve uden længde eller højde kan ikke centreres: {flat} +@msg type on a path in a thread cannot be centered on its top or bottom: {frames} tekst på en kurve i en tekstkæde kan ikke centreres på dens top eller bund: {frames} +@msg select type on a closed path markér tekst på en lukket kurve +@msg `Custom` stands for changed settings: save the preset under another name `Custom` står for ændrede indstillinger: gem forudindstillingen under et andet navn +@msg the selection has no gradient fill markeringen har ingen gradientudfyldning +@msg the selection has no gradient stroke markeringen har ingen gradientstreg +@msg the selection has no pattern fill markeringen har ingen mønsterudfyldning +@msg the selection has no pattern stroke markeringen har ingen mønsterstreg +@msg no gradient swatch `{name}` ingen gradientfarveprøve `{name}` +@msg no pattern swatch `{name}` ingen mønsterfarveprøve `{name}` +@msg the library item is damaged bibliotekselementet er beskadiget +@msg its zlib compression is damaged dens zlib-komprimering er beskadiget +@msg its Zstandard compression is damaged dens Zstandard-komprimering er beskadiget +@msg it decompresses to more than {_1} MB den udpakkes til mere end {_1} MB +@msg its editing data is for a different page dens redigeringsdata er til en anden side +@msg `fontHeightReference` must be "emBox", "capHeight", "xHeight" or "icfBox" `fontHeightReference` skal være "emBox", "capHeight", "xHeight" eller "icfBox" +@msg Choose a .js or .jsx script Vælg et .js- eller .jsx-script +@msg Ran {name} Kørte {name} +@msg The script was stopped and its changes were undone Scriptet blev stoppet, og dets ændringer blev fortrudt +@msg a script is running: stop it or wait for it to finish et script kører: stop det, eller vent, til det er færdigt +@msg this is a compiled script, which cannot be run; use its source text dette er et kompileret script, som ikke kan køres; brug dets kildetekst +@msg the script is not UTF-8 text scriptet er ikke UTF-8-tekst +@msg the script is too large to run scriptet er for stort til at blive kørt +@msg a drag or an undo group is open; finish it before running a script en trækning eller en fortrydelsesgruppe er åben; afslut den, før du kører et script +@msg the script left a document that cannot be kept scriptet efterlod et dokument, der ikke kan beholdes +@msg the script is no longer in the Scripts folder scriptet ligger ikke længere i mappen Scripter +@msg the script's run is no longer available scriptets kørsel er ikke længere tilgængelig +@msg a failed command changed undo steps that could not be put back, so the script was stopped en mislykket kommando ændrede fortrydelsestrin, der ikke kunne genskabes, så scriptet blev stoppet +@msg a recovery copy could not be written, so the script was not run en gendannelseskopi kunne ikke skrives, så scriptet blev ikke kørt +@msg Script file access denied Scriptets filadgang blev nægtet +@msg Script file exceeds 8 MiB Scriptfilen er større end 8 MiB +@msg Script file is not open Scriptfilen er ikke åben +@msg Script file cannot be decoded Scriptfilen kan ikke afkodes +@msg Script file is not a regular file Scriptfilen er ikke en almindelig fil +@msg Script file does not exist Scriptfilen findes ikke +@msg Script file could not be read Scriptfilen kunne ikke læses +@msg Script folder has more than 2,048 entries Scriptmappen har mere end 2.048 elementer +@msg Script file open mode must be r, w, e or a Scriptfilens åbningstilstand skal være r, w, e eller a +@msg Script file writing is not available Skrivning af scriptfiler er ikke tilgængelig +@msg Script file dialogs are not available Scriptfildialoger er ikke tilgængelige +@msg Script files cannot be opened in another application Scriptfiler kan ikke åbnes i et andet program +@msg Script file access is unavailable on the web Scriptets filadgang er ikke tilgængelig på nettet +@msg Script has 256 files open Scriptet har 256 filer åbne +@msg Script file path exceeds 32,767 characters Scriptfilens sti er længere end 32.767 tegn +@msg Script folder mask takes too many steps to match Scriptmappens maske kræver for mange trin at matche +@msg the script opened more file data than one run may open (32 MiB) scriptet åbnede flere fildata, end én kørsel må åbne (32 MiB) +@msg File.seek: takes a position and a mode File.seek: tager en position og en tilstand +@msg vc.run: give a command id, as a string vc.run: angiv et kommando-id som en streng +@msg vc.run: takes a command id and an object of parameters vc.run: tager et kommando-id og et objekt med parametre +@msg vc.run: the parameters must be an object vc.run: parametrene skal være et objekt +@msg vc.run: the result{place} is too large for a script vc.run: resultatet{place} er for stort til et script +@msg $.evalFile requires a file path $.evalFile kræver en filsti +@msg $.evalFile: the timeout must be a finite number of milliseconds, 0 or more $.evalFile: tidsgrænsen skal være et endeligt antal millisekunder, 0 eller mere +@msg $.evalFile: takes a file path and a timeout $.evalFile: tager en filsti og en tidsgrænse +@msg {name}: line {line}, column {column} of {file}: {e} (thrown) {name}: linje {line}, kolonne {column} i {file}: {e} (kastet) +@msg {name}: line {line}, column {column}: {e} (thrown) {name}: linje {line}, kolonne {column}: {e} (kastet) +@msg {name}: {e} (thrown) {name}: {e} (kastet) +@msg {name}: line {line}, column {column} of {file}: {e} (limit) {name}: linje {line}, kolonne {column} i {file}: {e} (grænse) +@msg {name}: line {line}, column {column}: {e} (limit) {name}: linje {line}, kolonne {column}: {e} (grænse) +@msg {name}: {e} (limit) {name}: {e} (grænse) +@msg {name}: line {line}, column {column} of {file}: {e} (syntax) {name}: linje {line}, kolonne {column} i {file}: {e} (syntaksfejl) +@msg {name}: line {line}, column {column}: {e} (syntax) {name}: linje {line}, kolonne {column}: {e} (syntaksfejl) +@msg {name}: {e} (syntax) {name}: {e} (syntaksfejl) +@msg {name}: line {line}, column {column} of {file}: {e} (stopped) {name}: linje {line}, kolonne {column} i {file}: {e} (stoppet) +@msg {name}: line {line}, column {column}: {e} (stopped) {name}: linje {line}, kolonne {column}: {e} (stoppet) +@msg {name}: {e} (stopped) {name}: {e} (stoppet) +@msg {name}: line {line}, column {column} of {file}: {e} (internal) {name}: linje {line}, kolonne {column} i {file}: {e} (intern fejl) +@msg {name}: line {line}, column {column}: {e} (internal) {name}: linje {line}, kolonne {column}: {e} (intern fejl) +@msg {name}: {e} (internal) {name}: {e} (intern fejl) +@msg {e} (these files stay written: {paths}) {e} (disse filer forbliver skrevet: {paths}) +@msg $.{name}: give a name, as a string $.{name}: angiv et navn som en streng +@msg $.locale: give a locale such as "en" or "de_DE", or null for the interface's $.locale: angiv en landestandard som "en" eller "de_DE", eller null for brugerfladens +@msg $.localize: give true or false $.localize: angiv true eller false +@msg $.flags: only 512, which turns localization on, or 0 can be set $.flags: kun 512, som slår lokalisering til, eller 0 kan angives +@msg $.flags: give a number $.flags: angiv et tal +@msg $.appEncoding: scripts here read and write UTF-8 only $.appEncoding: scripter her læser og skriver kun UTF-8 +@msg $.appEncoding: give an encoding's name, as a string $.appEncoding: angiv navnet på en tegnkodning som en streng +@msg $.memCache: the memory cache size cannot be set $.memCache: hukommelsescachens størrelse kan ikke angives +@msg $.setenv: give a value, as a string $.setenv: angiv en værdi som en streng +@msg $.setenv: only {_1} can be set $.setenv: kun {_1} kan angives +@msg $.setenv: a value has at most {MAX_ENV_BYTES} letters, digits and _ . - @ $.setenv: en værdi har højst {MAX_ENV_BYTES} bogstaver, cifre og _ . - @ +@msg $.sleep: give a number of milliseconds from 0 to {MAX_SLEEP_MS} $.sleep: angiv et antal millisekunder fra 0 til {MAX_SLEEP_MS} +@msg $.sleep: give a number of milliseconds $.sleep: angiv et antal millisekunder +@msg $.sleep: a run cannot wait inside a callback of a built-in function, a getter, a conversion or a file $.evalFile runs; call it from the script's own code $.sleep: en kørsel kan ikke vente i et tilbagekald fra en indbygget funktion, en getter, en konvertering eller en fil, som $.evalFile kører; kald den fra scriptets egen kode +@msg $.bp: scripts here have no debugger $.bp: scripter her har ingen debugger +@msg $.colorPicker: scripts cannot show a dialog yet $.colorPicker: scripter kan endnu ikke vise en dialog +@msg {name}: give a message {name}: angiv en meddelelse +@msg {name}: give the text the field starts with {name}: angiv den tekst, feltet starter med +@msg {name}: the message is longer than 16 KiB {name}: meddelelsen er længere end 16 KiB +@msg {name}: the title is longer than 256 bytes {name}: titlen er længere end 256 byte +@msg {name}: the text the field starts with is longer than 16 KiB {name}: den tekst, feltet starter med, er længere end 16 KiB +@msg {name}: a dialog cannot open inside a callback of a built-in function, a getter, a conversion or a file $.evalFile runs; call it from the script's own code {name}: en dialog kan ikke åbne i et tilbagekald fra en indbygget funktion, en getter, en konvertering eller en fil, som $.evalFile kører; kald den fra scriptets egen kode +@msg {name}: the next answer in `answers` is for {_1}, not {name} {name}: det næste svar i `answers` er til {_1}, ikke til {name} +@msg {name}: `answers` has no answer left for this dialog {name}: `answers` har intet svar tilbage til denne dialog +@msg vc.document{place} is too large for a script vc.document{place} er for stort til et script +@msg vc.log: the log of this run is full vc.log: loggen for denne kørsel er fuld +@msg this function's script run has ended denne funktions scriptkørsel er afsluttet +@msg the script made more host calls than one run may make scriptet foretog flere værtskald, end én kørsel må foretage +@msg the script passed more data to and from commands than one run may pass scriptet sendte flere data til og fra kommandoer, end én kørsel må sende +@msg the script added more objects and data to the documents than one run may add scriptet tilføjede flere objekter og data til dokumenterne, end én kørsel må tilføje +@msg {what}: the object is not a File or a Folder {what}: objektet er ikke en File eller en Folder +@msg {what}: the count must be a finite number, 0 or more {what}: antallet skal være et endeligt tal, 0 eller mere +@msg File.seek: the position must be a finite number File.seek: positionen skal være et endeligt tal +@msg File.seek: the mode must be 0, 1 or 2 File.seek: tilstanden skal være 0, 1 eller 2 +@msg script file is compiled: {value} scriptfilen er kompileret: {value} +@msg `name` must be the name of a script engine `name` skal være navnet på en scriptmotor +@msg the main script engine is new for each run and is never kept, so it cannot be dropped hovedscriptmotoren er ny ved hver kørsel og bevares aldrig, så den kan ikke fjernes +@msg there is no script engine named {name} der findes ingen scriptmotor med navnet {name} +@msg Running a script Kører et script +@msg Data Recovery can't keep copies in {path}: {e} Datagendannelse kan ikke opbevare kopier i {path}: {e} +@msg Plug-ins can't be loaded from {path}: {e} Plug-ins kan ikke indlæses fra {path}: {e} +@msg select a path markér en kurve +@msg Save writes {_1}, not {_2}: use file.saveAsTemplate to write it Gem skriver {_1}, ikke {_2}: brug file.saveAsTemplate til at skrive det +@msg aiVersion 7 writes legacy .ai files, not .ait templates aiVersion 7 skriver ældre .ai-filer, ikke .ait-skabeloner +@msg `groupColors` must be an array `groupColors` skal være et array +@msg `preserve` must be an object `preserve` skal være et objekt +@msg `{key}` must be an array `{key}` skal være et array +@msg `{key}` must be a #rrggbb color `{key}` skal være en #rrggbb-farve +@msg `{key}` must be a #rrggbb color or a layer color name `{key}` skal være en #rrggbb-farve eller navnet på en lagfarve +@msg unknown Live Paint option `{key}` ukendt Live Paint-indstilling `{key}` +@msg unknown Shape Builder option `{key}` ukendt Formbygger-indstilling `{key}` +@msg `{key}` must be a profile name or null `{key}` skal være et profilnavn eller null +@msg `top` and `below` can't both be true `top` og `below` kan ikke begge være true +@msg `select` must be true or false `select` skal være true eller false +@msg `minAnchors` and `maxAnchors` must be whole numbers `minAnchors` og `maxAnchors` skal være heltal +@msg `{key}` must be a number not below 0 `{key}` skal være et tal, der ikke er under 0 +@msg `{key}` must be four numbers not below 0: [tl, tr, br, bl] `{key}` skal være fire tal, der ikke er under 0: [tl, tr, br, bl] +@msg `{key}` must be [x, y, width, height]: four finite numbers, width and height not negative `{key}` skal være [x, y, width, height]: fire endelige tal, hvor width og height ikke er negative +@msg `{min_key}` is greater than `{max_key}` `{min_key}` er større end `{max_key}` +@msg give at least one of `name`, `kind`, `text`, `within`, `intersects`, `minWidth`, `maxWidth`, `minHeight`, `maxHeight`, `minThickness`, `maxThickness`, `minArea`, `maxArea`, `minAnchors`, `maxAnchors` angiv mindst én af `name`, `kind`, `text`, `within`, `intersects`, `minWidth`, `maxWidth`, `minHeight`, `maxHeight`, `minThickness`, `maxThickness`, `minArea`, `maxArea`, `minAnchors`, `maxAnchors` +@msg a process color can't colorize a grayscale image with transparency: use a spot color, black or white en procesfarve kan ikke farvelægge et gråtonebillede med gennemsigtighed: brug en staffagefarve, sort eller hvid +@msg a key object sits on that point et nøgleobjekt ligger på det punkt +@msg `name` is empty `name` er tom +@msg `newName` is empty `newName` er tom +@msg `{name}` is a built-in preset and can't change: save it under another name `{name}` er en indbygget forudindstilling og kan ikke ændres: gem den under et andet navn +@msg `{name}` is a built-in preset and stays `{name}` er en indbygget forudindstilling og bevares +@msg a spine keeps at least two points en rygrad beholder mindst to punkter +@msg `guideLabel` is null, a layer color's name or #rrggbb `guideLabel` er null, navnet på en lagfarve eller #rrggbb +@msg the Simplify slider is not open skyderen Forenkl er ikke åben +@msg the Smooth slider is not open skyderen Udglat er ikke åben +@msg handle must be in or out handle skal være in eller out +@msg index must be ≥ 0 index skal være ≥ 0 +@msg the page of artboard {n} is {size}, longer than PDF's page limit of 14,400 pt (200 in): it is written with a UserUnit of {unit}, so apps that ignore UserUnit show it that many times smaller siden for tegneflade {n} er {size}, længere end PDF's sidegrænse på 14.400 pt (200 in): den skrives med en UserUnit på {unit}, så programmer, der ignorerer UserUnit, viser den så mange gange mindre +@msg the page of artboard {n} is {size}, longer than PDF's page limit of 14,400 pt (200 in): it is written with a UserUnit of {unit}, so apps that ignore UserUnit show it that many times smaller: turn on Write pages longer than 200 in at full size in points siden for tegneflade {n} er {size}, længere end PDF's sidegrænse på 14.400 pt (200 in): den skrives med en UserUnit på {unit}, så programmer, der ignorerer UserUnit, viser den så mange gange mindre: slå Skriv sider længere end 200 in i fuld størrelse i punkter til +@msg the page of artboard {n} is {size}, longer than PDF's page limit of 14,400 pt (200 in): {label} pages are written without a UserUnit, in points, and apps that keep to the limit cut the page to 200 in siden for tegneflade {n} er {size}, længere end PDF's sidegrænse på 14.400 pt (200 in): {label}-sider skrives uden en UserUnit, i punkter, og programmer, der overholder grænsen, beskærer siden til 200 in +@msg PDF 1.5 has no UserUnit, which a page longer than 14,400 pt (200 in) needs to keep its size: the file is written as PDF 1.6 PDF 1.5 har ingen UserUnit, som en side længere end 14.400 pt (200 in) har brug for for at bevare sin størrelse: filen skrives som PDF 1.6 +@msg PDF 1.4 has no UserUnit, which a page longer than 14,400 pt (200 in) needs to stay within PDF/A-2's page limit of 14,400 units: the file is written as PDF 1.6 PDF 1.4 har ingen UserUnit, som en side længere end 14.400 pt (200 in) har brug for for at holde sig inden for PDF/A-2's sidegrænse på 14.400 enheder: filen skrives som PDF 1.6 +@msg the page of artboard {n} is {size}, too long and narrow for PDF's page sides of 3 to 14,400 units: it is written with a UserUnit of {unit}, which keeps the short side at 3 units or more, and apps that keep to the limit cut the long side to 14,400 units siden for tegneflade {n} er {size}, for lang og smal til PDF's sidelængder på 3 til 14.400 enheder: den skrives med en UserUnit på {unit}, som holder den korte side på 3 enheder eller mere, og programmer, der overholder grænsen, beskærer den lange side til 14.400 enheder +@msg {label} files hold real numbers up to {max}, and the page of artboard {n}, {side} pt long, needs larger ones: choose PDF 1.6 or later without a PDF/X standard, or a smaller artboard {label}-filer rummer reelle tal op til {max}, og siden for tegneflade {n}, der er {side} pt lang, kræver større: vælg PDF 1.6 eller nyere uden en PDF/X-standard, eller en mindre tegneflade +@msg line {line}: value for `{var}` must be true or false linje {line}: værdien for `{var}` skal være true eller false +@msg line {line}: value for `{var}` is longer than {MAX_GRAPH_CSV} bytes linje {line}: værdien for `{var}` er længere end {MAX_GRAPH_CSV} byte +@msg a bristle brush name can have at most {max} characters: `{name}` has {count} navnet på en børstepensel kan have højst {max} tegn: `{name}` har {count} +@msg `{file}` isn't a VectorCraft document, a PDF or a PDF-compatible .ai file `{file}` er ikke et VectorCraft-dokument, en PDF eller en PDF-kompatibel .ai-fil +@msg no saved preset named `{name}` ingen gemt forudindstilling med navnet `{name}` +@msg no saved preset named `{name}` to rename ingen gemt forudindstilling med navnet `{name}` at omdøbe +@msg `{ID}`: the reference would be larger than {MAX_BYTES} bytes `{ID}`: referencen ville være større end {MAX_BYTES} byte +@msg the artboard is locked tegnefladen er låst +@msg no such spine point intet sådant rygradpunkt +@msg `with` must be a list of at most 10000 spine point indexes `with` skal være en liste med højst 10000 rygradpunktindekser +@msg `with` moves points, not a handle `with` flytter punkter, ikke et håndtag +@msg no such spine segment intet sådant rygradsegment +@msg node {_1} is on a locked artboard node {_1} ligger på en låst tegneflade +@msg not a blend ikke en blanding +@msg not a PICT file ikke en PICT-fil +@msg thumbnail: no page {n} (the file has 1) miniature: ingen side {n} (filen har 1) +@msg select a blend or give `id` markér en blanding, eller angiv `id` +@msg select an Objects on Path group markér en gruppe med objekter på kurve +@msg choose the object to detach with its Select/Move widget vælg det objekt, der skal frigøres, med dets Markér/Flyt-kontrol +@msg a path inside a compound path, blend, envelope, repeat or Intertwine group can't take objects en kurve i en sammensat kurve, blanding, kuvert, gentagelse eller sammenfletningsgruppe kan ikke modtage objekter +@msg a clipping path can't take objects en beskæringskurve kan ikke modtage objekter +@msg objects inside a blend, envelope, repeat or Intertwine group can't be attached to a path objekter i en blanding, kuvert, gentagelse eller sammenfletningsgruppe kan ikke knyttes til en kurve +@msg a clipping path can't be attached to a path en beskæringskurve kan ikke knyttes til en kurve +@msg `background` must be edges, corners or colors `background` skal være edges, corners eller colors +@msg `match` must be distance or channels `match` skal være distance eller channels +@msg `{key}` must be from 0 to 100 `{key}` skal være fra 0 til 100 +@msg `colors` must list 1 to 16 colors, each hex (#rrggbb or #rgb) or black, white, red, green or blue `colors` skal angive 1 til 16 farver, hver som hex (#rrggbb eller #rgb) eller black, white, red, green eller blue +@msg the linked image shows its preview: embed or relink it first det sammenkædede billede viser sin forhåndsvisning: integrér det, eller genopret linket først +@msg a CMYK image has no transparency to set et CMYK-billede har ingen gennemsigtighed at angive +@msg every pixel matches the background: no image would be left hver pixel matcher baggrunden: der ville ikke være noget billede tilbage +@msg an image of {w} × {h} pixels is too large to change et billede på {w} × {h} pixels er for stort til at ændre +@msg select an image with a color mask markér et billede med en farvemaske +@msg the color mask's settings are out of range farvemaskens indstillinger er uden for området +@msg a CMYK image can't take a color mask et CMYK-billede kan ikke få en farvemaske +@msg the mask would hide every pixel masken ville skjule hver pixel +@msg Recolor completed; {locked} and {hidden} were left unchanged Omfarvning fuldført; {locked} og {hidden} blev ikke ændret +@msg Recolor completed; {objects} were left unchanged Omfarvning fuldført; {objects} blev ikke ændret +@msg Recolor completed; {objects} was left unchanged Omfarvning fuldført; {objects} blev ikke ændret +@msg 1 locked object 1 låst objekt +@msg {n} locked objects {n} låste objekter +@msg 1 hidden object 1 skjult objekt +@msg {n} hidden objects {n} skjulte objekter +@msg t, dx and dy must be finite t, dx og dy skal være endelige +@msg the blend has no spine blandingen har ingen rygrad +@msg the spine has no segments rygraden har ingen segmenter +@msg panels can't be shown in Presentation Mode paneler kan ikke vises i Præsentationstilstand +@msg unknown preset `{name}`: not a built-in or saved preset (see imageTrace.presets) ukendt forudindstilling `{name}`: ikke en indbygget eller gemt forudindstilling (se imageTrace.presets) +@msg variables are locked variablerne er låst +@msg more than {MAX_CSV_ROWS} data sets: Import Data Sets reads at most {MAX_CSV_ROWS} mere end {MAX_CSV_ROWS} datasæt: Importér datasæt læser højst {MAX_CSV_ROWS} +@msg more than {MAX_CSV_COLS} variables: Import Data Sets reads at most {MAX_CSV_COLS} mere end {MAX_CSV_COLS} variabler: Importér datasæt læser højst {MAX_CSV_COLS} +@msg `{key}: {top}` and `options.{key}: {inner}` disagree: give the option once `{key}: {top}` og `options.{key}: {inner}` er uenige: angiv indstillingen én gang +@msg x and y must be finite x og y skal være endelige +@msg {_1} is an exporter (File › Export › Export As…) {_1} er en eksportør (Filer › Eksportér › Eksportér som…) +@msg `documentData` is {size} bytes (limit {MAX_DOCUMENT_DATA}) `documentData` fylder {size} byte (grænse {MAX_DOCUMENT_DATA}) +@msg a mask that covers several layers comes in as a clipping group in each layer it covers en maske, der dækker flere lag, indlæses som en beskæringsgruppe i hvert lag, den dækker +@msg a graph's records don't end where they should: its art was read as it is en grafs poster slutter ikke, hvor de skal: dens grafik blev læst, som den er +@msg `{w}` takes no operands `{w}` tager ingen operander +@msg a wraparound group is inside a compound path en ombrydningsgruppe ligger i en sammensat kurve +@msg it ends inside a multi-layer mask den slutter inde i en maske over flere lag +@msg a multi-layer mask's operators are out of order operatorerne i en maske over flere lag står i forkert rækkefølge +@msg a multi-layer mask needs one clipping path en maske over flere lag kræver én beskæringskurve +@msg a group crosses a multi-layer mask en gruppe krydser en maske over flere lag +@msg graphs come in as plain art, without their data or settings grafer indlæses som almindelig grafik uden deres data eller indstillinger +@msg the active document changed while the question was open; nothing was deleted det aktive dokument ændrede sig, mens spørgsmålet var åbent; intet blev slettet +@msg The link is inside a symbol, graph design, pattern or opacity mask and has no place on the canvas Linket ligger i et symbol, et grafdesign, et mønster eller en opacitetsmaske og har ingen plads på lærredet +@msg source must be the id of a Shaper Group's original shape source skal være id’et for en shapergruppes oprindelige form +@msg group must be the id of a Shaper Group group skal være id’et for en shapergruppe +@msg the shape is not an editable original shape of that Shaper Group formen er ikke en redigerbar oprindelig form i den shapergruppe +@msg id {value} is a layer, not an object id {value} er et lag, ikke et objekt +@msg id {value} is a layer, not an object; move layers with layer.move id {value} er et lag, ikke et objekt; flyt lag med layer.move +@msg the PCX file is cut short PCX-filen er afkortet +@msg the PCX file's header is damaged PCX-filens header er beskadiget +@msg PCX files with {bits}-bit pixels in {planes} planes can't be read PCX-filer med {bits}-bit-pixels i {planes} planer kan ikke læses +@msg the Pixar file is cut short Pixar-filen er afkortet +@msg the Pixar file's header is damaged Pixar-filens header er beskadiget +@msg the Pixar file's image data is damaged Pixar-filens billeddata er beskadigede +@msg the JPEG 2000 file can't be read JPEG 2000-filen kan ikke læses +@msg the JPEG 2000 file has an unsupported color space or JPX content JPEG 2000-filen har et farverum eller JPX-indhold, der ikke understøttes +@msg the HEIC or HEIF file can't be read HEIC- eller HEIF-filen kan ikke læses +@msg the HEIC or HEIF file holds a kind of image that can't be read HEIC- eller HEIF-filen indeholder en billedtype, der ikke kan læses +@msg HEIC and HEIF images can't be opened in this build HEIC- og HEIF-billeder kan ikke åbnes i denne version +@msg the JPEG XL file is cut short JPEG XL-filen er afkortet +@msg the JPEG XL container is too large to read JPEG XL-containeren er for stor til at blive læst +@msg CMYK JPEG XL images can't be read CMYK-billeder i JPEG XL kan ikke læses +@msg the JPEG XL file can't be read: {e} JPEG XL-filen kan ikke læses: {e} +@msg Extrude & Bevel Ekstrudér og facet +@msg Revolve Omdrej +@msg Inflate Pust op +@msg Rotate Rotér +@msg Extrude & Bevel (Classic) Ekstrudér og facet (klassisk) +@msg Revolve (Classic) Omdrej (klassisk) +@msg Rotate (Classic) Rotér (klassisk) +@msg This effect Denne effekt +@msg clipping paths beskæringskurver +@msg other objects andre objekter +@msg {label} requires a group holding a shape with at least two anchors {label} kræver en gruppe, der indeholder en form med mindst to ankerpunkter +@msg Revolve requires a group holding a shape with at least two anchors Omdrej kræver en gruppe, der indeholder en form med mindst to ankerpunkter +@msg Revolve requires a shape with at least two anchors Omdrej kræver en form med mindst to ankerpunkter +@msg {label} on a group needs paths, compound paths, type, groups, clipping groups, blends and symbol instances: release or expand its {what} first. {label} på en gruppe kræver kurver, sammensatte kurver, tekst, grupper, beskæringsgrupper, blandinger og symbolforekomster: frigør eller udvid først dens {what}. +@msg {label} on a group takes at most {MAX_MEMBERS} objects {label} på en gruppe tager højst {MAX_MEMBERS} objekter +@msg A 3D effect on a group or layer goes on the group itself, not on its fill or stroke En 3D-effekt på en gruppe eller et lag anvendes på selve gruppen, ikke på dens udfyldning eller streg +@msg a 3D effect on a group's fill or stroke draws nothing en 3D-effekt på en gruppes udfyldning eller streg tegner intet +@msg a 3D effect on a group's fill or stroke draws nothing to export en 3D-effekt på en gruppes udfyldning eller streg tegner intet at eksportere +@msg The 3D object is too complex to export 3D-objektet er for komplekst til at blive eksporteret +@msg the object has no 3D effect (Extrude & Bevel, Revolve, Inflate or Rotate) objektet har ingen 3D-effekt (Ekstrudér og facet, Omdrej, Pust op eller Rotér) +@msg its 3D effect builds no solid dens 3D-effekt danner intet legeme +@msg bevel must be none or one of {_1} bevel skal være none eller en af {_1} +@msg {_1} can't build solids from this art: its symbol instances, blends and clipping groups hold too many objects {_1} kan ikke danne legemer af denne grafik: dens symbolforekomster, blandinger og beskæringsgrupper indeholder for mange objekter +@msg {label} can't build a solid from the instance of symbol "{symbol}": the symbol's art isn't available {label} kan ikke danne et legeme af forekomsten af symbolet "{symbol}": symbolets grafik er ikke tilgængelig +@msg {_1}: the clipping path is too complex to cut the art with {_1}: beskæringskurven er for kompleks til at skære grafikken med +@msg {_1} takes at most {MAX_MEMBERS} objects {_1} tager højst {MAX_MEMBERS} objekter +@msg {_1} can't build solids from art nested more than {MAX_DEPTH} levels deep {_1} kan ikke danne legemer af grafik, der er indlejret mere end {MAX_DEPTH} niveauer dybt +@msg Revolve paint is too complex for this surface; reduce Segments or simplify the gradient Omdrej-malingen er for kompleks til denne overflade; reducér Segmenter, eller forenkl gradienten +@msg Revolve edge must be left or right Omdrej: edge skal være left eller right +@msg Revolve cap must be a boolean Omdrej: cap skal være en boolesk værdi +@msg the revolved object has no fill or stroke det omdrejede objekt har ingen udfyldning eller streg +@msg lightColor must be an opaque RGB color in #rrggbb form lightColor skal være en uigennemsigtig RGB-farve i formen #rrggbb +@msg lights must contain one to ten lights lights skal indeholde et til ti lys +@msg each light must be an object hvert lys skal være et objekt +@msg unknown light setting: use color, intensity, rotation, height, softness or behind ukendt lysindstilling: brug color, intensity, rotation, height, softness eller behind +@msg light {key} must be a number from {min} to {max} lys: {key} skal være et tal fra {min} til {max} +@msg light color must use opaque #rrggbb lys: color skal bruge uigennemsigtig #rrggbb +@msg light behind must be true or false lys: behind skal være true eller false +@msg shadow must be true or false shadow skal være true eller false +@msg shadowPosition must be behind or below shadowPosition skal være behind eller below +@msg {key} must be a number from {min} to {max} {key} skal være et tal fra {min} til {max} +@msg each of `{GRAPHICS}` must be an object hvert element i `{GRAPHICS}` skal være et objekt +@msg `{GRAPHICS}` entries take symbol, face, x, y, scale and rotation, not `{k}` elementer i `{GRAPHICS}` tager symbol, face, x, y, scale og rotation, ikke `{k}` +@msg each of `{GRAPHICS}` needs `symbol`, the name of a symbol hvert element i `{GRAPHICS}` kræver `symbol`, navnet på et symbol +@msg `{GRAPHICS}` face must be front or back `{GRAPHICS}`: face skal være front eller back +@msg `{GRAPHICS}` {key} must be a number from {lo} to {hi} `{GRAPHICS}`: {key} skal være et tal fra {lo} til {hi} +@msg `{GRAPHICS}` must be an array `{GRAPHICS}` skal være et array +@msg Graphics map onto the Plane, Extrude and Revolve object types Grafik kortlægges på objekttyperne Plan, Ekstrudér og Omdrej +@msg Revolve maps graphics onto its side: map them onto the front Omdrej kortlægger grafik på siden: kortlæg den på forsiden +@msg The graphics are too complex for this surface: simplify the symbols' art or reduce Segments Grafikken er for kompleks til denne overflade: forenkl symbolernes grafik, eller reducér Segmenter +@msg `{GRAPHICS}` takes at most {MAX_GRAPHICS} graphics `{GRAPHICS}` tager højst {MAX_GRAPHICS} grafikelementer +@msg A plane has one face: map its graphics onto the front Et plan har én flade: kortlæg dets grafik på forsiden +@msg The graphics are too complex: simplify the symbols' art Grafikken er for kompleks: forenkl symbolernes grafik +@msg A graphic's art may be nested at most {MAX_LEVELS} levels deep: simplify the symbol's art Et grafikelements grafik må højst være indlejret {MAX_LEVELS} niveauer dybt: forenkl symbolets grafik +@msg A graphic's art may nest symbols at most {MAX_NESTING} levels deep: simplify the symbol's art Et grafikelements grafik må højst indlejre symboler {MAX_NESTING} niveauer dybt: forenkl symbolets grafik +@msg A graphic's art holds too many objects: simplify the symbol's art Et grafikelements grafik indeholder for mange objekter: forenkl symbolets grafik +@msg A graphic draws at most {MAX_PIECES} fills and strokes: simplify the symbol's art Et grafikelement tegner højst {MAX_PIECES} udfyldninger og streger: forenkl symbolets grafik +@msg `surface` must be one of {_1} `surface` skal være en af {_1} +@msg {label} draws the source art for this object instead of its 3D surface {label} tegner kildegrafikken for dette objekt i stedet for dets 3D-overflade +@msg {label} draws no faces for this object {label} tegner ingen flader for dette objekt +@msg the wireframe has more than {MAX_WIRE_EDGES} edges trådmodellen har mere end {MAX_WIRE_EDGES} kanter +@msg the params must be an object, not {got} parametrene skal være et objekt, ikke {got} +@msg unknown parameter `{path}`; the object takes no keys ukendt parameter `{path}`; objektet tager ingen nøgler +@msg unknown parameter `{path}`; it takes: {keys} ukendt parameter `{path}`; den tager: {keys} +@msg missing `{path}` `{path}` mangler +@msg `{path}` must be {expected}, not {got} `{path}` skal være {expected}, ikke {got} +@msg `{path}` is nested too deeply `{path}` er indlejret for dybt +@msg {path}: not a PNG, JPEG, WebP, TIFF or BMP image {path}: ikke et PNG-, JPEG-, WebP-, TIFF- eller BMP-billede +@msg the image is {iw} × {ih} pixels and the artboard at that width is {rw} × {rh} pixels: their proportions differ billedet er {iw} × {ih} pixels, og tegnefladen i den bredde er {rw} × {rh} pixels: deres proportioner er forskellige +@msg `threshold` must be a number from 0 to 1 `threshold` skal være et tal fra 0 til 1 +@msg background {v}: a color such as "#808080" baggrund {v}: en farve som "#808080" +@msg comparing with an image file isn't available in the browser sammenligning med en billedfil er ikke tilgængelig i browseren +@msg columns {first} and {column} both name the variable `{name}` kolonnerne {first} og {column} angiver begge variablen `{name}` +@msg nothing in data set {name} was applied: no object is bound to a variable it has a value for intet i datasættet {name} blev anvendt: intet objekt er bundet til en variabel, som det har en værdi for +@msg no bound object reports a value (locked objects and embedded images report none) intet bundet objekt rapporterer en værdi (låste objekter og integrerede billeder rapporterer ingen) +@msg Edit Original opens image and document files only; “{name}” is not one Redigér original åbner kun billed- og dokumentfiler; »{name}« er ikke en sådan +@msg only http, https and mailto URLs open in the browser, not “{url}” kun http-, https- og mailto-URL’er åbnes i browseren, ikke »{url}« +@msg {id} opens another app: only the person at the keyboard can run it {id} åbner et andet program: kun personen ved tastaturet kan køre den +@msg “{name}” is not a file »{name}« er ikke en fil +@msg a mask has more than one art block en maske har mere end én grafikblok +@msg mask art ends outside a mask dictionary maskegrafik slutter uden for en maskeordbog +@msg cancelled; nothing was changed annulleret; intet blev ændret +@msg a linked image can't become a gradient mesh: embed it first et sammenkædet billede kan ikke blive et gradientnet: integrér det først +@msg the image has no usable size or position billedet har ingen brugbar størrelse eller position +@msg the image's pixels can't be read billedets pixels kan ikke læses +@msg Persistent libraries not found: {files} Vedvarende biblioteker blev ikke fundet: {files} +@msg `{given}` isn't a library panel (see window.swatchLibrary) `{given}` er ikke et bibliotekspanel (se window.swatchLibrary) +@msg library panel `{given}` isn't open bibliotekspanelet `{given}` er ikke åbent +@msg too many changed shortcuts for mange ændrede genveje +@msg library panel `{given}` isn't missing its library bibliotekspanelet `{given}` mangler ikke sit bibliotek +@msg too many library panels were opened in this session der blev åbnet for mange bibliotekspaneler i denne session +@msg The image is {_1} × {_2} pixels, more than {LARGE_TRACE_SIDE} on a side: it is traced, but more slowly. A lower-fidelity preset or a smaller copy of the image traces faster. Billedet er {_1} × {_2} pixels, mere end {LARGE_TRACE_SIDE} på en side: det spores, men langsommere. En forudindstilling med lavere præcision eller en mindre kopi af billedet spores hurtigere. +@msg The image was traced at {_1} × {_2} pixels, scaled down from {_3} × {_4} ({_5} megapixels at most). Billedet blev sporet ved {_1} × {_2} pixels, skaleret ned fra {_3} × {_4} (højst {_5} megapixel). +@msg byColor must be true or false byColor skal være true eller false +@msg byColor can't split Auto Grouping groups: trace without Auto Grouping byColor kan ikke opdele grupper fra Autogruppering: spor uden Autogruppering +@msg byColor can't regroup a trace with a live Pathfinder effect: expand without byColor byColor kan ikke omgruppere en sporing med en live Pathfinder-effekt: udvid uden byColor +@msg Flat Logo mode does not use keepColors: it traces the colors named in logoColors, or the colors it finds. Tilstanden Fladt logo bruger ikke keepColors: den sporer de farver, logoColors angiver, eller de farver, den finder. +@msg Nothing was traced: none of the colors the trace found matches keepColors ({_1}). Intet blev sporet: ingen af de farver, sporingen fandt, matcher keepColors ({_1}). +@msg Nothing was traced: Ignore Color leaves out every area of the colors keepColors lists. Intet blev sporet: Ignorér farve udelader alle områder med de farver, keepColors angiver. +@msg Nothing was traced: the colors that match keepColors produce no paths. Intet blev sporet: de farver, der matcher keepColors, danner ingen kurver. +@msg keepColors: none of the colors the trace found matches {c}, so nothing of it is traced. keepColors: ingen af de farver, sporingen fandt, matcher {c}, så intet af den spores. +@msg `colorGroup` needs `library: "document"` `colorGroup` kræver `library: "document"` +@msg `colorGroup` needs `palette: "documentLibrary"` `colorGroup` kræver `palette: "documentLibrary"` +@msg `colorGroup` needs `mode: "color"` `colorGroup` kræver `mode: "color"` +@msg no color group `{name}` in the document (see swatch.list) ingen farvegruppe `{name}` i dokumentet (se swatch.list) +@msg Flat Logo traced the image at {_1} × {_2} pixels, averaged down from {_3} × {_4}. Fladt logo sporede billedet ved {_1} × {_2} pixels, udjævnet ned fra {_3} × {_4}. +@msg {label} keeps 1 of the document's {n} artboards: the file reopens with one. Turn on Preserve Editing Capabilities to keep them all. {label} beholder 1 af dokumentets {n} tegneflader: filen genåbnes med én. Slå Bevar redigeringsfunktioner til for at beholde dem alle. +@msg `spread` must be pad, repeat or reflect `spread` skal være pad, repeat eller reflect +@msg `repeats` must be a whole number from 1 to {MAX_REPEATS} `repeats` skal være et heltal fra 1 til {MAX_REPEATS} +@msg `spread` applies to linear, radial, conic and diamond gradients `spread` gælder for lineære, radiale, koniske og diamantformede gradienter +@msg {_1} finished, but the document or the plug-in changed meanwhile: run it again {_1} blev færdig, men dokumentet eller plug-in'et ændrede sig i mellemtiden: kør det igen +@msg Running “{name}”… Kører »{name}«… +@msg a live effect runs while the document is drawn, so it can't be `heavy` en live-effekt kører, mens dokumentet tegnes, så den kan ikke være `heavy` +@msg an exporter always gets an export's budget: leave `budget` out en eksportør får altid et eksportbudget: udelad `budget` +@msg “{_1}” asks for a heavy budget (each run up to {minutes} minutes and {times} times the usual instructions): a person has to approve it in the desktop app, or pass `allowHeavy: true` in a session started with --allow-plugin-grants »{_1}« beder om et tungt budget (hver kørsel op til {minutes} minutter og {times} gange de sædvanlige instruktioner): en person skal godkende det i skrivebordsappen, eller send `allowHeavy: true` i en session startet med --allow-plugin-grants +@msg `name` must be 1-64 printable characters `name` skal være 1-64 tegn, der kan udskrives +@msg `version`, `description` or `author` is too long `version`, `description` eller `author` er for lang +@msg `warnings` must be a list of strings `warnings` skal være en liste med strenge +@msg `{k}` must be one of {_1} `{k}` skal være en af {_1} +@msg `{k}` must be {what} `{k}` skal være {what} +@msg an exporter declares 1 to {MAX_EXTENSIONS} `extensions` en eksportør angiver 1 til {MAX_EXTENSIONS} `extensions` +@msg at most {MAX_UNGROUPED} parameters outside groups: give the others a `group` højst {MAX_UNGROUPED} parametre uden for grupper: giv de andre en `group` +@msg can't encode the input kan ikke kode inputtet +@msg expected {{"dataBase64": "…"}} forventet: {{"dataBase64": "…"}} +@msg expected {{"objects": [...]}} forventet: {{"objects": [...]}} +@msg export `{name}`: {e} eksport `{name}`: {e} +@msg extension {e} is listed twice filtypenavnet {e} er angivet to gange +@msg extension {e}: use 1-16 lower-case letters and digits, without the dot filtypenavn {e}: brug 1-16 små bogstaver og cifre uden punktum +@msg invalid id {_1}: use 1-64 characters from A-Z a-z 0-9 . _ - ugyldigt id {_1}: brug 1-64 tegn fra A-Z a-z 0-9 . _ - +@msg invalid parameter name {k} ugyldigt parameternavn {k} +@msg manifest is not UTF-8 manifestet er ikke UTF-8 +@msg manifest is too long (limit {MAX_MANIFEST_BYTES} bytes) manifestet er for langt (grænse {MAX_MANIFEST_BYTES} byte) +@msg manifest is {_1} bytes (limit {MAX_MANIFEST_BYTES}) manifestet fylder {_1} byte (grænse {MAX_MANIFEST_BYTES}) +@msg module is {_1} bytes (limit {_2}) modulet fylder {_1} byte (grænse {_2}) +@msg needs approval: it asks for {0}, which the user grants when installing it on its own kræver godkendelse: det beder om {0}, som brugeren tildeler, når det installeres for sig selv +@msg not JSON: {e} ikke JSON: {e} +@msg not a WebAssembly binary (missing \0asm header) ikke en WebAssembly-binærfil (headeren \0asm mangler) +@msg parameter `{k}`: {what} parameteren `{k}`: {what} +@msg parameter `{name}`: {what} parameteren `{name}`: {what} +@msg plug-in parameters must be a JSON object plug-in-parametrene skal være et JSON-objekt +@msg the module must export its linear memory as `memory` modulet skal eksportere sin lineære hukommelse som `memory` +@msg the objects to change are no longer in the document de objekter, der skal ændres, er ikke længere i dokumentet +@msg the runtime panicked runtime-miljøet gik i panik +@msg unexpected host trap uventet trap i værten +@msg vc_abi_version returned {v}; this host implements version {ABI_VERSION} vc_abi_version returnerede {v}; denne vært implementerer version {ABI_VERSION} +@msg vc_alloc returned 0 (out of memory) vc_alloc returnerede 0 (ikke mere hukommelse) +@msg vc_alloc returned a block outside memory vc_alloc returnerede en blok uden for hukommelsen +@msg vc_run returned error code {packed} vc_run returnerede fejlkoden {packed} +@msg {_1} is not a file {_1} er ikke en fil +@msg {_1} is {_2} bytes (limit {_3}) {_1} fylder {_2} byte (grænse {_3}) +@msg {_1} objects (limit {MAX_OBJECTS}) {_1} objekter (grænse {MAX_OBJECTS}) +@msg {at}: {msg} {at}: {msg} +@msg {what} points outside memory {what} peger uden for hukommelsen +@msg `angle` must be a number `angle` skal være et tal +@msg `aspect` must be from 0.01 to 100 `aspect` skal være fra 0,01 til 100 +@msg `cap` must be "butt", "round" or "square" `cap` skal være "butt", "round" eller "square" +@msg `fillRule` must be "nonzero" or "evenodd" `fillRule` skal være "nonzero" eller "evenodd" +@msg `helpUrl` must be an https:// address of at most {MAX_HELP_URL} bytes `helpUrl` skal være en https://-adresse på højst {MAX_HELP_URL} byte +@msg `help` must be 1-{MAX_HELP} printable characters `help` skal være 1-{MAX_HELP} tegn, der kan udskrives +@msg `id` must be an effect id of 1-{MAX_EFFECT_ID} bytes `id` skal være et effekt-id på 1-{MAX_EFFECT_ID} byte +@msg `id` must be the id of an input object `id` skal være id’et for et inputobjekt +@msg `join` must be "miter", "round" or "bevel" `join` skal være "miter", "round" eller "bevel" +@msg `label` and `group` must be 1-{MAX_LABEL} printable characters `label` og `group` skal være 1-{MAX_LABEL} tegn, der kan udskrives +@msg `maxLength` must be 1-{MAX_TEXT}, and the default no longer `maxLength` skal være 1-{MAX_TEXT}, og standardværdien må ikke være længere +@msg `minItems` and `maxItems` must be 0-{MAX_LIST}, `maxItems` at least 1, and the default that many `minItems` og `maxItems` skal være 0-{MAX_LIST}, `maxItems` mindst 1, og standardværdien skal have så mange +@msg `name` must be a string `name` skal være en streng +@msg `opacity` must be a number `opacity` skal være et tal +@msg `type` must be "path" or "compound" `type` skal være "path" eller "compound" +@msg `unit` must be 1-{MAX_UNIT} printable characters `unit` skal være 1-{MAX_UNIT} tegn, der kan udskrives +@msg `visibleWhen` lists 1 to {MAX_CONDITION_VALUES} values for `{other}` `visibleWhen` angiver 1 til {MAX_CONDITION_VALUES} værdier for `{other}` +@msg `visibleWhen` names `{other}`, which isn't a parameter declared before this one `visibleWhen` angiver `{other}`, som ikke er en parameter, der er erklæret før denne +@msg `visibleWhen` names at most {MAX_CONDITIONS} parameters `visibleWhen` angiver højst {MAX_CONDITIONS} parametre +@msg `visibleWhen` takes `{other}`'s values, of a checkbox or a choice `visibleWhen` tager værdierne for `{other}`, fra et afkrydsningsfelt eller et valg +@msg `visible` must be true or false `visible` skal være true eller false +@msg `{key}` must be a number from {_1} to {_2} `{key}` skal være et tal fra {_1} til {_2} +@msg `{key}` must be an array of at most {MAX_ITEMS} entries `{key}` skal være et array med højst {MAX_ITEMS} elementer +@msg a color default must be a color object or a hex string such as "#3a7bd5" en standardfarve skal være et farveobjekt eller en hex-streng som "#3a7bd5" +@msg a color object or a hex string such as "#3a7bd5" et farveobjekt eller en hex-streng som "#3a7bd5" +@msg a freeform gradient holds at most {MAX_ITEMS} points and lines en friformsgradient indeholder højst {MAX_ITEMS} punkter og linjer +@msg a freeform gradient line names a point it doesn't have en linje i en friformsgradient angiver et punkt, som gradienten ikke har +@msg a gradient needs 1-{MAX_ITEMS} stops en gradient kræver 1-{MAX_ITEMS} farvestop +@msg a list of numbers en liste med tal +@msg a list of {min_items} to {max_items} numbers en liste med {min_items} til {max_items} tal +@msg a new object needs a `path` et nyt objekt kræver en `path` +@msg a number et tal +@msg a painted live effect returns only `objects` en malet live-effekt returnerer kun `objects` +@msg a painted live effect's piece has only `path`, `fillRule`, `fills` and `strokes` et stykke fra en malet live-effekt har kun `path`, `fillRule`, `fills` og `strokes` +@msg a read-only object was left out et skrivebeskyttet objekt blev udeladt +@msg a read-only object was left out: return it, as {{"id": …}} alone or as it was given et skrivebeskyttet objekt blev udeladt: returnér det, som {{"id": …}} alene eller som det blev givet +@msg a string en streng +@msg a stroke has only `paint`, `width`, `join`, `cap` and `miterLimit` en streg har kun `paint`, `width`, `join`, `cap` og `miterLimit` +@msg a stroke must be {{"paint", "width"?, "join"?, "cap"?, "miterLimit"?}} en streg skal være {{"paint", "width"?, "join"?, "cap"?, "miterLimit"?}} +@msg an effect must be {{"id", "params"?, "visible"?}} en effekt skal være {{"id", "params"?, "visible"?}} +@msg an effect on a fill returns only `fills` en effekt på en udfyldning returnerer kun `fills` +@msg an effect on a stroke returns only `strokes` en effekt på en streg returnerer kun `strokes` +@msg an object must be a JSON object et objekt skal være et JSON-objekt +@msg bad range ugyldigt interval +@msg choices must be 1-64 non-empty, printable strings valgmulighederne skal være 1-64 ikke-tomme strenge, der kan udskrives +@msg coordinate out of range (limit ±{MAX_COORD} pt) koordinat uden for området (grænse ±{MAX_COORD} pt) +@msg default is not one of the options standardværdien er ikke en af valgmulighederne +@msg each piece needs a `path` hvert stykke kræver en `path` +@msg is listed twice er angivet to gange +@msg pattern transform out of range mønstertransformation uden for området +@msg read-only field '{field}' changed det skrivebeskyttede felt '{field}' blev ændret +@msg stops must be in order of `offset` farvestoppene skal stå i rækkefølge efter `offset` +@msg the document has no pattern {pattern} dokumentet har intet mønster {pattern} +@msg the document has no swatch {n} dokumentet har ingen farveprøve {n} +@msg the output outputtet +@msg the path has no anchor points kurven har ingen ankerpunkter +@msg too many anchor points (limit {MAX_ANCHORS}) for mange ankerpunkter (grænse {MAX_ANCHORS}) +@msg true or false true eller false +@msg a number >= {_1} et tal >= {_1} +@msg a number > {_1} et tal > {_1} +@msg a number <= {_1} et tal <= {_1} +@msg a number < {_1} et tal < {_1} +@msg a number {_1} to {_2} et tal fra {_1} til {_2} +@msg a number >= {_1} and < {_2} et tal >= {_1} og < {_2} +@msg a number > {_1} and <= {_2} et tal > {_1} og <= {_2} +@msg a number > {_1} and < {_2} et tal > {_1} og < {_2} +@msg a number ({_1}) et tal ({_1}) +@msg a number >= {_1} ({_2}) et tal >= {_1} ({_2}) +@msg a number > {_1} ({_2}) et tal > {_1} ({_2}) +@msg a number <= {_1} ({_2}) et tal <= {_1} ({_2}) +@msg a number < {_1} ({_2}) et tal < {_1} ({_2}) +@msg a number {_1} to {_2} ({_3}) et tal fra {_1} til {_2} ({_3}) +@msg a number >= {_1} and < {_2} ({_3}) et tal >= {_1} og < {_2} ({_3}) +@msg a number > {_1} and <= {_2} ({_3}) et tal > {_1} og <= {_2} ({_3}) +@msg a number > {_1} and < {_2} ({_3}) et tal > {_1} og < {_2} ({_3}) +@msg an integer et heltal +@msg an integer >= {_1} et heltal >= {_1} +@msg an integer <= {_1} et heltal <= {_1} +@msg an integer {_1} to {_2} et heltal fra {_1} til {_2} +@msg one of {_1} en af {_1} +@msg an object id (a non-negative integer) et objekt-id (et ikke-negativt heltal) +@msg a list of object ids en liste med objekt-id’er +@msg a color (a string, [r, g, b] or an object) en farve (en streng, [r, g, b] eller et objekt) +@msg [x, y] (two numbers) [x, y] (to tal) +@msg an object et objekt +@msg a list en liste +@msg unknown key `{k}`; the keys here are {_1} ukendt nøgle `{k}`; nøglerne her er {_1} +@msg {_1} is listed twice {_1} er angivet to gange +@msg {_1} is not the id of an input object {_1} er ikke id’et for et inputobjekt +@msg {_1} is read-only {_1} er skrivebeskyttet +@msg {what} must be from {lo} to {hi} {what} skal være fra {lo} til {hi} +@msg a color component en farvekomponent +@msg a plate en plade +@msg Automation asked to install this plug-in. To install it, choose Object › Plug-ins › Install Plug-in… yourself. Automatiseringen bad om at installere dette plug-in. Vælg selv Objekt › Plug-ins › Installér plug-in… for at installere det. +@msg “{name}” is another module than the plug-in with id {_1} and would get the settings and cache kept for it: install it with `replace: true` once the user agrees »{name}« er et andet modul end plug-in'et med id {_1} og ville få de indstillinger og den cache, der er gemt til det: installér det med `replace: true`, når brugeren accepterer +@msg “{name}” lists `{id}` in `commandAllowlist`, which a plug-in may not call »{name}« angiver `{id}` i `commandAllowlist`, som et plug-in ikke må kalde +@msg vc_call `{shown}`: {why} vc_call `{shown}`: {why} +@msg vc_call `{shown}`: the manifest's `commandAllowlist` doesn't list it vc_call `{shown}`: manifestets `commandAllowlist` angiver den ikke +@msg vc_call `{shown}`: the id or the arguments lie outside the module's memory vc_call `{shown}`: id’et eller argumenterne ligger uden for modulets hukommelse +@msg vc_call `{shown}`: the id or the arguments are not UTF-8 vc_call `{shown}`: id’et eller argumenterne er ikke UTF-8 +@msg vc_call `{shown}`: the arguments are not a JSON object vc_call `{shown}`: argumenterne er ikke et JSON-objekt +@msg only a filter with the usual budget declares `commandAllowlist` kun et filter med det sædvanlige budget angiver `commandAllowlist` +@msg command calls ({MAX_CALLS} calls and {MAX_CALL_BYTES} bytes per run) kommandokald ({MAX_CALLS} kald og {MAX_CALL_BYTES} byte pr. kørsel) +@msg command call size (vc_call `{shown}`: {len} bytes, limit {MAX_CALL_DATA}) kommandokaldets størrelse (vc_call `{shown}`: {len} byte, grænse {MAX_CALL_DATA}) +@msg at most {MAX_ALLOWED_COMMANDS} commands in `commandAllowlist` højst {MAX_ALLOWED_COMMANDS} kommandoer i `commandAllowlist` +@msg a plug-in may not call this command et plug-in må ikke kalde denne kommando +@msg `commandAllowlist` needs the permission `commands`, and `commands` needs a `commandAllowlist` `commandAllowlist` kræver tilladelsen `commands`, og `commands` kræver en `commandAllowlist` +@msg `commandAllowlist` names `{shown}`, which is not a command id or is listed twice `commandAllowlist` angiver `{shown}`, som ikke er et kommando-id eller er angivet to gange +@msg permission `{permission}` needs plug-in API level {PROGRESS_API}: give `api` {PROGRESS_API} tilladelsen `{permission}` kræver plug-in-API-niveau {PROGRESS_API}: angiv `api` {PROGRESS_API} +@msg the module imports `env.{name}` with another type than the host's: it is {signature} modulet importerer `env.{name}` med en anden type end værtens: den er {signature} +@msg the module imports `env.{name}`, which a plug-in may use only at plug-in API level {PROGRESS_API} (`api` {PROGRESS_API}) and with the permission `{permission}` modulet importerer `env.{name}`, som et plug-in kun må bruge på plug-in-API-niveau {PROGRESS_API} (`api` {PROGRESS_API}) og med tilladelsen `{permission}` +@msg the module imports `{from}.{name}`, which the host doesn't offer: a plug-in may import only the `env.vc_*` functions that docs/plugins.md lists (plug-in API level 2), and no WASI, files or network modulet importerer `{from}.{name}`, som værten ikke tilbyder: et plug-in må kun importere de `env.vc_*`-funktioner, som docs/plugins.md angiver (plug-in-API-niveau 2), og ingen WASI, filer eller netværk +@msg `api` is {_1}; this host implements plug-in API levels 1 to {API_LEVEL} `api` er {_1}; denne vært implementerer plug-in-API-niveau 1 til {API_LEVEL} +@msg `paintOutput` may only be true `paintOutput` må kun være true +@msg `paintOutput` is only for a live effect (`kind` "effect") at `api` 2 `paintOutput` er kun til en live-effekt (`kind` "effect") ved `api` 2 +@msg `grant` must be a list of permission names `grant` skal være en liste med tilladelsesnavne +@msg `grant` names an unknown permission `{shown}` `grant` angiver en ukendt tilladelse `{shown}` +@msg a plug-in of kind `{_1}` needs the permission `{_2}` et plug-in af typen `{_1}` kræver tilladelsen `{_2}` +@msg at most {MAX_PERMISSIONS} `permissions` højst {MAX_PERMISSIONS} `permissions` +@msg permission `{shown}` doesn't apply to a plug-in of kind `{_1}` tilladelsen `{shown}` gælder ikke for et plug-in af typen `{_1}` +@msg permission `{shown}` is listed twice tilladelsen `{shown}` er angivet to gange +@msg only a filter declares `inputs` kun et filter angiver `inputs` +@msg `inputs` names an unknown object kind `{shown}`: use type, image or symbolInstance `inputs` angiver en ukendt objekttype `{shown}`: brug type, image eller symbolInstance +@msg `inputs` lists `{shown}` twice `inputs` angiver `{shown}` to gange +@msg `inputs` needs `api` 2 or later `inputs` kræver `api` 2 eller nyere +@msg `inputs` needs the permission `doc.read` `inputs` kræver tilladelsen `doc.read` +@msg memory budget (object {_1} needs {size} bytes of input, {left} left) hukommelsesbudget (objekt {_1} kræver {size} byte input, {left} tilbage) +@msg object budget (more than {MAX_OBJECTS} read-only objects) objektbudget (mere end {MAX_OBJECTS} skrivebeskyttede objekter) +@msg instruction budget instruktionsbudget +@msg time budget tidsbudget +@msg memory budget (the selection is too large to hand over) hukommelsesbudget (markeringen er for stor til at blive overdraget) +@msg output size ({what} is {len} bytes, limit {max}) outputstørrelse ({what} fylder {len} byte, grænse {max}) +@msg the plug-in's grant can't be removed from the plug-in store plug-in'ets tilladelse kan ikke fjernes fra plug-in-lageret +@msg permission `{shown}` is reserved for a later plug-in API; this host doesn't grant it tilladelsen `{shown}` er reserveret til et senere plug-in-API; denne vært tildeler den ikke +@msg unknown permission `{shown}` ukendt tilladelse `{shown}` +@msg “{name}” asks for the permissions {asked}: a person has to approve them in the desktop app, or pass `grant` in a session started with --allow-plugin-grants »{name}« beder om tilladelserne {asked}: en person skal godkende dem i skrivebordsappen, eller send `grant` i en session startet med --allow-plugin-grants +@msg `grant` and `allowHeavy` take effect only in a session started with --allow-plugin-grants: approve the plug-in in the desktop app `grant` og `allowHeavy` virker kun i en session startet med --allow-plugin-grants: godkend plug-in'et i skrivebordsappen +@msg Only the person at the keyboard can approve installing a plug-in Kun personen ved tastaturet kan godkende installation af et plug-in +@msg “{name}” wasn't granted `{p}`, which its output needs: nothing was changed »{name}« fik ikke tildelt `{p}`, som dets output kræver: intet blev ændret +@msg a heavy filter runs in the background, so it can't declare `commands` et tungt filter kører i baggrunden, så det kan ikke angive `commands` +@msg at most {MAX_COMMANDS} `commands` højst {MAX_COMMANDS} `commands` +@msg command {name} is declared twice kommandoen {name} er erklæret to gange +@msg command {name}: `label` must be 1-64 printable characters kommandoen {name}: `label` skal være 1-64 tegn, der kan udskrives +@msg command {name}: use an id of 1-{MAX_COMMAND_ID} characters from a-z 0-9 _ that starts with a letter kommandoen {name}: brug et id på 1-{MAX_COMMAND_ID} tegn fra a-z 0-9 _, der starter med et bogstav +@msg command {name}: {e} kommandoen {name}: {e} +@msg manifest: {e} manifest: {e} +@msg manifest: {e} at line {line} column {column} manifest: {e} på linje {line} kolonne {column} +@msg `commands` must be a list of commands, each with an `id` and a `label` `commands` skal være en liste med kommandoer, hver med et `id` og et `label` +@msg command {name} must be an object with an `id` and a `label` kommandoen {name} skal være et objekt med et `id` og et `label` +@msg missing field `{key}` feltet `{key}` mangler +@msg duplicate field `{key}` dobbelt felt `{key}` +@msg only a filter or a live effect declares `commands` kun et filter eller en live-effekt angiver `commands` +@msg “{name}” › {label}: {e} »{name}« › {label}: {e} +@msg the module doesn't have the SHA-256 `hash` gives: nothing was installed modulet har ikke den SHA-256, som `hash` angiver: intet blev installeret +@msg select one object markér ét objekt +@msg no similar objects found (rotated or reflected copies are not matched); change the Global Edit Options and try again ingen lignende objekter fundet (roterede eller spejlede kopier matches ikke); ændr Indstillinger for global redigering, og prøv igen +@msg Global Edit is off Global redigering er slået fra +@msg only an object of the Global Edit other than the one it started from can be left out kun et objekt i den globale redigering, bortset fra det, den startede fra, kan udelades +@msg `artboards`: all or a range such as 1, 3-5, not {v} `artboards`: all eller et interval som 1, 3-5, ikke {v} +@msg `{_1}` has no symbols to place `{_1}` har ingen symboler at placere +@msg missing `library` (see symbol.libraries) `library` mangler (se symbol.libraries) +@msg no symbol `{n}` intet symbol `{n}` +@msg no symbol `{n}` in `{_1}` intet symbol `{n}` i `{_1}` +@msg no symbol library `{key}` (see symbol.libraries) intet symbolbibliotek `{key}` (se symbol.libraries) +@msg the document has no symbols to save dokumentet har ingen symboler at gemme +@msg `{_1}` has no brushes to apply `{_1}` har ingen pensler at anvende +@msg missing `library` (see brush.libraries) `library` mangler (se brush.libraries) +@msg no brush `{n}` ingen pensel `{n}` +@msg no brush `{n}` in `{_1}` ingen pensel `{n}` i `{_1}` +@msg no brush library `{key}` (see brush.libraries) intet penselbibliotek `{key}` (se brush.libraries) +@msg the document has no brushes to save dokumentet har ingen pensler at gemme +@msg a {width}×{height} screenshot's byte count overflows bytetallet for et skærmbillede på {width}×{height} løber over +@msg the window's {what} ({points} points × {scale} DPI) cannot size a screenshot vinduets {what} ({points} punkter × {scale} DPI) kan ikke give et skærmbillede en størrelse +@msg {label} must be a number {label} skal være et tal +@msg {label} must be a number of at least 0.1 {label} skal være et tal på mindst 0,1 +@msg {label} must be a number from {min} to {max} {label} skal være et tal fra {min} til {max} +@msg {label} must be a whole number from {min} to {max} {label} skal være et heltal fra {min} til {max} +@msg features lists at most {max} tags features angiver højst {max} tags +@msg unknown OpenType feature `{t}` ukendt OpenType-funktion `{t}` +@msg {label} must be a number above 0 and at most {max} {label} skal være et tal over 0 og højst {max} +@msg {label} must be at most {max} characters {label} må højst være {max} tegn +@msg {label} must be one character {label} skal være ét tegn +@msg at most {max} tab stops højst {max} tabulatorstop +@msg unknown align `{other}` ukendt justering `{other}` +@msg no character style `{name}` ingen tegnstil `{name}` +@msg {_1} is an importer (File › Open With) {_1} er en importør (Filer › Åbn med) +@msg missing `importer` (see document.formats) `importer` mangler (se document.formats) +@msg `importer` must be a plug-in format id (see document.formats) `importer` skal være id’et for et plug-in-format (se document.formats) +@msg “{shown}” opens {_1} files, not `{_2}` »{shown}« åbner {_1}-filer, ikke `{_2}` +@msg `{_1}` is a plug-in of kind `{_2}`, not an importer `{_1}` er et plug-in af typen `{_2}`, ikke en importør +@msg `{_1}` is a plug-in of kind `{_2}`, not an exporter `{_1}` er et plug-in af typen `{_2}`, ikke en eksportør +@msg `{_1}` is an importer: open a file with document.open {{"importer": "{_2}"}} `{_1}` er en importør: åbn en fil med document.open {{"importer": "{_2}"}} +@msg `{_1}` is an exporter: export with document.export {{"format": "{_2}"}} `{_1}` er en eksportør: eksportér med document.export {{"format": "{_2}"}} +@msg `{_1}` is a live effect: apply it with effect.apply {{"effect": "{_2}"}} `{_1}` er en live-effekt: anvend den med effect.apply {{"effect": "{_2}"}} +@msg the importer returned no objects importøren returnerede ingen objekter +@msg an importer returns `objects` and `warnings`, not `{key}` en importør returnerer `objects` og `warnings`, ikke `{key}` +@msg the file is larger than the {MAX_INPUT_BYTES} bytes an importer reads filen er større end de {MAX_INPUT_BYTES} byte, en importør læser +@msg an importer declares 1 to {MAX_EXTENSIONS} `extensions` en importør angiver 1 til {MAX_EXTENSIONS} `extensions` +@msg only an exporter or an importer declares `extensions` kun en eksportør eller en importør angiver `extensions` +@msg an importer always gets an import's budget: leave `budget` out en importør får altid et importbudget: udelad `budget` +@msg only paths and compound paths can be aligned to the pixel grid kun kurver og sammensatte kurver kan justeres til pixelgitteret +@msg `first` and `last` must be numbers of lines the console holds, first before last `first` og `last` skal være numre på linjer, som konsollen indeholder, first før last +@msg no plug-in effect is deferred ingen plug-in-effekt er udskudt +@msgplural Opened with {n} plug-in effect deferred. Effect › Refresh Deferred Effects runs it.|Opened with {n} plug-in effects deferred. Effect › Refresh Deferred Effects runs them. Åbnede med {n} udskudt plug-in-effekt. Effekt › Opdatér udskudte effekter kører den.|Åbnede med {n} udskudte plug-in-effekter. Effekt › Opdatér udskudte effekter kører dem. +@msg The selected art has no islands to bridge Den markerede grafik har ingen øer at bygge bro over +@msg No bridge of this width fits across the selected art Ingen bro med denne bredde passer hen over den markerede grafik +@msg Some islands could not be joined to the area around the art Nogle øer kunne ikke forbindes med området omkring grafikken +@msg `width` must be from {lo} to {hi} pt `width` skal være fra {lo} til {hi} pt +@msg `count` must be a whole number from 1 to {MAX_BRIDGES_PER_ISLAND} `count` skal være et heltal fra 1 til {MAX_BRIDGES_PER_ISLAND} + File Filer New… Ny… New Without Dialog Ny uden dialog @@ -2329,6 +2938,7 @@ digits Malayalam Malayalam Export Asset Eksportér aktiv Export to: Eksportér til: Format Settings Formatindstillinger + Format Settings… Formatindstillinger… Formats: Formater: Full Document Hele dokumentet Include Bleed Medtag beskæring @@ -4598,6 +5208,7 @@ imageTrace Outline Kontur @msg no action set to save intet handlingssæt at gemme @msg the action set file can't be read in full handlingssætfilen kan ikke læses helt @msgplural Loaded “{name}”: {n} step was left out|Loaded “{name}”: {n} steps were left out Indlæste »{name}«: {n} trin blev udeladt|Indlæste »{name}«: {n} trin blev udeladt +@msgplural Loaded “{name}”: {n} step has an unknown command and was kept: “{action}”, step {step}, `{command}`|Loaded “{name}”: {n} steps have unknown commands and were kept; the first: “{action}”, step {step}, `{command}` Indlæste »{name}«: {n} trin har en ukendt kommando og blev beholdt: »{action}«, trin {step}, `{command}`|Indlæste »{name}«: {n} trin har ukendte kommandoer og blev beholdt; det første: »{action}«, trin {step}, `{command}` @msg not an action set file ikke en handlingssætfil @msg the action set file is larger than 8 MiB handlingssætfilen er større end 8 MiB @msg the action set file holds more than 1,000 actions handlingssætfilen indeholder mere end 1.000 handlinger @@ -6914,3 +7525,6 @@ breakText Glyphs Glyffer Grommets Maljer Grommet Inset Maljernes afstand fra kanten Diameter Diameter + Nest Shapes… Pak figurer tæt… + Nest Shapes Pak figurer tæt + Roll Rulle diff --git a/crates/ui-egui/src/i18n/de.tsv b/crates/ui-egui/src/i18n/de.tsv index 698d16a546..df4d5df82e 100644 --- a/crates/ui-egui/src/i18n/de.tsv +++ b/crates/ui-egui/src/i18n/de.tsv @@ -1332,6 +1332,7 @@ file-filter Templates Vorlagen Format Format Format Options Formatoptionen Format Settings Formateinstellungen + Format Settings… Formateinstellungen… Format: Format: Formats: Formate: Fractional Widths Gebrochene Breiten @@ -9042,3 +9043,6 @@ breakText Glyphs Glyphen Grommets Ösen Grommet Inset Randabstand der Ösen Diameter Durchmesser + Nest Shapes… Formen verschachteln… + Nest Shapes Formen verschachteln + Roll Rolle diff --git a/crates/ui-egui/src/i18n/el.tsv b/crates/ui-egui/src/i18n/el.tsv index a838aaac80..9f3ee177e5 100644 --- a/crates/ui-egui/src/i18n/el.tsv +++ b/crates/ui-egui/src/i18n/el.tsv @@ -1265,6 +1265,7 @@ font-technology Variable Μεταβλητές Format Μορφή Format Options Επιλογές μορφής Format Settings Ρυθμίσεις μορφής + Format Settings… Ρυθμίσεις μορφής… Format: Μορφή: Formats: Μορφές: Fractional Widths Κλασματικά πλάτη @@ -7466,6 +7467,9 @@ breakText Glyphs Γλύφοι Grommets Τρουκς Grommet Inset Απόσταση τρουκς από την άκρη Diameter Διάμετρος + Nest Shapes… Ένθεση σχημάτων… + Nest Shapes Ένθεση σχημάτων + Roll Ρολό # Messages of the effects, 3D, image export, color profiles, swatch exchange files, color books, DXF import and PDF export. @msg 3D effects require finite coordinates within 100 million points Τα εφέ 3D απαιτούν πεπερασμένες συντεταγμένες εντός 100 εκατομμυρίων τυπογραφικών στιγμών diff --git a/crates/ui-egui/src/i18n/es.tsv b/crates/ui-egui/src/i18n/es.tsv index 719b9dc668..555e51b8c7 100644 --- a/crates/ui-egui/src/i18n/es.tsv +++ b/crates/ui-egui/src/i18n/es.tsv @@ -1341,6 +1341,7 @@ font-technology Variable Variables Format Formato Format Options Opciones de formato Format Settings Ajustes de formato + Format Settings… Ajustes de formato… Format: Formato: Formats: Formatos: Fractional Widths Anchuras fraccionarias @@ -7573,3 +7574,6 @@ breakText Glyphs Glifos Grommets Ojales Grommet Inset Distancia de los ojales al borde Diameter Diámetro + Nest Shapes… Anidar formas… + Nest Shapes Anidar formas + Roll Rollo diff --git a/crates/ui-egui/src/i18n/fi.tsv b/crates/ui-egui/src/i18n/fi.tsv index 5c04678407..363042b43d 100644 --- a/crates/ui-egui/src/i18n/fi.tsv +++ b/crates/ui-egui/src/i18n/fi.tsv @@ -2491,6 +2491,7 @@ digits Malayalam Malajalam Export Asset Vie resurssi Export to: Vie kohteeseen: Format Settings Muotoasetukset + Format Settings… Muotoasetukset… Formats: Muodot: Full Document Koko asiakirja Include Bleed Sisällytä leikkuuvara @@ -7548,6 +7549,9 @@ breakText Glyphs Merkit Grommets Purjerenkaat Grommet Inset Purjerenkaiden etäisyys reunasta Diameter Halkaisija + Nest Shapes… Sijoittele muodot… + Nest Shapes Sijoittele muodot + Roll Rulla # Messages of the effects, 3D, image and PDF export, color profiles, swatch exchange files, color books and DXF import. @msg 3D effects require finite coordinates within 100 million points 3D-tehosteet vaativat äärelliset koordinaatit 100 miljoonan pisteen rajoissa diff --git a/crates/ui-egui/src/i18n/fr.tsv b/crates/ui-egui/src/i18n/fr.tsv index d25cc7069c..494a2d944a 100644 --- a/crates/ui-egui/src/i18n/fr.tsv +++ b/crates/ui-egui/src/i18n/fr.tsv @@ -1335,6 +1335,7 @@ font-technology Variable Variables Format Format Format Options Options de format Format Settings Paramètres de format + Format Settings… Paramètres de format… Format: Format : Formats: Formats : Fractional Widths Largeurs fractionnaires @@ -9312,6 +9313,9 @@ breakText Glyphs Glyphes Grommets Œillets Grommet Inset Distance des œillets au bord Diameter Diamètre + Nest Shapes… Imbriquer les formes… + Nest Shapes Imbriquer les formes + Roll Rouleau # Messages of the effects, 3D, image export, color profiles, swatch exchange files, color books and DXF import. @msg 3D effects require finite coordinates within 100 million points Les effets 3D exigent des coordonnées finies comprises dans 100 millions de points diff --git a/crates/ui-egui/src/i18n/gu.tsv b/crates/ui-egui/src/i18n/gu.tsv index a36ac35c7a..7382b4fe9e 100644 --- a/crates/ui-egui/src/i18n/gu.tsv +++ b/crates/ui-egui/src/i18n/gu.tsv @@ -1457,6 +1457,7 @@ Format Options ફોર્મેટ વિકલ્પો Format Presets ફોર્મેટ પ્રીસેટ્સ Format Settings ફોર્મેટ સેટિંગ્સ + Format Settings… ફોર્મેટ સેટિંગ્સ… Format: ફોર્મેટ: Formats: ફોર્મેટ્સ: Fractional Widths અપૂર્ણાંક પહોળાઈ diff --git a/crates/ui-egui/src/i18n/hi.tsv b/crates/ui-egui/src/i18n/hi.tsv index f4a3a04807..edfbe0818f 100644 --- a/crates/ui-egui/src/i18n/hi.tsv +++ b/crates/ui-egui/src/i18n/hi.tsv @@ -1993,6 +1993,7 @@ antiAlias Sharp शार्प Format Options फ़ॉर्मेट ऑप्शन्स Format Presets फ़ॉर्मैट प्रीसेट Format Settings फ़ॉर्मैट सेटिंग + Format Settings… फ़ॉर्मैट सेटिंग… Format: फ़ॉर्मेट: Formats: फ़ॉर्मैट: Fractional Widths आंशिक चौड़ाई @@ -8904,3 +8905,6 @@ theme Appearance अपीयरेंस Grommets आईलेट Grommet Inset किनारे से आईलेट की दूरी Diameter व्यास + Nest Shapes… आकृतियाँ सटाकर रखें… + Nest Shapes आकृतियाँ सटाकर रखें + Roll रोल diff --git a/crates/ui-egui/src/i18n/hu.tsv b/crates/ui-egui/src/i18n/hu.tsv index bea893c5ff..27dda10840 100644 --- a/crates/ui-egui/src/i18n/hu.tsv +++ b/crates/ui-egui/src/i18n/hu.tsv @@ -1666,6 +1666,7 @@ file-filter Templates Sablonok Format Formátum Format Options Formátumbeállítások Format Settings Formátumbeállítások + Format Settings… Formátumbeállítások… Format: Formátum: Formats: Formátumok: Fractional Widths Tört szélességek @@ -7497,6 +7498,10 @@ breakText Glyphs Glifák Grommets Ringlik Grommet Inset Ringlik távolsága a széltől Diameter Átmérő + Nest Shapes… Alakzatok elrendezése… + Nest Shapes Alakzatok elrendezése + Roll Tekercs + # Status and error messages of commands, scripts, file import and export, and plug-ins. @msg 'caller', 'callee' and 'arguments' cannot be used on strict mode functions a 'caller', a 'callee' és az 'arguments' nem használható szigorú módú függvényeken @msg A profile supports one Revolve effect. Edit the existing effect's options instead. Egy profilon egy Forgatás tengely körül effektus lehet. Inkább a meglévő effektus beállításait szerkeszd. diff --git a/crates/ui-egui/src/i18n/id.tsv b/crates/ui-egui/src/i18n/id.tsv index 92d97e00d9..f870492477 100644 --- a/crates/ui-egui/src/i18n/id.tsv +++ b/crates/ui-egui/src/i18n/id.tsv @@ -2329,6 +2329,7 @@ digits Malayalam Malayalam Export Asset Ekspor aset Export to: Ekspor ke: Format Settings Pengaturan format + Format Settings… Pengaturan format… Formats: Format: Full Document Dokumen penuh Include Bleed Sertakan bleed @@ -6914,3 +6915,6 @@ breakText Glyphs Glif Grommets Mata ayam Grommet Inset Jarak mata ayam dari tepi Diameter Diameter + Nest Shapes… Susun Bentuk… + Nest Shapes Susun Bentuk + Roll Gulungan diff --git a/crates/ui-egui/src/i18n/it.tsv b/crates/ui-egui/src/i18n/it.tsv index 6fb07b1a01..7ef7dedc00 100644 --- a/crates/ui-egui/src/i18n/it.tsv +++ b/crates/ui-egui/src/i18n/it.tsv @@ -1362,6 +1362,7 @@ file-filter Templates Modelli Format Formato Format Options Opzioni formato Format Settings Impostazioni formato + Format Settings… Impostazioni formato… Format: Formato: Formats: Formati: Fractional Widths Larghezze frazionarie @@ -9075,3 +9076,6 @@ breakText Glyphs Glifi Grommets Occhielli Grommet Inset Distanza degli occhielli dal bordo Diameter Diametro + Nest Shapes… Annida forme… + Nest Shapes Annida forme + Roll Rotolo diff --git a/crates/ui-egui/src/i18n/ja.tsv b/crates/ui-egui/src/i18n/ja.tsv index 6b59f5a370..c18d4c55ca 100644 --- a/crates/ui-egui/src/i18n/ja.tsv +++ b/crates/ui-egui/src/i18n/ja.tsv @@ -1837,6 +1837,7 @@ antiAlias Sharp シャープ Format 形式 Format Options 形式オプション Format Settings 形式の設定 + Format Settings… 形式の設定… Format: 形式: Formats: 形式: Fractions 分数 @@ -9013,6 +9014,9 @@ breakText Glyphs 字形 Grommets ハトメ Grommet Inset 端からハトメまでの距離 Diameter 直径 + Nest Shapes… 図形のネスティング… + Nest Shapes 図形のネスティング + Roll ロール # Messages no catalog translated yet: image export and color profiles, color books and swatch exchange files, 3D and Revolve, and script, zip, line and file errors. @msg 3D effects require finite coordinates within 100 million points 3D 効果には 1 億ポイント以内の有限の座標が必要です diff --git a/crates/ui-egui/src/i18n/ka.tsv b/crates/ui-egui/src/i18n/ka.tsv index 7c872ed9b8..5ab6945aec 100644 --- a/crates/ui-egui/src/i18n/ka.tsv +++ b/crates/ui-egui/src/i18n/ka.tsv @@ -2255,6 +2255,7 @@ digits Malayalam მალაიალამური Export Asset რესურსის ექსპორტირება Export to: ექსპორტის ადგილი: Format Settings ფორმატის პარამეტრები + Format Settings… ფორმატის პარამეტრები… Formats: ფორმატები: Full Document მთელი დოკუმენტი Include Bleed ჭრისპირა არის ჩართვა @@ -7627,3 +7628,6 @@ font-script Japanese იაპონური Grommets ლუვერები Grommet Inset ლუვერების დაშორება კიდიდან Diameter დიამეტრი + Nest Shapes… ფორმების განლაგება… + Nest Shapes ფორმების განლაგება + Roll რულონი diff --git a/crates/ui-egui/src/i18n/ko.tsv b/crates/ui-egui/src/i18n/ko.tsv index 04a3234343..9ed9cd6a98 100644 --- a/crates/ui-egui/src/i18n/ko.tsv +++ b/crates/ui-egui/src/i18n/ko.tsv @@ -1827,6 +1827,7 @@ antiAlias Sharp 날카롭게 Format 형식 Format Options 형식 옵션 Format Settings 형식 설정 + Format Settings… 형식 설정… Format: 형식: Formats: 형식: Fractions 분수 @@ -7104,6 +7105,9 @@ breakText Glyphs 글리프 Grommets 아일렛 Grommet Inset 가장자리에서 아일렛까지 거리 Diameter 지름 + Nest Shapes… 도형 네스팅… + Nest Shapes 도형 네스팅 + Roll 롤 # Messages of the 3D effects, image export, color profiles, swatch exchange files, color books, DXF import, # path operations, libraries, scripts and zip files. diff --git a/crates/ui-egui/src/i18n/nl.tsv b/crates/ui-egui/src/i18n/nl.tsv index 09459378a0..711ce4c8a8 100644 --- a/crates/ui-egui/src/i18n/nl.tsv +++ b/crates/ui-egui/src/i18n/nl.tsv @@ -2847,6 +2847,7 @@ digits Malayalam Malayalam Export Asset Asset exporteren Export to: Exporteren naar: Format Settings Indelingsinstellingen + Format Settings… Indelingsinstellingen… Formats: Indelingen: Full Document Volledig document Include Bleed Afrulling opnemen @@ -9358,6 +9359,9 @@ breakText Glyphs Glyphs Grommets Zeilringen Grommet Inset Afstand van zeilringen tot de rand Diameter Diameter + Nest Shapes… Vormen nesten… + Nest Shapes Vormen nesten + Roll Rol # Status and error messages: 3D effects and Revolve, image export, color profiles, swatch exchange files and # color books, regular expressions in scripts, whole-number parameters, and DXF and DWG files. diff --git a/crates/ui-egui/src/i18n/pl.tsv b/crates/ui-egui/src/i18n/pl.tsv index 01c8429004..7e29ed89c5 100644 --- a/crates/ui-egui/src/i18n/pl.tsv +++ b/crates/ui-egui/src/i18n/pl.tsv @@ -2451,6 +2451,7 @@ digits Malayalam Malajalam Export Asset Eksportuj zasób Export to: Eksportuj do: Format Settings Ustawienia formatu + Format Settings… Ustawienia formatu… Formats: Formaty: Full Document Cały dokument Include Bleed Uwzględnij spad @@ -9180,6 +9181,9 @@ breakText Glyphs Glify @msg {what} can't be intertwined {what} nie można przeplatać @msg {what} doesn't fit the paper with its bleed and marks {what} nie mieści się na papierze wraz ze spadem i znacznikami @msg {what} would print on more than {MAX_TILES} tiles: scale it down {what}: wydruk zająłby ponad {MAX_TILES} kafelków: zmniejsz skalę + Nest Shapes… Rozmieść kształty… + Nest Shapes Rozmieść kształty + Roll Rolka # Messages of the effects, 3D, image export, color profiles, swatch exchange files, color books and DXF import. @msg 3D effects require finite coordinates within 100 million points Efekty 3D wymagają skończonych współrzędnych w zakresie 100 milionów punktów diff --git a/crates/ui-egui/src/i18n/pt-br.tsv b/crates/ui-egui/src/i18n/pt-br.tsv index 1255008e2b..b9d233f1c9 100644 --- a/crates/ui-egui/src/i18n/pt-br.tsv +++ b/crates/ui-egui/src/i18n/pt-br.tsv @@ -1203,6 +1203,7 @@ antiAlias Sharp Nítido Format Formato Format Options Opções de Formato Format Settings Configurações de Formato + Format Settings… Configurações de Formato… Format: Formato: Formats: Formatos: Fractions Frações @@ -9514,3 +9515,6 @@ swatch Yellow Green Verde-amarelado @msg turn on at least one side ative pelo menos um lado @msg with no hem and no grommets there is nothing to mark sem bainha e sem ilhoses não há nada para marcar @msg {saving} failed: {e} {saving} falhou: {e} + Nest Shapes… Aninhar formas… + Nest Shapes Aninhar formas + Roll Rolo diff --git a/crates/ui-egui/src/i18n/ru.tsv b/crates/ui-egui/src/i18n/ru.tsv index 26e958353a..5845c34c1d 100644 --- a/crates/ui-egui/src/i18n/ru.tsv +++ b/crates/ui-egui/src/i18n/ru.tsv @@ -1351,6 +1351,7 @@ file-filter Templates Шаблоны Format Формат Format Options Параметры формата Format Settings Параметры формата + Format Settings… Параметры формата… Format: Формат: Formats: Форматы: Fractional Widths Дробные ширины @@ -7602,6 +7603,9 @@ shape Open Открытый Grommets Люверсы Grommet Inset Отступ люверсов от края Diameter Диаметр + Nest Shapes… Раскладка фигур… + Nest Shapes Раскладка фигур + Roll Рулон # Messages of the effects, 3D, image export, color profiles, swatch exchange files, color books and DXF import. @msg 3D effects require finite coordinates within 100 million points 3D-эффектам нужны конечные координаты в пределах 100 миллионов пунктов diff --git a/crates/ui-egui/src/i18n/sv.tsv b/crates/ui-egui/src/i18n/sv.tsv index ae45dc0181..9e37b924b0 100644 --- a/crates/ui-egui/src/i18n/sv.tsv +++ b/crates/ui-egui/src/i18n/sv.tsv @@ -1300,6 +1300,7 @@ font-technology Variable Variabla Format Format Format Options Formatalternativ Format Settings Formatinställningar + Format Settings… Formatinställningar… Format: Format: Formats: Format: Fractional Widths Bråkdelsbredder @@ -8988,6 +8989,9 @@ digits Hindi Hindi Grommets Öljetter Grommet Inset Öljetternas avstånd från kanten Diameter Diameter + Nest Shapes… Packa former… + Nest Shapes Packa former + Roll Rulle # Messages of the effects, 3D, image export, color profiles, swatch exchange files, color books and DXF import. @msg 3D effects require finite coordinates within 100 million points 3D-effekter kräver ändliga koordinater inom 100 miljoner punkter diff --git a/crates/ui-egui/src/i18n/tests.rs b/crates/ui-egui/src/i18n/tests.rs index 230ca2d958..b23fc994a9 100644 --- a/crates/ui-egui/src/i18n/tests.rs +++ b/crates/ui-egui/src/i18n/tests.rs @@ -4259,7 +4259,7 @@ fn translated_messages_keep_the_field_names_and_values_their_english_lists() { /// the messages each one lacks. const COMPLETE_MESSAGES: &[&str] = &[ "cs", "de", "el", "es", "fr", "hi", "hu", "it", "ja", "ka", "ko", "pl", "pt-br", "ru", "sv", "uk", "vi", "zh-hans", "zh-hant", "fi", "tr", "ca", - "nl", + "nl", "da", ]; /// Crates whose error and status messages reach the status bar, the crates whose errors the diff --git a/crates/ui-egui/src/i18n/tr.tsv b/crates/ui-egui/src/i18n/tr.tsv index a193574ca7..e5278e58c6 100644 --- a/crates/ui-egui/src/i18n/tr.tsv +++ b/crates/ui-egui/src/i18n/tr.tsv @@ -1293,6 +1293,7 @@ font-technology Variable Değişken Format Biçim Format Options Biçim Seçenekleri Format Settings Biçim Ayarları + Format Settings… Biçim Ayarları… Format: Biçim: Formats: Formatlar: Fractional Widths Kesirli Genişlikler @@ -7586,3 +7587,6 @@ breakText Glyphs Glifler Grommets Kuşgözleri Grommet Inset Kuşgözlerinin kenara uzaklığı Diameter Çap + Nest Shapes… Şekilleri yerleştir… + Nest Shapes Şekilleri yerleştir + Roll Rulo diff --git a/crates/ui-egui/src/i18n/uk.tsv b/crates/ui-egui/src/i18n/uk.tsv index 31c15f10b5..160d9a2c09 100644 --- a/crates/ui-egui/src/i18n/uk.tsv +++ b/crates/ui-egui/src/i18n/uk.tsv @@ -1356,6 +1356,7 @@ file-filter Templates Шаблони Format Формат Format Options Параметри формату Format Settings Параметри формату + Format Settings… Параметри формату… Format: Формат: Formats: Формати: Fractional Widths Дробові ширини @@ -8934,3 +8935,6 @@ breakText Glyphs Гліфи @msg turn on at least one side увімкніть принаймні одну сторону @msg with no hem and no grommets there is nothing to mark без підгину й люверсів немає чого розмічати @msg {saving} failed: {e} {saving}: не вдалося: {e} + Nest Shapes… Розкладка фігур… + Nest Shapes Розкладка фігур + Roll Рулон diff --git a/crates/ui-egui/src/i18n/vi.tsv b/crates/ui-egui/src/i18n/vi.tsv index 30e42d6b53..45312793f3 100644 --- a/crates/ui-egui/src/i18n/vi.tsv +++ b/crates/ui-egui/src/i18n/vi.tsv @@ -1576,6 +1576,7 @@ file-filter Templates Mẫu Format Định dạng Format Options Tùy chọn định dạng Format Settings Cài đặt định dạng + Format Settings… Cài đặt định dạng… Format: Định dạng: Formats: Các định dạng: Fractional Widths Chiều rộng phân số @@ -7124,6 +7125,9 @@ breakText Glyphs Glyph Grommets Khoen Grommet Inset Khoảng cách từ mép đến khoen Diameter Đường kính + Nest Shapes… Xếp hình tiết kiệm vật liệu… + Nest Shapes Xếp hình tiết kiệm vật liệu + Roll Cuộn # Messages of the effects, 3D, image export, color profiles, swatch exchange files, color books and DXF import. @msg 3D effects require finite coordinates within 100 million points Hiệu ứng 3D yêu cầu tọa độ hữu hạn trong phạm vi 100 triệu điểm diff --git a/crates/ui-egui/src/i18n/zh-hans.tsv b/crates/ui-egui/src/i18n/zh-hans.tsv index 9d5fe1c4b6..0b3c38c9ca 100644 --- a/crates/ui-egui/src/i18n/zh-hans.tsv +++ b/crates/ui-egui/src/i18n/zh-hans.tsv @@ -1292,6 +1292,7 @@ file-filter Templates 模板 Format 格式 Format Options 格式选项 Format Settings 格式设置 + Format Settings… 格式设置… Format: 格式: Formats: 格式: Fractional Widths 比例宽度 @@ -9166,6 +9167,9 @@ breakText Glyphs 字形 @msg {what} can't be intertwined {what} 不能交织 @msg {what} doesn't fit the paper with its bleed and marks {what} 连同出血和标记在纸张上放不下 @msg {what} would print on more than {MAX_TILES} tiles: scale it down {what} 会打印成超过 {MAX_TILES} 个拼贴:请将其缩小 + Nest Shapes… 形状排料… + Nest Shapes 形状排料 + Roll 卷材 # Messages of the effects, 3D, image export, color profiles, swatch exchange files, color books and DXF import. @msg 3D effects require finite coordinates within 100 million points 3D 效果需要 1 亿点以内的有限坐标 diff --git a/crates/ui-egui/src/i18n/zh-hant.tsv b/crates/ui-egui/src/i18n/zh-hant.tsv index 528e0a57ca..4c8b7f2d3e 100644 --- a/crates/ui-egui/src/i18n/zh-hant.tsv +++ b/crates/ui-egui/src/i18n/zh-hant.tsv @@ -1327,6 +1327,7 @@ file-filter Templates 範本 Format 格式 Format Options 格式選項 Format Settings 格式設定 + Format Settings… 格式設定… Format: 格式: Formats: 格式: Fractional Widths 小數字寬 @@ -7160,6 +7161,9 @@ breakText Glyphs 字符 Grommets 扣眼 Grommet Inset 扣眼邊距 Diameter 直徑 + Nest Shapes… 形狀排料… + Nest Shapes 形狀排料 + Roll 捲材 # Messages of the effects, 3D, image export, color profiles, swatch exchange files, color books and DXF import. @msg 3D effects require finite coordinates within 100 million points 3D 效果需要位於 1 億點以內的有限座標 diff --git a/crates/ui-egui/src/panels/asset_export.rs b/crates/ui-egui/src/panels/asset_export.rs index 021bc848c9..39b3a0ea85 100644 --- a/crates/ui-egui/src/panels/asset_export.rs +++ b/crates/ui-egui/src/panels/asset_export.rs @@ -11,6 +11,12 @@ //! marked 3D on their tiles. While the panel's assets include 3D assets, a Format row picks their //! mesh format (USDA, USDZ, glTF or OBJ), and Export writes them as mesh files beside the pictures. //! +//! The panel menu also holds Format Settings… (Export for Screens showing the first row's format +//! settings), and Create Sub-folders and Open Location after Export, the dialog's two checkboxes, +//! which Export follows. Create Sub-folders sorts the pictures into a folder per format row, or per +//! format or item when Export for Screens remembers that choice; 3D assets' mesh files go in the +//! folder itself. +//! //! Everything runs through the `assets.*` commands. use std::cell::RefCell; @@ -26,7 +32,7 @@ use vectorcraft_engine::cmd::fileio::{FORMATS_3D, SCREEN_SETTINGS_KEYS, format_3 use super::{pstate, set_pstate}; use crate::dialogs::{ EXPORT_FOR_SCREENS, ScreenPresetPick, drop_unread_screen_settings, open_export_for_screens_assets, screen_export_preset, screen_formats, - screen_import_presets, screen_preset_list, screen_saved_options, screen_saved_rows, + screen_import_presets, screen_preset_list, screen_saved_options, screen_saved_rows, screen_settings_format, }; use crate::state::Dialog; use crate::theme::Tokens; @@ -229,7 +235,14 @@ fn export_now(app: &mut VectorcraftApp, ids: Vec, files_each: usize) { let (three_d, ids): (Vec, Vec) = ids.into_iter().partition(|id| format(id).is_some()); // An OBJ is two files. let mesh_files: usize = three_d.iter().map(|id| if format(id).as_deref() == Some("obj") { 2 } else { 1 }).sum(); - let mut p = json!({ "assets": ids }); + // Create Sub-folders as the panel menu checks it: on by default, and remembered folders (per + // row, format or item) are used as Export for Screens uses them (a remembered `subfolders` + // false beside them would alone put every file in the folder itself). + let saved = &st.doc.export_settings; + let mut p = keep_subfolders(saved, json!({ "assets": ids })); + if let Some(f) = saved.get("folders").and_then(Value::as_str).filter(|f| !matches!(*f, "none" | "")) { + p["folders"] = json!(f); + } // Match the shared dialog's TIFF defaults, retaining saved choices and other formats. if let Some(st) = app.session.active() && screen_saved_rows(&st.doc.export_settings) @@ -267,7 +280,7 @@ fn export_now(app: &mut VectorcraftApp, ids: Vec, files_each: usize) { } }; p["folder"] = json!(folder); - p["openLocation"] = json!(saved.get("openLocation").and_then(Value::as_bool).unwrap_or(true)); + p["openLocation"] = json!(open_location(&saved)); } let mut meshes = json!({ "assets": three_d }); if io::is_web(app) { @@ -290,6 +303,43 @@ fn export_now(app: &mut VectorcraftApp, ids: Vec, files_each: usize) { } } +/// Create Sub-folders for the remembered settings `saved`, as Export for Screens opens on it: off +/// for remembered folders `none`, on for any other remembered folders (a folder per row, format or +/// item); without them the remembered `subfolders`, else on for a document without settings or +/// with a preset's rows. +fn subfolders_on(saved: &serde_json::Map) -> bool { + match saved.get("folders").and_then(Value::as_str) { + // An export reads empty folders as none. + Some("none" | "") => false, + Some(_) => true, + None => saved.get("subfolders").and_then(Value::as_bool).unwrap_or_else(|| saved.is_empty() || screen_saved_rows(saved).1.is_some()), + } +} + +/// `p` (params of `assets.settings.set` or `assets.export`) with `subfolders: true` when `saved` +/// makes no Create Sub-folders choice and it is on by default: the export would otherwise write +/// into the folder itself, and settings `p` adds would turn the default off. +fn keep_subfolders(saved: &serde_json::Map, mut p: Value) -> Value { + if !saved.contains_key("folders") && !saved.contains_key("subfolders") && subfolders_on(saved) { + p["subfolders"] = json!(true); + } + p +} + +/// Open Location after Export for the remembered settings `saved` (on until it is turned off). +fn open_location(saved: &serde_json::Map) -> bool { + saved.get("openLocation").and_then(Value::as_bool).unwrap_or(true) +} + +/// Open Export for Screens on the Assets tab with assets `ids` checked, showing the Format Settings +/// of format `format`. +fn format_settings(app: &mut VectorcraftApp, ids: &[u64], format: &str) { + open_export_for_screens_assets(app, Some(ids)); + if let Some(d) = app.ui.dialog.as_mut() { + d.fields.insert("__settings".into(), json!(format)); + } +} + /// What the export settings rows asked for. enum Settings { /// Change the shared settings (`assets.settings.set`). @@ -328,7 +378,7 @@ fn settings(ui: &mut Ui, st: &DocState) -> (Option, usize) { Some(Settings::Set(json!({ "preset": picked, "subfolders": true }))) } else if picked != preset || formats.as_array() != Some(&rows) { // Rows edited, or Custom picked: the rows as they are, without the preset. - Some(Settings::Set(json!({ "formats": formats, "preset": "" }))) + Some(Settings::Set(keep_subfolders(&st.doc.export_settings, json!({ "formats": formats, "preset": "" })))) } else { None }; @@ -432,12 +482,7 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) { Some(Settings::Set(p)) => { run(app, "assets.settings.set", p); } - Some(Settings::Format(f)) => { - open_export_for_screens_assets(app, Some(&sel_or_all(&sel, &all))); - if let Some(d) = app.ui.dialog.as_mut() { - d.fields.insert("__settings".into(), json!(f)); - } - } + Some(Settings::Format(f)) => format_settings(app, &sel_or_all(&sel, &all), &f), None => {} } if let Some(pick) = save_pick { @@ -575,6 +620,9 @@ pub fn menu(app: &mut VectorcraftApp, ui: &mut Ui) { let doc = st.uid; let (has_art, sel) = (!st.selection.is_empty(), selected(&ctx, st)); let all: Vec = st.doc.assets.iter().map(|a| a.id).collect(); + let saved = &st.doc.export_settings; + let (subfolders, open_after) = (subfolders_on(saved), open_location(saved)); + let settings_format = screen_settings_format(&screen_saved_rows(saved).0); if menu_item(ui, tl!("Add Selected Artwork as One Asset"), has_art, false) { collect(app, &ctx, None, true); } @@ -599,6 +647,17 @@ pub fn menu(app: &mut VectorcraftApp, ui: &mut Ui) { if menu_item(ui, tl!("Export for Screens…"), true, false) { open_export_for_screens_assets(app, Some(&sel_or_all(&sel, &all))); } + if menu_item(ui, tl!("Format Settings…"), true, false) { + format_settings(app, &sel_or_all(&sel, &all), settings_format); + } + ui.separator(); + if menu_item(ui, tl!("Create Sub-folders"), true, subfolders) { + run(app, "assets.settings.set", json!({ "subfolders": !subfolders })); + } + // The web downloads the files: there is no location to open. + if !io::is_web(app) && menu_item(ui, tl!("Open Location after Export"), true, open_after) { + run(app, "assets.settings.set", json!({ "openLocation": !open_after })); + } ui.separator(); let (preset_doc, current): (u64, String) = pstate(&ctx, PRESET); let current = if preset_doc == doc { current } else { String::new() }; diff --git a/crates/ui-egui/src/panels/tests_asset_export.rs b/crates/ui-egui/src/panels/tests_asset_export.rs index 82a6f21245..8b9a9ed884 100644 --- a/crates/ui-egui/src/panels/tests_asset_export.rs +++ b/crates/ui-egui/src/panels/tests_asset_export.rs @@ -514,8 +514,7 @@ fn the_preset_menu_saves_changes_and_opens_import_and_export() { #[cfg(not(target_arch = "wasm32"))] #[test] fn export_writes_into_the_picked_folder() { - let dir = std::env::temp_dir().join(format!("vc-asset-panel-{}", std::process::id())); - let _ = std::fs::remove_dir_all(&dir); + let dir = vectorcraft_testkit::TempDir::new("asset-panel"); let folder = dir.to_string_lossy().replace('\\', "/"); let (mut app, _, [a, _]) = app(false); let picked = folder.clone(); @@ -526,8 +525,179 @@ fn export_writes_into_the_picked_folder() { frame(&ctx, &mut app, vec![], false); let texts = frame(&ctx, &mut app, vec![], false); frame(&ctx, &mut app, click(at(&texts, "Export")), false); - assert!(dir.join("Badge.png").is_file(), "{:?}", std::fs::read_dir(&dir).map(|d| d.count())); + // Create Sub-folders is on by default: the 1x row's folder. + assert!(dir.join("1x").join("Badge.png").is_file(), "{:?}", std::fs::read_dir(&dir).map(|d| d.count())); +} + +/// The panel menu's Create Sub-folders and Open Location after Export are check items, on by +/// default, that change the settings the panel shares with Export for Screens, and the panel's +/// Export follows them. +#[cfg(not(target_arch = "wasm32"))] +#[test] +fn the_menu_sets_create_subfolders_and_open_location_and_export_follows_them() { + let dir = vectorcraft_testkit::TempDir::new("asset-panel-menu"); + let folder = dir.to_string_lossy().replace('\\', "/"); + let (mut app, _, [a, _]) = app(false); + let picked = folder.clone(); + app.services.pick_folder = Some(Box::new(move || Some(picked.clone()))); + let shown = Rc::new(RefCell::new(Vec::::new())); + let s = shown.clone(); + app.services.reveal = Some(Box::new(move |path: &str| { + s.borrow_mut().push(path.to_string()); + Ok(()) + })); + app.run("assets.add", json!({"ids": [a], "name": "Badge"})).unwrap(); + let ctx = egui::Context::default(); + crate::theme::install_fonts(&ctx); + menu_frame(&ctx, &mut app, vec![]); + let texts = menu_frame(&ctx, &mut app, vec![]); + for item in ["✓ Create Sub-folders", "✓ Open Location after Export", " Format Settings…"] { + assert!(has(&texts, item), "{item}"); + } + // Both on: the 1x row's folder, and the file manager shows the file. + let export = |app: &mut VectorcraftApp| { + frame(&ctx, app, vec![], false); + let texts = frame(&ctx, app, vec![], false); + frame(&ctx, app, click(at(&texts, "Export")), false); + }; + export(&mut app); + assert!(dir.join("1x").join("Badge.png").is_file()); + assert_eq!(shown.borrow().len(), 1); + // Both turned off (a click lands on the items of the frame before). + menu_frame(&ctx, &mut app, vec![]); + menu_frame(&ctx, &mut app, click(at(&texts, "✓ Create Sub-folders"))); + let texts = menu_frame(&ctx, &mut app, vec![]); + menu_frame(&ctx, &mut app, click(at(&texts, "✓ Open Location after Export"))); + let settings = app.run("document.exportSettings", json!({})).unwrap()["settings"].clone(); + assert_eq!((&settings["subfolders"], &settings["openLocation"]), (&json!(false), &json!(false)), "{settings}"); + let texts = menu_frame(&ctx, &mut app, vec![]); + assert!(has(&texts, " Create Sub-folders") && has(&texts, " Open Location after Export")); + // Export for Screens opens on the same choices. + app.run("file.exportForScreens", json!({})).unwrap(); + let d = app.ui.dialog.as_ref().unwrap(); + assert_eq!((&d.fields["subfolders"], &d.fields["openLocation"]), (&json!(false), &json!(false))); + crate::dialogs::cancel(&mut app); let _ = std::fs::remove_dir_all(&dir); + export(&mut app); + assert!(dir.join("Badge.png").is_file() && !dir.join("1x").exists()); + assert_eq!(shown.borrow().len(), 1, "no file manager after export"); + // Create Sub-folders on again: a folder per row. + menu_frame(&ctx, &mut app, vec![]); + menu_frame(&ctx, &mut app, click(at(&texts, " Create Sub-folders"))); + let settings = app.run("document.exportSettings", json!({})).unwrap()["settings"].clone(); + assert_eq!((&settings["subfolders"], &settings["folders"]), (&json!(true), &json!("rows")), "{settings}"); + // Only true and false are taken. + for bad in [json!({"openLocation": "yes"}), json!({"subfolders": 1})] { + assert!(app.run("assets.settings.set", bad.clone()).is_err(), "{bad}"); + } +} + +/// A folder per format, remembered by Export for Screens or a preset, is used by the panel's Export +/// too. +#[cfg(not(target_arch = "wasm32"))] +#[test] +fn export_uses_a_remembered_folder_per_format() { + let dir = vectorcraft_testkit::TempDir::new("asset-panel-format"); + let folder = dir.to_string_lossy().replace('\\', "/"); + let (mut app, _, [a, _]) = app(false); + app.services.pick_folder = Some(Box::new(move || Some(folder.clone()))); + let asset = app.run("assets.add", json!({"ids": [a], "name": "Badge"})).unwrap()["assets"][0].clone(); + // An export as Export for Screens sends it with Create Sub-folders by Format, which the + // document remembers. + let formats = json!([{"format": "png"}, {"format": "svg"}]); + app.run("document.exportForScreens", json!({"assets": [asset], "formats": formats, "folders": "format", "subfolders": false})).unwrap(); + let ctx = egui::Context::default(); + crate::theme::install_fonts(&ctx); + menu_frame(&ctx, &mut app, vec![]); + assert!(has(&menu_frame(&ctx, &mut app, vec![]), "✓ Create Sub-folders")); + frame(&ctx, &mut app, vec![], false); + let texts = frame(&ctx, &mut app, vec![], false); + frame(&ctx, &mut app, click(at(&texts, "Export")), false); + assert!(dir.join("png").join("Badge.png").is_file() && dir.join("svg").join("Badge.svg").is_file()); +} + +/// Remembered empty folders are none, as an export reads them: Create Sub-folders is off in the +/// panel menu and in Export for Screens. +#[test] +fn empty_remembered_folders_are_create_subfolders_off() { + let (mut app, _, [a, _]) = app(true); + let asset = app.run("assets.add", json!({"ids": [a]})).unwrap()["assets"][0].clone(); + app.run("document.exportForScreens", json!({"assets": [asset], "formats": [{"format": "svg"}], "folders": "", "subfolders": true})).unwrap(); + let ctx = egui::Context::default(); + crate::theme::install_fonts(&ctx); + menu_frame(&ctx, &mut app, vec![]); + assert!(has(&menu_frame(&ctx, &mut app, vec![]), " Create Sub-folders")); + app.run("file.exportForScreens", json!({})).unwrap(); + assert_eq!(app.ui.dialog.as_ref().unwrap().fields["subfolders"], false); +} + +/// Open Location after Export turned off in a document without settings leaves Create Sub-folders +/// on, in the panel menu and in Export for Screens. +#[test] +fn open_location_off_leaves_create_subfolders_on() { + let (mut app, _, _) = app(false); + let ctx = egui::Context::default(); + crate::theme::install_fonts(&ctx); + menu_frame(&ctx, &mut app, vec![]); + let texts = menu_frame(&ctx, &mut app, vec![]); + menu_frame(&ctx, &mut app, click(at(&texts, "✓ Open Location after Export"))); + let texts = menu_frame(&ctx, &mut app, vec![]); + assert!(has(&texts, "✓ Create Sub-folders") && has(&texts, " Open Location after Export")); + app.run("file.exportForScreens", json!({})).unwrap(); + let d = app.ui.dialog.as_ref().unwrap(); + assert_eq!((&d.fields["subfolders"], &d.fields["openLocation"]), (&json!(true), &json!(false))); +} + +/// Editing the rows of a document without a Create Sub-folders choice keeps it on, in Export for +/// Screens too; a preset that turns it off shows it off, and the menu turns it on again. +#[test] +fn row_edits_keep_create_subfolders_on_and_a_preset_can_turn_it_off() { + let (mut app, _, [a, _]) = app(true); + app.run("assets.add", json!({"ids": [a]})).unwrap(); + let ctx = egui::Context::default(); + crate::theme::install_fonts(&ctx); + frame(&ctx, &mut app, vec![], false); + let texts = frame(&ctx, &mut app, vec![], false); + frame(&ctx, &mut app, click(at(&texts, "+ Add Scale")), false); + assert!(has(&menu_frame(&ctx, &mut app, vec![]), "✓ Create Sub-folders")); + app.run("file.exportForScreens", json!({})).unwrap(); + assert_eq!(app.ui.dialog.as_ref().unwrap().fields["subfolders"], true); + crate::dialogs::cancel(&mut app); + // Logo Package sorts by format: checked, as in Export for Screens. Turned off, the sorting + // ends; turned on again, a folder per row. + app.run("assets.settings.set", json!({"exportPreset": "Logo Package"})).unwrap(); + let texts = menu_frame(&ctx, &mut app, vec![]); + menu_frame(&ctx, &mut app, click(at(&texts, "✓ Create Sub-folders"))); + let settings = app.run("document.exportSettings", json!({})).unwrap()["settings"].clone(); + assert_eq!((&settings["subfolders"], &settings["folders"]), (&json!(false), &json!("none")), "{settings}"); + app.run("file.exportForScreens", json!({})).unwrap(); + assert_eq!(app.ui.dialog.as_ref().unwrap().fields["subfolders"], false); + crate::dialogs::cancel(&mut app); + let texts = menu_frame(&ctx, &mut app, vec![]); + menu_frame(&ctx, &mut app, click(at(&texts, " Create Sub-folders"))); + let settings = app.run("document.exportSettings", json!({})).unwrap()["settings"].clone(); + assert_eq!((&settings["subfolders"], &settings["folders"]), (&json!(true), &json!("rows")), "{settings}"); + let texts = menu_frame(&ctx, &mut app, vec![]); + assert!(has(&texts, "✓ Create Sub-folders")); + // The web downloads the files: the menu has no Open Location after Export. + assert!(!texts.iter().any(|(t, _)| t.ends_with("Open Location after Export"))); +} + +/// Format Settings… in the panel menu opens Export for Screens on its Assets tab, showing the +/// first row's format settings. +#[test] +fn format_settings_opens_the_first_rows_format_settings() { + let (mut app, _, [a, b]) = app(true); + app.run("assets.add", json!({"ids": [a, b], "multiple": true})).unwrap(); + app.run("assets.settings.set", json!({"formats": [{"format": "jpeg"}, {"format": "svg"}]})).unwrap(); + let ctx = egui::Context::default(); + crate::theme::install_fonts(&ctx); + menu_frame(&ctx, &mut app, vec![]); + let texts = menu_frame(&ctx, &mut app, vec![]); + menu_frame(&ctx, &mut app, click(at(&texts, " Format Settings…"))); + let d = app.ui.dialog.as_ref().expect("Export for Screens"); + assert_eq!((d.kind.as_str(), &d.fields["tab"], &d.fields["__settings"]), (crate::dialogs::EXPORT_FOR_SCREENS, &json!("assets"), &json!("jpg"))); + assert_eq!(d.fields["assets"].as_array().map(Vec::len), Some(2)); } #[test] @@ -716,8 +886,7 @@ fn tiff_panel_preserves_shared_antialias_and_explicit_overrides() { #[cfg(not(target_arch = "wasm32"))] #[test] fn tiff_saved_aliases_use_document_defaults_and_explicit_rgb() { - let dir = std::env::temp_dir().join(format!("vc-asset-tiff-aliases-{}", std::process::id())); - let _ = std::fs::remove_dir_all(&dir); + let dir = vectorcraft_testkit::TempDir::new("asset-tiff-aliases"); // The first case is the historical control: saved tif without an explicit model must be CMYK. // Native picked folders let the mixed-row case inspect each file without introducing a ZIP decoder. for (case, (alias, rgb_override, mixed)) in @@ -764,7 +933,6 @@ fn tiff_saved_aliases_use_document_defaults_and_explicit_rgb() { "direct export preserves saved aliases and explicit options" ); } - let _ = std::fs::remove_dir_all(&dir); } /// Export 3D Object in the panel menu collects the selected 3D object; its tile is marked 3D, the diff --git a/docs/control-protocol.md b/docs/control-protocol.md index 02bf102103..5f91465c9d 100644 --- a/docs/control-protocol.md +++ b/docs/control-protocol.md @@ -122,7 +122,7 @@ the two can still win that race. | Native file dialogs | any command with no `path` that needs one (`file.open`, `file.saveAs`, `file.place`, exports…) | on macOS (a sheet on the window) and Linux the dialog doesn't hold the window: the call returns `"cancelled"` at once, `ui.inspect` reports `fileDialog`, and when the person at the computer picks a file the command runs again on the document it was asked for. While it is open another dialog is refused: that call returns `"cancelled"` too, and the status bar (`ui.inspect` → `ui.status`) says `"A file dialog is open: choose there first"`; on macOS `app.quit`, `file.close` and `file.closeAll` return that message and the menu bar does nothing (#592, #867). On Windows the dialog is shown in line: the call returns once it closes. Pass `path` to skip the dialog | | Background Save / Export | `app.save`, `app.export {path}`, `file.save`, `file.saveAs`, `file.saveCopy`, `file.exportForScreens {folder, …}` | with the `backgroundSave` / `backgroundExport` preferences on (the default) the desktop app writes the file from a snapshot on a worker thread: the call returns `{path, background: true, status}` at once (`file.exportForScreens`: `{background: true, status}`, and the files are written into the folder from a snapshot taken at the call; an `app.export` given an option the format doesn't read: also `warnings`, naming the option), `ui.inspect` lists the jobs still running in `background` (labels such as `"Saving a.vectorcraft"`), and the document counts as saved (as of the snapshot) once its file is written. Quit and Close wait for them. A job that fails reports the error in the status bar only (`ui.inspect` → `ui.status`), a path outside `--automation-write-root` included. With the preferences off (`prefs.set`), saves return once written. `app.save {path?, wait: true}` and `app.export {path, wait: true}` wait for the jobs already running, write the file before they reply whatever the preferences are set to, and return its result or the error. When the save asks first (the question before replacing a file that opened with losses, or on macOS and Linux the file dialog for an untitled document without `path`), the reply is `{pending}` or `"cancelled"` as before, and the save that runs after the answer uses the preferences. A `wait` that is not `true`, `false` or `null` is refused with "`wait` must be true or false", and nothing is written | | `engine.execute` `file.exportForScreens` | `{}` / `{…document.exportForScreens params}` | without params opens `exportForScreens` on the settings the document last exported with (`document.exportSettings`). While the Artboard tool is in use, the artboards it has selected (its `selected` option, the rows the Artboards panel highlights) are checked instead, and `select` is `range`, or `all` when every artboard is selected. Fields: `tab` (`artboards` \| `assets` \| `layers` \| `selection`; OK reads Export Layer or Export Selection on those), `layers` ([layer id…] checked), `selectionMode` (`one` \| `each`), `name` (the file name pattern of `document.exportForScreens`), `counterStart`, `folders` (`none` \| `rows` \| `format` \| `item` \| `itemFormat`), `exportPreset` (the preset applied last), `__savePreset` (the name typed to save one), `select` (`all` \| `range` \| `full`), `range` ("1-3, 5"), `boards` (a bool per artboard; checking them sets `select` and `range`), `includeBleed`, `folder` (desktop; default the Desktop, else home), `openLocation`, `subfolders`, `prefix`, `preset` (`""` \| `mobile` \| `density`), `formats` (rows `{format, scale: "2x" \| "100w" \| "100h" \| "72ppi", suffix, quality?, folder?}`), `settings` (`{png \| png8 \| jpg \| webp \| gif \| svg \| pdf: {…options}}`, the gear's Format Settings; `__settings` names the format shown). `ui.dialog.confirm` exports (a bad range or no folder keeps it open); on the web OK reads "Download" and the files download (several as one `.zip`). With params it runs `document.exportForScreens`: no `folder` returns the files (or with `zip: true` one zip) as `dataBase64`; with `openLocation` the first file is shown in the file manager. When `warnings` is not empty, an export into a folder or a download shows the first in the status bar as Save As shows a save's notes | -| Export for Screens' Assets tab, Asset Export panel | `ui.dialog.set` `tab: "assets"`, `assets: [asset id…]`; `window.panel {panel: "assetExport"}` | on the Assets tab of `exportForScreens` the `assets` field lists the checked asset ids (all of them when it opens); `ui.dialog.confirm` runs `document.exportForScreens {assets}` (nothing checked keeps it open) and OK reads "Export Asset". The Asset Export panel acts through `assets.*` and shares the format rows with the dialog (`assets.settings.set`). The dialog has the format presets menu (☰). In the dialog, `exportPreset` names the preset applied last (`ui.dialog.set` of it applies the preset it names, as picking it does; a name that is not a preset's is an error and leaves the dialog as it was), `__exportPresets` lists the presets, and `__savePreset` holds the name being typed after Save Preset…. The panel menu applies presets through `assets.settings.set {exportPreset}` and holds Save As Preset…, Save Changes, Delete Preset, Import Preset… and Export Preset…. Import and Export use picked files. | +| Export for Screens' Assets tab, Asset Export panel | `ui.dialog.set` `tab: "assets"`, `assets: [asset id…]`; `window.panel {panel: "assetExport"}` | on the Assets tab of `exportForScreens` the `assets` field lists the checked asset ids (all of them when it opens); `ui.dialog.confirm` runs `document.exportForScreens {assets}` (nothing checked keeps it open) and OK reads "Export Asset". The Asset Export panel acts through `assets.*` and shares the format rows with the dialog (`assets.settings.set`). The dialog has the format presets menu (☰). In the dialog, `exportPreset` names the preset applied last (`ui.dialog.set` of it applies the preset it names, as picking it does; a name that is not a preset's is an error and leaves the dialog as it was), `__exportPresets` lists the presets, and `__savePreset` holds the name being typed after Save Preset…. The panel menu applies presets through `assets.settings.set {exportPreset}` and holds Save As Preset…, Save Changes, Delete Preset, Import Preset… and Export Preset…. Import and Export use picked files. Its Create Sub-folders and Open Location after Export run `assets.settings.set {subfolders}` and `{openLocation}`; its Format Settings… opens the dialog on the Assets tab with `__settings` set to the first row's format. | | `engine.execute` `file.exportSelection` | `{multiple?}` | File › Export Selection…: collects the selected objects as one asset (`assets.add`; one per object with `multiple: true`) and opens `exportForScreens` on its Assets tab with just them checked → `{assets}`. `document.exportSelection` with params still exports the selection in one go, without the dialog; with no params or `{}` the save panel picks the file and its format's options dialog follows, as after Export As, and the call returns `null` while the dialog is open (OK returns what `file.exportAs` returns, without `format` or `bounds`). `file.exportAs {path, exportSelection: true, …options}` writes the selection without a dialog | | CSS Properties panel | `window.panel {panel: "cssProperties"}`; `engine.execute` `css.copy` / `css.exportFile` | the panel shows `css.selection` of the selection (after Generate CSS, while nothing is selected, `css.generate`); its menu holds the options the commands take (`units`, `position`, `dimensions`, `unnamed`, `rasterize`). `css.copy {scope?, …options}` copies the CSS to the clipboard → `{css, rules}`; `css.exportFile {path?, scope?, …options}` writes `css.export` to `path` (else a picked file; the web downloads it) with the pictures of rasterized art next to it → `{path, rules, images, warnings}`, and the status bar shows `warnings` as Export As shows an export's notes | diff --git a/docs/development.md b/docs/development.md index 846d2c02d8..7c22d6ae98 100644 --- a/docs/development.md +++ b/docs/development.md @@ -297,7 +297,7 @@ other `sv-*` locale resolve to it), Georgian (`ka`, complete; `ka-GE` and `ka_GE placeholders are translated in turn (the reason after `: {e}` is often a message too). A literal brace in a row is written `{{` or `}}`, in the English and in the translation: a message made with `format!` keeps the doubled braces of its format string, and a plain string literal has each of its braces doubled, so the row for - `"expected {\"objects\": [...]}"` is `expected {{"objects": [...]}}`. Czech, German, Greek, Spanish, French, Georgian, Hindi, Hungarian, Italian, Japanese, Korean, Polish, Brazilian Portuguese, Russian, Simplified Chinese, Traditional Chinese, Swedish, Ukrainian, Vietnamese, Finnish, Turkish, Catalan and Dutch + `"expected {\"objects\": [...]}"` is `expected {{"objects": [...]}}`. Czech, German, Greek, Spanish, French, Georgian, Hindi, Hungarian, Italian, Japanese, Korean, Polish, Brazilian Portuguese, Russian, Simplified Chinese, Traditional Chinese, Swedish, Ukrainian, Vietnamese, Finnish, Turkish, Catalan, Dutch and Danish translate status and error messages (`COMPLETE_MESSAGES` in `i18n::tests`); the untranslated report lists the message literals the scan finds, and the phrases a parameter schema's error builds at run time (`SCHEMA_PHRASES`), that one of them has no row for. The scan covers the crates in `MESSAGE_CRATES`, the diff --git a/docs/localization-parity.md b/docs/localization-parity.md index 20311a5182..1fededbc4e 100644 --- a/docs/localization-parity.md +++ b/docs/localization-parity.md @@ -15,7 +15,7 @@ translation never fails a test or a build: the interface shows the English text `cargo xtask ci` prints the untranslated report (`cargo xtask i18n-report` alone), which lists per language the strings missing from each catalog with the `complete_menus` flag and the messages missing from each language in the `COMPLETE_MESSAGES` list. Every non-English language marked "full" has the flag, and all of them except -Bosnian, Bulgarian, Danish and Indonesian are in the list. No language has help content: there is no translated (or +Bosnian, Bulgarian and Indonesian are in the list. No language has help content: there is no translated (or English) in-app help, and the docs are English. **Native review:** every catalog was written clean-room from the English meaning, most of it by agents @@ -55,7 +55,7 @@ plus review. Native-speaker review is human work and isn't in the hours. | Traditional Chinese (Taiwan) | `zh-hant` | 8,820 rows, 3,438 of them `@msg` and 48 `@msgplural` (counted 2026-11-13): the untranslated report lists 40 missing messages for it; menus, `tl!` literals and messages were complete when counted on 2026-10-21 (80% overall) | partial | | Italian | `it` | 8,924 rows, 3,560 of them `@msg` and 48 `@msgplural` (counted 2026-11-13): the untranslated report lists 54 missing messages for it, which open #7116, #7211, #7378 and #7405 translate | full | | Dutch | `nl` | 3,447 `@msg` and 48 `@msgplural` rows (counted 2026-11-13): in `COMPLETE_MESSAGES`, and the untranslated report lists 41 missing messages for it; one/other plurals | full | -| Danish | `da` | 5,279 rows, 750 of them `@msg` (counted 2026-10-21): menus and `tl!` literals complete when counted; its messages are partial; one/other plurals | full | +| Danish | `da` | 7,422 rows, 1,636 of them `@msg` and 48 `@msgplural` (counted 2026-11-14 on `main` at `edb228ac3`): in `COMPLETE_MESSAGES`, and the untranslated report lists the same missing messages for it as for Greek, Spanish and Finnish; one/other plurals | full | | Russian | `ru` | 4,009 / 4,020 | full | | Ukrainian | `uk` | 8,802 rows, 3,439 of them `@msg` and 48 `@msgplural` (counted 2026-11-13); one/few/many plurals | full | | Hungarian | `hu` | 9,166 rows, 3,444 of them `@msg` and 48 `@msgplural` (counted 2026-11-13): the untranslated report lists 40 missing messages for it, and lists 47 menu strings, 25 `tl!` strings, 11 undo labels and 45 swatch names without a row; one plural form | full | diff --git a/docs/mcp.md b/docs/mcp.md index 58ba2b0488..ed707a36d9 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -868,6 +868,13 @@ kind takes them when the graph is drawn again. The graph saves the paragraph att `categoriesPara` and `legendPara` in its label styles, and still sets each label's alignment for its place. The legend leaves room for a legend label's stored indents, spacing and leading. +When a graph is drawn again, its plot moves and scales with any move or scale of the graph made since it was last +drawn. The graph saves the bounds of its art other than type as `placedArt` (and the bounds of all of its art as +`placed`), and measures the move or scale on those. Changing the size, indents or spacing of the labels, moving a label +on its own and `type.createOutlines` on labels do not move or scale the plot. A graph opened from a `.ai` file saves +`placedArt` too. A graph saved without `placedArt`, or with a `placedArt` that has no width or height, is measured on +`placed`. + The `appearance.*` commands, `graphicStyle.apply` and `graphicStyle.redefine`, and the paint, stroke, gradient and transparency commands given an `item` (or the Appearance panel's active row) store a graph label's object rows for its kind of label: the paint, weight, opacity and blend mode of its topmost visible fill and stroke row, their order and @@ -8101,7 +8108,12 @@ moving the art moves the crop, and deleting it removes it from its asset (an ass format row of the document's export settings, the ones Export for Screens remembers (`document.exportSettings`); any `formats`, `preset`, `settings`, `prefix` or `subfolders` given win for that export. `assets.settings.set` changes those shared settings (not an undo step); its `subfolders: true` sets Export for Screens' Folders to a folder per row, -and `subfolders: false` sets a folder per row back to All in One Folder. `document.exportForScreens {assets: [id…]}` does the same with its own params +and `subfolders: false` sets a folder per row (without `exportPreset`, also a folder per format or item) back to All +in One Folder. Its `openLocation: true|false` +is Open Location after Export, kept in `document.exportSettings` (not in presets). The panel menu's Create Sub-folders and +Open Location after Export run these two (sub-folders sort the pictures; `assets.export3D` writes mesh files in the +folder itself), and its Format Settings… opens Export for Screens on the first row's format +settings. `document.exportForScreens {assets: [id…]}` does the same with its own params (the dialog's Assets tab). An asset's crop, and the crop of `export` with `selection: true`, is the bounds the renderer measures for what the art draws, its effects included. @@ -11380,8 +11392,8 @@ A `configure` call that changes no shape records no undo step and returns `{upda ## Cutting Tools add-on Cutting Tools is a built-in add-on that prepares art for cutting. Its commands are `addon.cutting.bridges`, -`addon.cutting.weed` and `addon.cutting.banner`; in the desktop app they are Object › Plug-ins › Cutting Tools › Stencil -Bridges…, Weed Box and Lines… and Banner Hems and Grommets…. +`addon.cutting.weed`, `addon.cutting.banner` and `addon.cutting.nest`; in the desktop app they are Object › Plug-ins › +Cutting Tools › Stencil Bridges…, Weed Box and Lines…, Banner Hems and Grommets… and Nest Shapes…. ### Stencil Bridges @@ -11502,6 +11514,39 @@ Banner Hems and Grommets… opens the command dialog with Preview. {"name":"run_command","arguments":{"command":"addon.cutting.banner","params":{"hem":"1 in","grommetInset":"0.5 in","spacing":"2 ft"}}} ``` +### Nest Shapes + +`addon.cutting.nest {roll?, width?, height?, margin?, spacing?, rotate?, usePreviewBounds?, artboard?, patterns?}` moves +the selected objects close together onto a sheet or a roll of material +([packing problems](https://en.wikipedia.org/wiki/Packing_problems)). The ranges, defaults and rules below are +VectorCraft's own. The sheet starts at the top-left corner of artboard `artboard` (0-based; by default the artboard +under the center of the selection, else the first). It is `width` × `height` points (0 to 1000000 each; 0, the default, +takes the artboard's width or height). With `roll` true it is a roll `width` wide with no end, and `height` is not used. +`margin` (0 to 100000, default 18) is kept clear inside each edge of the sheet; on a roll, inside the sides and the +start. `spacing` (0 to 100000, default 9) is kept between two objects. + +Each selected object is packed by its bounding box: with its strokes and effects, including the reach of shadows, glows +and blurs, when `usePreviewBounds` is true (the default), else by its geometry. A group is one object. The tallest +objects go first, each where its bottom edge is highest and then furthest left. With `rotate` true (false by default), +an object may turn 90° clockwise where that puts its bottom edge higher, or where only the turned object fits; the +longest side then decides the order, and a turned object is measured as it is after the turn. A path of a +pixel-aligned compound path is not turned. Objects are moved, and turned with `rotate`, never resized. An object on the +pixel grid moves by whole points and takes 1 pt more room across and down. Objects that do not fit go in a column to +the right of the sheet, from its top, `spacing` from its edge and from each other, in stacking order (back to front). +Objects without bounds, such as an empty group, are not moved. `patterns` moves pattern fills and strokes with the art +(default: General › Transform Pattern Tiles). + +All of it is one undo step, and nothing changes when no object moves. The command fails when nothing selected can be +measured, when the margin leaves no room on the sheet, when no selected object fits, with more than 10000 objects +selected, and with bad params. Result: `{placed, unplaced, rotated, skipped, length}`: the number of objects placed, +the ids of the objects put beside the sheet, the ids of the objects turned, the ids of the objects without bounds, and +the length of material used in points, from the sheet's top edge to the margin below the lowest object placed. Nest +Shapes… opens the command dialog with Preview. + +```json +{"name":"run_command","arguments":{"command":"addon.cutting.nest","params":{"roll":true,"width":"24 in","margin":"0.25 in","spacing":"0.125 in","rotate":true}}} +``` + ## Color Effects add-on Color Effects is a built-in add-on: its effects are colour adjustments in Effect › Color Effects, its command is diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 2377243b42..e6292095da 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -802,6 +802,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur - Batch… over data sets offers None, Save and Close and Folder as the destination, as for a folder source. Save and Close saves the open document over its own file after each data set, so the file keeps only the last data set, unless a recorded Save step that Override Save points into the destination folder first makes each data set's file the document's own. A recorded Save, Save a Copy, Save as Template or export step that writes a file and that the batch does not point into the destination folder (destination None, Override Save or Override Export off, or a copy, template, PDF, EPS, DXF, Save for Web or Save for Office Documents step) also writes the same file for every data set. In either case the dialog shows a warning and `action.batch` returns a `warning`. Unless a recorded Save or Save As step writes a file and makes it the document's own first, `action.batch` refuses Save and Close over the data sets of a document that was never saved, was converted from an older version or was restored by Data Recovery. - Export for Screens: the dialog's Example line shows the path of the file the export writes first, sub-folder included, named as the export names it, for the first chosen artboard, asset, layer or object (`document.exportForScreens`). - Export presets: the Asset Export panel menu lists the export presets of Export for Screens, with Import Preset… and Export Preset…, which read and write `.vcexportpresets` files (`document.exportPresets.import`, `document.exportPresets.export`). An imported preset whose name is taken gets a number. In the panel, a preset applies to the shared export settings (`assets.settings.set {exportPreset}`), and Save Preset… saves them. With Create Sub-folders off, the panel's Export writes every file into the folder itself, mobile and density rows included. + - Asset Export panel menu: Create Sub-folders (on by default) and Open Location after Export are check items that set the dialog's two checkboxes (`assets.settings.set {subfolders, openLocation}`), and the panel's Export follows them, including a folder per format or item that Export for Screens remembers (3D assets' mesh files go in the folder itself); Format Settings… opens Export for Screens on the first row's format settings ([Asset Export panel](https://helpx.adobe.com/illustrator/desktop/save-and-export/export-files-to-different-formats/collect-and-export-assets-from-asset-export-panel.html)). Tests: `panels::tests_asset_export`. - Export for Screens is laid out as the reference app's dialog: on the left the Artboards and Assets tabs (thumbnails with their checkboxes, number and name, in a grid or a list), Clear Selection and Prefix; on the right Select (All, Range, Include Bleed, Include Artboard Fill, Full Document), Export to, Open Location after Export, Create Sub-folders by Scale or by Format (on by default), Export PDFs As (Single File writes the chosen artboards as the pages of one PDF named after the document, `pdfs: "single"`), and the format rows with the iOS and Android buttons, the format presets (☰) and Format Settings; under them, how many items are selected and how many files the export writes. File name patterns, the Layers and Selection sources and per-item folders stay available through `document.exportForScreens` (scripts and MCP). Tests: `dialogs::tests_screens` (layout, iOS/Android, Clear Selection, sub-folders, Single File) and `fileio::tests_screens::single_file_pdfs_hold_every_chosen_artboard`. - Save for Web: Preview in Browser writes each preview's page and images to a folder of its own inside `VectorCraft Save for Web` in the system's temporary folder. A later preview, from the same app or another one running, does not overwrite an earlier one. Each preview first removes the preview folders there that are an hour old or older and, on Linux, macOS and FreeBSD, belong to the current user; it leaves other files and folders alone. The folders a preview makes there are open to their owner only on those systems. When `VectorCraft Save for Web` is a link or is not a folder, or on those systems belongs to another user or can be written by other users, the preview goes to a new folder of its own in the temporary folder, which a later preview removes once it is an hour old (`file.saveForWeb.browser`). - Export for Screens, Save for Web (slice images, and files named after the document's title) and the Libraries panel name files with the characters Windows does not allow written as `-` and with a `-` after a Windows device name such as `CON`, on every platform (`document.exportForScreens`, `document.exportForWeb`, `library.create`). @@ -1294,6 +1295,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur - A 3D effect on one fill or stroke ([the help page](https://helpx.adobe.com/illustrator/using/effects.html)) leaves the object's other fills and strokes drawn with the path's or compound path's fill rule, so an even-odd compound path keeps its holes in them; this holds for Extrude & Bevel, Inflate, Rotate and Revolve, and for the Classic Extrude & Bevel, Rotate and Revolve. Tests: `solids::tests::a_fill_or_stroke_without_the_effect_keeps_the_even_odd_hole`, `revolve::tests::a_fill_without_the_revolve_keeps_the_even_odd_hole`. - With caps and opaque paint, Extrude & Bevel (both kinds) and Inflate draw each side face of the fill's solid before the faces of the same object's strokes that overlap it (strokes on closed paths without dashes, a width profile or a brush, in a solid color or an opaque gradient), so the stroke's walls cover the fill's walls at concave corners instead of showing stripes of the fill. Tests: `solids::tests::a_fills_walls_are_drawn_before_the_stroke_faces_over_them`, `solids::tests::the_draw_order_moves_a_fill_wall_before_the_stroke_faces_it_overlaps`, `solid::tests::facets_tell_the_sides_from_the_ends_and_the_faces_turned_away`. - When the fill and strokes are at full opacity with Normal blending, under a stroke that leaves parts of the fill's edge uncovered (dashed, with a width profile or a brush, on an open subpath, or in a paint that may show through), Extrude & Bevel (both kinds) and Inflate cut each stroke face that passes through a face of the fill's solid, or of another of the shape's strokes, where their depths meet, and draw the faces of the shape's fill and strokes that overlap in the order of their depths where they overlap, instead of by the depths of their centers. Inflate orders a plain stroke the same way, since its fill can rise in front of the stroke. The fill no longer paints over a nearer dash, and a dash's end no longer covers the fill wall in front of it; a fill wall in a gap between dashes stays visible. The same order applies inside a group member's own opacity mask or blending mode, to a gradient with transparent stops under a colored light with graphics on its caps, and to Inflate in gradients under a colored light. Tests: `solids::tests::fill_walls_and_strokes_that_leave_gaps_draw_in_the_order_of_their_depths_where_they_overlap`, `solids::tests::local_order_puts_overlapping_fill_walls_and_stroke_faces_in_depth_order`, `solids::tests::a_stroke_face_through_a_fill_wall_is_cut_where_their_depths_meet`, `solids::tests::a_masked_members_fill_and_stroke_faces_go_in_one_order`, `solids::tests::a_colored_inflate_in_gradients_draws_its_faces_in_one_order`, `solids::tests::a_depth_cycle_keeps_the_cap_last`, `solids::graphics::tests::a_transparent_gradient_with_a_graphic_keeps_the_shared_face_order`. + - A graphic mapped onto Extrude & Bevel's cap or onto a Plane is drawn once when the fill is a gradient with transparent stops under a colored light and the object has a stroke (dashed or plain) with the same opacity and blending mode: the fill and stroke share it, as they do without the mask, and it is drawn after the stroke's cap with the mask of the stroke's surface (none for an opaque stroke), instead of once on the fill's cap and again on the stroke's. Members of a 3D group drawn with a mask keep graphics of their own. Unmasked fills and strokes alone draw as before. Tests: `solids::graphics::tests::a_transparent_gradient_with_a_graphic_keeps_the_shared_face_order`, `solids::graphics::tests::a_plane_in_a_transparent_gradient_with_a_stroke_draws_its_graphic_once`, `solids::graphics::tests::members_in_transparent_gradients_keep_graphics_of_their_own`. - Revolve and Revolve (Classic) draw two overlapping faces of a surface in the order of their depths where they overlap, instead of by the depths of their centers, on one object and across a group's members, so a tilted profile with long edges or a profile of several contours no longer draws a nearer face under a farther one. A surface whose faces were already in the right order keeps it. Tests: `solids::tests_revolve_order::a_revolved_surface_draws_the_nearer_of_two_overlapping_faces_last`, `solids::tests_revolve_order::a_revolved_groups_members_draw_the_nearer_of_two_overlapping_faces_last`, `solids::tests_revolve_order::a_classic_revolved_surface_draws_the_nearer_of_two_overlapping_facets_last`, `solids::tests_revolve_order::a_surface_already_in_order_keeps_it`, `solids::tests_revolve_order::surface_order_puts_the_nearer_face_where_they_overlap_last`, `tests::a_revolved_face_measures_its_depth_on_its_plane`. - Under a colored light, an Inflate fill and strokes in solid colors, opaque with Normal blending, also draw their faces in that one order, inside a group member's own opacity mask too, instead of each solid as one composite with the stroke's faces over every fill face. Each face keeps the composite's paint, light ramps and seam width; a lone solid, the solids of an object with a translucent or blended fill or stroke, and such solids holding more than 30,720 faces together (VectorCraft's own limit) are still drawn as one composite each. Tests: `solids::tests::colored_solid_fills_and_strokes_share_face_depth_order`, `solids::tests::a_colored_solid_face_on_its_own_keeps_the_composites_colors`, `solids::tests::colored_solid_inflate_batches_channels_and_clips_once_per_surface`, `solids::tests::colored_solids_past_the_face_limit_draw_as_composites`. - Without a twist, a tapered Extrude & Bevel wall (and each bevel band) is one flat face; only a twist cuts walls into slices, so no seam between slices paints a light tick or a light line on a tapered, untwisted wall (#7019). Test: `solid::tests::a_tapered_wall_is_one_flat_face_and_a_twisted_one_is_sliced`. @@ -1431,7 +1433,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur When a drag on the Tabs panel's ruler or a value typed in its X field moves a tab stop past another stop, the moved stop stays selected. With the Selection tool, the Tabs panel shows the ruler and stops of the first paragraph of the first selected text object, also when the text is inside a selected group or a shape is selected before it; a selection without text shows "No text selected". A stop set on that ruler is set on every selected text object and the text in selected groups, and other objects are left as they were. This is VectorCraft's own rule. Tests `panels::tabs::tests::the_ruler_shows_text_selected_after_an_object_that_is_not_text`, `the_ruler_shows_text_inside_a_selected_group`, `a_stop_added_on_a_mixed_selection_sets_every_selected_text` and `a_selection_without_text_shows_no_ruler`. The Tabs panel menu's Clear All Tabs (`text.tabs.clear`), Delete Tab and Repeat Tab (`text.tabs.set`) can be chosen while the panel's ruler shows tab stops. Delete Tab removes the stop selected on the ruler. Repeat Tab replaces the stops after the selected stop with up to 20 copies of it. The copies are spaced by the selected stop's distance from the stop before it, or from the paragraph's left indent (18 pt further in for each list level past the first) when it is the first stop, and no copy is placed at 2,000 pt or beyond. Clear All Tabs removes every stop, so the default stops every ½ inch apply again. -- **Interface languages, vertical type:** Preferences ▸ User Interface (or VectorCraft ▸ Language on macOS) picks Automatic (the system locale) or a registered language, persisted as `interfaceLanguage`. Translations come from per-language catalogs (`i18n/*.tsv`): Traditional and Simplified Chinese, Japanese, German (every `de-*` locale), Spanish (neutral international Spanish, for every `es-*` locale), French (every `fr-*` locale), Italian (every `it-*` locale), Russian (every `ru-*` locale) and Ukrainian (every `uk-*` locale), Catalan (every `ca-*` locale) and Polish (every `pl-*` locale; AI-drafted, native review pending) cover the whole interface, Czech every menu label; Czech, German, Greek, Spanish, French, Georgian, Hindi, Hungarian, Italian, Japanese, Korean, Vietnamese, Polish, Brazilian Portuguese, Russian, Simplified Chinese, Traditional Chinese, Swedish, Ukrainian, Finnish, Turkish, Catalan and Dutch also translate status-bar and error messages as they are shown (`@msg` templates, every message literal tested; the messages themselves stay English for agents); untranslated text stays English. Japanese and Chinese glyphs come from the craft-fonts build input (release builds) or the installed fonts, Czech, German, Spanish, French, Italian, Russian and Ukrainian ones from the bundled UI fonts. The web app follows the browser's languages (or `?lang=`). The Basic toolbar's Type button holds Vertical Type in its flyout. Vertical Type, Vertical Area Type and Vertical Type on a Path create vertical text (columns right to left, upright CJK glyphs and marks with `vert` alternates, Latin and longer numbers on their side, two- and three-digit numbers set as tate-chu-yoko in one em of the column (kept whole when justified), upright glyphs centred on the column (tracked or justified too), point type's anchor on the first column's centre line; on a vertical path, upright glyphs and tate-chu-yoko blocks face the path's start, and letters on their side are a quarter turn clockwise from them (with Skew, 3D Ribbon and Stair Step only on a path that runs to the right), in one column that Align to Path Baseline or Center centers on the path and that the caret and selection span); kinsoku applies to horizontal and vertical Japanese (Paragraph panel › Kinsoku Set, `text.setFormat {kinsoku}`: Hard, the default, keeps closing marks, iteration marks, the prolonged sound mark and small kana off the line start and opening brackets off its end; Soft lets 々, ー and small kana start a line as JLREQ's level 3 rules do; None turns it off), and justified Japanese lines spread their room between characters (not inside Latin words); Paragraph panel › Mojikumi Set (shown with Preferences › Type › Show East Asian Options, `text.setFormat {mojikumi}`): None or Line-end Punctuation Half Width (new type's default, as in Illustrator; older documents and imported text keep None) shares one half-em space between consecutive punctuation, sets a closing mark ending a line half width and an opening bracket starting a line flush with its start (a paragraph's first line at its first-line indent, JIS X 4051's principle in JLREQ 3.1.5), and puts a quarter em between Japanese and Latin letters or digits; Paragraph panel menu › Top-to-Top / Bottom-to-Bottom Leading measures leading from em box top to top (area type's first line touches the frame) or from baseline to baseline (new type in a Japanese interface starts top to top, with em box centre character alignment, #432); Paragraph panel menu › Burasagari None / Regular / Force (`text.setFormat {burasagari: "none"|"standard"|"forced"}`) hangs an East Asian comma or full stop (、。,.、。) ending an area type line outside it, Regular (new type's default) when it doesn't fit and Force always (the rest of the line then fills the measure; closing brackets and Latin punctuation don't hang); Character panel menu › Character Alignment (with the East Asian options; `text.setFormat` or `text.setRangeStyle {charAlign}`) lines characters smaller than the largest on their line up on the Roman baseline, the em box top/right, centre or bottom/left, or the ICF (ideographic character face) top/right or bottom/left, from the font's BASE table or, without one, the average ink box of some ideographs and kana; Proportional Metrics (the same menu; `{proportionalMetrics}`) sets full-width glyphs on the font's proportional widths (OpenType `palt`) in horizontal type and upright glyphs on its proportional heights (`vpal`, shaped top to bottom; fonts without vertical metrics keep their cells) in vertical type, and Line-end Punctuation Half Width takes nothing more off the punctuation they re-spaced; Type ▸ Type Orientation switches existing text; caret, selection, hit testing and arrow keys follow the writing direction. Vertical text exports to PDF as real text and to SVG as outlines. The interface is available in Polish (`pl`): menus, panels, dialogs and messages, with one/few/many plurals; AI-drafted, native review pending (#1498). +- **Interface languages, vertical type:** Preferences ▸ User Interface (or VectorCraft ▸ Language on macOS) picks Automatic (the system locale) or a registered language, persisted as `interfaceLanguage`. Translations come from per-language catalogs (`i18n/*.tsv`): Traditional and Simplified Chinese, Japanese, German (every `de-*` locale), Spanish (neutral international Spanish, for every `es-*` locale), French (every `fr-*` locale), Italian (every `it-*` locale), Russian (every `ru-*` locale) and Ukrainian (every `uk-*` locale), Catalan (every `ca-*` locale) and Polish (every `pl-*` locale; AI-drafted, native review pending) cover the whole interface, Czech every menu label; Czech, German, Greek, Spanish, French, Georgian, Hindi, Hungarian, Italian, Japanese, Korean, Vietnamese, Polish, Brazilian Portuguese, Russian, Simplified Chinese, Traditional Chinese, Swedish, Ukrainian, Finnish, Turkish, Catalan, Dutch and Danish also translate status-bar and error messages as they are shown (`@msg` templates, every message literal tested; the messages themselves stay English for agents); untranslated text stays English. Japanese and Chinese glyphs come from the craft-fonts build input (release builds) or the installed fonts, Czech, German, Spanish, French, Italian, Russian and Ukrainian ones from the bundled UI fonts. The web app follows the browser's languages (or `?lang=`). The Basic toolbar's Type button holds Vertical Type in its flyout. Vertical Type, Vertical Area Type and Vertical Type on a Path create vertical text (columns right to left, upright CJK glyphs and marks with `vert` alternates, Latin and longer numbers on their side, two- and three-digit numbers set as tate-chu-yoko in one em of the column (kept whole when justified), upright glyphs centred on the column (tracked or justified too), point type's anchor on the first column's centre line; on a vertical path, upright glyphs and tate-chu-yoko blocks face the path's start, and letters on their side are a quarter turn clockwise from them (with Skew, 3D Ribbon and Stair Step only on a path that runs to the right), in one column that Align to Path Baseline or Center centers on the path and that the caret and selection span); kinsoku applies to horizontal and vertical Japanese (Paragraph panel › Kinsoku Set, `text.setFormat {kinsoku}`: Hard, the default, keeps closing marks, iteration marks, the prolonged sound mark and small kana off the line start and opening brackets off its end; Soft lets 々, ー and small kana start a line as JLREQ's level 3 rules do; None turns it off), and justified Japanese lines spread their room between characters (not inside Latin words); Paragraph panel › Mojikumi Set (shown with Preferences › Type › Show East Asian Options, `text.setFormat {mojikumi}`): None or Line-end Punctuation Half Width (new type's default, as in Illustrator; older documents and imported text keep None) shares one half-em space between consecutive punctuation, sets a closing mark ending a line half width and an opening bracket starting a line flush with its start (a paragraph's first line at its first-line indent, JIS X 4051's principle in JLREQ 3.1.5), and puts a quarter em between Japanese and Latin letters or digits; Paragraph panel menu › Top-to-Top / Bottom-to-Bottom Leading measures leading from em box top to top (area type's first line touches the frame) or from baseline to baseline (new type in a Japanese interface starts top to top, with em box centre character alignment, #432); Paragraph panel menu › Burasagari None / Regular / Force (`text.setFormat {burasagari: "none"|"standard"|"forced"}`) hangs an East Asian comma or full stop (、。,.、。) ending an area type line outside it, Regular (new type's default) when it doesn't fit and Force always (the rest of the line then fills the measure; closing brackets and Latin punctuation don't hang); Character panel menu › Character Alignment (with the East Asian options; `text.setFormat` or `text.setRangeStyle {charAlign}`) lines characters smaller than the largest on their line up on the Roman baseline, the em box top/right, centre or bottom/left, or the ICF (ideographic character face) top/right or bottom/left, from the font's BASE table or, without one, the average ink box of some ideographs and kana; Proportional Metrics (the same menu; `{proportionalMetrics}`) sets full-width glyphs on the font's proportional widths (OpenType `palt`) in horizontal type and upright glyphs on its proportional heights (`vpal`, shaped top to bottom; fonts without vertical metrics keep their cells) in vertical type, and Line-end Punctuation Half Width takes nothing more off the punctuation they re-spaced; Type ▸ Type Orientation switches existing text; caret, selection, hit testing and arrow keys follow the writing direction. Vertical text exports to PDF as real text and to SVG as outlines. The interface is available in Polish (`pl`): menus, panels, dialogs and messages, with one/few/many plurals; AI-drafted, native review pending (#1498). With Line-end Punctuation Half Width, both composers measure a line with its last character set as narrow as Mojikumi sets it ([the help page](https://helpx.adobe.com/illustrator/desktop/design-with-text/edit-format-text/use-mojikumi-for-japanese-text-composition.html)): a closing bracket, comma or full stop that fits at half width ends the line, and the character before it no longer goes to the next line with it. A mark that already lost its half to the punctuation after it and a line that ends in a hyphen take nothing more off, and the quarter em between Japanese and Latin text counts only where the line ends there. Kinsoku Shori Type and Burasagari Regular measure lines the same way; right-to-left lines and Mojikumi None break as before. In a frame that is not a rectangle, or with text wrap, a line's first word is measured as it is set (an opening bracket at its start flush, its last character narrowed) when layout looks for a line wide enough for it, and it ends only where the line may break, so kinsoku keeps an opening bracket with the character after it and a No Break run counts whole (`crates/text/src/layout/wrapping_tests.rs`, `crates/text/src/tests/vertical.rs`). Scores and estimates are unchanged. The Paragraph panel's Mojikumi Set lists the five predefined sets the help page names (None, YakumonoHankaku, GyomatsuYakumonoHankaku, GyomatsuYakumonoZenkaku and YakumonoZenkaku), then Line-end Punctuation Half Width, and `text.setFormat {mojikumi}` takes each of them. YakumonoHankaku sets every bracket, comma and full stop half width and takes a quarter em off each side of a middle dot; GyomatsuYakumonoHankaku keeps punctuation full width inside a line, sets an opening bracket flush at a line's start and a closing mark half width at its end, and takes the quarter em after a middle dot off at a line's end; GyomatsuYakumonoZenkaku keeps punctuation full width and adds a quarter em between Japanese and Latin text; YakumonoZenkaku sets characters as None does. These widths are VectorCraft's own rules, from the help page's one-line description of each set. Both composers and vertical type measure each line as it is set, the sets save with the document and paragraph styles, and a `.ai` save reports the new sets as left out (`crates/text/src/tests/vertical.rs`, `crates/ui-egui/src/panels/paragraph/tests.rs`). Scores and estimates are unchanged. Interface text in characters the interface's fonts lack, drawn in a bundled Japanese, Georgian or Devanagari face or in an installed font that covers them, sits on the baseline of the Latin text beside it, monospace text included (#5816; `crates/ui-egui/src/ui_fonts.rs`, `crates/ui-egui/src/theme.rs`). @@ -1845,6 +1847,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur - PDF export writes a CMYK image that keeps its numbers in an `ICCBased` color space holding, byte for byte, the CMYK profile the image carries (a CMYK TIFF's tag 34675, or a CMYK JPEG's `ICC_PROFILE` APP2 segments) when `output.profiles` is `all` or `taggedSource`, and in PDF/A-2b, PDF/X-3 and PDF/X-4 unless it is `destination` ([the help page](https://helpx.adobe.com/illustrator/desktop/save-and-export/export-files-to-different-formats/pdf-marks-bleeds-and-color-output-options.html)). This covers a CMYK JPEG embedded as it is, one the Compression settings resample or compress again, a TIFF written again, and an image written with its mask as its soft mask. With `none`, and in PDF/X-1a, the image is DeviceCMYK and a JPEG's own profile is left out, so PDF/X-1a export writes a CMYK JPEG that carries a profile. Pixels the Output settings convert are never tagged with the profile of their old numbers. A profile tags an image only when its size, signature, class, CMYK color space and tag table are well formed and it is at most 16 MiB (VectorCraft's own limit). Transparency in a tagged CMYK document blends in the document's CMYK profile; when the file holds no space of that profile it blends in DeviceCMYK, or keeps its RGB space in PDF/A, and never in an image's own profile (`crates/pdf/src/tests/cmykimages.rs`, `export::writer::tests`). - An image that Open and Place store again keeps the ICC profile its file embeds and its 16-bit samples. A turned PNG, JPEG, WebP or TIFF stored as PNG, and a TIFF the image decoder reads, keep their gray or RGB samples at 8 or 16 bits with their gray or RGB profile in the PNG's `iCCP` chunk ([PNG specification, iCCP](https://www.w3.org/TR/png-3/#11iCCP)). A CMYK TIFF stored with one plane per ink and a turned CMYK JPEG or TIFF keep their CMYK profile as TIFF tag 34675, a JPEG's put together from all its APP2 segments ([ICC image-format guide](https://github.com/InternationalColorConsortium/iccDEV/blob/master/iccdev-mcp/docs/icc-format-guide.md)). A profile whose color space differs from the stored samples' is left out. The profile is kept in the stored image; how CMYK inks are drawn and exported to PDF does not change (`crates/engine/src/cmd/fileio/tests.rs`). - SVG import shows a raster `` whose EXIF orientation or TIFF Orientation tag turns or flips it upright, linked or as a data URL, and inside an SVG shown as an image: the pixels are stored turned, the width and height swap, and the `` box fits the turned image under `preserveAspectRatio`, as CSS `image-orientation: from-image` describes (). A turned CMYK JPEG or TIFF keeps its inks as a CMYK TIFF, as does a CMYK TIFF that is not turned. TIFF data URLs (`image/tiff`, or untyped TIFF bytes) are read, and TIFFs with an alpha or other extra channel, CCITT Group 3 TIFFs and CMYK TIFFs stored one plane per ink are read as Open and Place read them. The pixels are decoded within the decode allowance, and a TIFF claiming more strips or tiles than the decoder can count is refused before it is decoded (`crates/svg/tests/it/image_orientation.rs`). + - An image that SVG import stores again, as PNG or as a CMYK TIFF, keeps the resolution its PNG, JPEG, TIFF, WebP or BMP file declares, as Open and Place keep it: in a PNG `pHYs` chunk () or the TIFF's resolution tags, with the horizontal and vertical values swapped when the image turns a quarter. An image that declares no resolution is stored without one (`a_turned_raster_keeps_its_resolution`, `an_upright_tiff_keeps_its_resolution` and `a_cmyk_tiff_keeps_its_resolution_turned_or_not` in `crates/svg/tests/it/image_orientation.rs`). - SVG import leaves out an SVG shown as an image that it can't read (text that isn't XML, an SVG without a size) and goes on with the images after it, as SVG 2's error processing describes (). Only images that decode count toward the 256 nested SVG images an SVG may show, so an image after many broken ones is imported. Every nested SVG image read, decoded or not, counts toward VectorCraft's own limits of 1,024 reads and 512 MB of text per import; past them, nested SVG images are left out with a warning (`crates/svg/tests/it/import_details.rs`). - SVG import reads the files that a linked SVG file's own `` elements link to, relative paths from that file's folder (), as it reads the imported SVG's links: a raster stays linked, an SVG file imports as vector art, and one that can't be read gets the placeholder, its link and a warning. SVG 2 tells a viewer not to load these links (); reading them is VectorCraft's own rule. The same href in SVG files of two folders names two files. A link that leads back to an SVG file showing it gets the placeholder and a warning, and reading the links of linked SVG files stops at the nesting depth cap and at 1,024 reads and 512 MB per import, the same limits decoded nested SVG images have, counted separately from them. The text of every linked SVG file read counts, and so does every file read for the links of a linked SVG file, raster or SVG; rasters the imported SVG links to don't. Once either limit is passed, no file linked from a linked SVG file is read and its image is left out with a warning, and an SVG file the imported SVG links to is still shown, without the files its own links name, with a warning. Another href to an SVG file already read, spelled differently or in another SVG file, does not read it again, unless a link back or the depth cap changes what it shows there (`crates/svg/tests/it/import_details.rs`, `crates/svg/src/import/files.rs`). - File › Place and a file dropped on the canvas refuse a PNG, JPEG, GIF or WebP file whose pixels can't be read, such as one cut short by a download or a sync still running, and the status bar shows "Couldn't place : the image's pixels can't be read". Paste and Relink refuse such an image too, and Update Links, or Open when it reads the file again, counts it as missing. A JPEG cut short still places when its pixels can be decoded (`file.place`, `crates/engine/src/tests_place.rs`). @@ -2324,6 +2327,10 @@ A VectorCraft addition, not a feature of the reference app, so no area score mov A VectorCraft addition, so no area score moves. Object › Plug-ins › Cutting Tools › Banner Hems and Grommets… (`addon.cutting.banner`) marks where a printed banner is hemmed ([hem](https://en.wikipedia.org/wiki/Hem)) and where its grommets go ([grommet](https://en.wikipedia.org/wiki/Grommet)). The rest is VectorCraft's own: the banner is the selected art's bounds or each artboard; each chosen side gets a hem line at a set depth inside its edge (1 in by default) and grommet marks at a set inset (0.5 in), one at each end of the side and the rest spread evenly at most a set spacing apart (24 in), with one grommet at a shared corner. Each grommet mark is a circle with a cross through its center. The marks are stroked in a chosen swatch ([Registration] by default), in one group named Banner Marks per banner on top of the current layer, added as one undo step. The dialog has Preview. Tests: `cmd::addons::cutting::banner::tests` in `vectorcraft-engine`, `banner_hems_and_grommets_previews_cancels_and_runs_on_ok` in `vectorcraft-ui-egui` and `command_sweep`. Commands: `docs/mcp.md` › Cutting Tools add-on › Banner Hems and Grommets. +## Cutting Tools add-on: Nest Shapes (2026-11-14) + +A VectorCraft addition, so no area score moves. Object › Plug-ins › Cutting Tools › Nest Shapes… (`addon.cutting.nest`) moves the selected objects close together onto a sheet or a roll of material ([packing problems](https://en.wikipedia.org/wiki/Packing_problems)). The rest is VectorCraft's own: the sheet is an artboard, or a set width and height from its top-left corner, and a roll is a set width with no end; a margin is kept inside the edges and a spacing between objects. Objects are packed by their bounding boxes, with or without strokes and effects (shadows, glows and blurs included), the tallest first, each where its bottom edge is highest and then furthest left; with Rotate an object may turn 90° where that places it higher or where only the turned object fits. Objects that do not fit go in a column to the right of the sheet, in stacking order. The command is one undo step, and its result gives the length of material used. The dialog has Preview. Tests: `cmd::addons::cutting::nest::tests` in `vectorcraft-engine`, `nest_shapes_previews_cancels_and_runs_on_ok` in `vectorcraft-ui-egui` and `command_sweep`. Commands: `docs/mcp.md` › Cutting Tools add-on › Nest Shapes. + ## Path Precision Tools add-on: Divide into Dashes (2026-11-01) A VectorCraft addition, not a feature of the reference app, so no area score moves. Object › Plug-ins › Path Precision Tools › Divide into Dashes… cuts the selected paths into separate open paths along the curve (arc length, not straight distance): each `dash` long (0.01 to 1000000 pt, 10 by default) with `gap` between them (0 by default: the pieces touch); the gap pieces are left out unless Remove Gaps is off. With direct-selected segments (or anchors) only those runs are divided, the pattern running on across segments that follow one another, and the rest of such a path stays as pieces of its own. Fit to Corners and Ends gives every run between corners and ends a whole number of dashes, all stretched or squeezed alike so it starts and ends on a dash (a closed path without corners holds whole periods); otherwise the lengths are exact and each run's last piece is what is left. Every piece keeps the path's appearance and is selected; one undo step; at most 100000 pieces (more is refused). The dialog takes lengths in any unit and previews on the canvas. Tests: `dashes::tests` in `vectorcraft-pathops` (exact and fitted lengths, curves measured along them, picked segments on open and closed paths, the piece cap and bad patterns), `divide_into_dashes_*` in `cmd::addons::pathtools::tests` in `vectorcraft-engine` (gaps removed or kept, fitted lengths, the stroke kept, one undo step, selected segments only, bad params and too many pieces refused), `divide_into_dashes_opens_its_dialog_previews_and_runs_on_ok` in `vectorcraft-ui-egui` and `command_sweep`. Commands: `docs/mcp.md` › Path Precision Tools add-on. diff --git a/docs/three-d.md b/docs/three-d.md index 37f02f4646..f12ca4675c 100644 --- a/docs/three-d.md +++ b/docs/three-d.md @@ -163,7 +163,8 @@ The rest is our reading: depths where they overlap, with caps on or off. A fill wall in a gap stays in its own depth order. Inflate's curved surfaces follow this only as closely as their flat facets do. A solid painted with a gradient that has transparent stops under a light that is not white is drawn with an opacity mask: its faces keep their places in - this order, in runs that each carry the mask, and so do its graphics. A member of a 3D group with its own opacity + this order, in runs that each carry the mask, and a graphic drawn after one of its faces carries the mask too + ([Graphics](#graphics)). A member of a 3D group with its own opacity mask or blending mode draws its fill's and strokes' faces in this order inside that mask or blending mode when they are opaque with Normal blending. Otherwise each solid is drawn on its own with its opacity and blending, the lower item's first. With a light that is not white, a puffed, shaded Inflate surface in a solid color takes its @@ -389,8 +390,11 @@ These rules are VectorCraft's own: turned, foreshortened by the perspective and twisted and tapered with the face, and clipped to it. It is shaded with the face's light: each color is multiplied by the light that falls on the face, except in pattern tiles, which keep their own colors. It is drawn with the - opacity and blending mode of the fill or stroke the face is painted with. A graphic on a face turned away from - the viewer is not drawn. + opacity and blending mode of the fill or stroke the face is painted with. Solids whose fills and strokes have the + same opacity and blending mode share one graphic on each face, clipped to their faces there and drawn after the + last of them. An object's fill and stroke share it when the fill is drawn with an opacity mask too, and the + graphic takes the mask of the solid it is drawn after; members of a 3D group drawn with a mask keep graphics of + their own. A graphic on a face turned away from the viewer is not drawn. - On the back cap, X runs to the right as the back is seen from behind, so the graphic reads the right way round there. - On Revolve a graphic lies on the swept side, which has one face (Front), unrolled flat. X runs around the axis: