diff --git a/Cargo.lock b/Cargo.lock index 32fcfc62ad..267130fc41 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -6824,6 +6824,7 @@ version = "0.24.1" dependencies = [ "craft-docpath", "craft-svg", + "data-url", "flate2", "image", "kurbo 0.13.1", diff --git a/crates/color/src/cms/icc.rs b/crates/color/src/cms/icc.rs index 0b09062b84..f6a2316b89 100644 --- a/crates/color/src/cms/icc.rs +++ b/crates/color/src/cms/icc.rs @@ -172,6 +172,21 @@ impl IccProfile { Some(dst.map(|x| x.clamp(0.0, 1.0))) } + /// Device pixels `v` (RGB / CMYK / Gray, 0..1, the profile's channels a pixel, any number of + /// pixels) → sRGB in `dst` (three values a pixel), converted with `intent` in one pass of the + /// cached transform. `None` when `v` doesn't hold whole pixels, `dst` doesn't hold three values + /// for each, or no transform can be built. + pub fn to_srgb_pixels(&self, v: &[f32], dst: &mut [f32], intent: Intent) -> Option<()> { + let n = self.channels(); + if !v.len().is_multiple_of(n) || dst.len() != v.len() / n * 3 { + return None; + } + let xf = self.to_xf(intent)?; + xf.transform(v, dst).ok()?; + dst.iter_mut().for_each(|x| *x = x.clamp(0.0, 1.0)); + Some(()) + } + /// sRGB → device values. pub fn from_srgb(&self, rgb: [f32; 3], intent: Intent) -> Option> { let xf = self.inverse_xf(intent)?; diff --git a/crates/color/src/cms/tests_icc.rs b/crates/color/src/cms/tests_icc.rs index 1d312c09c3..b2920c7c7b 100644 --- a/crates/color/src/cms/tests_icc.rs +++ b/crates/color/src/cms/tests_icc.rs @@ -48,6 +48,28 @@ fn gray_is_srgb_toned() { } } +#[test] +fn many_pixels_convert_as_each_one_does() { + let p = IccProfile::from_bytes(None, &icc_bytes(DISPLAY_P3).unwrap()).unwrap(); + let px = [[1.0f32, 0.0, 0.0], [0.2, 0.6, 0.9], [0.5, 0.5, 0.5], [0.0, 1.0, 0.0]]; + for intent in Intent::ALL { + let v: Vec = px.iter().flatten().copied().collect(); + let mut dst = vec![0.0; v.len()]; + p.to_srgb_pixels(&v, &mut dst, intent).unwrap(); + for (i, rgb) in px.iter().enumerate() { + let one = p.to_srgb(rgb, intent).unwrap(); + assert!(dst[i * 3..i * 3 + 3].iter().zip(one).all(|(a, b)| (a - b).abs() < 1e-5), "{intent:?} {rgb:?}: {dst:?} vs {one:?}"); + } + } + // Lengths that aren't whole pixels, or a destination of another size, are refused. + assert!(p.to_srgb_pixels(&[0.5; 4], &mut [0.0; 3], Intent::Perceptual).is_none()); + assert!(p.to_srgb_pixels(&[0.5; 3], &mut [0.0; 6], Intent::Perceptual).is_none()); + let gray = IccProfile::from_bytes(None, &icc_bytes(GRAY).unwrap()).unwrap(); + let mut dst = [0.0; 6]; + gray.to_srgb_pixels(&[0.25, 0.75], &mut dst, Intent::RelativeColorimetric).unwrap(); + assert!(dst.iter().zip([0.25, 0.25, 0.25, 0.75, 0.75, 0.75]).all(|(a, b)| (a - b).abs() < 0.01), "{dst:?}"); +} + #[test] fn encoded_profiles_are_reproducible() { assert_eq!(icc::builtin_rgb_bytes(DISPLAY_P3).unwrap(), icc::builtin_rgb_bytes(DISPLAY_P3).unwrap()); diff --git a/crates/doc/src/pattern.rs b/crates/doc/src/pattern.rs index d1333bef0b..b2c00d8ab3 100644 --- a/crates/doc/src/pattern.rs +++ b/crates/doc/src/pattern.rs @@ -173,6 +173,10 @@ pub struct PatternDef { /// Size Tile to Art: the tile is the art bounds grown by the spacing. #[serde(default)] pub size_tile_to_art: bool, + /// Move Tile with Art: in pattern editing mode, a move of all of the tile art moves the tile by + /// the same offset. + #[serde(default, skip_serializing_if = "std::ops::Not::not")] + pub move_tile_with_art: bool, #[serde(default)] pub h_spacing: f64, #[serde(default)] @@ -202,6 +206,7 @@ impl PatternDef { art, overlap: Overlap::default(), size_tile_to_art: false, + move_tile_with_art: false, h_spacing: 0.0, v_spacing: 0.0, copies: 5, diff --git a/crates/doc/src/text/char_style.rs b/crates/doc/src/text/char_style.rs index 97f744dba8..41fbf168ae 100644 --- a/crates/doc/src/text/char_style.rs +++ b/crates/doc/src/text/char_style.rs @@ -298,6 +298,15 @@ impl GlyphForm { GlyphForm::QuarterWidth => b"qwid", }) } + + /// Is this one of the monospaced width forms (`fwid`, `hwid`, `twid`, `qwid`)? A font may + /// draw them with other glyphs (`GSUB`) or with other widths for the same glyphs (`GPOS`), and + /// they set the characters on a fixed width, so Proportional Metrics (`palt`) is not applied + /// with them. + /// + pub fn is_width(self) -> bool { + matches!(self, GlyphForm::FullWidth | GlyphForm::HalfWidth | GlyphForm::ThirdWidth | GlyphForm::QuarterWidth) + } } /// A glyph chosen for one character ([`CharStyle::glyph_alternate`]): the glyph id and the face it @@ -785,6 +794,15 @@ impl CharStyle { self.position = self.position.in_range(); self.small_caps = self.small_caps.map(small_caps_in_range); } + /// Sets the East Asian glyph form ([`CharStyle::glyph_form`]). A form other than the default + /// also takes away the character's glyph choice ([`CharStyle::glyph_alternate`]), which would + /// otherwise keep drawing its own glyph; the default form keeps it. + pub fn set_glyph_form(&mut self, form: GlyphForm) { + self.glyph_form = form; + if form != GlyphForm::Default { + self.glyph_alternate = None; + } + } } impl crate::Document { diff --git a/crates/engine/src/cmd/colormgmt.rs b/crates/engine/src/cmd/colormgmt.rs index 8bf6841e17..5922787723 100644 --- a/crates/engine/src/cmd/colormgmt.rs +++ b/crates/engine/src/cmd/colormgmt.rs @@ -64,7 +64,7 @@ pub fn specs() -> Vec { "Assign Profile…", ["Edit"], None, - "{rgb?: profile|null, cmyk?: profile|null} tag the document with profiles (color numbers are kept; null: the working space). Colors then show and convert through the profiles assigned; the working spaces stay as edit.colorSettings set them → {rgb, cmyk}", + "{rgb?: profile|null, cmyk?: profile|null} tag the document with profiles (color numbers are kept; null: no profile, Don't Color Manage This Document, so colors show through the working space; the working space's name: that profile stays assigned when the working space changes). Colors then show and convert through the profiles assigned; the working spaces stay as edit.colorSettings set them → {rgb, cmyk}", has_doc, assign_profile ), diff --git a/crates/engine/src/cmd/draw2/commands.rs b/crates/engine/src/cmd/draw2/commands.rs index ffa11e362c..49e277e5a0 100644 --- a/crates/engine/src/cmd/draw2/commands.rs +++ b/crates/engine/src/cmd/draw2/commands.rs @@ -92,7 +92,7 @@ pub fn specs() -> Vec { "Eraser", [], None, - "{points: [[x,y]…] or [[x,y,pressure]…] (pen pressure 0..1), size?: pt (10), sizeMode?: \"fixed\" (default) | \"pressure\" (Eraser Tool Options' Size set to Pressure: the tip is size − sizeVariation across where the pen presses lightest and size + sizeVariation where it presses hardest, as path.blob), sizeVariation?: pt (0), angle?: ° (0: the tip's long axis counter-clockwise from level), roundness?: % (100, kept within 1–100)} erase the stroke of an elliptical tip from paths (selected, or all) → {ids}. The tip is `size` across its long axis, which runs at `angle` counter-clockwise from horizontal; its short axis is `roundness` percent of the long one", + "{points: [[x,y]…] or [[x,y,pressure]…] or [[x,y,pressure,tiltX,tiltY,rotation]…] (pen pressure 0..1, tilt −90..90°, barrel rotation ° clockwise, as path.freehand), size?: pt (10), angle?: ° (0: the tip's long axis counter-clockwise from level), roundness?: % (100, kept within 1–100), angleMode?, roundnessMode?, sizeMode?: \"fixed\" (default) | \"random\" | \"pressure\" | \"tilt\" | \"bearing\" | \"rotation\" (Eraser Tool Options' variation menus; a varied angle turns either way, clockwise or counter-clockwise), angleVariation?: ° (0–180, default 0, either way: clockwise or counter-clockwise), roundnessVariation?: % (0–100, default 0), sizeVariation?: pt (0 or more, default 0)} erase the stroke of an elliptical tip from paths (selected, or all) → {ids}. The tip is `size` across its long axis, which runs at `angle` counter-clockwise from horizontal; its short axis is `roundness` percent of the long one. A value whose mode is not fixed varies by up to its variation either way: random draws one value for the drag; pressure goes from the value less the variation where the pen presses lightest to the value plus it where it presses hardest (size on pressure as path.blob); tilt from less with the pen lying flat to more with it upright; bearing and rotation from less at −180° to more at 180° of the pen's lean direction or barrel turn. A drag without that pen reading (a mouse) erases at the value itself. An unknown mode, or a variation below 0 or past its range, is an error and erases nothing", has_doc, erase_region ), diff --git a/crates/engine/src/cmd/draw2/cut.rs b/crates/engine/src/cmd/draw2/cut.rs index 645c184b14..64b8bdee16 100644 --- a/crates/engine/src/cmd/draw2/cut.rs +++ b/crates/engine/src/cmd/draw2/cut.rs @@ -1,11 +1,14 @@ use super::common::{ - dist_to_polyline, fill_targets, ids_json, path_mut, path_of, points_param, poly_bounds, pressure_region, replace_with_pieces, samples_param, - sibling_with, size_pressure, target_paths, usize_opt, + PenRecord, Sample, dist_to_polyline, fill_targets, ids_json, path_mut, path_of, points_param, poly_bounds, pressure_region, replace_with_pieces, + samples_param, sibling_with, size_pressure, target_paths, usize_opt, }; use super::{ - Anchor, AnchorKind, Beside, BezPath, BoolOp, Cap, EngineError, FillRule, Join, NodeId, ParamCurve, PathData, Point, Result, Session, SubPath, - Value, bad, eraser_area, eraser_tip, f64_or, fill_contains, json, number_or, object_param, po, + Anchor, AnchorKind, Beside, BezPath, BoolOp, Cap, EngineError, FillRule, Join, NodeId, Paint, ParamCurve, PathData, Point, Result, Session, + SubPath, Value, Vec2, bad, eraser_area, eraser_tip, f64_or, fill_contains, json, number_or, object_param, po, }; +use vectorcraft_brush::{Brush, BrushKind, Calligraphic, Variation}; +use vectorcraft_doc::StrokeLayer; +use vectorcraft_tools::draw2::MIN_BRUSH_SIZE; /// Split a filled path into connected pieces (each outer contour with its holes). pub(in super::super) fn components(pd: &PathData) -> Vec { @@ -278,7 +281,11 @@ pub(super) fn erase_region(s: &mut Session, p: &Value) -> Result { } let angle = number_or(p, "angle", 0.0, C)?; let roundness = number_or(p, "roundness", 100.0, C)?; - let pressure = size_pressure(p, &samples, C)?; + let vary = eraser_variations(p, C)?; + if varies_beyond_size_pressure(&vary) { + return erase_varied(s, &samples, [angle, roundness, size], vary); + } + let pressure = if vary[2].0 == Variation::Pressure { size_pressure(p, &samples, C)? } else { None }; let pts: Vec = samples.into_iter().map(|s| s.pt).collect(); let r = size / 2.0; // The tip's short axis (its narrowest with Pressure): an open path is sampled at least every half of it. @@ -318,6 +325,101 @@ pub(super) fn erase_region(s: &mut Session, p: &Value) -> Result { apply_plan(s, "Eraser", plan) } +/// The variation modes `path.eraseRegion` takes for each of `angleMode`, `roundnessMode` and +/// `sizeMode`. +const ERASER_MODES: &str = "\"fixed\", \"random\", \"pressure\", \"tilt\", \"bearing\" or \"rotation\""; + +/// Eraser Tool Options' Angle, Roundness and Size variations: for each of angle (`angleMode` with +/// `angleVariation` in degrees, at most 180), roundness (`roundnessMode` with `roundnessVariation` +/// in percent, at most 100) and size (`sizeMode` with `sizeVariation` in points), its mode and how +/// far it varies either way. A mode left out or null is Fixed, and a variation left out or null +/// is 0; any other mode, or a variation that is negative or past its most, is refused. +fn eraser_variations(p: &Value, cmd: &str) -> Result<[(Variation, f64); 3]> { + const KEYS: [(&str, &str, f64); 3] = + [("angleMode", "angleVariation", 180.0), ("roundnessMode", "roundnessVariation", 100.0), ("sizeMode", "sizeVariation", f64::INFINITY)]; + let mut out = [(Variation::Fixed, 0.0); 3]; + for (slot, (mode_key, var_key, most)) in out.iter_mut().zip(KEYS) { + let mode = match p.get(mode_key).filter(|v| !v.is_null()) { + None => Variation::Fixed, + Some(Value::String(id)) => Variation::parse(id).ok_or_else(|| bad(cmd, format!("{mode_key} is one of {ERASER_MODES}, not `{id}`")))?, + Some(other) => return Err(bad(cmd, format!("{mode_key} is one of {ERASER_MODES}, not {other}"))), + }; + let variation = number_or(p, var_key, 0.0, cmd)?; + if !(variation.is_finite() && variation >= 0.0) { + return Err(bad(cmd, format!("{var_key} must be 0 or more"))); + } + if variation > most { + return Err(bad(cmd, format!("{var_key} must be at most {most}"))); + } + *slot = (mode, variation); + } + Ok(out) +} + +/// Does any of the Eraser's Angle, Roundness and Size vary with something other than Size on +/// Pressure? A mode whose variation is 0 does not vary. +fn varies_beyond_size_pressure(vary: &[(Variation, f64); 3]) -> bool { + vary.iter().enumerate().any(|(i, (mode, v))| *v > 0.0 && *mode != Variation::Fixed && !(i == 2 && *mode == Variation::Pressure)) +} + +/// `path.eraseRegion` with its tip varying along the drag ([`eraser_variations`]): the area the +/// tip covers is swept as a Calligraphic brush nib of the same angle, roundness, size, modes and +/// variations, with what the pen recorded along the drag. Random draws one value for the drag +/// and the pen modes follow the pen at each point; a drag without that recording (a mouse) takes +/// the value itself. `tip`: the angle, roundness and size. +fn erase_varied(s: &mut Session, samples: &[Sample], tip: [f64; 3], vary: [(Variation, f64); 3]) -> Result { + let [angle, roundness, size] = tip; + let mut pts: Vec = samples.iter().map(|s| s.pt).collect(); + // A press without a drag is a dab: a sweep needs some length. + if let [only] = pts[..] { + pts.push(only + Vec2::new(0.01, 0.0)); + } + // A nib's size is kept within 1296 pt and the stroke's width scales it: a larger tip is swept + // at a fraction of its size on a stroke as many times wider. + let [(am, av), (rm, rv), (sm, sv)] = vary; + let sv = if sm == Variation::Fixed { 0.0 } else { sv }; + let scale = ((size + sv) / Calligraphic::RANGES[2].1).max(1.0); + let nib = Calligraphic { + angle, + roundness: roundness.clamp(1.0, 100.0), + size: size / scale, + variation: [av, rv, sv / scale], + modes: Some([am, rm, sm]), + }; + let brush = Brush { name: "Eraser".into(), kind: BrushKind::Calligraphic(nib) }; + let mut stroke = StrokeLayer::new(Paint::None, scale); + PenRecord::of_samples(samples).write(&mut stroke); + let spine = SubPath::polyline(&pts, false); + let st = s.doc()?; + let pieces = vectorcraft_brush::stroke_pieces(&st.doc, &brush, &PathData::single(spine).to_bezpath(), &stroke); + let areas: Vec = pieces.iter().filter_map(|n| n.path_data().cloned()).collect(); + if areas.is_empty() { + return Ok(json!({ "ids": [] })); + } + let region = + po::try_unite_all(&areas.iter().map(|a| (a, FillRule::NonZero)).collect::>()).map_err(|e| EngineError::Other(e.to_string()))?; + let bp = region.to_bezpath(); + // The narrowest the tip gets across: an open path is sampled at least every half of it. + let low = |i: usize| match vary[i] { + (Variation::Fixed, _) => tip[i], + (_, v) => tip[i] - v, + }; + let short = low(2).max(MIN_BRUSH_SIZE) * low(1).clamp(1.0, 100.0) / 100.0; + let reach = (size + sv) / 2.0; + let mut plan: Vec<(NodeId, Vec)> = vec![]; + for (id, pd, rule) in fill_targets(&st.doc, &st.selection, poly_bounds(&pts, reach))? { + if pd.is_closed() { + if po::area(&po::boolean(&pd, rule, ®ion, FillRule::NonZero, BoolOp::Intersect), FillRule::NonZero) <= 1e-6 { + continue; + } + plan.push((id, components(&po::boolean(&pd, rule, ®ion, FillRule::NonZero, BoolOp::Difference)))); + } else if let Some(pieces) = erase_outline(&pd, short / 2.0, &|q| fill_contains(&bp, FillRule::NonZero, q)) { + plan.push((id, pieces)); + } + } + apply_plan(s, "Eraser", plan) +} + fn apply_plan(s: &mut Session, label: &str, plan: Vec<(NodeId, Vec)>) -> Result { if plan.is_empty() { return Ok(json!({ "ids": [] })); diff --git a/crates/engine/src/cmd/fileio.rs b/crates/engine/src/cmd/fileio.rs index 6d972beda8..ce919c7cc9 100644 --- a/crates/engine/src/cmd/fileio.rs +++ b/crates/engine/src/cmd/fileio.rs @@ -91,6 +91,7 @@ pub(crate) use mesh3d::{mesh_file_names, mesh_files}; pub use naming::{NameParts as FileNameParts, parse_date as parse_file_date, render as render_file_name}; pub(crate) use native::preview_png; pub use native::with_compression_pref; +pub(crate) use pdfimport::opened_page_document; pub use pdfimport::{LoadOptions, page_document}; pub(crate) use save::job_for; pub(crate) use save::save as save_document; diff --git a/crates/engine/src/cmd/fileio/commands.rs b/crates/engine/src/cmd/fileio/commands.rs index a46f46492a..1710f57eb9 100644 --- a/crates/engine/src/cmd/fileio/commands.rs +++ b/crates/engine/src/cmd/fileio/commands.rs @@ -9,7 +9,7 @@ pub fn specs() -> Vec { "Open Document", [], None, - "{path | name | dataBase64 (a file's path, or its name with its bytes), pages?: \"2-3, 5\" (PDF/.ai: 1-based, default all; one artboard each) | page?: n, cropTo?: bounding|art|crop (default)|trim|bleed|media (the box each artboard gets; bounding: the art's bounds; trim: the bleed box round it becomes the document's bleed; a PDF-compatible .ai or a PDF saved with its editing data opens on its trim boxes for crop), password? (encrypted PDFs; see document.pdfInfo), textAs?: text (default: point type per line, lines wrapped in a frame as area type where they lay out the same, in the file's fonts by name; missing ones are listed in warnings) | outlines (glyph paths), layers?: true (default: optional content groups become layers with their visibility, print state and lock — art that is off comes in as a hidden layer; other art goes to a layer per page; a PSD file's layers become layers holding their pixels, with their names, visibility, opacity and blend modes, groups as layers of sublayers, layer masks as opacity masks, clipped layers and layers in blend modes VectorCraft doesn't have merged with the layers below) | false (one layer per page, without the art that is off; Save then asks for a name; a PSD file opens as its merged image), hiddenLayers?: false (a PSD file's hidden layers come in hidden instead of being left out), editingData?: true (default: an EPS, .ai or PDF that carries its editor's own copy of the art opens from it, with its layers, hidden objects, guides, artboards and the art outside them; a .ai or PDF whose type shows opens from its PDF part with textAs: outlines) | \"always\" (as true, and a .ai's editing copy is used even where it draws differently from its PDF part, which can be older; the warnings say so) | false (only what its page or PDF part prints, for print pipelines; a .ai saved without PDF compatibility then can't be opened), legacyNameEncoding?: windows-1252 (default) | macintosh | windows-949 (also euc-kr) | shift_jis | gbk | big5 (the code page names in the editing data of a .ai or an EPS are read in when they aren't UTF-8: layers, objects, artboards, colors, gradients, patterns, graphic styles, swatches, brushes, symbols, variables and data sets, and data sets' text values; warnings list the first three such names as read, and count the rest), colorMode?: rgb|cmyk (the mode the document opens in, its colours converted as file.documentColorMode does, grays? as there; default: the file's — a PDF keeps CMYK, Gray and spot inks (spot swatches at a tint) and opens in CMYK when painted mostly in CMYK), grays?: as file.documentColorMode, dxf?: {layout?: \"Model\" (default) | a paper layout's name (document.dxfInfo lists them), unit?, scale? (the ratio: 1 unit of the art = scale drawing units; default: the drawing at 1:1 in its own unit), fit?: false (scale to fit an artboard of fitTo?: [612, 792], turned to the art's orientation), scaleLineweights?: false (lineweights scale with the art), center?: true (false: the drawing's origin on the artboard's bottom-left corner; fitted art's bottom-left corner), mergeLayers?: false (all art on one layer)} (DXF), crop?: as cropTo (its older name), linkPages?: false (true: each page `pages` picks of a PDF or PDF-compatible .ai opens as a placed document linked to that page of the file, as file.place with link places one, on an artboard of its cropTo box (bounding: its art's bounds), in a row, and a layer named after the page; links.embed makes a page editable art; refused for any other file, without a path, with a password or with textAs: outlines; Save then asks for a name), importer?: \"plugin.\" (an importer plug-in's format id, see document.formats: it opens the file, which must have an extension it lists, also one a built-in format reads), params?: {…} (the importer's own parameters, see plugin.info)} → {index, title, format, warnings, restored, missingLinks, modifiedLinks, updatedLinks: [{name, path, ids}]}; any readable format (see document.formats): .vectorcraft/.drawcraft, .svg/.svgz, .pdf/.ai, .ait, ASCII .dxf (each DXF layer with art a layer; blocks symbols; what can't come in is listed in warnings), PNG/JPEG/GIF/WebP/TIFF/BMP (an image opens as a document of its size at the resolution it declares, as file.place sizes it; 72 ppi, 1 px = 1 pt, when it declares none), .emf/.wmf (one artboard, the picture's frame; records VectorCraft doesn't read are skipped with one warning), PICT .pct/.pic/.pict (QuickDraw versions 1 and 2, with or without the file's 512-byte header: one artboard, the picture's frame; shapes, lines and regions as paths, text as point type, bitmaps, pixel maps and QuickTime JPEG images as images; what isn't read is listed in warnings), HP-GL plot files .plt/.hpgl/.hgl/.hpg (one artboard round the art; what isn't drawn is listed in warnings), .eps and PostScript .ai (one artboard, the bounding box: an EPS file VectorCraft wrote restores the document it carries, restored: true; other files are read by a PostScript interpreter — paths, colours and spot inks, clips, gradients, images, type in the fonts named — and come in as their TIFF preview, with a warning, when their PostScript can't be read). A PDF/.ai/.ait or SVG saved with Preserve Editing restores the native document it carries (restored: true; a PDF only when no pages are picked), unless the file was changed elsewhere since or the data can't be read: then its artwork is imported and the first warning says why. Templates (native templates, .ait) open as a new untitled document; a restored .ai keeps its path (Save writes .ai again) and opens on the artboard its editing copy names as active. A file that no built-in format reads, by its content or its extension, opens with the installed importer plug-in that lists its extension (the one whose id sorts first, when several do): it is given the file, at most 128 MiB, and its objects become a new document whose artboard is their bounds; Save then asks for a name, and the warnings are the importer's. Linked images are read from their files (looked for at their path, then relative to the document): missing ones show their saved preview (links.relink), modified ones are read again only with Preferences › Update Links: Automatically (else links.update). An SVG's files (relative links from the SVG's folder) stay linked, SVG files become art, missing ones a placeholder in their box (a warning and missingLinks). An SVG's variable data (SVG Options › Include Variable Data, a variableSets element) gives the document its text variables and data sets, each text variable bound to the type object its docRef names, unless the SVG restores the native document; variable data variable.loadLibrary would refuse is left out with a warning", + "{path | name | dataBase64 (a file's path, or its name with its bytes), pages?: \"2-3, 5\" (PDF/.ai: 1-based, default all; one artboard each) | page?: n, cropTo?: bounding|art|crop (default)|trim|bleed|media (the box each artboard gets; bounding: the art's bounds; trim: the bleed box round it becomes the document's bleed; a PDF-compatible .ai or a PDF saved with its editing data opens on its trim boxes for crop), password? (encrypted PDFs; see document.pdfInfo), textAs?: text (default: point type per line, lines wrapped in a frame as area type where they lay out the same, in the file's fonts by name; missing ones are listed in warnings) | outlines (glyph paths), layers?: true (default: optional content groups become layers with their visibility, print state and lock — art that is off comes in as a hidden layer; other art goes to a layer per page; a PSD file's layers become layers holding their pixels, with their names, visibility, opacity and blend modes, groups as layers of sublayers, layer masks as opacity masks, clipped layers and layers in blend modes VectorCraft doesn't have merged with the layers below) | false (one layer per page, without the art that is off; Save then asks for a name; a PSD file opens as its merged image), hiddenLayers?: false (a PSD file's hidden layers come in hidden instead of being left out), editingData?: true (default: an EPS, .ai or PDF that carries its editor's own copy of the art opens from it, with its layers, hidden objects, guides, artboards and the art outside them; a .ai or PDF whose type shows opens from its PDF part with textAs: outlines) | \"always\" (as true, and a .ai's editing copy is used even where it draws differently from its PDF part, which can be older; the warnings say so) | false (only what its page or PDF part prints, for print pipelines; a .ai saved without PDF compatibility then can't be opened), legacyNameEncoding?: windows-1252 (default) | macintosh | windows-949 (also euc-kr) | shift_jis | gbk | big5 (the code page names in the editing data of a .ai or an EPS are read in when they aren't UTF-8: layers, objects, artboards, colors, gradients, patterns, graphic styles, swatches, brushes, symbols, variables and data sets, and data sets' text values; warnings list the first three such names as read, and count the rest), colorMode?: rgb|cmyk (the mode the document opens in, its colours converted as file.documentColorMode does, grays? as there; default: the file's — a PDF keeps CMYK, Gray and spot inks (spot swatches at a tint) and opens in CMYK when painted mostly in CMYK), grays?: as file.documentColorMode, dxf?: {layout?: \"Model\" (default) | a paper layout's name (document.dxfInfo lists them), unit?, scale? (the ratio: 1 unit of the art = scale drawing units; default: the drawing at 1:1 in its own unit), fit?: false (scale to fit an artboard of fitTo?: [612, 792], turned to the art's orientation), scaleLineweights?: false (lineweights scale with the art), center?: true (false: the drawing's origin on the artboard's bottom-left corner; fitted art's bottom-left corner), mergeLayers?: false (all art on one layer)} (DXF), crop?: as cropTo (its older name), linkPages?: false (true: each page `pages` picks of a PDF or PDF-compatible .ai opens as a placed document linked to that page of the file, as file.place with link places one, on an artboard of its cropTo box (bounding: its art's bounds; a blank page: its crop box; a PDF-compatible .ai: its trim box for crop; on its trim box a page shows its bleed box round the artboard and gives the document its bleed), in a row 36 pt apart (on trim boxes, plus the bleed on both sides), and a layer named after the page; links.embed makes a page editable art; refused for any other file, without a path, with a password or with textAs: outlines; Save then asks for a name), importer?: \"plugin.\" (an importer plug-in's format id, see document.formats: it opens the file, which must have an extension it lists, also one a built-in format reads), params?: {…} (the importer's own parameters, see plugin.info)} → {index, title, format, warnings, restored, missingLinks, modifiedLinks, updatedLinks: [{name, path, ids}]}; any readable format (see document.formats): .vectorcraft/.drawcraft, .svg/.svgz, .pdf/.ai, .ait, ASCII .dxf (each DXF layer with art a layer; blocks symbols; what can't come in is listed in warnings), PNG/JPEG/GIF/WebP/TIFF/BMP (an image opens as a document of its size at the resolution it declares, as file.place sizes it; 72 ppi, 1 px = 1 pt, when it declares none), .emf/.wmf (one artboard, the picture's frame; records VectorCraft doesn't read are skipped with one warning), PICT .pct/.pic/.pict (QuickDraw versions 1 and 2, with or without the file's 512-byte header: one artboard, the picture's frame; shapes, lines and regions as paths, text as point type, bitmaps, pixel maps and QuickTime JPEG images as images; what isn't read is listed in warnings), HP-GL plot files .plt/.hpgl/.hgl/.hpg (one artboard round the art; what isn't drawn is listed in warnings), .eps and PostScript .ai (one artboard, the bounding box: an EPS file VectorCraft wrote restores the document it carries, restored: true; other files are read by a PostScript interpreter — paths, colours and spot inks, clips, gradients, images, type in the fonts named — and come in as their TIFF preview, with a warning, when their PostScript can't be read). A PDF/.ai/.ait or SVG saved with Preserve Editing restores the native document it carries (restored: true; a PDF only when no pages are picked), unless the file was changed elsewhere since or the data can't be read: then its artwork is imported and the first warning says why. Templates (native templates, .ait) open as a new untitled document; a restored .ai keeps its path (Save writes .ai again) and opens on the artboard its editing copy names as active. A file that no built-in format reads, by its content or its extension, opens with the installed importer plug-in that lists its extension (the one whose id sorts first, when several do): it is given the file, at most 128 MiB, and its objects become a new document whose artboard is their bounds; Save then asks for a name, and the warnings are the importer's. Linked images are read from their files (looked for at their path, then relative to the document): missing ones show their saved preview (links.relink), modified ones are read again only with Preferences › Update Links: Automatically (else links.update). An SVG's files (relative links from the SVG's folder) stay linked, SVG files become art, missing ones a placeholder in their box (a warning and missingLinks). An SVG's variable data (SVG Options › Include Variable Data, a variableSets element) gives the document its text variables and data sets, each text variable bound to the type object its docRef names, unless the SVG restores the native document; variable data variable.loadLibrary would refuse is left out with a warning", always, load::open ), diff --git a/crates/engine/src/cmd/fileio/encode.rs b/crates/engine/src/cmd/fileio/encode.rs index c823b8866f..c5b8858f98 100644 --- a/crates/engine/src/cmd/fileio/encode.rs +++ b/crates/engine/src/cmd/fileio/encode.rs @@ -8,7 +8,7 @@ use std::sync::Arc; use serde::Deserialize; use serde::de::DeserializeOwned; use serde_json::Value; -use vectorcraft_doc::{Artboard, Document, Node, NodeKind, SymbolOverride}; +use vectorcraft_doc::{Artboard, Document, Node, NodeKind, SYMBOL_SIZES, SymbolOverride}; use vectorcraft_geom::Rect; use vectorcraft_render::AntiAlias; use vectorcraft_render::encode::jpeg::{self, JpegOptions}; @@ -454,7 +454,12 @@ pub fn export_art_bounds(doc: &Document, format: &str) -> Option { /// make the same appearance changes to its art are measured on a copy of the symbol of their own, /// in which each object is judged with those changes ([`as_drawn`]); the objects taken out of /// a dynamic symbol's art leave empty groups in their places, by which instances find the objects -/// they change. +/// they change. Each copy keeps its symbol's 9-slice guides and natural size. An instance whose art +/// goes through 9-slice guides at any depth is measured as its art placed through them +/// ([`placed_instances`]), with the objects `left_out` picks hidden after placing: the guides cut +/// the box of the whole art. Where an instance stays one, the art of a symbol with 9-slice guides, +/// and of the symbols whose instances are in that art at any depth ([`sliced_art`]), is measured +/// whole. fn bounds_without(doc: &Document, left_out: &dyn Fn(&Node) -> bool) -> Option { // The symbols instances show (the first of each name). let mut names = HashSet::new(); @@ -492,12 +497,15 @@ fn bounds_without(doc: &Document, left_out: &dyn Fn(&Node) -> bool) -> Option)> = shown @@ -517,11 +525,25 @@ fn bounds_without(doc: &Document, left_out: &dyn Fn(&Node) -> bool) -> Option)> = entries .iter() .filter_map(|(i, c)| { - let s = src.symbols.get(*i).filter(|s| !empty.contains(s.name.as_str()))?; + let s = src.symbols.get(*i).filter(|s| !empty.contains(s.name.as_str()) && !whole.contains(s.name.as_str()))?; without_nodes(&s.art, left_out, *c, &mut Vec::new()).map(|art| (*i, art)) }) .collect(); @@ -540,6 +562,135 @@ fn bounds_without(doc: &Document, left_out: &dyn Fn(&Node) -> bool) -> Option HashSet<&str> { + let mut arts: HashMap<&str, &Arc> = HashMap::new(); + for s in &doc.symbols { + arts.entry(s.name.as_str()).or_insert(&s.art); + } + let mut todo: Vec<&str> = arts.keys().copied().filter(|name| doc.symbol_nine_slice(name).is_some()).collect(); + let mut found = HashSet::new(); + while let Some(name) = todo.pop() { + if !found.insert(name) { + continue; + } + if let Some(&art) = arts.get(name) { + art.walk(&mut |n| { + if let NodeKind::SymbolInstance { symbol, .. } = &n.kind { + todo.push(symbol.as_str()); + } + }); + } + } + found +} + +/// The names of the symbols of `doc` in `sliced`, and of the symbols whose art holds an instance of +/// one of those at any depth. +fn holding<'a>(doc: &'a Document, sliced: &HashSet<&'a str>) -> HashSet<&'a str> { + let mut held_by: HashMap<&str, Vec<&str>> = HashMap::new(); + let mut seen = HashSet::new(); + for s in doc.symbols.iter().filter(|s| seen.insert(s.name.as_str())) { + s.art.walk(&mut |n| { + if let NodeKind::SymbolInstance { symbol, .. } = &n.kind { + held_by.entry(symbol.as_str()).or_default().push(s.name.as_str()); + } + }); + } + let mut found = HashSet::new(); + let mut todo: Vec<&str> = sliced.iter().copied().collect(); + while let Some(name) = todo.pop() { + if found.insert(name) { + todo.extend(held_by.get(name).into_iter().flatten().copied()); + } + } + found +} + +/// The most instances deep [`placed_instances`] places symbol art. +const MAX_PLACED_DEPTH: usize = 32; + +/// `n` with each instance of the `placing` symbols in it, and in its layers and groups at any depth, +/// made a group with the instance's appearance that holds the instance's art (with its changes and +/// stain) as `doc` places it, through the symbol's 9-slice guides when it has them, without the +/// objects `picked` picks ([`without_nodes`]; hidden when it picks the whole art); `None` when `n` +/// holds no such instance, or is hidden. An instance on a perspective grid, one +/// [`MAX_PLACED_DEPTH`] instances deep, one in another kind of object (a blend, for example), and +/// one whose art would take the objects placed in all (`budget`) past +/// [`vectorcraft_doc::MAX_SYMBOL_NODES`] stay instances. +/// Their symbols' art is measured whole, as taking objects out of it would change the box the +/// guides cut, and the page can come out larger than the plot. +fn placed_instances( + doc: &Document, + n: &Arc, + placing: &HashSet<&str>, + picked: &dyn Fn(&Node) -> bool, + budget: &mut u64, + depth: usize, +) -> Option> { + // Hidden art is not plotted, and placing it would take from the budget of the art that is. + if !n.visible { + return None; + } + match &n.kind { + NodeKind::SymbolInstance { symbol, xf } => { + if !placing.contains(symbol.as_str()) || depth >= MAX_PLACED_DEPTH || n.projection().is_some() { + return None; + } + let sym = doc.symbols.iter().find(|s| s.name == *symbol)?; + let art = vectorcraft_doc::symbol_overrides::instance_art(&sym.art, n); + let most = doc.symbol_slice_map(symbol, *xf).map_or(art.count_with_masks() as u64, |m| m.most_placed(&art, doc)); + if budget.saturating_add(most) > vectorcraft_doc::MAX_SYMBOL_NODES { + return None; + } + let art = Arc::new(doc.place_symbol_art(symbol, &art, *xf)?); + *budget = budget.saturating_add(art.count_with_masks() as u64); + let art = placed_instances(doc, &art, placing, picked, budget, depth + 1).unwrap_or(art); + let art = if picked(&art) { + let mut hidden = (*art).clone(); + hidden.visible = false; + Arc::new(hidden) + } else { + without_nodes(&art, picked, None, &mut Vec::new()).unwrap_or(art) + }; + let mut g = (**n).clone(); + g.kind = NodeKind::Group { children: vec![art], clip: false }; + Some(Arc::new(g)) + } + NodeKind::Layer { children, .. } | NodeKind::Group { children, .. } => { + let mut out: Option>> = None; + for (i, c) in children.iter().enumerate() { + if let Some(new) = placed_instances(doc, c, placing, picked, budget, depth) + && let Some(slot) = out.get_or_insert_with(|| children.clone()).get_mut(i) + { + *slot = new; + } + } + out.map(|c| { + let mut m = (**n).clone(); + if let Some(children) = m.children_mut() { + *children = c; + } + Arc::new(m) + }) + } + _ => None, + } +} + /// The appearance changes instance `n` makes to its symbol's art, without those that change nothing. fn own_changes(n: &Node) -> Vec<&SymbolOverride> { n.extras().symbol_overrides.iter().filter(|o| !o.is_empty()).collect() @@ -600,9 +751,10 @@ fn all_left_out(n: &Node, left_out: &dyn Fn(&Node) -> bool, empty: &HashSet<&str /// Add the sets of appearance changes that the instances in `n` and its subtree make to the art of /// the symbols `sets` names, each set once, and those of the instances in the art of the symbols the -/// instances show (`walked`, the symbols already looked in). The instances that `left_out` picks or -/// that are in an object it picks are left out, and with `hidden` (in a layer) the hidden ones and -/// those in a hidden object. A symbol with more than [`MAX_ART_COPIES`] sets gets `None`. +/// instances show (`walked`, the symbols already looked in). With `hidden` (in a layer), the +/// instances that are hidden or that `left_out` picks, and those in such an object, are left out. +/// In symbol art every instance counts, since an instance of the symbol can change the objects +/// that hold it. A symbol with more than [`MAX_ART_COPIES`] sets gets `None`. fn drawn_changes<'a>( doc: &'a Document, n: &'a Node, @@ -611,7 +763,7 @@ fn drawn_changes<'a>( walked: &mut HashSet<&'a str>, sets: &mut HashMap<&'a str, Option>>>, ) { - if hidden && !n.visible || left_out(n) { + if hidden && (!n.visible || left_out(n)) { return; } if let NodeKind::SymbolInstance { symbol, .. } = &n.kind { diff --git a/crates/engine/src/cmd/fileio/pdf.rs b/crates/engine/src/cmd/fileio/pdf.rs index da6da069e9..77902b4a15 100644 --- a/crates/engine/src/cmd/fileio/pdf.rs +++ b/crates/engine/src/cmd/fileio/pdf.rs @@ -128,7 +128,7 @@ pub fn specs() -> Vec { "Export PDF", [], None, - "{path?, preset?: \"VectorCraft Default\" (built-in or saved: pdf.preset.list), artboard? | artboards?: [i…] | range?: \"1-3, 5\" (1-based; default all, one page each), useArtboards?: true (false: one page, the bounds of the visible art the page draws (non-printing layers left out unless includeNonPrinting or createLayers keeps them), as document.export writes it; artboard, artboards and range are then ignored, Preserve Editing is left out (with a warning when it is on), and a document with no visible art is refused), standard?: none|pdfA2b|pdfX1a (CMYK, grey and spot colours only: colours and images converted to output.destination or the CMYK output intent, untagged; transparency flattened with flattenerPreset; PDF 1.3)|pdfX3 (colours ICC-tagged; transparency flattened; PDF 1.3)|pdfX4 (transparency and layers kept; colours ICC-tagged; PDF 1.6 at most) (PDF/X: a /GTS_PDFX output intent, by default the CMYK profile in effect, embedded; TrimBox, Trapped, GTS_PDFXVersion, title and dates; fonts embedded or outlined), compatibility?: 1.3 (no transparency: it is flattened with flattenerPreset, without the layers the file leaves out; images keep no see-through pixels)|1.4|1.5|1.6|1.7 (default)|2.0 (choosing a standard sets its version: 1.7 for pdfA2b, 1.6 for pdfX4, 1.3 for pdfX1a and pdfX3, which write 1.4 as 1.3 too; pdfA2b refuses 1.3; in pdfA2b files, and with longPagesInPoints: false, a page with a side longer than 14,400 pt keeps its size with a UserUnit from 1.6 on: 1.5 is then written as 1.6, with 1.5's encryption, and so is 1.4 in pdfA2b files, with a warning; PDF/X files and other 1.3 and 1.4 files write it in points, and 1.3 and 1.4 (pdfX1a and pdfX3 too) refuse a page past 32,767 pt whose size or art needs a real number past 32,767; a page whose short side is 3 pt or longer but would be under 3 units at that UserUnit gets a smaller one that keeps the short side at 3 units; a warning gives each such page's size), longPagesInPoints?: true (such a page is written at its size in points, without a UserUnit, at every version, and 1.5 stays 1.5, in files without a standard (PDF/A files keep the UserUnit); apps that keep to the limit cut it to 200 in; false: the UserUnit as above, with a warning that names this option), preserveEditing? (on in VectorCraft Default: the native document as an embedded file, which document.open restores; choosing a standard turns it off, a standard refuses it), thumbnails? (each page drawn as a /Thumb image, 106 px on its long side), fastWebView? (a linearised file: the first page shows while the rest downloads; it stays linearised when encrypted), viewAfterSaving? (the app opens the written file), createLayers? (each layer and sublayer but template layers as a PDF layer, an optional content group listed under its layer's: hidden layers off, non-printing ones /PrintState /OFF, locked ones locked; needs 1.5 or later; they reopen as layers; choosing pdfX1a or pdfX3 turns it off; they refuse it), includeNonPrinting? (keep layers whose Print option is off; left out by default unless createLayers), flattenerPreset?: \"\" (High Resolution) | high | medium | low | a saved preset (flattener.presets.list): what PDF 1.3 files flatten transparency with, flattener?: {object.flattenTransparency options over it}, compression?: {color?, gray?: {downsample: none|average|subsample|bicubic, ppi: 300, abovePpi: 450, compression: none|zip|jpeg|jpeg2000|auto, quality: minimum|low|medium|high|maximum}, mono?: {downsample, ppi: 1200, abovePpi: 1800, compression: none|ccittG3|ccittG4|zip|runLength} (black-and-white images), compressText?: true} (images above abovePpi are resampled to ppi; auto keeps JPEGs JPEG, the others lossless; JPEG needs opaque images; none, jpeg2000, CCITT and runLength are written as ZIP with a warning), marks?: {trim, registration, colorBars, pageInfo, kind: roman|japanese, weight: 0.25 (trim marks and targets), offset: 6 (pt from the artboard, at least the bleed)} (printer's marks in [Registration], every plate: trim marks, registration targets mid-side, CMYK/spot/black-tint colour bars on top, page information (title, artboard, date UTC) below), bleed?: {useDocument (the document's bleed, document.setup), top, bottom, left, right} (pt; art in the bleed is kept; a PDF/X file with no bleed on a side where the document's bleed has one comes back with a warning). Each page: TrimBox = artboard, BleedBox = artboard + bleed, MediaBox = that + the marks' room (art clipped to the BleedBox), output?: {conversion: none (default)|destination (every colour into the destination profile's model, CMYK or RGB, also colours of that model in another profile)|preserveNumbers (only colours of the other model and Lab; those in the destination's model keep their numbers) (grey stays grey; images are converted too, printer's marks are not), destination: profile name (edit.colorSettings lists them; default: the document's profile for its colour mode), profiles: none (device colours)|all|destination|taggedSource (documents assigned a profile: edit.assignProfile) (ICC-based colours: CMYK with the destination or document CMYK profile, RGB as sRGB, grey with the sRGB tone curve; PDF/A, pdfX3 and pdfX4 always, whatever profiles says; a CMYK image that keeps its numbers carries the CMYK profile it embeds with all and taggedSource, and in PDF/A, pdfX3 and pdfX4 unless destination, never in pdfX1a), outputIntent: profile name (embedded as the catalog's /GTS_PDFX output intent; a name that isn't a profile is only named, which pdfX1a and pdfX3 allow only with a registry and pdfX4 refuses; pdfX1a needs a CMYK one), outputCondition, outputConditionId (default: outputIntent), registry, trapped (/Trapped /True; /False when an output intent is written)} (PDF/A files keep their own output intent), advanced?: {fontSubsetPercent: 100 (below 100 is accepted with a warning: fonts are always embedded as subsets), outlineText: false (type as real, selectable and searchable text in embedded subset fonts with a ToUnicode map; fonts whose licence forbids embedding, or allows only whole fonts, stay outlines with a warning; true: every glyph as an outline), overprint: preserve (overprinting fills and strokes set /OP /op true, /OPM 1; white ones knock out with document.setup discardWhiteOverprint)|discard (they knock out)}, security?: {openPassword, permissionsPassword, printing: none|low|high, changes: none|pages|forms|comments|any, copy, screenReader, plaintextMetadata}, acknowledgeLoss?: bool (write over the file the document was read from although reading it left things out), format?: \"pdf\"} → {path, bytes, warnings}; no path → {dataBase64, bytes, warnings}. The options apply over the preset (null keeps its value); options accepted but not applied yet come back as warnings; a standard with a PDF version it doesn't allow and a password with a standard are refused, and so is a PDF/X file that would still break its standard (transparency the flattener left, RGB in pdfX1a, a font not embedded) or a PDF 1.3 file with transparency left. document.export {format: pdf} takes the same options. A key PDF doesn't take, and an unknown key inside one of the option objects, are refused", + "{path?, preset?: \"VectorCraft Default\" (built-in or saved: pdf.preset.list), artboard? | artboards?: [i…] | range?: \"1-3, 5\" (1-based; default all, one page each), useArtboards?: true (false: one page, the bounds of the visible art the page draws (non-printing layers left out unless includeNonPrinting or createLayers keeps them), as document.export writes it; artboard, artboards and range are then ignored, Preserve Editing is left out (with a warning when it is on), and a document with no visible art is refused), standard?: none|pdfA2b|pdfX1a (CMYK, grey and spot colours only: colours and images converted to output.destination or the CMYK output intent, untagged; transparency flattened with flattenerPreset; PDF 1.3)|pdfX3 (colours ICC-tagged; transparency flattened; PDF 1.3)|pdfX4 (transparency and layers kept; colours ICC-tagged; PDF 1.6 at most) (PDF/X: a /GTS_PDFX output intent, by default the CMYK profile in effect, embedded; TrimBox, Trapped, GTS_PDFXVersion, title and dates; fonts embedded or outlined), compatibility?: 1.3 (no transparency: it is flattened with flattenerPreset, without the layers the file leaves out; images keep no see-through pixels)|1.4|1.5|1.6|1.7 (default)|2.0 (choosing a standard sets its version: 1.7 for pdfA2b, 1.6 for pdfX4, 1.3 for pdfX1a and pdfX3, which write 1.4 as 1.3 too; pdfA2b refuses 1.3; in pdfA2b files, and with longPagesInPoints: false, a page with a side longer than 14,400 pt keeps its size with a UserUnit from 1.6 on: 1.5 is then written as 1.6, with 1.5's encryption, and so is 1.4 in pdfA2b files, with a warning; PDF/X files and other 1.3 and 1.4 files write it in points, and 1.3 and 1.4 (pdfX1a and pdfX3 too) refuse a page past 32,767 pt whose size or art needs a real number past 32,767; a page whose short side is 3 pt or longer but would be under 3 units at that UserUnit gets a smaller one that keeps the short side at 3 units; a warning gives each such page's size), longPagesInPoints?: true (such a page is written at its size in points, without a UserUnit, at every version, and 1.5 stays 1.5, in files without a standard (PDF/A files keep the UserUnit); apps that keep to the limit cut it to 200 in; false: the UserUnit as above, with a warning that names this option), preserveEditing? (on in VectorCraft Default: the native document as an embedded file, which document.open restores; choosing a standard turns it off, a standard refuses it), thumbnails? (each page drawn as a /Thumb image, 106 px on its long side), fastWebView? (a linearised file: the first page shows while the rest downloads; it stays linearised when encrypted), viewAfterSaving? (the app opens the written file), createLayers? (each layer and sublayer but template layers as a PDF layer, an optional content group listed under its layer's: hidden layers off, non-printing ones /PrintState /OFF, locked ones locked; needs 1.5 or later; they reopen as layers; choosing pdfX1a or pdfX3 turns it off; they refuse it), includeNonPrinting? (keep layers whose Print option is off; left out by default unless createLayers), flattenerPreset?: \"\" (High Resolution) | high | medium | low | a saved preset (flattener.presets.list): what PDF 1.3 files flatten transparency with, flattener?: {object.flattenTransparency options over it}, compression?: {color?, gray?: {downsample: none|average|subsample|bicubic, ppi: 300, abovePpi: 450, compression: none|zip|jpeg|jpeg2000|auto, quality: minimum|low|medium|high|maximum}, mono?: {downsample, ppi: 1200, abovePpi: 1800, compression: none|ccittG3|ccittG4|zip|runLength} (black-and-white images), compressText?: true} (images above abovePpi are resampled to ppi; auto keeps JPEGs JPEG, the others lossless; JPEG needs opaque images; none, jpeg2000, CCITT and runLength are written as ZIP with a warning), marks?: {trim, registration, colorBars, pageInfo, kind: roman|japanese, weight: 0.25 (trim marks and targets), offset: 6 (pt from the artboard, at least the bleed)} (printer's marks in [Registration], every plate: trim marks, registration targets mid-side, CMYK/spot/black-tint colour bars on top, page information (title, artboard, date UTC) below), bleed?: {useDocument (the document's bleed, document.setup), top, bottom, left, right} (pt; art in the bleed is kept; a PDF/X file with no bleed on a side where the document's bleed has one comes back with a warning). Each page: TrimBox = artboard, BleedBox = artboard + bleed, MediaBox = that + the marks' room (art clipped to the BleedBox), output?: {conversion: none (default)|destination (every colour into the destination profile's model, CMYK or RGB, also colours of that model in another profile)|preserveNumbers (only colours of the other model and Lab; those in the destination's model keep their numbers) (grey stays grey; images are converted too, and with destination and an sRGB destination a gray or RGB image tagged with an ICC profile is converted from it and written untagged; with destination and a CMYK destination, or another RGB one images are converted to (not when colours are ICC-tagged), an RGB image tagged with one is converted from it through sRGB; printer's marks are not), destination: profile name (edit.colorSettings lists them; default: the document's profile for its colour mode), profiles: none (device colours)|all|destination|taggedSource (documents assigned a profile: edit.assignProfile) (ICC-based colours: CMYK with the destination or document CMYK profile, RGB as sRGB, grey with the sRGB tone curve; PDF/A, pdfX3 and pdfX4 always, whatever profiles says; a CMYK image that keeps its numbers carries the CMYK profile it embeds with all and taggedSource, and in PDF/A, pdfX3 and pdfX4 unless destination, never in pdfX1a), outputIntent: profile name (embedded as the catalog's /GTS_PDFX output intent; a name that isn't a profile is only named, which pdfX1a and pdfX3 allow only with a registry and pdfX4 refuses; pdfX1a needs a CMYK one), outputCondition, outputConditionId (default: outputIntent), registry, trapped (/Trapped /True; /False when an output intent is written)} (PDF/A files keep their own output intent), advanced?: {fontSubsetPercent: 100 (below 100 is accepted with a warning: fonts are always embedded as subsets), outlineText: false (type as real, selectable and searchable text in embedded subset fonts with a ToUnicode map; fonts whose licence forbids embedding, or allows only whole fonts, stay outlines with a warning; true: every glyph as an outline), overprint: preserve (overprinting fills and strokes set /OP /op true, /OPM 1; white ones knock out with document.setup discardWhiteOverprint)|discard (they knock out)}, security?: {openPassword, permissionsPassword, printing: none|low|high, changes: none|pages|forms|comments|any, copy, screenReader, plaintextMetadata}, acknowledgeLoss?: bool (write over the file the document was read from although reading it left things out), format?: \"pdf\"} → {path, bytes, warnings}; no path → {dataBase64, bytes, warnings}. The options apply over the preset (null keeps its value); options accepted but not applied yet come back as warnings; a standard with a PDF version it doesn't allow and a password with a standard are refused, and so is a PDF/X file that would still break its standard (transparency the flattener left, RGB in pdfX1a, a font not embedded) or a PDF 1.3 file with transparency left. document.export {format: pdf} takes the same options. A key PDF doesn't take, and an unknown key inside one of the option objects, are refused", has_doc, export_pdf ), diff --git a/crates/engine/src/cmd/fileio/pdfimport.rs b/crates/engine/src/cmd/fileio/pdfimport.rs index b0f08ca4c5..00eef89a11 100644 --- a/crates/engine/src/cmd/fileio/pdfimport.rs +++ b/crates/engine/src/cmd/fileio/pdfimport.rs @@ -205,7 +205,7 @@ pub fn page_document(bytes: &[u8], page: usize, o: &LoadOptions) -> Result<(Docu } /// [`page_document`] of `opened`, the PDF `bytes` already read. -fn opened_page_document(bytes: &[u8], opened: &vectorcraft_pdf::Opened, page: usize, o: &LoadOptions) -> Result<(Document, Vec)> { +pub(crate) fn opened_page_document(bytes: &[u8], opened: &vectorcraft_pdf::Opened, page: usize, o: &LoadOptions) -> Result<(Document, Vec)> { vectorcraft_pdf::import_opened(bytes, opened, &page_options(page, o)).map(|r| (r.document, r.warnings)).map_err(err) } @@ -217,8 +217,11 @@ fn page_options(page: usize, o: &LoadOptions) -> ImportOptions { /// `document.open` with `linkPages`: the pages `o` picks of the PDF (or PDF-compatible `.ai` or /// `.ait`) `name`, read from `path`, as a document holding one placed document per page, linked to /// that page of the file as File › Place with Link places one ([`document::source`]). Each page -/// gets an artboard of its `o.crop` box (its art's bounds for `bounding`), in a row from the left -/// with the gap a PDF's artboards open with, and a layer named after the page. The document opens +/// gets an artboard of its `o.crop` box (its art's bounds for `bounding`, a blank page's crop box; +/// a PDF-compatible `.ai` asked for its crop box gets its trim box, as it opens), in a row from the +/// left with the gap a PDF's artboards open with, and a layer named after the page. On its trim box, +/// a page shows its bleed box round the artboard, and the document's bleed is the pages' bleed (the +/// widest on each side); the artboards are then the gap apart plus the bleed on both sides. The document opens /// in CMYK when most of the pages read in CMYK, unless `o.color_mode` names a mode. Refused without a /// path, for any other file, with a password (a linked page is read again without one), with /// `textAs: outlines` (a linked page shows the file's own type) and for a file over @@ -239,12 +242,16 @@ pub(super) fn linked_pages(name: &str, bytes: &[u8], path: Option<&str>, o: &Loa if bytes.len() > document::MAX_FILE { return Err(bad(C, format!("linkPages: `{file}` is over {} MB", document::MAX_FILE >> 20))); } - let count = vectorcraft_pdf::open_pdf(bytes, None).map_err(err)?.page_count(); + // The file is read once: every page is read from it. + let opened = vectorcraft_pdf::open_pdf(bytes, None).map_err(err)?; + let count = opened.page_count(); let picked = match &o.pages { None => (0..count).collect(), Some(range) => parse_range(range, count).map_err(|e| err(format!("pages: {e}")))?, }; let bounding = o.crop == CropTo::Bounding; + // A PDF-compatible `.ai` asked for its crop box opens on its trim boxes ([`LoadOptions::import_options`]). + let crop = if o.crop == CropTo::Crop && opened.has_illustrator_data() { CropTo::Trim } else { o.crop }; let mut doc = Document::new(1.0, 1.0); let board = doc.artboards.first().cloned().ok_or_else(|| err("a new document has no artboard"))?; doc.artboards.clear(); @@ -253,14 +260,55 @@ pub(super) fn linked_pages(name: &str, bytes: &[u8], path: Option<&str>, o: &Loa let gap = ImportOptions::default().artboard_gap; // Every page's object keeps the same one copy of the file. let shared = Arc::new(bytes.to_vec()); - let (mut x, mut warnings, mut cmyk_pages) = (0.0, Vec::::new(), 0); - for (i, &index) in picked.iter().enumerate() { + // The file is hashed once for all its pages. + let hash = link.hash.clone().unwrap_or_else(|| vectorcraft_doc::links::hash_bytes(bytes)); + let (mut warnings, mut cmyk_pages) = (Vec::::new(), 0); + // Each page as read, with its bleed (top, bottom, left, right). The pages are laid out once + // the document's bleed is known. + let mut read_pages = Vec::with_capacity(picked.len()); + for &index in &picked { let page = index as u32 + 1; - let s = document::shared_source(name, &shared, Some(path), page, bounding, document::page_box(o.crop), C)?; + let file_once = document::SharedFile { bytes: &shared, hash: &hash, opened: &opened }; + // On its trim box, a page shows its bleed box, the artboard on the trim box inside it, so + // the art in the bleed shows and prints as bleed. + let bleed = if crop == CropTo::Trim { opened.page_bleed(index) } else { None }; + let page_box = if bleed.is_some() { vectorcraft_doc::PageBox::Bleed } else { document::page_box(crop) }; + let read = |bounding| document::shared_source(name, file_once, Some(path), page, bounding, page_box, C); + // A blank page has no art's bounds: it keeps its crop box, as opening it as art does. + let (mut s, bounding) = match read(bounding) { + Err(e) if bounding && e.to_string().contains("has no art to place") => (read(false)?, false), + s => (s?, bounding), + }; + // A page that is only a placeholder (a `.ai` saved without PDF compatibility) shows the + // file's editing data, not a page of the file. + if s.editing_data { + return Err(bad(C, format!("linkPages: `{file}` isn't a PDF or a PDF-compatible .ai file"))); + } cmyk_pages += usize::from(s.color_mode == ColorMode::Cmyk); + // The document's bleed is the widest of the pages' on each side. + let sides = bleed.unwrap_or_default(); + for (side, b) in doc.setup.bleed.iter_mut().zip(sides) { + *side = side.max(b.min(vectorcraft_doc::setup::MAX_BLEED)); + } + for w in std::mem::take(&mut s.warnings) { + if !warnings.contains(&w) { + warnings.push(w); + } + } + read_pages.push((page, s, bounding, sides)); + } + // Each artboard is the gap from the one before plus the bleed on both sides of it: the + // document's bleed, which export adds round every artboard, or a page's own bleed box where it + // reaches farther. Neither page then shows or exports the other's art. + let [_, _, doc_left, doc_right] = doc.setup.bleed; + let mut x = 0.0; + for (i, (page, s, bounding, [top, bottom, left, right])) in read_pages.into_iter().enumerate() { + if i > 0 { + x += doc_left.max(left); + } let mut node = document::node(&mut doc, &s, LinkInfo { page: Some(page), ..link.clone() }, bounding); if let NodeKind::PlacedDocument(p) = &mut node.kind { - p.xf = Affine::translate((x, 0.0)); + p.xf = Affine::translate((x - left, -top)); } node.name = Some(file.clone()); let mut layer = Node::layer(doc.alloc_id(), &format!("Page {page}"), LayerColor::Preset((i % 27) as u8)); @@ -268,14 +316,9 @@ pub(super) fn linked_pages(name: &str, bytes: &[u8], path: Option<&str>, o: &Loa children.push(Arc::new(node)); } doc.layers.push(Arc::new(layer)); - let rect = Rect::new(x, 0.0, x + s.frame.width(), s.frame.height()); + let rect = Rect::new(x, 0.0, x + s.frame.width() - left - right, s.frame.height() - top - bottom); doc.artboards.push(Artboard { id: i as u32 + 1, name: format!("Artboard {}", i + 1), rect, ..board.clone() }); - x = rect.x1 + gap; - for w in s.warnings { - if !warnings.contains(&w) { - warnings.push(w); - } - } + x = rect.x1 + doc_right.max(right) + gap; } // Pages painted mostly in CMYK read as CMYK documents, as opening them as art does: the // document opens in CMYK (with the CMYK default swatches) when most of its pages do. diff --git a/crates/engine/src/cmd/fileio/tests_aiimport.rs b/crates/engine/src/cmd/fileio/tests_aiimport.rs index c48b437b76..8b588975c4 100644 --- a/crates/engine/src/cmd/fileio/tests_aiimport.rs +++ b/crates/engine/src/cmd/fileio/tests_aiimport.rs @@ -1160,6 +1160,91 @@ fn an_ai_file_saved_without_its_pdf_part_places_from_its_editing_data() { assert!(e.contains("placeholder"), "{e}"); } +/// `document.open` with `linkPages` refuses a `.ai` saved without PDF compatibility, whose page is +/// only a placeholder, and opens a PDF-compatible one. +#[test] +fn link_pages_refuses_an_ai_file_saved_without_its_pdf_part() { + let d = std::env::temp_dir().join(format!("vc-ailinkpages-{}", std::process::id())); + std::fs::create_dir_all(&d).unwrap(); + let file = |name: &str, page: &str| { + let path = d.join(name); + std::fs::write(&path, ai::ai(&compressed(&ai::sample_data()), page)).unwrap(); + path.to_string_lossy().to_string() + }; + let (plain, compatible) = (file("plain.ai", PLACEHOLDER), file("compatible.ai", "0 g 10 10 40 40 re f")); + let mut s = Session::new(); + let e = s.execute("document.open", &json!({"path": plain, "linkPages": true})).unwrap_err().to_string(); + assert!(e.contains("isn't a PDF or a PDF-compatible .ai file"), "{e}"); + assert!(s.documents().is_empty(), "nothing opened"); + s.execute("document.open", &json!({"path": compatible, "linkPages": true})).unwrap(); + assert!(s.doc().unwrap().doc.has_placed()); + let _ = std::fs::remove_dir_all(&d); +} + +/// `document.open` with `linkPages` opens a PDF-compatible `.ai` asked for its crop box on its trim +/// box, as it opens as art, showing its bleed box round the artboard, which is the document's bleed. +#[test] +fn link_pages_open_an_ai_file_on_its_trim_box_with_its_bleed() { + let d = std::env::temp_dir().join(format!("vc-ailinkpagestrim-{}", std::process::id())); + std::fs::create_dir_all(&d).unwrap(); + let path = d.join("trimmed.ai"); + let boxes = "/TrimBox [10 10 190 90] /BleedBox [4 2 196 98]"; + std::fs::write(&path, ai::ai_with(&compressed(&ai::sample_data()), "0 g 10 10 40 40 re f", boxes)).unwrap(); + let path = path.to_string_lossy().to_string(); + let mut s = Session::new(); + s.execute("document.open", &json!({"path": path, "linkPages": true})).unwrap(); + let doc = &s.doc().unwrap().doc; + let board = doc.artboards[0].rect; + assert_eq!((board.width(), board.height()), (180.0, 80.0), "the trim box"); + assert_eq!(doc.setup.bleed, [8.0, 8.0, 6.0, 6.0], "top, bottom, left, right"); + // The page shows its bleed box, so the art in the bleed is kept. + let placed = doc.layers[0].children().unwrap()[0].clone(); + let NodeKind::PlacedDocument(p) = &placed.kind else { panic!("{:?}", placed.kind) }; + assert_eq!((p.width, p.height, p.page_box), (192.0, 96.0, vectorcraft_doc::PageBox::Bleed)); + assert_eq!(p.xf.translation(), vectorcraft_geom::Vec2::new(-6.0, -8.0), "the trim box on the artboard"); + let _ = std::fs::remove_dir_all(&d); +} + +#[test] +fn link_pages_on_their_trim_boxes_keep_the_bleed_between_the_artboards() { + // Two 100 pt trim boxes: the first with 60 pt of bleed on each side, the second with 80 pt on + // its left, more than the largest document bleed (72 pt). + let entries = format!("/PieceInfo << /Illustrator << /Private << /AIPrivateData1 {} 0 R /NumBlock 1 >> >> >>", first_extra(2)); + let first = PdfPage { + bleed: Some([0.0, 0.0, 220.0, 220.0]), + trim: Some([60.0, 60.0, 160.0, 160.0]), + entries, + ..PdfPage::new(220.0, 220.0, "0 g 0 0 220 220 re f") + }; + let second = PdfPage { + bleed: Some([0.0, 0.0, 240.0, 220.0]), + trim: Some([80.0, 60.0, 180.0, 160.0]), + ..PdfPage::new(240.0, 220.0, "0 g 0 0 240 220 re f") + }; + let d = std::env::temp_dir().join(format!("vc-ailinkpagesgap-{}", std::process::id())); + std::fs::create_dir_all(&d).unwrap(); + let path = d.join("pages.ai"); + std::fs::write(&path, pdf_with(&[first, second], &["<< /Length 4 >>\nstream\ndata\nendstream"], None)).unwrap(); + let path = path.to_string_lossy().to_string(); + let mut s = Session::new(); + s.execute("document.open", &json!({"path": path, "linkPages": true})).unwrap(); + let doc = &s.doc().unwrap().doc; + assert_eq!(doc.setup.bleed, [60.0, 60.0, 72.0, 60.0], "top, bottom, left, right"); + // The first page's bleed ends at 160; the second's starts 36 pt later, 80 pt left of its trim box. + let boards: Vec<(f64, f64)> = doc.artboards.iter().map(|a| (a.rect.x0, a.rect.x1)).collect(); + assert_eq!(boards, [(0.0, 100.0), (276.0, 376.0)]); + let shown: Vec = doc + .layers + .iter() + .map(|l| match &l.children().unwrap()[0].kind { + NodeKind::PlacedDocument(p) => p.xf.translation().x, + k => panic!("{k:?}"), + }) + .collect(); + assert_eq!(shown, [-60.0, 196.0], "each page's bleed box round its artboard"); + let _ = std::fs::remove_dir_all(&d); +} + /// A page of text alone, as text-only art has in its PDF part: no path is painted. const TEXT_ART: &str = "BT /F1 36 Tf 10 40 Td (Text only) Tj ET"; diff --git a/crates/engine/src/cmd/fileio/tests_hpgl.rs b/crates/engine/src/cmd/fileio/tests_hpgl.rs index 82957468d8..8714072257 100644 --- a/crates/engine/src/cmd/fileio/tests_hpgl.rs +++ b/crates/engine/src/cmd/fileio/tests_hpgl.rs @@ -338,6 +338,152 @@ fn a_plot_of_the_arts_bounds_leaves_out_images_in_dynamic_symbol_art() { assert!(e.contains("no visible art"), "{e}"); } +/// A rectangle filled black, or one that paints nothing. +fn plot_rect(d: &mut vectorcraft_doc::Document, r: vectorcraft_geom::Rect, painted: bool) -> std::sync::Arc { + use vectorcraft_color::{Color, Paint}; + let fill = if painted { Paint::solid(Color::BLACK) } else { Paint::None }; + let appearance = vectorcraft_doc::Appearance::basic(fill, Paint::None, 0.0); + std::sync::Arc::new(vectorcraft_doc::Node::path(d.alloc_id(), vectorcraft_geom::shapes::rectangle(r), appearance)) +} + +/// An instance of symbol `symbol` placed by `xf`. +fn plot_instance(d: &mut vectorcraft_doc::Document, symbol: &str, xf: vectorcraft_geom::Affine) -> vectorcraft_doc::Node { + vectorcraft_doc::Node::new(d.alloc_id(), NodeKind::SymbolInstance { symbol: symbol.into(), xf }) +} + +/// Symbol `name` holding `art` in a group. +fn plot_symbol(d: &mut vectorcraft_doc::Document, name: &str, art: Vec>, dynamic: bool) { + let group = vectorcraft_doc::Node::group(d.alloc_id(), art); + d.symbols.push(vectorcraft_doc::Symbol { dynamic, name: name.into(), art: std::sync::Arc::new(group) }); +} + +/// Puts `n` on the first layer of `d`. +fn plot_on_layer(d: &mut vectorcraft_doc::Document, n: vectorcraft_doc::Node) { + std::sync::Arc::make_mut(&mut d.layers[0]).children_mut().unwrap().push(std::sync::Arc::new(n)); +} + +/// The art of a symbol with 9-slice guides, static or dynamic, is placed whole and measured +/// without what the plot leaves out: a frame that paints nothing sets where the guides place the +/// painted art inside it, and is not measured itself. +#[test] +fn a_plot_of_the_arts_bounds_places_9_slice_symbol_art_by_all_of_it() { + use vectorcraft_geom::{Affine, Rect}; + // Static and dynamic, and with the instance's fill staining the art. + for (dynamic, stained) in [(false, false), (true, false), (false, true)] { + let mut d = vectorcraft_doc::Document::new(400.0, 400.0); + let frame = plot_rect(&mut d, Rect::new(0.0, 0.0, 100.0, 100.0), false); + let square = plot_rect(&mut d, Rect::new(40.0, 40.0, 60.0, 60.0), true); + plot_symbol(&mut d, "Panel", vec![frame, square], dynamic); + d.set_symbol_nine_slice("Panel", Some(vectorcraft_doc::SymbolNineSlice::default())); + let mut wide = plot_instance(&mut d, "Panel", Affine::scale_non_uniform(2.0, 1.0)); + if stained { + let red = vectorcraft_color::Paint::solid(vectorcraft_color::Color::rgb(1.0, 0.0, 0.0)); + wide.appearance = vectorcraft_doc::Appearance::basic(red, vectorcraft_color::Paint::None, 0.0).into(); + } + plot_on_layer(&mut d, wide); + // Scaled 2 times across, the middle column maps x to 3x - 50: the square draws from x 70 + // to 130. + let b = super::export_art_bounds(&d, "hpgl").unwrap(); + let exact = [(b.x0, 70.0), (b.y0, 40.0), (b.x1, 130.0), (b.y1, 60.0)].iter().all(|(a, e)| (a - e).abs() < 1e-6); + assert!(exact, "dynamic: {dynamic}, stained: {stained}, {b:?}"); + } +} + +/// In art placed through 9-slice guides, a group with a fill of its own keeps the path it paints, +/// which paints nothing by itself. +#[test] +fn a_plot_of_the_arts_bounds_keeps_what_a_group_paints_in_9_slice_symbol_art() { + use vectorcraft_geom::{Affine, Rect}; + let mut d = vectorcraft_doc::Document::new(400.0, 400.0); + let frame = plot_rect(&mut d, Rect::new(0.0, 0.0, 100.0, 100.0), false); + let bare = plot_rect(&mut d, Rect::new(40.0, 40.0, 60.0, 60.0), false); + let mut group = vectorcraft_doc::Node::group(d.alloc_id(), vec![bare]); + let black = vectorcraft_color::Paint::solid(vectorcraft_color::Color::BLACK); + group.appearance = vectorcraft_doc::Appearance::basic(black, vectorcraft_color::Paint::None, 0.0).into(); + plot_symbol(&mut d, "Panel", vec![frame, std::sync::Arc::new(group)], false); + d.set_symbol_nine_slice("Panel", Some(vectorcraft_doc::SymbolNineSlice::default())); + let wide = plot_instance(&mut d, "Panel", Affine::scale_non_uniform(2.0, 1.0)); + plot_on_layer(&mut d, wide); + let b = super::export_art_bounds(&d, "hpgl").expect("the group's path is plotted"); + let exact = [(b.x0, 70.0), (b.y0, 40.0), (b.x1, 130.0), (b.y1, 60.0)].iter().all(|(a, e)| (a - e).abs() < 1e-6); + assert!(exact, "{b:?}"); +} + +/// A symbol without guides in the art of a symbol with 9-slice guides is measured without what the +/// plot leaves out, in the placed art and in an instance of its own. +#[test] +fn a_plot_of_the_arts_bounds_leaves_out_unplotted_art_of_symbols_in_9_slice_art() { + use vectorcraft_geom::{Affine, Rect}; + let mut d = vectorcraft_doc::Document::new(400.0, 400.0); + let far = plot_rect(&mut d, Rect::new(45.0, -500.0, 55.0, -480.0), false); + let dot = plot_rect(&mut d, Rect::new(45.0, 45.0, 55.0, 55.0), true); + plot_symbol(&mut d, "Dot", vec![far, dot], false); + let frame = plot_rect(&mut d, Rect::new(0.0, 0.0, 100.0, 100.0), false); + let inner = std::sync::Arc::new(plot_instance(&mut d, "Dot", Affine::IDENTITY)); + plot_symbol(&mut d, "Panel", vec![frame, inner], false); + d.set_symbol_nine_slice("Panel", Some(vectorcraft_doc::SymbolNineSlice::default())); + let wide = plot_instance(&mut d, "Panel", Affine::scale_non_uniform(2.0, 1.0)); + plot_on_layer(&mut d, wide); + let near = |b: Rect, e: [f64; 4]| [b.x0, b.y0, b.x1, b.y1].iter().zip(e).all(|(a, e)| (a - e).abs() < 1e-6); + // The dot's box is in the middle column, which maps x to 3x - 50. + let b = super::export_art_bounds(&d, "hpgl").unwrap(); + assert!(near(b, [85.0, 45.0, 115.0, 55.0]), "{b:?}"); + let own = plot_instance(&mut d, "Dot", Affine::translate((200.0, 0.0))); + plot_on_layer(&mut d, own); + let b = super::export_art_bounds(&d, "hpgl").unwrap(); + assert!(near(b, [85.0, 45.0, 255.0, 55.0]), "{b:?}"); +} + +/// The copy of a dynamic symbol that an instance's changes are measured on keeps the symbol's +/// 9-slice guides. +#[test] +fn a_plot_of_the_arts_bounds_measures_changed_9_slice_instances_by_their_guides() { + use vectorcraft_geom::{Affine, Rect}; + let mut d = vectorcraft_doc::Document::new(400.0, 400.0); + let bar = plot_rect(&mut d, Rect::new(10.0, 40.0, 70.0, 60.0), true); + plot_symbol(&mut d, "Bar", vec![bar], false); + let frame = plot_rect(&mut d, Rect::new(0.0, 0.0, 100.0, 100.0), false); + let inner = std::sync::Arc::new(plot_instance(&mut d, "Bar", Affine::IDENTITY)); + plot_symbol(&mut d, "Panel", vec![frame, inner], true); + d.set_symbol_nine_slice("Panel", Some(vectorcraft_doc::SymbolNineSlice::default())); + let mut wide = plot_instance(&mut d, "Panel", Affine::scale_non_uniform(2.0, 1.0)); + wide.extras_mut().symbol_overrides = + vec![vectorcraft_doc::SymbolOverride { path: vec![0], fill: Some(vectorcraft_color::Paint::None), ..Default::default() }]; + plot_on_layer(&mut d, wide); + // The bar crosses the left guide and is placed by the middle column's map, x to 3x - 50: it + // draws from x -20 to 160. + let b = super::export_art_bounds(&d, "hpgl").unwrap(); + let exact = |b: Rect| [(b.x0, -20.0), (b.y0, 40.0), (b.x1, 160.0), (b.y1, 60.0)].iter().all(|(a, e)| (a - e).abs() < 1e-6); + assert!(exact(b), "{b:?}"); + // The same instance in the art of a symbol without guides. + let wide = std::sync::Arc::make_mut(&mut d.layers[0]).children_mut().unwrap().pop().unwrap(); + plot_symbol(&mut d, "Holder", vec![wide], false); + let holder = plot_instance(&mut d, "Holder", Affine::IDENTITY); + plot_on_layer(&mut d, holder); + let b = super::export_art_bounds(&d, "hpgl").unwrap(); + assert!(exact(b), "{b:?}"); +} + +/// An instance in a dynamic symbol's art, in a group at 0% opacity that an instance of the symbol +/// brings back to full opacity, is measured with the paint it gives its own symbol's art. +#[test] +fn a_plot_of_the_arts_bounds_measures_instances_that_an_instance_brings_back() { + use vectorcraft_geom::{Affine, Rect}; + let mut d = vectorcraft_doc::Document::new(400.0, 400.0); + let bare = plot_rect(&mut d, Rect::new(300.0, 300.0, 320.0, 320.0), false); + plot_symbol(&mut d, "Inner", vec![bare], true); + let mut inner = plot_instance(&mut d, "Inner", Affine::IDENTITY); + let black = vectorcraft_color::Paint::solid(vectorcraft_color::Color::BLACK); + inner.extras_mut().symbol_overrides = vec![vectorcraft_doc::SymbolOverride { path: vec![0], fill: Some(black), ..Default::default() }]; + plot_symbol(&mut d, "Outer", vec![std::sync::Arc::new(inner)], true); + std::sync::Arc::make_mut(&mut d.symbols[1].art).opacity = 0.0; + let mut outer = plot_instance(&mut d, "Outer", Affine::IDENTITY); + outer.extras_mut().symbol_overrides = vec![vectorcraft_doc::SymbolOverride { path: vec![], opacity: Some(1.0), ..Default::default() }]; + plot_on_layer(&mut d, outer); + let b = super::export_art_bounds(&d, "hpgl").expect("the painted rectangle is plotted"); + assert!((b.x0 - 300.0).abs() < 1e-6 && (b.x1 - 320.0).abs() < 1e-6, "{b:?}"); +} + /// A placed image with a Vector Halftone effect is measured when the effect makes dots, and left /// out when it would take more cells than the effect makes. #[test] diff --git a/crates/engine/src/cmd/object/transform.rs b/crates/engine/src/cmd/object/transform.rs index 88362d7c49..25a831622e 100644 --- a/crates/engine/src/cmd/object/transform.rs +++ b/crates/engine/src/cmd/object/transform.rs @@ -158,6 +158,9 @@ fn apply_transform_moving_guides( } None => ids.clone(), }; + // Pattern editing with Move Tile with Art: the tile art's bounds before a move. + let moves_only = c[0] == 1.0 && c[1] == 0.0 && c[2] == 0.0 && c[3] == 1.0; + let tile_art = if objects && !copy && moves_only { crate::cmd::patterncmds::tile_art_bounds(d) } else { None }; // Transforms don't move nodes in the tree, so reuse the paths found for the no-copy // case; copies and a settled typing session need paths from the edited document. let paths = if reindex { d.index_paths(targets.iter().copied()) } else { paths }; @@ -172,6 +175,10 @@ fn apply_transform_moving_guides( for id in &targets { super::pixelgrid::keep_compound_aligned(d, *id, sc); } + // Moving all of the tile art moves the tile as far. + if let Some(before) = tile_art { + crate::cmd::patterncmds::move_tile_with_art(d, &targets, before); + } } if with_guides && (delta.x != 0.0 || delta.y != 0.0) { for i in sel.guides.clone() { diff --git a/crates/engine/src/cmd/panelcmds.rs b/crates/engine/src/cmd/panelcmds.rs index fa0492c2db..8f4e3c71f4 100644 --- a/crates/engine/src/cmd/panelcmds.rs +++ b/crates/engine/src/cmd/panelcmds.rs @@ -67,7 +67,7 @@ pub fn specs() -> Vec { "Character / Paragraph", [], None, - "{ids?|id?, kerning?: 1/1000 em|\"auto\"|\"optical\", baselineShift?: pt, hScale?: %, vScale?: %, rotation?: deg counter-clockwise (0: upright), underline?, strikethrough?, allCaps?, smallCaps?: bool, position?: \"normal\"|\"superscript\"|\"subscript\" (sizes from Document Setup), antiAlias?: \"none\"|\"sharp\"|\"crisp\"|\"strong\" (Character panel › Anti-aliasing Method, how the type's edges are drawn as pixels; it sets each targeted type object, with or without a range; each frame of a thread keeps its own method; graph labels keep it for every label of their kind when the graph is drawn again; new type: \"sharp\"), leftIndent?, rightIndent?, firstLineIndent?, spaceBefore?, spaceAfter?: pt (±1296), list?: \"none\"|\"bullet\"|\"number\", bullet?: \"none\"|\"dot\"|\"dash\"|\"circle\"|\"square\", number?: \"none\"|\"arabic\"|\"upperLetters\"|\"lowerLetters\"|\"upperRoman\"|\"lowerRoman\"|\"twoDigits\"|\"threeDigits\", listAlign?: \"left\"|\"right\", listLevel?: 1..9 (Paragraph panel › Bullets and Numbered List: each paragraph of a list starts with its marker, drawn in the font, size and color of the paragraph's first character, and its text starts at the next tab stop after the marker, the left indent counting as a stop; a bulleted list's marker is its bullet, and a numbered list's is the paragraph's number and a period: 1, 2, 3; A to Z, then AA, BB; a to z, then aa, bb; I, II, III; i, ii, iii; 01 to 99, then 100; 001 to 999, then 1000; letters and roman numerals past 3999 in Arabic digits; the numbers count the story's numbered paragraphs, its threaded frames included, from 1 after a paragraph in no list, and an empty or bulleted paragraph is not counted; list \"bullet\" keeps a list's bullet, else a dot, and list \"number\" keeps a numbered list's style, else arabic; without list, a number style makes a numbered list and a bullet makes a bulleted one; \"none\" in any of the three turns the list off; a paragraph that joins a list with left and first-line indents of 0 gets 18 and -18 unless the call sets an indent; a horizontal right-to-left list paragraph has its indents mirrored, measured from the right; listAlign \"right\" (Bullets and Numbering…'s Alignment) puts the marker against the text, and \"left\" (the default) puts it at the first line's start, the text starting at the same place with either; it sets the paragraphs that are in a list or that the call puts in one, and a list keeps it when it changes between bullets and numbers; listLevel (Tab and Shift+Tab at the start of a list paragraph, Bullets and Numbering…'s Level) is a whole number from 1 to 9 that sets the paragraphs in a list or that the call puts in one, a list keeping it when it changes between bullets and numbers, and a list turned on without listLevel starting at level 1; each level past the first sets the paragraph 18 pt further in than its left indent, and each level counts its own numbers: a list paragraph with text sets the counts of the levels deeper than its own back to 0, so a deeper numbered paragraph after it counts from 1, and a paragraph back at a shallower level goes on with that level's count), hyphenate?: bool, wordsLongerThan?: letters 1..25 (5), afterFirst?, beforeLast?: letters 1..15 (3), hyphenLimit?: lines 0..25 (0: no limit), hyphenationZone?: pt 0..1296 (0; Single-line Composer only), hyphenateCapitalized?: bool (true) (Paragraph panel menu › Hyphenation…: only words longer than wordsLongerThan break, keeping afterFirst letters before the hyphen and beforeLast after it; at most hyphenLimit lines in a row end with a hyphen; a line whose text already ends within hyphenationZone of its right edge breaks before the next word instead of hyphenating it; the counts are rounded, and every value is clamped to its range), wordSpacing?: % 0..1000 (80, 100, 133), letterSpacing?: % of a word space -100..500 (0, 0, 0), glyphScaling?: % 50..200 (100, 100, 100): each [min, desired, max] or {min?, desired?, max?} (Paragraph panel menu › Justification…: Desired applies to every line, Minimum and Maximum bound what justified lines may use; values are clamped to their range, and Minimum ≤ Desired ≤ Maximum must hold afterwards or nothing changes), autoLeading?: % of the size 0..500 (120; the leading of characters whose leading is Auto), singleWordJustification?: \"fullJustify\"|\"alignLeft\"|\"alignCenter\"|\"alignRight\" (a justified line holding one word), mojikumi?: \"none\"|\"yakumonoHankaku\"|\"gyomatsuYakumonoHankaku\"|\"gyomatsuYakumonoZenkaku\"|\"yakumonoZenkaku\"|\"lineEndHalf\" (Mojikumi Set: Japanese punctuation spacing), kinsoku?: \"none\"|\"hard\"|\"soft\" (Kinsoku Set: Soft lets 々, ー and small kana start a line), kinsokuType?: \"pushInFirst\"|\"pushOutFirst\"|\"pushOutOnly\" (Paragraph panel menu › Kinsoku Shori Type: how area type keeps those characters off a line's ends; pushInFirst sets them on the line when its gaps next to CJK characters can close up enough, by at most a tenth of an em each, pushOutFirst does so only when moving the character before them to the next line would leave the line more than an em short, and pushOutOnly, the default, moves it unless Burasagari hangs an overflowing comma or full stop; no effect with kinsoku \"none\"), bunriKinshi?: bool (Paragraph panel menu › Bunri-Kinshi: a line never breaks inside an inseparable pair, two em dashes, two horizontal ellipses, two two-dot leaders or the two halves of a vertical kana repeat mark, and justification adds no space inside one; kept but without effect with kinsoku \"none\"; off by default), direction?: \"auto\"|\"leftToRight\"|\"rightToLeft\" (paragraph direction; auto: from each paragraph's first strong character), leadingModel?: \"romanBaseline\"|\"emBoxTop\" (leading measured baseline to baseline, or em box top to top), charAlign?: \"romanBaseline\"|\"emBoxTop\"|\"emBoxCenter\"|\"emBoxBottom\"|\"icfTop\"|\"icfBottom\" (where characters smaller than the largest on their line line up with it), proportionalMetrics?: bool (full-width glyphs on the font's proportional widths: `palt` in horizontal type, `vpal` in vertical type), tateChuYoko?: bool (Character panel menu › Tate-chu-yoko, with Show East Asian Options: in vertical type those characters stand side by side across one em of the column; kept on the characters and ignored in horizontal type), tcyUpDown?, tcyLeftRight?: pt -1296..1296 (Character panel menu › Tate-chu-yoko Settings, as text.setRangeStyle: a tate-chu-yoko block moves up or down, right or left, and on a vertical path toward or away from the path's start, to its left or right), warichu?: bool, warichuLines?: 1..6, warichuGap?: pt 0..100, warichuScale?: % 10..100, warichuAlign?: \"auto\"|\"left\"|\"center\"|\"right\"|\"justifyLeft\"|\"justifyCenter\"|\"justifyRight\"|\"justifyAll\" (Character panel menu › Warichu and Warichu Settings, as text.setRangeStyle: the characters set smaller and stacked on lines inside their line), akiLeft?, akiRight?: 1/1000 em 0..1000 (Character panel › Insert Aki Left and Right, with Show East Asian Options, as text.setRangeStyle: space before and after each character in reading order), tsume?: % 0..100 (Character panel › Tsume, with Show East Asian Options, as text.setRangeStyle: the share of the blank space beside each character's ink taken away in horizontal type), standardVerticalRoman?: bool (Character panel menu › Standard Vertical Roman Alignment, as text.setRangeStyle), noBreak?: bool (Character panel menu › No Break: these characters stay on one line, including across a space inside the run), kashidas?: bool (Character panel menu › Kashidas: selected characters allow insertion only when both joined neighbors have it on; on by default), charDirection?: \"default\"|\"leftToRight\"|\"rightToLeft\" (Character panel menu › Character Direction: the characters are set left to right or right to left inside their paragraph, as a Unicode bidi embedding; default: as the bidi algorithm sets them), digits?: \"default\"|\"arabic\"|\"hindi\"|\"farsi\"|\"devanagari\"|\"bengali\"|\"gurmukhi\"|\"gujarati\"|\"odia\"|\"tamil\"|\"telugu\"|\"kannada\"|\"malayalam\" (Character panel menu › Digits: the numeral system every digit is drawn in, whatever was typed: Western, Arabic-Indic (hindi), Eastern Arabic-Indic (farsi) or an Indic script's own digits), glyphForm?: \"traditional\"|\"expert\"|\"jis04\"|\"jis90\"|\"jis78\"|\"jis83\"|\"fullWidth\"|\"halfWidth\"|\"thirdWidth\"|\"quarterWidth\"|\"default\" (Glyphs panel menu, with Show East Asian Options, as text.setRangeStyle: the characters drawn in one OpenType form of the font, or its default glyphs), fontHeightReference?: \"emBox\"|\"capHeight\"|\"xHeight\"|\"icfBox\" (Character panel › Set Font Height Reference, as text.setRangeStyle: the height of the font that the size measures), language?: name|tag (Character panel › Language, as text.setRangeStyle), fontAxes?: {\"wght\": 650, …} (Character panel › Variable Font, as text.setRangeStyle: each axis named set in its units and clamped to its range, null back to the style's own setting), burasagari?: \"none\"|\"standard\"|\"forced\" (Paragraph panel menu › Burasagari None/Regular/Force: an East Asian comma or full stop ending an area type line hangs outside it: when it doesn't fit, or always; new type: \"standard\"), romanHangingPunctuation?: bool (Paragraph panel menu › Roman Hanging Punctuation: quotation marks, hyphens, full stops and commas starting or ending a line hang outside the margin, asterisks, tildes, ellipses, dashes, colons and semicolons by half; off by default), koreanLineBreakAnywhere?: bool (Paragraph panel menu › Korean Line Break Anywhere: a line may break between any two Hangul syllables; off, a word with Hangul in it breaks only at its spaces and punctuation, or where it is wider than the line; new type: false, paragraphs saved before the option: true), kashida?: \"none\"|\"short\"|\"medium\"|\"long\"|\"stylistic\" (Paragraph panel › Insert Kashidas: justified lines of horizontal area type lengthen each Arabic word with a kashida between its last pair of joined letters, at most a quarter, half or whole em long, or whole tatweels up to an em for stylistic, before they widen their word spaces; none by default), composer?: \"singleLine\"|\"everyLine\" (line breaking, Every-line by default), start?: byte, end?: byte} (baselineShift is in document points and hScale includes the object transform; groups include their text descendants; with a range: the character attributes style that range and the paragraph attributes apply to the paragraphs it touches; without: all the text, and graph labels keep the character and paragraph attributes for every label of their kind when the graph is drawn again, the graph still setting each label's alignment; a number or switch given as another type fails the call, and a null value leaves its attribute as it is, so a call with only null values fails with nothing to change; a call that gives the values the text already has records no undo step). Or {paragraphIndents: [{id, start: byte where the paragraph starts, leftIndent?, firstLineIndent?: pt -1296..1296}…]} alone (Tabs panel indent markers over paragraphs with indents of their own): each entry sets one paragraph of a type object, at least one indent each, at most 100000 entries; every entry is checked before anything changes, and an unknown key, a value out of range, an object that is not type, a byte where no paragraph starts or two entries for one paragraph with other values fail the call; all of it is one edit, a threaded story flowing again once, and when no paragraph's indents change nothing is recorded → {ids}", + "{ids?|id?, kerning?: 1/1000 em|\"auto\"|\"optical\", baselineShift?: pt, hScale?: %, vScale?: %, rotation?: deg counter-clockwise (0: upright), underline?, strikethrough?, allCaps?, smallCaps?: bool, position?: \"normal\"|\"superscript\"|\"subscript\" (sizes from Document Setup), antiAlias?: \"none\"|\"sharp\"|\"crisp\"|\"strong\" (Character panel › Anti-aliasing Method, how the type's edges are drawn as pixels; it sets each targeted type object, with or without a range; each frame of a thread keeps its own method; graph labels keep it for every label of their kind when the graph is drawn again; new type: \"sharp\"), leftIndent?, rightIndent?, firstLineIndent?, spaceBefore?, spaceAfter?: pt (±1296), list?: \"none\"|\"bullet\"|\"number\", bullet?: \"none\"|\"dot\"|\"dash\"|\"circle\"|\"square\", number?: \"none\"|\"arabic\"|\"upperLetters\"|\"lowerLetters\"|\"upperRoman\"|\"lowerRoman\"|\"twoDigits\"|\"threeDigits\", listAlign?: \"left\"|\"right\", listLevel?: 1..9 (Paragraph panel › Bullets and Numbered List: each paragraph of a list starts with its marker, drawn in the font, size and color of the paragraph's first character, and its text starts at the next tab stop after the marker, the left indent counting as a stop; a bulleted list's marker is its bullet, and a numbered list's is the paragraph's number and a period: 1, 2, 3; A to Z, then AA, BB; a to z, then aa, bb; I, II, III; i, ii, iii; 01 to 99, then 100; 001 to 999, then 1000; letters and roman numerals past 3999 in Arabic digits; the numbers count the story's numbered paragraphs, its threaded frames included, from 1 after a paragraph in no list, and an empty or bulleted paragraph is not counted; list \"bullet\" keeps a list's bullet, else a dot, and list \"number\" keeps a numbered list's style, else arabic; without list, a number style makes a numbered list and a bullet makes a bulleted one; \"none\" in any of the three turns the list off; a paragraph that joins a list with left and first-line indents of 0 gets 18 and -18 unless the call sets an indent; a horizontal right-to-left list paragraph has its indents mirrored, measured from the right; listAlign \"right\" (Bullets and Numbering…'s Alignment) puts the marker against the text, and \"left\" (the default) puts it at the first line's start, the text starting at the same place with either; it sets the paragraphs that are in a list or that the call puts in one, and a list keeps it when it changes between bullets and numbers; listLevel (Tab and Shift+Tab at the start of a list paragraph, Bullets and Numbering…'s Level) is a whole number from 1 to 9 that sets the paragraphs in a list or that the call puts in one, a list keeping it when it changes between bullets and numbers, and a list turned on without listLevel starting at level 1; each level past the first sets the paragraph 18 pt further in than its left indent, and each level counts its own numbers: a list paragraph with text sets the counts of the levels deeper than its own back to 0, so a deeper numbered paragraph after it counts from 1, and a paragraph back at a shallower level goes on with that level's count), hyphenate?: bool, wordsLongerThan?: letters 1..25 (5), afterFirst?, beforeLast?: letters 1..15 (3), hyphenLimit?: lines 0..25 (0: no limit), hyphenationZone?: pt 0..1296 (0; Single-line Composer only), hyphenateCapitalized?: bool (true) (Paragraph panel menu › Hyphenation…: only words longer than wordsLongerThan break, keeping afterFirst letters before the hyphen and beforeLast after it; at most hyphenLimit lines in a row end with a hyphen; a line whose text already ends within hyphenationZone of its right edge breaks before the next word instead of hyphenating it; the counts are rounded, and every value is clamped to its range), wordSpacing?: % 0..1000 (80, 100, 133), letterSpacing?: % of a word space -100..500 (0, 0, 0), glyphScaling?: % 50..200 (100, 100, 100): each [min, desired, max] or {min?, desired?, max?} (Paragraph panel menu › Justification…: Desired applies to every line, Minimum and Maximum bound what justified lines may use; values are clamped to their range, and Minimum ≤ Desired ≤ Maximum must hold afterwards or nothing changes), autoLeading?: % of the size 0..500 (120; the leading of characters whose leading is Auto), singleWordJustification?: \"fullJustify\"|\"alignLeft\"|\"alignCenter\"|\"alignRight\" (a justified line holding one word), mojikumi?: \"none\"|\"yakumonoHankaku\"|\"gyomatsuYakumonoHankaku\"|\"gyomatsuYakumonoZenkaku\"|\"yakumonoZenkaku\"|\"lineEndHalf\" (Mojikumi Set: Japanese punctuation spacing), kinsoku?: \"none\"|\"hard\"|\"soft\" (Kinsoku Set: Soft lets 々, ー and small kana start a line), kinsokuType?: \"pushInFirst\"|\"pushOutFirst\"|\"pushOutOnly\" (Paragraph panel menu › Kinsoku Shori Type: how area type keeps those characters off a line's ends; pushInFirst sets them on the line when its gaps next to CJK characters can close up enough, by at most a tenth of an em each, pushOutFirst does so only when moving the character before them to the next line would leave the line more than an em short, and pushOutOnly, the default, moves it unless Burasagari hangs an overflowing comma or full stop; no effect with kinsoku \"none\"), bunriKinshi?: bool (Paragraph panel menu › Bunri-Kinshi: a line never breaks inside an inseparable pair, two em dashes, two horizontal ellipses, two two-dot leaders or the two halves of a vertical kana repeat mark, and justification adds no space inside one; kept but without effect with kinsoku \"none\"; off by default), direction?: \"auto\"|\"leftToRight\"|\"rightToLeft\" (paragraph direction; auto: from each paragraph's first strong character), leadingModel?: \"romanBaseline\"|\"emBoxTop\" (leading measured baseline to baseline, or em box top to top), charAlign?: \"romanBaseline\"|\"emBoxTop\"|\"emBoxCenter\"|\"emBoxBottom\"|\"icfTop\"|\"icfBottom\" (where characters smaller than the largest on their line line up with it), proportionalMetrics?: bool (full-width glyphs on the font's proportional widths: `palt` in horizontal type, `vpal` in vertical type), tateChuYoko?: bool (Character panel menu › Tate-chu-yoko, with Show East Asian Options: in vertical type those characters stand side by side across one em of the column; kept on the characters and ignored in horizontal type), tcyUpDown?, tcyLeftRight?: pt -1296..1296 (Character panel menu › Tate-chu-yoko Settings, as text.setRangeStyle: a tate-chu-yoko block moves up or down, right or left, and on a vertical path toward or away from the path's start, to its left or right), warichu?: bool, warichuLines?: 1..6, warichuGap?: pt 0..100, warichuScale?: % 10..100, warichuAlign?: \"auto\"|\"left\"|\"center\"|\"right\"|\"justifyLeft\"|\"justifyCenter\"|\"justifyRight\"|\"justifyAll\" (Character panel menu › Warichu and Warichu Settings, as text.setRangeStyle: the characters set smaller and stacked on lines inside their line), akiLeft?, akiRight?: 1/1000 em 0..1000 (Character panel › Insert Aki Left and Right, with Show East Asian Options, as text.setRangeStyle: space before and after each character in reading order), tsume?: % 0..100 (Character panel › Tsume, with Show East Asian Options, as text.setRangeStyle: the share of the blank space beside each character's ink taken away in horizontal type), standardVerticalRoman?: bool (Character panel menu › Standard Vertical Roman Alignment, as text.setRangeStyle), noBreak?: bool (Character panel menu › No Break: these characters stay on one line, including across a space inside the run), kashidas?: bool (Character panel menu › Kashidas: selected characters allow insertion only when both joined neighbors have it on; on by default), charDirection?: \"default\"|\"leftToRight\"|\"rightToLeft\" (Character panel menu › Character Direction: the characters are set left to right or right to left inside their paragraph, as a Unicode bidi embedding; default: as the bidi algorithm sets them), digits?: \"default\"|\"arabic\"|\"hindi\"|\"farsi\"|\"devanagari\"|\"bengali\"|\"gurmukhi\"|\"gujarati\"|\"odia\"|\"tamil\"|\"telugu\"|\"kannada\"|\"malayalam\" (Character panel menu › Digits: the numeral system every digit is drawn in, whatever was typed: Western, Arabic-Indic (hindi), Eastern Arabic-Indic (farsi) or an Indic script's own digits), glyphForm?: \"traditional\"|\"expert\"|\"jis04\"|\"jis90\"|\"jis78\"|\"jis83\"|\"fullWidth\"|\"halfWidth\"|\"thirdWidth\"|\"quarterWidth\"|\"default\" (Glyphs panel menu, with Show East Asian Options, as text.setRangeStyle: the characters drawn in one OpenType form of the font, or its default glyphs; a form other than \"default\" takes away their glyph choice), fontHeightReference?: \"emBox\"|\"capHeight\"|\"xHeight\"|\"icfBox\" (Character panel › Set Font Height Reference, as text.setRangeStyle: the height of the font that the size measures), language?: name|tag (Character panel › Language, as text.setRangeStyle), fontAxes?: {\"wght\": 650, …} (Character panel › Variable Font, as text.setRangeStyle: each axis named set in its units and clamped to its range, null back to the style's own setting), burasagari?: \"none\"|\"standard\"|\"forced\" (Paragraph panel menu › Burasagari None/Regular/Force: an East Asian comma or full stop ending an area type line hangs outside it: when it doesn't fit, or always; new type: \"standard\"), romanHangingPunctuation?: bool (Paragraph panel menu › Roman Hanging Punctuation: quotation marks, hyphens, full stops and commas starting or ending a line hang outside the margin, asterisks, tildes, ellipses, dashes, colons and semicolons by half; off by default), koreanLineBreakAnywhere?: bool (Paragraph panel menu › Korean Line Break Anywhere: a line may break between any two Hangul syllables; off, a word with Hangul in it breaks only at its spaces and punctuation, or where it is wider than the line; new type: false, paragraphs saved before the option: true), kashida?: \"none\"|\"short\"|\"medium\"|\"long\"|\"stylistic\" (Paragraph panel › Insert Kashidas: justified lines of horizontal area type lengthen each Arabic word with a kashida between its last pair of joined letters, at most a quarter, half or whole em long, or whole tatweels up to an em for stylistic, before they widen their word spaces; none by default), composer?: \"singleLine\"|\"everyLine\" (line breaking, Every-line by default), start?: byte, end?: byte} (baselineShift is in document points and hScale includes the object transform; groups include their text descendants; with a range: the character attributes style that range and the paragraph attributes apply to the paragraphs it touches; without: all the text, and graph labels keep the character and paragraph attributes for every label of their kind when the graph is drawn again, the graph still setting each label's alignment; a number or switch given as another type fails the call, and a null value leaves its attribute as it is, so a call with only null values fails with nothing to change; a call that gives the values the text already has records no undo step). Or {paragraphIndents: [{id, start: byte where the paragraph starts, leftIndent?, firstLineIndent?: pt -1296..1296}…]} alone (Tabs panel indent markers over paragraphs with indents of their own): each entry sets one paragraph of a type object, at least one indent each, at most 100000 entries; every entry is checked before anything changes, and an unknown key, a value out of range, an object that is not type, a byte where no paragraph starts or two entries for one paragraph with other values fail the call; all of it is one edit, a threaded story flowing again once, and when no paragraph's indents change nothing is recorded → {ids}", has_doc, set_format ), @@ -824,7 +824,7 @@ fn set_format(s: &mut Session, p: &Value) -> Result { st.digits = v; } if let Some(v) = glyph_form { - st.glyph_form = v; + st.set_glyph_form(v); } if let Some(v) = &language { st.language.clone_from(v); diff --git a/crates/engine/src/cmd/patterncmds.rs b/crates/engine/src/cmd/patterncmds.rs index 2800e8e674..57f9094e48 100644 --- a/crates/engine/src/cmd/patterncmds.rs +++ b/crates/engine/src/cmd/patterncmds.rs @@ -78,7 +78,7 @@ pub fn specs() -> Vec { "Pattern Options", ["Window", "Pattern Options"], None, - "{name? (default: the pattern being edited), newName? (trimmed; blank: the name is kept), tileType?, brickOffset?, width?, height?, hSpacing?, vSpacing?, sizeTileToArt?: bool, overlap?: {h: left|right, v: top|bottom}, copies?: 3|5|7|9, dimCopies?: %, showTileEdge?: bool, showSwatchBounds?: bool (outline the part of the tiling the swatch repeats, dashed)}", + "{name? (default: the pattern being edited), newName? (trimmed; blank: the name is kept), tileType?, brickOffset?, width?, height?, hSpacing?, vSpacing?, sizeTileToArt?: bool, moveTileWithArt?: bool (in pattern editing mode, a move of all of the tile art moves the tile with it; off for a new pattern), overlap?: {h: left|right, v: top|bottom}, copies?: 3|5|7|9, dimCopies?: %, showTileEdge?: bool, showSwatchBounds?: bool (outline the part of the tiling the swatch repeats, dashed)}", has_doc, pattern_options ), @@ -530,7 +530,7 @@ fn pattern_options(s: &mut Session, p: &Value) -> Result { let cur = s.doc()?.doc.pattern(&name).cloned().ok_or_else(|| bad(C, format!("no pattern `{name}`")))?; let tt = tile_type_param(p, C, cur.tile_type)?; check_numbers(p, &["width", "height", "hSpacing", "vSpacing", "copies", "dimCopies"], C)?; - check_switches(p, &["sizeTileToArt", "showTileEdge", "showSwatchBounds"], C)?; + check_switches(p, &["sizeTileToArt", "moveTileWithArt", "showTileEdge", "showSwatchBounds"], C)?; let num = |k: &str| p.get(k).and_then(Value::as_f64); for k in ["width", "height"] { if num(k).is_some_and(|v| !(v.is_finite() && v > 0.0)) { @@ -560,11 +560,11 @@ fn pattern_options(s: &mut Session, p: &Value) -> Result { }; let edit_layer = editing(s).filter(|e| e.pattern == name).map(|e| e.layer); let out_name = new_name.clone().unwrap_or(name.clone()); - s.edit("Pattern Options", |d, _| { - // While editing, the tile art is the edit layer's. - let live_art: Option>> = - edit_layer.and_then(|l| d.node(l)).and_then(|l| l.children()).map(|ch| ch.iter().filter(|c| !c.is_ruler_guide()).cloned().collect()); - let def = d.pattern_mut(&name).ok_or_else(|| EngineError::Other("pattern missing".into()))?; + // While editing, the tile art is the edit layer's. + let live_art = |d: &Document| -> Option>> { + edit_layer.and_then(|l| d.node(l)).and_then(|l| l.children()).map(|ch| ch.iter().filter(|c| !c.is_ruler_guide()).cloned().collect()) + }; + let apply = |def: &mut PatternDef, live_art: Option>>| { def.tile_type = tt; def.overlap = overlap; if let Some(v) = num("hSpacing") { @@ -576,6 +576,9 @@ fn pattern_options(s: &mut Session, p: &Value) -> Result { if let Some(v) = p.get("sizeTileToArt").and_then(Value::as_bool) { def.size_tile_to_art = v; } + if let Some(v) = p.get("moveTileWithArt").and_then(Value::as_bool) { + def.move_tile_with_art = v; + } if let Some(v) = num("copies") { def.copies = (v as u32).clamp(1, 15) | 1; } @@ -590,13 +593,24 @@ fn pattern_options(s: &mut Session, p: &Value) -> Result { } if def.size_tile_to_art { let saved = std::mem::take(&mut def.art); - def.art = live_art.clone().unwrap_or_else(|| saved.clone()); + def.art = live_art.unwrap_or_else(|| saved.clone()); def.fit_tile_to_art(); def.art = saved; } else if num("width").is_some() || num("height").is_some() { let c = def.tile.center(); def.tile = Rect::from_center_size(c, (num("width").unwrap_or(def.tile.width()), num("height").unwrap_or(def.tile.height()))); } + }; + // Options that change nothing leave the document and its undo history as they are. + let mut next = cur.clone(); + apply(&mut next, live_art(&s.doc()?.doc)); + if next == cur && new_name.is_none() { + return Ok(json!({ "name": out_name })); + } + s.edit("Pattern Options", |d, _| { + let live = live_art(d); + let def = d.pattern_mut(&name).ok_or_else(|| EngineError::Other("pattern missing".into()))?; + apply(def, live); if let Some(nn) = &new_name { def.name = nn.clone(); rename_pattern_uses(d, &name, nn); @@ -609,6 +623,47 @@ fn pattern_options(s: &mut Session, p: &Value) -> Result { Ok(json!({ "name": out_name })) } +/// The tile art's objects (the editing layer's, ruler guides aside) while `d` edits a pattern that +/// has Move Tile with Art on, with the pattern's name. +fn moving_tile_art(d: &Document) -> Option<(String, Vec>)> { + let pe = d.pattern_edit.as_ref()?; + if !d.pattern(&pe.pattern).is_some_and(|p| p.move_tile_with_art) { + return None; + } + let art = d.node(pe.layer)?.children()?.iter().filter(|c| !c.is_ruler_guide()).cloned().collect(); + Some((pe.pattern.clone(), art)) +} + +/// Before a move: the bounds of the tile art for [`move_tile_with_art`], `None` unless `d` edits a +/// pattern with Move Tile with Art on. +pub(crate) fn tile_art_bounds(d: &Document) -> Option { + moving_tile_art(d).and_then(|(_, art)| vectorcraft_doc::live::nodes_bounds(&art)) +} + +/// Move Tile with Art: after a move of `moved` that takes every object of the tile art (a group +/// counting when all of its objects moved), the tile moves by as much as the art did, measured from +/// its bounds `before` the move (objects aligned to the pixel grid snap back to it). A move of only +/// some of the art leaves the tile where it is. +pub(crate) fn move_tile_with_art(d: &mut Document, moved: &[NodeId], before: Rect) { + let Some((name, art)) = moving_tile_art(d) else { return }; + let moved: std::collections::HashSet = moved.iter().copied().collect(); + // An object moves whole when it moved, or when every object in it did (a group whose members + // were selected and moved one by one). + fn whole(n: &Node, moved: &std::collections::HashSet) -> bool { + moved.contains(&n.id) || n.children().is_some_and(|ch| !ch.is_empty() && ch.iter().all(|c| whole(c, moved))) + } + if art.is_empty() || !art.iter().all(|c| whole(c, &moved)) { + return; + } + let Some(after) = vectorcraft_doc::live::nodes_bounds(&art) else { return }; + let by = after.origin() - before.origin(); + if (by.x != 0.0 || by.y != 0.0) + && let Some(def) = d.pattern_mut(&name) + { + def.tile = def.tile + by; + } +} + /// Side `side` of the `overlap` object `o` gives `cmd`: true for the second of `names`, false for the /// first, `current` when `o` leaves it out or gives null. Any other value is an error. fn overlap_side(o: &Value, side: &str, names: [&str; 2], current: bool, cmd: &str) -> Result { @@ -680,6 +735,7 @@ fn pattern_list(s: &mut Session, _: &Value) -> Result { "hSpacing": p.h_spacing, "vSpacing": p.v_spacing, "sizeTileToArt": p.size_tile_to_art, + "moveTileWithArt": p.move_tile_with_art, "overlap": {"h": if p.overlap.right_in_front { "right" } else { "left" }, "v": if p.overlap.bottom_in_front { "bottom" } else { "top" }}, "copies": p.copies, "dimCopies": p.dim_copies, diff --git a/crates/engine/src/cmd/place.rs b/crates/engine/src/cmd/place.rs index 8c9a19cb8d..0567be7e59 100644 --- a/crates/engine/src/cmd/place.rs +++ b/crates/engine/src/cmd/place.rs @@ -257,7 +257,7 @@ fn load(p: &Value, cmd: &str, board: Option, link_pdf: bool) -> Result, link_pdf: bool) -> Result), path: Option<&str>, page: usize, opts: fileio::LoadOptions, @@ -321,11 +323,15 @@ pub(crate) fn pdf_art( let name = fileio::file_name(src); let index = page.checked_sub(1).ok_or_else(|| bad(cmd, "page must be a whole number from 1"))?; let o = fileio::LoadOptions { crop: if bounding { CropTo::Crop } else { opts.crop }, ..opts }; - let mut read = fileio::page_document(bytes, index, &o); + let page_document = |i: usize| match opened { + Some(opened) => fileio::opened_page_document(bytes, opened, i, &o), + None => fileio::page_document(bytes, i, &o), + }; + let mut read = page_document(index); // Such a file can have more artboards than placeholder pages. if page > 1 && read.is_err() - && let Err(e) = fileio::page_document(bytes, 0, &o) + && let Err(e) = page_document(0) && fileio::is_placeholder(&e) { read = Err(e); @@ -351,7 +357,7 @@ pub(crate) fn pdf_art( let clip = (!bounding).then_some(board); let frame = clip.or_else(|| shown_bounds(&symbols, &nodes)); let frame = frame.filter(|r| r.width() > 0.0 || r.height() > 0.0).ok_or_else(|| bad(cmd, format!("`{name}` has no art to place")))?; - Ok(document::DocumentArt { doc, nodes, clip, frame, warnings }) + Ok(document::DocumentArt { doc, nodes, clip, frame, warnings, editing_data: alone }) } /// The bounds of what `nodes` draw, as an export measures art diff --git a/crates/engine/src/cmd/place/document.rs b/crates/engine/src/cmd/place/document.rs index 5809990e0f..0d9a1470a8 100644 --- a/crates/engine/src/cmd/place/document.rs +++ b/crates/engine/src/cmd/place/document.rs @@ -100,6 +100,9 @@ pub(crate) struct DocumentArt { /// What is shown: the artboard, or the art's bounds. pub frame: Rect, pub warnings: Vec, + /// Whether a PDF or `.ai` page was read from the file's editing data (its page only a + /// placeholder, or its PDF part unreadable) instead of from the page. + pub editing_data: bool, } /// Artboard `page` (1-based) of the VectorCraft document `name` (`bytes`, read from `path` when @@ -114,6 +117,21 @@ pub(crate) fn document_art( bounding: bool, page_box: PageBox, cmd: &str, +) -> Result { + opened_document_art(name, (bytes, None), path, page, bounding, page_box, cmd) +} + +/// [`document_art`] of `bytes` and, for a PDF already read, `opened`: the PDF `bytes` as +/// [`vectorcraft_pdf::open_pdf`] read it without a password, so its page is read without reading +/// the file again. +fn opened_document_art( + name: &str, + (bytes, opened): (&[u8], Option<&vectorcraft_pdf::Opened>), + path: Option<&str>, + page: u32, + bounding: bool, + page_box: PageBox, + cmd: &str, ) -> Result { let file = fileio::file_name(name); let pdf = is_pdf(name, bytes); @@ -125,7 +143,7 @@ pub(crate) fn document_art( } if pdf { let o = fileio::LoadOptions { crop: crop_to(page_box), ..Default::default() }; - return super::pdf_art(name, bytes, path, page as usize, o, bounding, cmd); + return super::pdf_art(name, (bytes, opened), path, page as usize, o, bounding, cmd); } let mut l = fileio::load(name, bytes)?; // Its linked images show their files; its placed documents stay as they are, unless their @@ -145,7 +163,7 @@ pub(crate) fn document_art( let nodes = super::on_artboard(&symbols, &layers, board); let frame = if bounding { super::shown_bounds(&symbols, &nodes) } else { Some(board) }; let frame = frame.filter(|r| r.width() > 0.0 || r.height() > 0.0).ok_or_else(|| bad(cmd, format!("`{file}` has no art to place")))?; - Ok(DocumentArt { doc: l.doc, nodes, clip: (!bounding).then_some(board), frame, warnings: l.warnings }) + Ok(DocumentArt { doc: l.doc, nodes, clip: (!bounding).then_some(board), frame, warnings: l.warnings, editing_data: false }) } /// The most placed documents read inside one another while their links are resolved. @@ -223,6 +241,8 @@ pub(crate) struct Source { pub warnings: Vec, /// The colour mode the file's art was read in (a PDF page painted mostly in CMYK reads in CMYK). pub color_mode: ColorMode, + /// [`DocumentArt::editing_data`]. + pub editing_data: bool, } /// The VectorCraft document `name` (`bytes`, from `path` when there is one) read for showing @@ -232,33 +252,48 @@ pub(crate) fn source(name: &str, bytes: &[u8], path: Option<&str>, page: u32, bo read_source(name, bytes, None, path, (page, bounding, page_box), cmd) } -/// [`source`] of the file whose bytes `bytes` holds, kept by the blob without a copy of its own: -/// several pages of one file read this way share one copy of it. +/// A PDF (or PDF-compatible `.ai`) whose pages are read for placing linked, read once for them all. +#[derive(Clone, Copy)] +pub(crate) struct SharedFile<'a> { + /// The file's bytes, which every page's blob keeps without a copy of its own. + pub bytes: &'a Arc>, + /// The bytes' hash ([`hash_bytes`]). + pub hash: &'a str, + /// The bytes as [`vectorcraft_pdf::open_pdf`] read them, without a password. + pub opened: &'a vectorcraft_pdf::Opened, +} + +/// [`source`] of page `page` of `file`: several pages of one file read this way share one copy of +/// it, and the file is hashed and read once for them all. pub(crate) fn shared_source( name: &str, - bytes: &Arc>, + file: SharedFile<'_>, path: Option<&str>, page: u32, bounding: bool, page_box: PageBox, cmd: &str, ) -> Result { - read_source(name, bytes, Some(bytes), path, (page, bounding, page_box), cmd) + read_source(name, file.bytes, Some(file), path, (page, bounding, page_box), cmd) } -/// [`source`], the blob keeping `shared` when given (else a copy of `bytes`); `shown` is the page -/// (or artboard), whether the art's bounds are shown, and the page's box. +/// [`source`], the blob keeping `shared`'s bytes when given (else a copy of `bytes`), its hash +/// used for the key (else `bytes` hashed) and its pages read from what was read of it; `shown` is +/// the page (or artboard), whether the art's bounds are shown, and the page's box. fn read_source( name: &str, bytes: &[u8], - shared: Option<&Arc>>, + shared: Option>, path: Option<&str>, shown: (u32, bool, PageBox), cmd: &str, ) -> Result { let (page, bounding, page_box) = shown; + let hash = shared.map_or_else(|| hash_bytes(bytes), |f| f.hash.to_string()); + let opened = shared.map(|f| f.opened); + let shared = shared.map(|f| f.bytes); // The same document placed the same way is read once (placing it again is free). - let key = key_for(&hash_bytes(bytes), page, bounding, page_box); + let key = key_for(&hash, page, bounding, page_box); // Resolving its links depends on where the file is and on what the caller may read, so a hit // needs the same path, and nothing is shared with or from a confined (automation) caller. let confined = crate::file_access::current().is_some(); @@ -269,7 +304,7 @@ fn read_source( } return Ok(s); } - let a = document_art(name, bytes, path, page, bounding, page_box, cmd)?; + let a = opened_document_art(name, (bytes, opened), path, page, bounding, page_box, cmd)?; let bytes = shared.cloned().unwrap_or_else(|| Arc::new(bytes.to_vec())); let mut blob = ImageBlob { mime: mime(name, &bytes).into(), bytes, proxy: None }; let shown = PlacedDocument { @@ -284,10 +319,10 @@ fn read_source( }; let art = group(a.nodes, a.clip); blob.proxy = preview(&a.doc, &art, a.frame).map(Arc::new); - let color_mode = a.doc.color_mode; + let (color_mode, editing_data) = (a.doc.color_mode, a.editing_data); // Drawing it needn't read it again. vectorcraft_doc::placed_document::prime(&shown, blob.bytes.clone(), (a.doc, art, a.frame)); - let s = Source { blob, key, page_box, frame: a.frame, warnings: a.warnings, color_mode }; + let s = Source { blob, key, page_box, frame: a.frame, warnings: a.warnings, color_mode, editing_data }; if !confined { let mut m = lock_memo(); if m.len() >= MEMO_SIZE { diff --git a/crates/engine/src/cmd/scripts.rs b/crates/engine/src/cmd/scripts.rs index 385fd071c4..5b9e911b0a 100644 --- a/crates/engine/src/cmd/scripts.rs +++ b/crates/engine/src/cmd/scripts.rs @@ -11,7 +11,7 @@ use vectorcraft_scriptcheck::{Limits, Loaded, Loader, Report}; /// The ids a reply can hold, for the windows that show it. pub use vectorcraft_scriptcheck::{IncludeStatus, Need, OUTSIDE, PROBLEMS}; -use super::{CommandSpec, EngineError, Result, Session, always, bad, bool_or, cmd, str_param}; +use super::{CommandSpec, EngineError, Result, Session, always, bad, bool_or, cmd, str_param, switch_or}; use crate::script; /// The extensions of the scripts File › Open shows a check for. @@ -32,7 +32,7 @@ pub fn specs() -> Vec { "Run Script", [], None, - "{path?: a .js or .jsx file | source?: the script's text (exactly one of the two), name?: the name errors and the report use (default: the script's #script line, else the path's file name, or \"script\"), args?: {…} (at most 1 MiB as JSON; vc.args in the script), answers?: [{kind: alert|confirm|prompt, value: \"ok\" (alert) | true or false (confirm) | text or null (prompt)}] (for a run with nobody to ask: the answers to its dialogs, in order; at most 10000 and 1 MiB as JSON; refused in the app, where the person answers; an empty object is an empty list), maxFuel?: whole number from 1 to 100000000000 (instructions in all; default 1000000000)} run a JavaScript file against the open documents, through the vc object: vc.run(id, params) runs a command and returns its result, a failed command throws an error the script can catch and puts back the documents and the session state it changed, while any file it wrote stays written and is named; vc.document is a snapshot of the active document or null; vc.commands lists the commands a script may run; vc.log(value) adds to the report and the Script Console (script.console); $.write(...values) writes the values to the Script Console as String(value) gives them, and $.writeln(...values) does the same and ends the line; $.evalFile(path, timeout?) runs a file with the same globals and returns its last value (timeout, in milliseconds from 0, is taken and not waited on), $.fileName is the file of the code that reads it (the file being run for code that eval or Function compiled; empty for code compiled from a script given as text), $.includePath the #includepath list and $.engineName the engine's name (VectorCraft without #targetengine); alert(message[, title, errorIcon]), confirm(message[, noAsDflt, title]) and prompt(message, preset[, title]) open a dialog with a Stop button (default title \"Script Alert\"; message and preset as String(value) gives them; errorIcon and noAsDflt as Boolean(value) gives them; No is the default with noAsDflt): alert returns undefined, confirm true for Yes and false for No, prompt the field's text for OK and null for Cancel; in the app the run waits at the call for the person's answer and Stop ends it as script.stop does, a dialog cannot open inside a callback of a built-in, a getter, a conversion or a file $.evalFile runs and throws there (an #include file's code is the script's own and can), and a run with nobody to ask takes the next of answers, and ends when none is left or the next is for another kind of dialog, or when the script ends with answers unused; vc.args is args. Before anything runs, the script's preprocessor lines (# or //@ at the start of a line, outside strings and comments) are read: #include \"file\" and //@include put that file's text in place of the line, looked for beside the file that includes it, then in each #includepath folder in turn (separated by ;, relative to that file's folder; a later #includepath replaces the list), then in each JSINCLUDE folder; #script names the run; #target takes any value and changes nothing; #strict on makes an assignment to a read-only or getter-only property, or a new property on an object that is not extensible (preventExtensions, seal, freeze, vc, $), throw a TypeError, without the rest of ES5 strict mode, and #strict off turns that off (the last #strict line of the script and its includes sets the run; off without one; a $.evalFile file's own line holds for its code); #targetengine name runs the script in the named engine, whose globals and functions stay for the next run that names it until script.engine.drop drops it or the session ends (script.engines lists them; a name of letters, digits and _ not starting with a digit, or a quoted one of 1 to 64 bytes without control characters, matched as written; only in the script itself, once, before its code and #include lines; main, and a script without the line, get a new engine each run; a session keeps 8 named engines, a ninth name is refused, each has 64 MiB of heap, and an uncaught error, a limit or script.stop keeps the globals the run set while the documents go back; host functions kept in a global act for the run that calls them; an error in code an earlier run compiled is at that run's file, line and column, within 8 MiB of source maps kept for each engine, the oldest going first; not on the web); #engine is refused as unsupported, any other #name as unknown, and another //@name is a comment. A script run from a file reads its includes and $.evalFile's files only in that file's folder and the folders below it, refusing a path with a .. name or a link that leads out (a JSINCLUDE folder outside is passed over); a script given as text reads none. File(path) and Folder(path) name files and folders and read them in the same folders, and inside the automation read roots when they are set: exists, length, created, modified, getFiles(mask or function; at most 2048 entries), resolve, and open('r') with read, readln, readch, seek, tell and close; a path outside, a network or device path and a link that leads out give false or null with error \"Script file access denied\", a file is read whole at most 8 MiB (checked from its size first) and a run reads at most 32 MiB, and writing, the file dialogs and execute return false or null with error set. An error in another file is at that file's line and column; vc.api.version and vc.api.has(feature) describe the API. The run is one transaction: on success each changed document gets one undo step, Run Script, and each preference it changed keeps its value, and on an uncaught error, a limit or script.stop every open document and the session go back as they were; files a command wrote before that stay written and are named. While it runs, other commands that change anything end with a busy error. Commands that change anything outside the documents (script.*, except script.check, plugin.*, prefs.*, actionSet.*, shortcutSet.*, help.*, print.*, library.*, importMatches.*, edit.undoGroup.*, file.recovery.*, stroke.widthProfile.*, saving, importing or deleting a preset, text.addFontFiles, text.findFontFiles, text.rescanFonts, links.findFiles, file.place.queue, color.loadProfile, color.removeProfile, file.print, view.proofSetup, view.proofColors, view.overprintPreview, view.separationsPreview) throw. VectorCraft's own limits: 8 MiB of source a file and 32 MiB in all, 64 included or evaluated files 16 deep, 1024 paths looked up for them, 256 MiB of heap (128 MiB on the web), 2000000 host calls, 200000000 instructions without a pause, 64 levels, 16 MiB and 1048576 entries for a value crossing to or from a command, 10000 log entries and 4 MiB of log, 16 KiB of a dialog's message, 256 bytes of its title and 16 KiB of a prompt's preset (past these a RangeError), and 100000 objects and 512 MiB of new document data, past which a person is asked to continue or stop and a run with nobody to ask ends → {ok: true, name, changed: [document names], written: [paths], log: [values], fuelUsed, hostCalls, runTimeMs (the time the script ran, its commands included, waits between frames and for a person's answer left out; 0 without a clock), timingAvailable (false on the web), hostCallCounts: {vcRun, documentReads, commandListReads, vcLog, consoleWrites, fileCalls, fileExecutes}, slowestCommands: [{id, calls, totalMs, maxMs}] (at most 5, the longest in all first, then by id), console: {lines: [text], droppedLines, truncatedLines} (the run's own output, vc.log lines and error), dialogs: [{kind, message, title, errorIcon (alert), noAsDefault (confirm), preset (prompt), answer: \"ok\" | true | false | text | null | \"stop\" | \"missing\"}] (in the order they opened, at most 10000 and 1 MiB), dialogCount, dialogsTruncated}, or, in the app, {running: true, name} and the report later in ui.inspect's scriptReport; a run that fails ends with its error, and its report there also has kind, message, line, column, file (when the error is in another file than the script, or in code an earlier run of its engine compiled), fuelLeft, fuelUsed, hostCalls and the fields above", + "{path?: a .js or .jsx file | source?: the script's text (exactly one of the two), installed?: true or false (default false; true with a path that script.listInstalled gave: the file is read once, checked as File › Scripts checks it, and that text runs, its #include lines and $.evalFile reading in the folder that read found; a file that is no longer a listed script ends with \"the script is no longer in the Scripts folder\" and nothing runs), name?: the name errors and the report use (default: the script's #script line, else the path's file name, or \"script\"), args?: {…} (at most 1 MiB as JSON; vc.args in the script), answers?: [{kind: alert|confirm|prompt, value: \"ok\" (alert) | true or false (confirm) | text or null (prompt)}] (for a run with nobody to ask: the answers to its dialogs, in order; at most 10000 and 1 MiB as JSON; refused in the app, where the person answers; an empty object is an empty list), maxFuel?: whole number from 1 to 100000000000 (instructions in all; default 1000000000)} run a JavaScript file against the open documents, through the vc object: vc.run(id, params) runs a command and returns its result, a failed command throws an error the script can catch and puts back the documents and the session state it changed, while any file it wrote stays written and is named; vc.document is a snapshot of the active document or null; vc.commands lists the commands a script may run; vc.log(value) adds to the report and the Script Console (script.console); $.write(...values) writes the values to the Script Console as String(value) gives them, and $.writeln(...values) does the same and ends the line; $.evalFile(path, timeout?) runs a file with the same globals and returns its last value (timeout, in milliseconds from 0, is taken and not waited on), $.fileName is the file of the code that reads it (the file being run for code that eval or Function compiled; empty for code compiled from a script given as text), $.includePath the #includepath list and $.engineName the engine's name (VectorCraft without #targetengine); alert(message[, title, errorIcon]), confirm(message[, noAsDflt, title]) and prompt(message, preset[, title]) open a dialog with a Stop button (default title \"Script Alert\"; message and preset as String(value) gives them; errorIcon and noAsDflt as Boolean(value) gives them; No is the default with noAsDflt): alert returns undefined, confirm true for Yes and false for No, prompt the field's text for OK and null for Cancel; in the app the run waits at the call for the person's answer and Stop ends it as script.stop does, a dialog cannot open inside a callback of a built-in, a getter, a conversion or a file $.evalFile runs and throws there (an #include file's code is the script's own and can), and a run with nobody to ask takes the next of answers, and ends when none is left or the next is for another kind of dialog, or when the script ends with answers unused; vc.args is args. Before anything runs, the script's preprocessor lines (# or //@ at the start of a line, outside strings and comments) are read: #include \"file\" and //@include put that file's text in place of the line, looked for beside the file that includes it, then in each #includepath folder in turn (separated by ;, relative to that file's folder; a later #includepath replaces the list), then in each JSINCLUDE folder; #script names the run; #target takes any value and changes nothing; #strict on makes an assignment to a read-only or getter-only property, or a new property on an object that is not extensible (preventExtensions, seal, freeze, vc, $), throw a TypeError, without the rest of ES5 strict mode, and #strict off turns that off (the last #strict line of the script and its includes sets the run; off without one; a $.evalFile file's own line holds for its code); #targetengine name runs the script in the named engine, whose globals and functions stay for the next run that names it until script.engine.drop drops it or the session ends (script.engines lists them; a name of letters, digits and _ not starting with a digit, or a quoted one of 1 to 64 bytes without control characters, matched as written; only in the script itself, once, before its code and #include lines; main, and a script without the line, get a new engine each run; a session keeps 8 named engines, a ninth name is refused, each has 64 MiB of heap, and an uncaught error, a limit or script.stop keeps the globals the run set while the documents go back; host functions kept in a global act for the run that calls them; an error in code an earlier run compiled is at that run's file, line and column, within 8 MiB of source maps kept for each engine, the oldest going first; not on the web); #engine is refused as unsupported, any other #name as unknown, and another //@name is a comment. A script run from a file reads its includes and $.evalFile's files only in that file's folder and the folders below it, refusing a path with a .. name or a link that leads out (a JSINCLUDE folder outside is passed over); a script given as text reads none. File(path) and Folder(path) name files and folders and read them in the same folders, and inside the automation read roots when they are set: exists, length, created, modified, getFiles(mask or function; at most 2048 entries), resolve, and open('r') with read, readln, readch, seek, tell and close; a path outside, a network or device path and a link that leads out give false or null with error \"Script file access denied\", a file is read whole at most 8 MiB (checked from its size first) and a run reads at most 32 MiB, and writing, the file dialogs and execute return false or null with error set. An error in another file is at that file's line and column; vc.api.version and vc.api.has(feature) describe the API. The run is one transaction: on success each changed document gets one undo step, Run Script, and each preference it changed keeps its value, and on an uncaught error, a limit or script.stop every open document and the session go back as they were; files a command wrote before that stay written and are named. While it runs, other commands that change anything end with a busy error. Commands that change anything outside the documents (script.*, except script.check, plugin.*, prefs.*, actionSet.*, shortcutSet.*, help.*, print.*, library.*, importMatches.*, edit.undoGroup.*, file.recovery.*, stroke.widthProfile.*, saving, importing or deleting a preset, text.addFontFiles, text.findFontFiles, text.rescanFonts, links.findFiles, file.place.queue, color.loadProfile, color.removeProfile, file.print, view.proofSetup, view.proofColors, view.overprintPreview, view.separationsPreview) throw. VectorCraft's own limits: 8 MiB of source a file and 32 MiB in all, 64 included or evaluated files 16 deep, 1024 paths looked up for them, 256 MiB of heap (128 MiB on the web), 2000000 host calls, 200000000 instructions without a pause, 64 levels, 16 MiB and 1048576 entries for a value crossing to or from a command, 10000 log entries and 4 MiB of log, 16 KiB of a dialog's message, 256 bytes of its title and 16 KiB of a prompt's preset (past these a RangeError), and 100000 objects and 512 MiB of new document data, past which a person is asked to continue or stop and a run with nobody to ask ends → {ok: true, name, changed: [document names], written: [paths], log: [values], fuelUsed, hostCalls, runTimeMs (the time the script ran, its commands included, waits between frames and for a person's answer left out; 0 without a clock), timingAvailable (false on the web), hostCallCounts: {vcRun, documentReads, commandListReads, vcLog, consoleWrites, fileCalls, fileExecutes}, slowestCommands: [{id, calls, totalMs, maxMs}] (at most 5, the longest in all first, then by id), console: {lines: [text], droppedLines, truncatedLines} (the run's own output, vc.log lines and error), dialogs: [{kind, message, title, errorIcon (alert), noAsDefault (confirm), preset (prompt), answer: \"ok\" | true | false | text | null | \"stop\" | \"missing\"}] (in the order they opened, at most 10000 and 1 MiB), dialogCount, dialogsTruncated}, or, in the app, {running: true, name} and the report later in ui.inspect's scriptReport; a run that fails ends with its error, and its report there also has kind, message, line, column, file (when the error is in another file than the script, or in code an earlier run of its engine compiled), fuelLeft, fuelUsed, hostCalls and the fields above", always, run ), @@ -154,8 +154,17 @@ fn run(s: &mut Session, p: &Value) -> Result { return Err(param("`answers` is only for a run with nobody to ask: in the app, the person answers each dialog")); } let fuel = max_fuel(p)?; + let installed = switch_or(p, "installed", false, "script.run")?; + let mut checked_real = None; let text = match (path, source) { (Some(""), None) => return Err(param("`path` is empty")), + // The text runs as this one read checked it, and its folder is the one that read found. + (Some(path), None) if installed => { + let (text, real) = s.read_installed(path)?; + checked_real = Some(real); + text + } + (None, Some(_)) if installed => return Err(param("`installed` takes a `path`, not a `source`")), (Some(path), None) => { if path.to_ascii_lowercase().ends_with(".jsxbin") { return Err(EngineError::Other(COMPILED.to_string())); @@ -180,8 +189,17 @@ fn run(s: &mut Session, p: &Value) -> Result { }; let name = given_name.map(str::to_string).or_else(|| path.map(super::fileio::file_name)).unwrap_or_else(|| "script".to_string()); let locale = script::dollar::ui_locale(s.ui_language.as_deref().unwrap_or(&s.prefs.interface_language)); - let request = - script::Request { name, name_given: given_name.is_some(), file: path.map(str::to_string), source: text, args, fuel, locale, answers }; + let request = script::Request { + name, + name_given: given_name.is_some(), + file: path.map(str::to_string), + checked_real, + source: text, + args, + fuel, + locale, + answers, + }; let name = s.script_start(request)?; if s.script_frames { return Ok(json!({"running": true, "name": name})); @@ -273,15 +291,24 @@ impl Session { /// The text of the script at `path`, which a listing of the Scripts folder gave /// ([`script::folder::read`]), checked as `script.run` checks a file's. pub fn installed_script(&self, path: &str) -> Result { - let root = self.scripts_dir().ok_or_else(|| EngineError::Other(NOT_LISTED.to_string()))?; + self.read_installed(path).map(|(text, _)| text) + } + + /// [`Session::installed_script`] with the script's real path: the Scripts folder's, looked up + /// before the read, followed by the names under it that the read found to be no links. + fn read_installed(&self, path: &str) -> Result<(String, std::path::PathBuf)> { + let not_listed = || EngineError::Other(NOT_LISTED.to_string()); + let root = self.scripts_dir().ok_or_else(not_listed)?; crate::file_access::check_read(path).map_err(EngineError::Other)?; + let path = std::path::Path::new(path); + let mut real = std::fs::canonicalize(root).map_err(|_| not_listed())?; + real.extend(path.strip_prefix(root).map_err(|_| not_listed())?.components()); // One byte more than a script may have shows that the file is too large. - let bytes = script::folder::read(root, std::path::Path::new(path), script::MAX_SOURCE_BYTES + 1) - .ok_or_else(|| EngineError::Other(NOT_LISTED.to_string()))?; + let bytes = script::folder::read(root, path, script::MAX_SOURCE_BYTES + 1).ok_or_else(not_listed)?; if bytes.len() > script::MAX_SOURCE_BYTES { return Err(EngineError::Other(TOO_LARGE.to_string())); } - script_text(&bytes) + Ok((script_text(&bytes)?, real)) } } diff --git a/crates/engine/src/cmd/textedit.rs b/crates/engine/src/cmd/textedit.rs index 10f1d57342..769ca7e6c5 100644 --- a/crates/engine/src/cmd/textedit.rs +++ b/crates/engine/src/cmd/textedit.rs @@ -40,7 +40,7 @@ pub fn specs() -> Vec { "Character", [], None, - "{id? (default: the one selected type object), start?: byte, end?: byte (default: all text), story?: bool = false (all the text of every frame of the threaded story `id` is a frame of, in one undo step; no `start` or `end`), font?, style?, size?: pt 0.1..1296 (outside it: an error), leading?: pt|\"auto\", tracking?, kerning?: 1/1000 em|\"auto\"|\"optical\" (the space after each character), kerningBefore?: 1/1000 em -1000..10000 (the space before a character that starts a line by itself: the first character of the text, of a paragraph or after a forced line break; no effect elsewhere; 0 by default), baselineShift?: pt, hScale?: %, vScale?: %, rotation?: deg counter-clockwise (0: upright), fill?: colour|\"none\", stroke?: colour|\"none\" (#rrggbbaa and #rgba are refused in both), keepModel?: bool (fill and stroke, as paint.setFill), strokeWidth?: pt, strokeOptions?: {weight?, cap?, join?, miterLimit?, align?, dash?, dashOffset?, alignDashes?} (as stroke.set: the character stroke), underline?, strikethrough?, allCaps?: bool, smallCaps?: bool, position?: \"normal\"|\"superscript\"|\"subscript\" (sizes from Document Setup), features?: [\"dlig\", \"-liga\", \"salt=2\", …] (swsh, ss01–ss20, salt, titl, pnum, lnum, sups, subs, numr, dnom and aalt take the font's alternate number as `tag=N`, N 0–65535: \"salt=2\" draws the second alternate of each glyph that has one, \"salt\" is \"salt=1\", \"-salt\" and \"salt=0\" are off; \"hkna\" is H or V Style, kana in the font's forms for the writing direction, shaped as `vkna` in vertical type, and \"ital\" is Roman Italics, half-width alphanumerics in the font's italic forms, both on or off only; the range is shaped with its word, so a letter's alternate keeps it joined to its neighbours), featureChanges?: [\"tnum\", \"-onum\", \"salt=2\", …] (OpenType features turned on, or with - off, on top of each character's own, a value replacing the feature's last one; its other features are kept), charAlign?: \"romanBaseline\"|\"emBoxTop\"|\"emBoxCenter\"|\"emBoxBottom\"|\"icfTop\"|\"icfBottom\", proportionalMetrics?: bool (full-width glyphs on the font's proportional widths: `palt` in horizontal type, `vpal` in vertical type), tateChuYoko?: bool (Character panel menu › Tate-chu-yoko, with Show East Asian Options: in vertical type those characters stand side by side across one em of the column; kept on the characters and ignored in horizontal type), tcyUpDown?, tcyLeftRight?: pt -1296..1296 (Character panel menu › Tate-chu-yoko Settings: in vertical type a block of tate-chu-yoko characters moves up by a positive Up/Down and down by a negative one, right by a positive Left/Right and left by a negative one, as one piece with the values of its first character; on a vertical path a positive Up/Down moves it toward the path's start and a positive Left/Right to the path's left; 0 by default; automatic digit blocks and horizontal type do not move; neither turns tateChuYoko on), warichu?: bool (Character panel menu › Warichu, with Show East Asian Options: the characters are set at warichuScale percent of their size and stacked on warichuLines lines inside their line, one above the other in horizontal type and side by side in vertical type, the first line above or to the right; a stretch of them is one block with the settings of its first character that stays whole on one line and may run past its end; type on a path sets them at their own size), warichuLines?: 1..6 (default 2), warichuGap?: pt 0..100 (the space between the lines, default 0), warichuScale?: % 10..100 (default 50), warichuAlign?: \"auto\"|\"left\"|\"center\"|\"right\"|\"justifyLeft\"|\"justifyCenter\"|\"justifyRight\"|\"justifyAll\" (Warichu Settings: how the lines line up along the block, which is as long as its longest line; the justify values spread every line but the last, which is set left, centered or right, or spread with justifyAll; auto, the default, sets them as justifyLeft; left and right are top and bottom in vertical type; none of them turns warichu on), akiLeft?, akiRight?: 1/1000 em 0..1000 (Character panel › Insert Aki Left and Right, with Show East Asian Options: space before and after each character in reading order, on its left and right in left-to-right type, on its right and left in right-to-left type, above and below it in vertical type; a ligature or a letter with its marks takes the values of its first character; line wrapping counts it, alignment keeps it at a line's ends and justification does not stretch or narrow it; 0 by default; a value that is not a number or is outside the range is refused, naming the key), tsume?: % 0..100 (Character panel › Tsume, with Show East Asian Options: the share of the blank space on each side of each character's ink, inside its own width, that is taken away in horizontal type, the glyph keeping its shape; 100 sets the ink edge to edge; a character without ink, such as a space, keeps its width; a ligature or a letter with its marks takes the value of its first character; tracking, kerning and Insert Aki are added after it, and justification keeps it; vertical type keeps the characters' spacing; 0 by default; a value that is not a number or is outside the range is refused), standardVerticalRoman?: bool (Character panel menu › Standard Vertical Roman Alignment, with Show East Asian Options: in vertical type those half-width characters, such as Latin letters and digits, stand upright one below the other instead of being rotated; spaces and tate-chu-yoko blocks are not changed; kept on the characters and ignored in horizontal type; off by default), noBreak?: bool (Character panel menu › No Break: these characters stay on one line, including across a space inside the run), kashidas?: bool (Character panel menu › Kashidas: selected characters allow insertion only when both joined neighbors have it on; on by default), charDirection?: \"default\"|\"leftToRight\"|\"rightToLeft\" (Character panel menu › Character Direction: the characters are set left to right or right to left inside their paragraph, as a Unicode bidi embedding; default: as the bidi algorithm sets them), digits?: \"default\"|\"arabic\"|\"hindi\"|\"farsi\"|\"devanagari\"|\"bengali\"|\"gurmukhi\"|\"gujarati\"|\"odia\"|\"tamil\"|\"telugu\"|\"kannada\"|\"malayalam\" (Character panel menu › Digits: the numeral system every digit is drawn in, whatever was typed: Western, Arabic-Indic (hindi), Eastern Arabic-Indic (farsi) or an Indic script's own digits), glyphForm?: \"traditional\"|\"expert\"|\"jis04\"|\"jis90\"|\"jis78\"|\"jis83\"|\"fullWidth\"|\"halfWidth\"|\"thirdWidth\"|\"quarterWidth\"|\"default\" (Glyphs panel menu, with Show East Asian Options: the characters are drawn in that form of the font, the OpenType feature trad, expt, jp04, jp90, jp78, jp83, fwid, hwid, twid or qwid, one form at a time; a character the font has no such glyph for keeps its own; \"default\", Revert To Default Forms, draws the font's default glyphs; another value is refused), fontHeightReference?: \"emBox\"|\"capHeight\"|\"xHeight\"|\"icfBox\" (Character panel › Set Font Height Reference: the height of the font that size measures, the em box by default; with a font that has no such height, or one outside 0.1 to 2 em, the em box), language?: name|tag (Character panel › Language: one of Document Setup's languages by name or BCP 47 tag, case-insensitive, such as \"Turkish\" or \"tr\"; the characters are shaped in it, so the font uses that language's forms (OpenType `locl`); saved as the tag, English: USA, the default, as none; new type takes the document's language), fontAxes?: {\"wght\": 650, …} (Character panel › Variable Font: the setting of each variation axis named, in the axis's units and clamped to its range; null: back to the style's own setting; other axes keep theirs; tags the font has no axis for are left out; a font or style change clears them; text.fontList {family, style} lists the axes), glyphAlternate?: null (takes away the glyph choice text.setGlyphAlternate made on the characters, in the same undo step as the other attributes; any other value is refused)} style a character range (size/leading/baselineShift are document points and hScale includes the object transform; runs are split at the range ends) → {id, runs, warning?: text (the font or style set isn't available: its type is drawn in the fallback font or the family's closest style)}", + "{id? (default: the one selected type object), start?: byte, end?: byte (default: all text), story?: bool = false (all the text of every frame of the threaded story `id` is a frame of, in one undo step; no `start` or `end`), font?, style?, size?: pt 0.1..1296 (outside it: an error), leading?: pt|\"auto\", tracking?, kerning?: 1/1000 em|\"auto\"|\"optical\" (the space after each character), kerningBefore?: 1/1000 em -1000..10000 (the space before a character that starts a line by itself: the first character of the text, of a paragraph or after a forced line break; no effect elsewhere; 0 by default), baselineShift?: pt, hScale?: %, vScale?: %, rotation?: deg counter-clockwise (0: upright), fill?: colour|\"none\", stroke?: colour|\"none\" (#rrggbbaa and #rgba are refused in both), keepModel?: bool (fill and stroke, as paint.setFill), strokeWidth?: pt, strokeOptions?: {weight?, cap?, join?, miterLimit?, align?, dash?, dashOffset?, alignDashes?} (as stroke.set: the character stroke), underline?, strikethrough?, allCaps?: bool, smallCaps?: bool, position?: \"normal\"|\"superscript\"|\"subscript\" (sizes from Document Setup), features?: [\"dlig\", \"-liga\", \"salt=2\", …] (swsh, ss01–ss20, salt, titl, pnum, lnum, sups, subs, numr, dnom and aalt take the font's alternate number as `tag=N`, N 0–65535: \"salt=2\" draws the second alternate of each glyph that has one, \"salt\" is \"salt=1\", \"-salt\" and \"salt=0\" are off; \"hkna\" is H or V Style, kana in the font's forms for the writing direction, shaped as `vkna` in vertical type, and \"ital\" is Roman Italics, half-width alphanumerics in the font's italic forms, both on or off only; the range is shaped with its word, so a letter's alternate keeps it joined to its neighbours), featureChanges?: [\"tnum\", \"-onum\", \"salt=2\", …] (OpenType features turned on, or with - off, on top of each character's own, a value replacing the feature's last one; its other features are kept), charAlign?: \"romanBaseline\"|\"emBoxTop\"|\"emBoxCenter\"|\"emBoxBottom\"|\"icfTop\"|\"icfBottom\", proportionalMetrics?: bool (full-width glyphs on the font's proportional widths: `palt` in horizontal type, `vpal` in vertical type), tateChuYoko?: bool (Character panel menu › Tate-chu-yoko, with Show East Asian Options: in vertical type those characters stand side by side across one em of the column; kept on the characters and ignored in horizontal type), tcyUpDown?, tcyLeftRight?: pt -1296..1296 (Character panel menu › Tate-chu-yoko Settings: in vertical type a block of tate-chu-yoko characters moves up by a positive Up/Down and down by a negative one, right by a positive Left/Right and left by a negative one, as one piece with the values of its first character; on a vertical path a positive Up/Down moves it toward the path's start and a positive Left/Right to the path's left; 0 by default; automatic digit blocks and horizontal type do not move; neither turns tateChuYoko on), warichu?: bool (Character panel menu › Warichu, with Show East Asian Options: the characters are set at warichuScale percent of their size and stacked on warichuLines lines inside their line, one above the other in horizontal type and side by side in vertical type, the first line above or to the right; a stretch of them is one block with the settings of its first character that stays whole on one line and may run past its end; type on a path sets them at their own size), warichuLines?: 1..6 (default 2), warichuGap?: pt 0..100 (the space between the lines, default 0), warichuScale?: % 10..100 (default 50), warichuAlign?: \"auto\"|\"left\"|\"center\"|\"right\"|\"justifyLeft\"|\"justifyCenter\"|\"justifyRight\"|\"justifyAll\" (Warichu Settings: how the lines line up along the block, which is as long as its longest line; the justify values spread every line but the last, which is set left, centered or right, or spread with justifyAll; auto, the default, sets them as justifyLeft; left and right are top and bottom in vertical type; none of them turns warichu on), akiLeft?, akiRight?: 1/1000 em 0..1000 (Character panel › Insert Aki Left and Right, with Show East Asian Options: space before and after each character in reading order, on its left and right in left-to-right type, on its right and left in right-to-left type, above and below it in vertical type; a ligature or a letter with its marks takes the values of its first character; line wrapping counts it, alignment keeps it at a line's ends and justification does not stretch or narrow it; 0 by default; a value that is not a number or is outside the range is refused, naming the key), tsume?: % 0..100 (Character panel › Tsume, with Show East Asian Options: the share of the blank space on each side of each character's ink, inside its own width, that is taken away in horizontal type, the glyph keeping its shape; 100 sets the ink edge to edge; a character without ink, such as a space, keeps its width; a ligature or a letter with its marks takes the value of its first character; tracking, kerning and Insert Aki are added after it, and justification keeps it; vertical type keeps the characters' spacing; 0 by default; a value that is not a number or is outside the range is refused), standardVerticalRoman?: bool (Character panel menu › Standard Vertical Roman Alignment, with Show East Asian Options: in vertical type those half-width characters, such as Latin letters and digits, stand upright one below the other instead of being rotated; spaces and tate-chu-yoko blocks are not changed; kept on the characters and ignored in horizontal type; off by default), noBreak?: bool (Character panel menu › No Break: these characters stay on one line, including across a space inside the run), kashidas?: bool (Character panel menu › Kashidas: selected characters allow insertion only when both joined neighbors have it on; on by default), charDirection?: \"default\"|\"leftToRight\"|\"rightToLeft\" (Character panel menu › Character Direction: the characters are set left to right or right to left inside their paragraph, as a Unicode bidi embedding; default: as the bidi algorithm sets them), digits?: \"default\"|\"arabic\"|\"hindi\"|\"farsi\"|\"devanagari\"|\"bengali\"|\"gurmukhi\"|\"gujarati\"|\"odia\"|\"tamil\"|\"telugu\"|\"kannada\"|\"malayalam\" (Character panel menu › Digits: the numeral system every digit is drawn in, whatever was typed: Western, Arabic-Indic (hindi), Eastern Arabic-Indic (farsi) or an Indic script's own digits), glyphForm?: \"traditional\"|\"expert\"|\"jis04\"|\"jis90\"|\"jis78\"|\"jis83\"|\"fullWidth\"|\"halfWidth\"|\"thirdWidth\"|\"quarterWidth\"|\"default\" (Glyphs panel menu, with Show East Asian Options: the characters are drawn in that form of the font, the OpenType feature trad, expt, jp04, jp90, jp78, jp83, fwid, hwid, twid or qwid, one form at a time; a character the font has no such glyph for keeps its own; \"default\", Revert To Default Forms, draws the font's default glyphs; a form other than \"default\" takes away the characters' glyph choice (text.setGlyphAlternate); with fullWidth, halfWidth, thirdWidth or quarterWidth, proportionalMetrics is kept but `palt` is not applied, and the font's pair kerning is turned off; another value is refused), fontHeightReference?: \"emBox\"|\"capHeight\"|\"xHeight\"|\"icfBox\" (Character panel › Set Font Height Reference: the height of the font that size measures, the em box by default; with a font that has no such height, or one outside 0.1 to 2 em, the em box), language?: name|tag (Character panel › Language: one of Document Setup's languages by name or BCP 47 tag, case-insensitive, such as \"Turkish\" or \"tr\"; the characters are shaped in it, so the font uses that language's forms (OpenType `locl`); saved as the tag, English: USA, the default, as none; new type takes the document's language), fontAxes?: {\"wght\": 650, …} (Character panel › Variable Font: the setting of each variation axis named, in the axis's units and clamped to its range; null: back to the style's own setting; other axes keep theirs; tags the font has no axis for are left out; a font or style change clears them; text.fontList {family, style} lists the axes), glyphAlternate?: null (takes away the glyph choice text.setGlyphAlternate made on the characters, in the same undo step as the other attributes; any other value is refused)} style a character range (size/leading/baselineShift are document points and hScale includes the object transform; runs are split at the range ends) → {id, runs, warning?: text (the font or style set isn't available: its type is drawn in the fallback font or the family's closest style)}", has_doc, set_range_style ), @@ -1088,7 +1088,7 @@ impl CharChange { st.digits = v; } if let Some(v) = self.glyph_form { - st.glyph_form = v; + st.set_glyph_form(v); } if let Some(v) = self.font_height_reference { st.font_height_reference = v; diff --git a/crates/engine/src/cmd/typecmd/area_tests.rs b/crates/engine/src/cmd/typecmd/area_tests.rs index e0e5d8a926..a63ebff9c4 100644 --- a/crates/engine/src/cmd/typecmd/area_tests.rs +++ b/crates/engine/src/cmd/typecmd/area_tests.rs @@ -2008,6 +2008,38 @@ fn glyph_form_is_one_character_attribute_saved_only_when_set() { assert!(runs(&s).iter().all(|r| r.style.glyph_form == GlyphForm::Default)); } +/// A glyph choice (`text.setGlyphAlternate`) would keep drawing its own glyph over a glyph form, +/// so setting a form other than "default" takes it away, in `text.setRangeStyle` and in +/// `text.setFormat`; "default" (Revert To Default Forms) and calls without `glyphForm` keep it. +#[test] +fn a_glyph_form_takes_away_the_characters_glyph_choice() { + use vectorcraft_doc::{GlyphChoice, GlyphForm}; + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 400, "height": 400})).unwrap(); + let id = s.execute("text.create", &json!({"x": 10, "y": 50, "text": "亜唖"})).unwrap()["id"].as_u64().unwrap(); + let choice = GlyphChoice { ch: '唖', gid: 42, font: "Test-Regular".into(), version: "1".into() }; + if let Some(NodeKind::Text(t)) = std::sync::Arc::make_mut(&mut s.doc_mut().unwrap().doc).node_mut(NodeId(id)).map(|n| &mut n.kind) { + vectorcraft_text::edit::style_range(&mut t.runs, 3, 6, |st| st.glyph_alternate = Some(choice.clone())); + } + let state = |s: &Session| match &s.doc().unwrap().doc.node(NodeId(id)).unwrap().kind { + NodeKind::Text(t) => t.runs.iter().map(|r| (r.text.clone(), r.style.glyph_form, r.style.glyph_alternate.is_some())).collect::>(), + _ => panic!("text"), + }; + s.execute("text.setRangeStyle", &json!({"id": id, "size": 30})).unwrap(); + assert_eq!(state(&s), [("亜".into(), GlyphForm::Default, false), ("唖".into(), GlyphForm::Default, true)], "no glyphForm: kept"); + s.execute("text.setRangeStyle", &json!({"id": id, "glyphForm": "default"})).unwrap(); + assert_eq!(state(&s)[1], ("唖".into(), GlyphForm::Default, true), "default: kept"); + s.execute("text.setRangeStyle", &json!({"id": id, "start": 3, "end": 6, "glyphForm": "jis78"})).unwrap(); + assert_eq!(state(&s), [("亜".into(), GlyphForm::Default, false), ("唖".into(), GlyphForm::Jis78, false)]); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(state(&s)[1], ("唖".into(), GlyphForm::Default, true), "one undo step brings both back"); + s.execute("select.set", &json!({"ids": [id]})).unwrap(); + s.execute("text.setFormat", &json!({"glyphForm": "default"})).unwrap(); + assert!(state(&s).iter().any(|r| r.2), "default: kept"); + s.execute("text.setFormat", &json!({"glyphForm": "traditional"})).unwrap(); + assert!(state(&s).iter().all(|r| r.1 == GlyphForm::Traditional && !r.2), "{:?}", state(&s)); +} + /// No Break is a character attribute, saved only when on. #[test] fn no_break_is_a_character_attribute_saved_only_when_on() { diff --git a/crates/engine/src/file_access.rs b/crates/engine/src/file_access.rs index 9f676e737c..3c203b64ee 100644 --- a/crates/engine/src/file_access.rs +++ b/crates/engine/src/file_access.rs @@ -399,6 +399,12 @@ impl ScriptScope { Self { roots: std::fs::canonicalize(dir).ok().into_iter().collect() } } + /// The scope of a script run from a file in the folder whose real path, with its links already + /// followed, is `real`: that folder, which is not looked up again. + pub(crate) fn of_real_folder(real: PathBuf) -> Self { + Self { roots: vec![real] } + } + /// Is `path` refused by its name alone: a `..` component, a NUL, a Windows device name or /// stream, or a computer on the network? fn refused_by_name(path: &Path) -> bool { diff --git a/crates/engine/src/script/include.rs b/crates/engine/src/script/include.rs index f1928228df..4223d829ba 100644 --- a/crates/engine/src/script/include.rs +++ b/crates/engine/src/script/include.rs @@ -295,12 +295,14 @@ fn display(path: &Path) -> Arc { impl Files { /// The files of a run of the script at `path` (`None` for a script given as text), with the - /// `JSINCLUDE` folders `jsinclude`. - pub(crate) fn new(path: Option<&str>, jsinclude: Vec) -> Self { - let real = path.and_then(|p| std::fs::canonicalize(p).ok()); + /// `JSINCLUDE` folders `jsinclude`. `checked` is the script's real path when the read of its text + /// found it, which the run's folder then comes from; without it, `path` is looked up. + pub(crate) fn new(path: Option<&str>, checked: Option, jsinclude: Vec) -> Self { + let scope_of_checked = checked.as_deref().and_then(Path::parent).map(|d| ScriptScope::of_real_folder(d.to_path_buf())); + let real = checked.or_else(|| path.and_then(|p| std::fs::canonicalize(p).ok())); let dir = real.as_deref().and_then(folder_of); Self { - scope: dir.as_deref().map(ScriptScope::of_folder).unwrap_or_default(), + scope: scope_of_checked.or_else(|| dir.as_deref().map(ScriptScope::of_folder)).unwrap_or_default(), jsinclude, include_path: Vec::new(), files: 0, diff --git a/crates/engine/src/script/mod.rs b/crates/engine/src/script/mod.rs index 1b95b5fe2a..b52fa6e66a 100644 --- a/crates/engine/src/script/mod.rs +++ b/crates/engine/src/script/mod.rs @@ -163,6 +163,9 @@ pub(crate) struct Request { pub name_given: bool, /// The file the script was read from, which its included files are read beside. pub file: Option, + /// The real path of `file` as the read of `source` found it, which the included files are then + /// read beside instead of looking `file` up again. + pub checked_real: Option, pub source: String, pub args: Map, pub fuel: u64, @@ -1814,7 +1817,7 @@ impl Session { self.script_high_water.retain(|uid, _| open.contains(uid)); self.script_stop.clear(); self.script_report = None; - let mut files = include::Files::new(request.file.as_deref(), include::Files::jsinclude_env()); + let mut files = include::Files::new(request.file.as_deref(), request.checked_real, include::Files::jsinclude_env()); let expanded = files.expand_script(&request.source); let name = match &expanded { Ok(include::Expanded { name: Some(name), .. }) if !request.name_given => name.clone(), diff --git a/crates/engine/src/tests/draw2.rs b/crates/engine/src/tests/draw2.rs index e5e6174af0..a0129c1cd1 100644 --- a/crates/engine/src/tests/draw2.rs +++ b/crates/engine/src/tests/draw2.rs @@ -612,6 +612,100 @@ fn eraser_size_follows_the_pen_pressure() { assert!(err.to_string().contains("sizeMode"), "{err}"); } +/// Eraser Tool Options' Angle, Roundness and Size variation menus: a value on Rotation, Bearing, +/// Tilt or Pressure takes what the pen read along the drag, Random stays within its Variation, a +/// drag without the reading erases at the value itself, an open path is cut where the varied tip +/// covers it, and an unknown mode or a variation out of its range is refused with nothing erased. +#[test] +fn the_eraser_tip_varies_with_its_modes() { + // Which of `probes` a level drag through the middle of a 400 by 200 rectangle erases, each of + // its points `[x, 100]` followed by `pen` ([pressure, tiltX, tiltY, rotation]). + let erased = |tip: serde_json::Value, pen: serde_json::Value, probes: &[(f64, f64)]| -> Vec { + let mut s = session(); + rect(&mut s, 0.0, 0.0, 400.0, 200.0); + let points: Vec<_> = (0..=20) + .map(|i| { + let mut p = vec![json!(100.0 + f64::from(i) * 10.0), json!(100.0)]; + p.extend(pen.as_array().cloned().unwrap_or_default()); + serde_json::Value::Array(p) + }) + .collect(); + s.execute("path.eraseRegion", &with(&json!({"points": points, "size": 20}), tip.clone())).unwrap(); + let filled = |x: f64, y: f64| { + paths(&s).iter().any(|(_, pd)| vectorcraft_geom::hit::fill_contains(&pd.to_bezpath(), FillRule::NonZero, Point::new(x, y))) + }; + assert!(filled(200.0, 150.0), "{tip} {pen}: the rest of the rectangle stays"); + probes.iter().map(|&(x, y)| !filled(x, y)).collect() + }; + let none = json!(null); + // A 20 by 2 pt tip lying level erases a band 2 pt tall; standing upright it erases one 20 pt tall. + let level = json!({"roundness": 10}); + let probes = [(200.0, 100.5), (200.0, 108.0)]; + assert_eq!(erased(level.clone(), json!([]), &probes), [true, false], "Fixed"); + let on_rotation = with(&level, json!({"angleMode": "rotation", "angleVariation": 90})); + // The barrel turned half way round reads the most, which turns the tip upright. + assert_eq!(erased(on_rotation.clone(), json!([none, none, none, 180]), &probes), [true, true], "Rotation"); + assert_eq!(erased(on_rotation.clone(), json!([]), &probes), [true, false], "Rotation without a reading"); + // Leaning to the left (a bearing of 180°) reads the most too. + let on_bearing = with(&level, json!({"angleMode": "bearing", "angleVariation": 90})); + assert_eq!(erased(on_bearing.clone(), json!([none, -45, 0, none]), &probes), [true, true], "Bearing"); + assert_eq!(erased(on_bearing, json!([none, 45, 0, none]), &probes), [true, false], "Bearing at 0°"); + // A round tip: upright the pen gives 28 pt, nearly flat about 12 pt. + let on_tilt = json!({"sizeMode": "tilt", "sizeVariation": 8}); + let wide = [(200.0, 105.0), (200.0, 113.0)]; + assert_eq!(erased(on_tilt.clone(), json!([none, 0, 0, none]), &wide), [true, true], "Tilt upright"); + assert_eq!(erased(on_tilt, json!([none, 89, 0, none]), &wide), [true, false], "Tilt flat"); + // Roundness from 10 % less 90 (kept to 1 %) at the lightest pressure to 100 % at the heaviest. + let on_pressure = with(&level, json!({"roundnessMode": "pressure", "roundnessVariation": 90})); + assert_eq!(erased(on_pressure.clone(), json!([1.0]), &probes), [true, true], "Pressure at the heaviest"); + // At the lightest the tip is 0.2 pt across, narrower than the 2 pt band Fixed erases. + assert_eq!(erased(on_pressure, json!([0.0]), &[(200.0, 100.0), (200.0, 100.5)]), [true, false], "Pressure at the lightest"); + // Random: one size for the drag, from 16 to 24 pt. + let random = json!({"sizeMode": "random", "sizeVariation": 4}); + assert_eq!(erased(random, json!([]), &[(200.0, 107.5), (200.0, 112.5)]), [true, false], "Random"); + // A press on a line with the tip turned upright by its rotation cuts 5 pt of it, not 20. + let left_of_line = |pen: serde_json::Value| { + let mut s = session(); + line(&mut s, 300.0, 150.0, 500.0, 150.0); + let mut point = vec![json!(400), json!(150)]; + point.extend(pen.as_array().cloned().unwrap_or_default()); + let tip = json!({"points": [point], "size": 20, "roundness": 25, "angleMode": "rotation", "angleVariation": 90}); + s.execute("path.eraseRegion", &tip).unwrap(); + paths(&s).iter().map(|p| p.1.length()).sum::() + }; + let upright = left_of_line(json!([none, none, none, 180])); + assert!((upright - 195.0).abs() < 0.3, "the upright tip left {upright} pt of the line"); + let level_tip = left_of_line(json!([])); + assert!((level_tip - 180.0).abs() < 0.3, "the level tip left {level_tip} pt of the line"); + // A 1500 pt tip on Tilt erases 1500 pt across with a mouse, as it does on Fixed. + let mut s = session(); + rect(&mut s, 0.0, 0.0, 4000.0, 4000.0); + let big = json!({"points": [[1000, 2000], [3000, 2000]], "size": 1500, "sizeMode": "tilt", "sizeVariation": 10}); + s.execute("path.eraseRegion", &big).unwrap(); + let filled = + |x: f64, y: f64| paths(&s).iter().any(|(_, pd)| vectorcraft_geom::hit::fill_contains(&pd.to_bezpath(), FillRule::NonZero, Point::new(x, y))); + assert_eq!((filled(2000.0, 2740.0), filled(2000.0, 2760.0)), (false, true), "a 1500 pt tip"); + // A Size Variation kept while Size is Fixed leaves the size alone. + let kept = with(&on_rotation, json!({"sizeMode": "fixed", "sizeVariation": 1000000})); + assert_eq!(erased(kept, json!([]), &probes), [true, false], "Size on Fixed with a Variation"); + let mut s = session(); + let id = rect(&mut s, 0.0, 0.0, 100.0, 100.0); + s.execute("select.set", &json!({"ids": [id.0]})).unwrap(); + let stroke = json!({"points": [[-10, 50], [110, 50]]}); + let modes = "\"fixed\", \"random\", \"pressure\", \"tilt\", \"bearing\" or \"rotation\""; + for (extra, want) in [ + (json!({"angleMode": "wheel"}), format!("angleMode is one of {modes}, not `wheel`")), + (json!({"roundnessMode": 3}), format!("roundnessMode is one of {modes}, not 3")), + (json!({"sizeMode": "stylusWheel"}), format!("sizeMode is one of {modes}, not `stylusWheel`")), + (json!({"angleVariation": 181}), "angleVariation must be at most 180".to_string()), + (json!({"roundnessVariation": 101}), "roundnessVariation must be at most 100".to_string()), + (json!({"roundnessVariation": -1}), "roundnessVariation must be 0 or more".to_string()), + ] { + refuses_with(&mut s, "path.eraseRegion", &with(&stroke, extra), &want); + } + refuses_a_number_given_as_text(&mut s, "path.eraseRegion", &stroke, "angleVariation"); +} + /// A Blob Brush stroke merges with the topmost matching path it touches and with the touched /// matching paths next to it in stacking order in the same group or layer; matching paths in other /// layers or groups, or below an object of another kind, are left alone. diff --git a/crates/engine/src/tests/pattern.rs b/crates/engine/src/tests/pattern.rs index 0f29befd80..fafcc80ac6 100644 --- a/crates/engine/src/tests/pattern.rs +++ b/crates/engine/src/tests/pattern.rs @@ -492,6 +492,111 @@ fn options_set_tile_type_spacing_and_overlap() { assert!(s.execute("pattern.options", &json!({"name": "Dots", "width": -1})).is_err()); } +/// Move Tile with Art: in pattern editing mode, a move of all of the tile art moves the tile by the +/// same offset, through `object.move`, `object.nudge` and `object.transform`. With the option off +/// (a new pattern's default), a move of part of the art, a rotation or a copy, the tile stays. +/// Undo puts the tile back, and Done keeps the moved tile. +#[test] +fn move_tile_with_art_moves_the_tile_when_all_of_the_art_moves() { + let mut s = session(); + let a = rect(&mut s, 0.0, 0.0, 10.0, 10.0); + let b = rect(&mut s, 12.0, 0.0, 6.0, 6.0); + sel(&mut s, &[a, b]); + s.execute("object.pattern.make", &json!({"name": "P"})).unwrap(); + let tile = |s: &Session| doc(s).pattern("P").unwrap().tile; + let start = tile(&s); + let art: Vec = s.doc().unwrap().selection.objects.to_vec(); + assert_eq!(art.len(), 2); + assert!(!doc(&s).pattern("P").unwrap().move_tile_with_art, "off for a new pattern"); + s.execute("object.move", &json!({"dx": 5, "dy": 0})).unwrap(); + assert_eq!(tile(&s), start, "with the option off the tile stays"); + s.execute("object.move", &json!({"dx": -5, "dy": 0})).unwrap(); + + s.execute("pattern.options", &json!({"moveTileWithArt": true})).unwrap(); + assert_eq!(s.execute("pattern.list", &json!({})).unwrap()["patterns"][0]["moveTileWithArt"], true); + s.execute("object.move", &json!({"dx": 3, "dy": 4})).unwrap(); + assert_eq!(tile(&s), start + vectorcraft_geom::Vec2::new(3.0, 4.0)); + s.execute("object.transform", &json!({"matrix": [1, 0, 0, 1, -1, 2]})).unwrap(); + assert_eq!(tile(&s), start + vectorcraft_geom::Vec2::new(2.0, 6.0)); + let k = s.prefs.keyboard_increment; + s.execute("object.nudge", &json!({"dx": 1, "dy": 0})).unwrap(); + assert_eq!(tile(&s), start + vectorcraft_geom::Vec2::new(2.0 + k, 6.0)); + s.execute("edit.undo", &json!({})).unwrap(); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(tile(&s), start + vectorcraft_geom::Vec2::new(3.0, 4.0), "undo puts the tile back"); + let moved = tile(&s); + + sel(&mut s, &art[..1]); + s.execute("object.move", &json!({"dx": 7, "dy": 0})).unwrap(); + assert_eq!(tile(&s), moved, "a move of part of the art leaves the tile"); + sel(&mut s, &art); + s.execute("object.rotate", &json!({"angle": 90})).unwrap(); + assert_eq!(tile(&s), moved, "a rotation leaves the tile"); + s.execute("object.move", &json!({"dx": 1, "dy": 1, "copy": true})).unwrap(); + assert_eq!(tile(&s), moved, "a copy leaves the tile"); + + s.execute("object.pattern.done", &json!({})).unwrap(); + assert_eq!(tile(&s), moved, "Done keeps the moved tile"); + assert!(doc(&s).pattern("P").unwrap().move_tile_with_art); +} + +/// Move Tile with Art counts a group in the tile art as moved when each of its members moved, one +/// by one selected. +#[test] +fn move_tile_with_art_counts_a_group_whose_members_all_moved() { + let mut s = session(); + let a = rect(&mut s, 0.0, 0.0, 10.0, 10.0); + let b = rect(&mut s, 12.0, 0.0, 6.0, 6.0); + sel(&mut s, &[a, b]); + s.execute("object.group", &json!({})).unwrap(); + s.execute("object.pattern.make", &json!({"name": "G"})).unwrap(); + s.execute("pattern.options", &json!({"moveTileWithArt": true})).unwrap(); + let start = doc(&s).pattern("G").unwrap().tile; + let layer = doc(&s).pattern_edit.as_ref().unwrap().layer; + let group = doc(&s).node(layer).unwrap().children().unwrap()[0].clone(); + let members: Vec = group.children().unwrap().iter().map(|c| c.id).collect(); + assert_eq!(members.len(), 2); + sel(&mut s, &members[..1]); + s.execute("object.move", &json!({"dx": 4, "dy": 0})).unwrap(); + assert_eq!(doc(&s).pattern("G").unwrap().tile, start, "one member of two"); + sel(&mut s, &members); + s.execute("object.move", &json!({"dx": 0, "dy": 5})).unwrap(); + assert_eq!(doc(&s).pattern("G").unwrap().tile, start + vectorcraft_geom::Vec2::new(0.0, 5.0), "every member"); +} + +/// Move Tile with Art moves the tile as far as the art moved: when the art is aligned to the pixel +/// grid and a move snaps it to the grid, the tile moves by the snapped distance. +#[test] +fn move_tile_with_art_follows_art_that_snaps_to_the_pixel_grid() { + let mut s = session(); + let a = rect(&mut s, 0.0, 0.0, 10.0, 10.0); + sel(&mut s, &[a]); + s.execute("object.pattern.make", &json!({"name": "P"})).unwrap(); + s.execute("pattern.options", &json!({"moveTileWithArt": true})).unwrap(); + let art = s.doc().unwrap().selection.objects[0]; + std::sync::Arc::make_mut(&mut s.doc_mut().unwrap().doc).node_mut(art).unwrap().extras_mut().pixel_aligned = true; + let start = doc(&s).pattern("P").unwrap().tile; + let origin = |s: &Session| doc(s).node(art).unwrap().geometric_bounds().unwrap().origin(); + let before = origin(&s); + s.execute("object.move", &json!({"dx": 0.3, "dy": 0})).unwrap(); + let moved = origin(&s) - before; + assert!((moved.x - 0.3).abs() > 1e-6, "the art snapped to the grid: {moved:?}"); + assert_eq!(doc(&s).pattern("P").unwrap().tile, start + moved, "the tile moved as far"); +} + +/// Pattern Options that change nothing record no undo step. +#[test] +fn options_that_change_nothing_record_no_undo_step() { + let mut s = session(); + make_dots(&mut s); + let steps = |s: &Session| s.doc().unwrap().history.undo.len(); + let before = steps(&s); + s.execute("pattern.options", &json!({"name": "Dots", "moveTileWithArt": false, "copies": 5})).unwrap(); + assert_eq!(steps(&s), before); + s.execute("pattern.options", &json!({"name": "Dots", "moveTileWithArt": true})).unwrap(); + assert_eq!(steps(&s), before + 1); +} + #[test] fn options_rename_updates_swatch_and_paints() { let mut s = session(); @@ -1625,6 +1730,7 @@ fn pattern_commands_refuse_a_param_of_the_wrong_type() { ("pattern.options", options(json!({"copies": "5"})), number("copies", json!("5"))), ("pattern.options", options(json!({"dimCopies": "50"})), number("dimCopies", json!("50"))), ("pattern.options", options(json!({"sizeTileToArt": "true"})), switch("sizeTileToArt")), + ("pattern.options", options(json!({"moveTileWithArt": "yes"})), switch("moveTileWithArt")), ("pattern.options", options(json!({"showTileEdge": 1})), switch("showTileEdge")), ("pattern.options", options(json!({"showSwatchBounds": "no"})), switch("showSwatchBounds")), ("pattern.options", options(json!({"overlap": "right"})), "`overlap` must be an object, not \"right\"".into()), diff --git a/crates/engine/src/tests/placed_document.rs b/crates/engine/src/tests/placed_document.rs index 65808d2360..662e301caa 100644 --- a/crates/engine/src/tests/placed_document.rs +++ b/crates/engine/src/tests/placed_document.rs @@ -666,6 +666,26 @@ fn linked_pages_share_one_copy_of_the_file_and_open_in_the_pages_color_mode() { assert_eq!(s.doc().unwrap().doc.color_mode, vectorcraft_doc::ColorMode::Rgb, "colorMode wins"); } +#[test] +fn linked_pages_read_the_file_once_and_a_blank_page_keeps_its_crop_box() { + use vectorcraft_testkit::pdf::{PdfPage, pdf}; + let dir = Folder::new("open-pdf-link-pages-blank"); + let src = dir.file("blank.pdf"); + let art = || PdfPage::new(100.0, 50.0, "1 0 0 rg 10 10 20 20 re f"); + write(&src, &pdf(&[art(), PdfPage::new(80.0, 40.0, ""), art()], None)); + let mut s = Session::new(); + s.execute("document.open", &json!({"path": src, "linkPages": true, "cropTo": "bounding"})).unwrap(); + let d = &s.doc().unwrap().doc; + let boards: Vec = d.artboards.iter().map(|a| a.rect).collect(); + assert_eq!(boards.len(), 3, "{boards:?}"); + assert_eq!((boards[0].width(), boards[0].height()), (20.0, 20.0), "the art's bounds"); + assert_eq!((boards[1].width(), boards[1].height()), (80.0, 40.0), "the blank page's crop box"); + // The pages are read from the one copy every page's blob keeps: a page that read the file + // again would count here. + let blob = d.images.values().next().map(|b| b.bytes.clone()).unwrap(); + assert_eq!(vectorcraft_pdf::reads_of(&blob), 0, "the pages are read from the file read once"); +} + #[test] fn link_pages_is_refused_without_a_path_for_other_files_and_with_a_password_or_outlines() { let dir = Folder::new("open-pdf-link-pages-refused"); diff --git a/crates/engine/src/tests/script_folder.rs b/crates/engine/src/tests/script_folder.rs index 312db3e0fc..8ea0482826 100644 --- a/crates/engine/src/tests/script_folder.rs +++ b/crates/engine/src/tests/script_folder.rs @@ -300,6 +300,71 @@ fn an_installed_script_is_read_as_script_run_reads_a_file() { let _ = std::fs::remove_dir_all(dir); } +#[test] +fn an_installed_run_runs_the_text_its_own_read_checked() { + let dir = folder("installed-run"); + let root = dir.join("Scripts"); + let script = touch(&root, "draw.jsx"); + std::fs::write(&script, "#include \"lib.jsxinc\"\nvc.log(x);").unwrap(); + std::fs::write(root.join("lib.jsxinc"), "var x = 1;").unwrap(); + // A folder in the Scripts folder that is a link: script.run by path alone follows it. + let elsewhere = dir.join("elsewhere"); + std::fs::create_dir_all(&elsewhere).unwrap(); + std::fs::write(elsewhere.join("other.jsx"), "vc.log(2);").unwrap(); + link_folder(&elsewhere, &root.join("linked")); + let linked = root.join("linked").join("other.jsx").to_string_lossy().into_owned(); + let mut s = Session::new(); + s.attach_scripts_dir(root.clone()); + let path = script.to_string_lossy().into_owned(); + let r = s.execute("script.run", &json!({"path": path, "installed": true})).unwrap(); + assert_eq!(r["log"], json!([1])); + assert_eq!(s.execute("script.run", &json!({"path": linked})).unwrap()["log"], json!([2])); + // With `installed`, the run's own read is the check: a script behind a link is not run. + let e = s.execute("script.run", &json!({"path": linked, "installed": true})).unwrap_err(); + assert_eq!(e.to_string(), cmd::scripts::NOT_LISTED); + let outside = touch(&dir, "outside.js"); + let e = s.execute("script.run", &json!({"path": outside.to_string_lossy(), "installed": true})).unwrap_err(); + assert_eq!(e.to_string(), cmd::scripts::NOT_LISTED); + // `installed` is true or false, and takes a path. + assert!( + s.execute("script.run", &json!({"path": path, "installed": "yes"})).unwrap_err().to_string().contains("`installed` must be true or false") + ); + assert!(s.execute("script.run", &json!({"source": "1", "installed": true})).unwrap_err().to_string().contains("`installed` takes a `path`")); + assert_eq!(s.execute("script.run", &json!({"source": "vc.log(3);", "installed": false})).unwrap()["log"], json!([3])); + let _ = std::fs::remove_dir_all(dir); +} + +#[test] +fn an_installed_run_reads_its_includes_in_the_folder_its_read_found() { + let dir = folder("installed-scope"); + let checked = dir.join("Scripts").join("tools"); + let swapped = dir.join("swapped"); + for (folder, x) in [(&checked, 1), (&swapped, 2)] { + std::fs::create_dir_all(folder).unwrap(); + std::fs::write(folder.join("draw.jsx"), "").unwrap(); + std::fs::write(folder.join("lib.jsxinc"), format!("var x = {x};")).unwrap(); + } + // The path now leads through a link to another folder, as after a link was put in place of the + // folder once the script's text was read. + let link = dir.join("link"); + link_folder(&swapped, &link); + let mut s = Session::new(); + let request = script::Request { + name: "draw.jsx".into(), + name_given: false, + file: Some(link.join("draw.jsx").to_string_lossy().into_owned()), + checked_real: Some(std::fs::canonicalize(checked.join("draw.jsx")).unwrap()), + source: "#include \"lib.jsxinc\"\nvc.log(x);".into(), + args: Default::default(), + fuel: script::DEFAULT_FUEL, + locale: "en_US".into(), + answers: Vec::new(), + }; + s.script_start(request).unwrap(); + assert_eq!(s.script_run_to_end().unwrap()["log"], json!([1])); + let _ = std::fs::remove_dir_all(dir); +} + #[test] fn the_automation_read_root_keeps_an_agent_out_of_the_scripts_folder() { let dir = folder("roots"); diff --git a/crates/eps/src/import/ate.rs b/crates/eps/src/import/ate.rs index a45c203d75..0c73b65092 100644 --- a/crates/eps/src/import/ate.rs +++ b/crates/eps/src/import/ate.rs @@ -2,8 +2,9 @@ //! //! The editing copy of a file keeps the characters of its text objects, with their fonts, sizes, //! colours and where they sit, in one text document of its own: between `%AI11_BeginTextDocument` -//! and `%AI11_EndTextDocument`, ASCII85 of nested dictionaries with numbers for keys. A text object -//! in a layer is only a stub naming its story in it (`/StoryIndex`). +//! and `%AI11_EndTextDocument`, ASCII85 of nested dictionaries with numbers for keys (names in files +//! saved for version 11, and for keys newer than the version a file is saved for: see [`names`]). +//! A text object in a layer is only a stub naming its story in it (`/StoryIndex`). //! //! The format isn't documented. Most of what is read here was worked out from the files of the //! project's users, used locally and never committed, by comparing it with the type their pages @@ -32,6 +33,8 @@ use vectorcraft_doc::{ }; use vectorcraft_geom::{Affine, PathData, Point, Rect}; +mod names; + /// Longest story read (bytes). pub(crate) const MAX_STORY: usize = 1 << 20; /// Biggest text document read (bytes, decoded). @@ -631,7 +634,7 @@ impl Texts { if bytes.len() > MAX_BYTES { return None; } - let doc = read(&bytes)?; + let doc = names::numbered(read(&bytes)?); let fonts = doc .get("0")? .get("1")? diff --git a/crates/eps/src/import/ate/names.rs b/crates/eps/src/import/ate/names.rs new file mode 100644 index 0000000000..614be3f227 --- /dev/null +++ b/crates/eps/src/import/ate/names.rs @@ -0,0 +1,355 @@ +//! The keys of a text document written by name. +//! +//! Files saved for version 11 write every key of the text document by its name +//! (`/DocumentResources << /FontSet << /Resources [ ...`) and a typed node's kind under +//! `/StreamTag`, with the long names of the layout's strikes (`/FrameStrike`, `/LineStrike`). Later +//! versions write each key as its number (`/0 << /1 << /0 [ ...`, the kind under `/99`, `/F`, +//! `/L`), except the keys that are newer than the version the file is saved for, which they still +//! write by name (`/ParagraphDirection`, `/VersionString`). A key's number depends on the +//! dictionary it is in (`Leading` is 5 in a character style and 11 in a line), so the names are +//! read with the dictionary each one sits in, from the root down. +//! +//! A public reader of the format lists the names and numbers of the document's resources and +//! objects, the character and paragraph features, a story's model and view, a frame's data, the +//! line and glyph strikes, a font record's name, type and axes, and a color's type and values: +//! . +//! The other names here (the sets' lists, a resource and its kind, a font record's version, the +//! styles' name, parent and features, a Kinsoku set's name and data, a frame's point, curve, first +//! baseline and path options, the runs and their kerning, a strike's kind and origin, the tab stops +//! and the paints) are VectorCraft's own reading of the format, matched to the numbers the reader +//! already uses. +//! +//! Only the keys the text document's reader looks at, and the dictionaries on the way to them, are +//! given numbers; any other key keeps its name and is skipped as before. A key that the dictionary +//! also has as a number keeps the number's value. + +use std::collections::BTreeMap; + +use super::Val; + +/// The kind of a dictionary of the text document, which gives its keys' numbers. +#[derive(Clone, Copy, Debug, PartialEq)] +enum Kind { + Root, + Resources, + Objects, + FontSet, + FontEntry, + Font, + FontRecord, + KinsokuSet, + KinsokuEntry, + Kinsoku, + CharSheetSet, + CharSheetEntry, + CharSheet, + ParaSheetSet, + ParaSheetEntry, + ParaSheet, + FrameSet, + FrameEntry, + Frame, + Bezier, + FrameData, + FirstBaseline, + PathOptions, + TextObject, + Model, + ParaRuns, + ParaRun, + ParaRunData, + CharRuns, + CharRun, + CharRunData, + KernRuns, + KernRun, + KernRunData, + View, + FrameRef, + Strike, + Transform, + CharFeatures, + ParaFeatures, + TabStops, + TabStop, + Paint, + Color, + /// A dictionary none of whose keys are read: its keys stay as they are. + Other, +} + +/// A key of a dictionary: its name, its number, and the kind of the dictionaries its value holds +/// (the value itself, or the elements of a list). +type Key = (&'static str, &'static str, Kind); + +/// The keys of a set of resources (`FontSet`, `StyleSheetSet`, ...), whose list holds `entry`. +const fn set(entry: Kind) -> [Key; 2] { + [("Resources", "0", entry), ("DisplayList", "1", Kind::Other)] +} + +const FONT_SET: [Key; 2] = set(Kind::FontEntry); +const KINSOKU_SET: [Key; 2] = set(Kind::KinsokuEntry); +const CHAR_SHEET_SET: [Key; 2] = set(Kind::CharSheetEntry); +const PARA_SHEET_SET: [Key; 2] = set(Kind::ParaSheetEntry); +const FRAME_SET: [Key; 2] = set(Kind::FrameEntry); + +/// The character features the reader looks at. +const CHAR_FEATURES: [Key; 20] = [ + ("Font", "0", Kind::Other), + ("FontSize", "1", Kind::Other), + ("AutoLeading", "4", Kind::Other), + ("Leading", "5", Kind::Other), + ("HorizontalScale", "6", Kind::Other), + ("VerticalScale", "7", Kind::Other), + ("Tracking", "8", Kind::Other), + ("BaselineShift", "9", Kind::Other), + ("FontCaps", "12", Kind::Other), + ("BaselineDirection", "35", Kind::Other), + ("LeftAki", "49", Kind::Other), + ("RightAki", "50", Kind::Other), + ("FillColor", "53", Kind::Paint), + ("StrokeColor", "54", Kind::Paint), + ("FillFlag", "56", Kind::Other), + ("StrokeFlag", "57", Kind::Other), + ("LineCap", "61", Kind::Other), + ("LineJoin", "62", Kind::Other), + ("LineWidth", "63", Kind::Other), + ("MiterLimit", "64", Kind::Other), +]; + +/// The paragraph features the reader looks at. +const PARA_FEATURES: [Key; 22] = [ + ("Justification", "0", Kind::Other), + ("FirstLineIndent", "1", Kind::Other), + ("StartIndent", "2", Kind::Other), + ("EndIndent", "3", Kind::Other), + ("SpaceBefore", "4", Kind::Other), + ("SpaceAfter", "5", Kind::Other), + ("AutoLeading", "7", Kind::Other), + ("AutoHyphenate", "9", Kind::Other), + ("HyphenatedWordSize", "10", Kind::Other), + ("PreHyphen", "11", Kind::Other), + ("PostHyphen", "12", Kind::Other), + ("ConsecutiveHyphens", "13", Kind::Other), + ("Zone", "14", Kind::Other), + ("HyphenateCapitalized", "15", Kind::Other), + ("KeepTogether", "23", Kind::Other), + ("BurasagariType", "24", Kind::Other), + ("KinsokuOrder", "25", Kind::Other), + ("Kinsoku", "27", Kind::Other), + ("TabStops", "30", Kind::TabStops), + ("DefaultTabWidth", "31", Kind::Other), + ("DefaultStyle", "32", Kind::Other), + ("ParagraphDirection", "33", Kind::Other), +]; + +/// The keys of a strike of the layout. A glyph strike has no children; its own keys start at 5. Its +/// glyph data (key 21) has no name here: the public list above does not name it, and the files +/// checked do not write it by name. +const STRIKE: [Key; 5] = [ + ("StreamTag", "99", Kind::Other), + ("Transform", "0", Kind::Transform), + ("Bounds", "1", Kind::Other), + ("ChildProcession", "5", Kind::Other), + ("Children", "6", Kind::Strike), +]; +const GLYPH_STRIKE: [Key; 3] = [("StreamTag", "99", Kind::Other), ("Transform", "0", Kind::Transform), ("Bounds", "1", Kind::Other)]; + +/// The long names of the strikes' kinds, with the short ones later versions write. +const STRIKE_KINDS: [(&str, &str); 6] = + [("PathSelectGroupCharacter", "PC"), ("FrameStrike", "F"), ("RowColStrike", "R"), ("LineStrike", "L"), ("Segment", "S"), ("GlyphStrike", "G")]; + +impl Kind { + /// The keys of a dictionary of this kind; `tag` is the kind of typed node it is, for a strike. + fn keys(self, tag: Option<&str>) -> &'static [Key] { + use Kind as K; + match self { + K::Root => &[("DocumentResources", "0", K::Resources), ("DocumentObjects", "1", K::Objects)], + K::Resources => &[ + ("FontSet", "1", K::FontSet), + ("KinsokuSet", "4", K::KinsokuSet), + ("StyleSheetSet", "5", K::CharSheetSet), + ("ParagraphSheetSet", "6", K::ParaSheetSet), + ("TextFrameSet", "8", K::FrameSet), + ], + K::Objects => &[ + ("DocumentSettings", "0", K::Other), + ("TextObjects", "1", K::TextObject), + ("OriginalNormalStyleFeatures", "2", K::CharFeatures), + ("OriginalNormalParagraphFeatures", "3", K::ParaFeatures), + ], + K::FontSet => &FONT_SET, + K::FontEntry => &[("Resource", "0", K::Font)], + K::Font => &[("StreamTag", "99", K::Other), ("Identifier", "0", K::FontRecord)], + K::FontRecord => &[("Name", "0", K::Other), ("Type", "2", K::Other), ("MMAxis", "4", K::Other), ("VersionString", "5", K::Other)], + K::KinsokuSet => &KINSOKU_SET, + K::KinsokuEntry => &[("Resource", "0", K::Kinsoku)], + K::Kinsoku => &[("Name", "0", K::Other), ("Data", "5", K::Other)], + K::CharSheetSet => &CHAR_SHEET_SET, + K::CharSheetEntry => &[("Resource", "0", K::CharSheet)], + K::CharSheet => &[("Name", "0", K::Other), ("Parent", "5", K::Other), ("Features", "6", K::CharFeatures)], + K::ParaSheetSet => &PARA_SHEET_SET, + K::ParaSheetEntry => &[("Resource", "0", K::ParaSheet)], + K::ParaSheet => &[("Name", "0", K::Other), ("Features", "5", K::ParaFeatures), ("Parent", "6", K::Other)], + K::FrameSet => &FRAME_SET, + K::FrameEntry => &[("Resource", "0", K::Frame)], + K::Frame => &[("Point", "0", K::Other), ("Bezier", "1", K::Bezier), ("Data", "2", K::FrameData)], + K::Bezier => &[("Points", "0", K::Other)], + K::FrameData => &[ + ("Type", "0", K::Other), + ("LineOrientation", "1", K::Other), + ("FrameMatrix", "2", K::Other), + ("RowCount", "3", K::Other), + ("ColumnCount", "4", K::Other), + ("TextOnPathTRange", "6", K::Other), + ("RowGutter", "7", K::Other), + ("ColumnGutter", "8", K::Other), + ("FirstBaselineAlignment", "10", K::FirstBaseline), + ("PathData", "11", K::PathOptions), + ], + K::FirstBaseline => &[("Flag", "0", K::Other), ("Min", "1", K::Other)], + K::PathOptions => &[("Flip", "0", K::Other), ("Effect", "1", K::Other), ("Alignment", "2", K::Other), ("CS3Behavior", "6", K::Other)], + K::TextObject => &[("Model", "0", K::Model), ("View", "1", K::View)], + K::Model => &[("Text", "0", K::Other), ("ParagraphRun", "5", K::ParaRuns), ("StyleRun", "6", K::CharRuns), ("KernRun", "8", K::KernRuns)], + K::ParaRuns => &[("RunArray", "0", K::ParaRun)], + K::ParaRun => &[("RunData", "0", K::ParaRunData), ("Length", "1", K::Other)], + K::ParaRunData => &[("ParagraphSheet", "0", K::ParaSheet)], + K::CharRuns => &[("RunArray", "0", K::CharRun)], + K::CharRun => &[("RunData", "0", K::CharRunData), ("Length", "1", K::Other)], + K::CharRunData => &[("StyleSheet", "0", K::CharSheet)], + K::KernRuns => &[("RunArray", "0", K::KernRun)], + K::KernRun => &[("RunData", "0", K::KernRunData), ("Length", "1", K::Other)], + K::KernRunData => &[("ManualKern", "0", K::Other)], + K::View => &[("Frames", "0", K::FrameRef), ("Strikes", "2", K::Strike)], + K::FrameRef => &[("Resource", "0", K::Other)], + K::Strike if matches!(tag, Some("GlyphStrike" | "G")) => &GLYPH_STRIKE, + K::Strike => &STRIKE, + K::Transform => &[("Origin", "0", K::Other)], + K::CharFeatures => &CHAR_FEATURES, + K::ParaFeatures => &PARA_FEATURES, + K::TabStops => &[("TabStops", "0", K::TabStop)], + K::TabStop => &[("TabAdvance", "0", K::Other), ("TabType", "1", K::Other)], + K::Paint => &[("StreamTag", "99", K::Other), ("Color", "0", K::Color), ("CAIKnownStyleID", "5", K::Other)], + K::Color => &[("Type", "0", K::Other), ("Values", "1", K::Other)], + K::Other => &[], + } + } +} + +/// The text document `doc` (its top-level keys) with the keys it writes by name given their +/// numbers, as later versions write them. +pub(super) fn numbered(doc: Val) -> Val { + value(doc, Kind::Root) +} + +/// `v`, whose dictionaries are of kind `kind`, with its keys numbered. +fn value(v: Val, kind: Kind) -> Val { + match v { + Val::Dict(d) if kind != Kind::Other => Val::Dict(dict(d, kind)), + Val::List(l) if kind != Kind::Other => Val::List(l.into_iter().map(|x| value(x, kind)).collect()), + v => v, + } +} + +/// A run's style written by name as the number `n` of a paragraph style (`kind` `ParaSheet`) or a +/// character style (`CharSheet`) of the document: a style with nothing of its own, based on that +/// one. +fn based_on(kind: Kind, n: f64) -> Option { + let parent = match kind { + Kind::ParaSheet => "6", + Kind::CharSheet => "5", + _ => return None, + }; + Some(Val::Dict(BTreeMap::from([(parent.to_string(), Val::Num(n))]))) +} + +/// The entries of a dictionary of kind `kind` with their keys numbered. +fn dict(d: BTreeMap, kind: Kind) -> BTreeMap { + let tag = match d.get("StreamTag").or_else(|| d.get("99")) { + Some(Val::Name(n)) => Some(n.clone()), + _ => None, + }; + let keys = kind.keys(tag.as_deref()); + let mut out = BTreeMap::new(); + let mut named = vec![]; + for (k, v) in d { + match keys.iter().find(|(name, number, _)| *name == k || *number == k) { + Some((name, number, child)) => { + let v = match (*number, v) { + ("99", Val::Name(n)) => Val::Name(STRIKE_KINDS.iter().find(|(long, _)| *long == n).map_or(n, |(_, short)| short.to_string())), + (_, Val::Num(n)) if k == *name => based_on(*child, n).unwrap_or(Val::Num(n)), + (_, v) => value(v, *child), + }; + if k == *name { + named.push((number.to_string(), v)); + } else { + out.insert(k, v); + } + } + None => { + out.insert(k, v); + } + } + } + for (k, v) in named { + out.entry(k).or_insert(v); + } + out +} + +#[cfg(test)] +mod tests { + use super::super::read; + use super::*; + + fn parse(s: &str) -> Val { + read(s.as_bytes()).unwrap_or_else(|| panic!("{s}")) + } + + #[test] + fn named_keys_take_the_numbers_of_the_dictionary_they_are_in() { + // `Leading` is 5 in a character style and 11 in a line; `Name` is 0 in a style and in a font + // record. + let named = parse( + "/DocumentResources << /FontSet << /Resources [ << /Resource << /StreamTag /CoolTypeFont /Identifier << /Name (Serif) \ + /VersionString (Version 2) >> >> >> ] >> /StyleSheetSet << /Resources [ << /Resource << /Name (Red) /Parent 0 \ + /Features << /Leading 18.0 /FillColor << /StreamTag /SimplePaint /Color << /Type 1 /Values [ 1.0 1.0 0.0 0.0 ] >> >> >> >> >> ] >> >> \ + /DocumentObjects << /TextObjects [ << /Model << /Text (Hi) /ParagraphRun << /RunArray [ << /RunData << /ParagraphSheet 1 >> /Length 2 >> ] >> \ + /StyleRun << /RunArray [ << /RunData << /StyleSheet << /Parent 0 /Features << /FontSize 9.0 >> >> >> /Length 2 >> ] >> >> \ + /View << /Frames [ << /Resource 0 >> ] /Strikes [ << /StreamTag /FrameStrike /Transform << /Origin [ 1.0 2.0 ] >> \ + /Children [ << /StreamTag /LineStrike /Leading 14.4 /Children [ << /StreamTag /GlyphStrike /Glyphs [ 1 2 ] >> ] >> ] >> ] >> >> ] >>", + ); + let numbered_form = parse( + "/0 << /1 << /0 [ << /0 << /99 /CoolTypeFont /0 << /0 (Serif) /5 (Version 2) >> >> >> ] >> /5 << /0 [ << /0 << /0 (Red) /5 0 \ + /6 << /5 18.0 /53 << /99 /SimplePaint /0 << /0 1 /1 [ 1.0 1.0 0.0 0.0 ] >> >> >> >> >> ] >> >> \ + /1 << /1 [ << /0 << /0 (Hi) /5 << /0 [ << /0 << /0 << /6 1 >> >> /1 2 >> ] >> \ + /6 << /0 [ << /0 << /0 << /5 0 /6 << /1 9.0 >> >> >> /1 2 >> ] >> >> \ + /1 << /0 [ << /0 0 >> ] /2 [ << /99 /F /0 << /0 [ 1.0 2.0 ] >> \ + /6 [ << /99 /L /Leading 14.4 /6 [ << /99 /G /Glyphs [ 1 2 ] >> ] >> ] >> ] >> >> ] >>", + ); + // A character style's `Leading` is its key 5; a line's and a glyph's keys the reader doesn't + // look at keep their names. + assert_eq!(numbered(named), numbered_form); + } + + #[test] + fn a_number_wins_over_the_same_key_by_name() { + let doc = numbered(parse("/1 << /3 << /33 1 /ParagraphDirection 0 /Justification 2 >> >>")); + assert_eq!(doc, parse("/1 << /3 << /33 1 /0 2 >> >>")); + } + + #[test] + fn keys_of_dictionaries_no_reader_looks_at_keep_their_names() { + let doc = numbered(parse("/DocumentObjects << /DocumentSettings << /Name (x) /Leading 3 >> /Elsewhere << /Name (y) >> >>")); + assert_eq!(doc, parse("/1 << /0 << /Name (x) /Leading 3 >> /Elsewhere << /Name (y) >> >>")); + } + + #[test] + fn a_numbered_document_stays_as_it_is() { + let s = "/0 << /1 << /0 [ << /0 << /99 /CoolTypeFont /0 << /0 (Serif) >> >> >> ] >> >> \ + /1 << /1 [ << /0 << /0 (Hi) /5 << /0 [ << /0 << /0 1 >> /1 2 >> ] >> /6 << /0 [ << /0 << /0 0 >> /1 2 >> ] >> >> \ + /1 << /0 [ << /0 0 >> ] /2 [ << /99 /PC /6 [ << /99 /F /0 << /0 [ 1.0 2.0 ] >> >> ] >> ] >> >> ] /2 << /1 12.0 >> >>"; + // A run's style written as a number under its key's number stays a number. + assert_eq!(numbered(parse(s)), parse(s)); + } +} diff --git a/crates/eps/src/import/tests_native.rs b/crates/eps/src/import/tests_native.rs index 738f02d857..407ca72e9a 100644 --- a/crates/eps/src/import/tests_native.rs +++ b/crates/eps/src/import/tests_native.rs @@ -3169,6 +3169,29 @@ fn text_document(stories: &[Story<'_>]) -> String { ) } +/// [`text_document`] with its keys written by name, as files saved for version 11 write them: a +/// typed node's kind under `/StreamTag`, the strikes' kinds by their long names. +fn named_text_document(stories: &[Story<'_>]) -> String { + let story = |(text, align, anchor, _t, start): &Story<'_>| { + let n = text.chars().count(); + format!( + "<< /Model << /Text ({text}) /ParagraphRun << /RunArray [ << /RunData << /ParagraphSheet << /Name () /Features << /Justification {align} >> /Parent 0 >> >> /Length {n} >> ] >> \ + /StyleRun << /RunArray [ << /RunData << /StyleSheet << /Name () /Parent 0 /Features << /Font 0 /FontSize 14.0 /FillColor << /StreamTag /CAITextPaint /Color << /Type 2 /Values [ 1.0 0.0 1.0 0.0 0.0 ] >> >> >> >> >> /Length {n} >> ] >> >> \ + /View << /Frames [ << /Resource 0 >> ] /Strikes [ << /StreamTag /PathSelectGroupCharacter /Children [ << /StreamTag /FrameStrike /Transform << /Origin [ {} {} ] >> \ + /Children [ << /StreamTag /RowColStrike /Children [ << /StreamTag /RowColStrike /Children [ << /StreamTag /LineStrike /Children [ \ + << /StreamTag /Segment /Transform << /Origin [ {start} 0.0 ] >> /Mapping << /CharacterCount {n} >> >> ] >> ] >> ] >> ] >> ] >> ] >> >>", + anchor.0, anchor.1 + ) + }; + let frame = |(_, _, _, t, _): &Story<'_>| format!("<< /Resource << /Point [ 0 0 ] /Data << /FrameMatrix [ 1 0 0 1 {} {} ] >> >> >>", t.0, t.1); + format!( + "/DocumentResources << /FontSet << /Resources [ << /Resource << /StreamTag /CoolTypeFont /Identifier << /Name (Helvetica) >> >> >> ] >> \ + /TextFrameSet << /Resources [ {} ] >> >>\n/DocumentObjects << /TextObjects [ {} ] /OriginalNormalStyleFeatures << /FontSize 12.0 >> >>\n", + stories.iter().map(frame).collect::>().join(" "), + stories.iter().map(story).collect::>().join(" ") + ) +} + /// The editing copy of a 100 × 100 file with `layers` and the text `document`; its template box is /// centred at (50, 50). fn native_with_text(layers: &str, document: &str) -> String { @@ -3319,6 +3342,8 @@ fn type_from_the_text_document_takes_the_version_its_font_record_keeps() { // A record whose name differs from the copies' in punctuation alone still picks its version. assert_eq!(version("/0 (Vertest-Ink-Regular) /5 (1.1)"), "1.1"); assert_eq!(version("/0 (Vertest-Ink-Regular)"), "1.0"); + // Files saved for versions older than the key write it by name among the numbered ones. + assert_eq!(version("/0 (VertestInk-Regular) /VersionString (1.1)"), "1.1"); } /// A story with no runs, and a run or paragraph that names no style, is set in `[Normal Character @@ -3728,6 +3753,81 @@ fn shown_type_keeps_the_pages_and_type_off_the_page_comes_from_the_text_document assert!(!r.warnings.iter().any(|w| w.contains("order the page paints")), "{:?}", r.warnings); } +#[test] +fn a_text_document_with_named_keys_gives_type_off_the_page_too() { + // Files saved for version 11 write the text document's keys by name. Story 1 is off the page: + // only the text document has it, and story 0 stays in its own text object. + let stories = [("Hi there\r", 0, (8200.0, 8180.0), (0.0, 0.0), 0.0), ("Away\r", 0, (8300.0, 8180.0), (0.0, 0.0), 0.0)]; + let art = native_with_text(&layer("Art", true, &(text_object(0) + &text_object(1))), &named_text_document(&stories)); + let page = "0 0 0 1 setcmykcolor /Helvetica findfont 14 scalefont setfont 58.5 61.5 moveto (Hi there) show"; + let r = import(&eps(page, &art)).unwrap(); + let texts = text_of(&r.document.layers[0]); + assert_eq!(texts.iter().map(|t| t.plain_text()).collect::>(), ["Hi there", "Away"], "{:?}", r.warnings); + let x = |t: &vectorcraft_doc::TextObject| t.xf.as_coeffs()[4]; + assert!((x(texts[0]) - 58.5).abs() < 1e-6 && (x(texts[1]) - 158.5).abs() < 1e-6, "{:?} {:?}", texts[0].xf, texts[1].xf); +} + +#[test] +fn a_text_document_with_named_keys_reads_as_the_same_one_with_numbers() { + // Type on a hidden layer comes only from the text document: its styles, paints, frames and + // layout read the same whichever way its keys are written. + let stories = [("Hi there\r", 0, (8200.0, 8180.0), (0.0, 0.0), 0.0), ("Mid\r", 2, (8191.5, 8191.5), (0.0, 0.0), -10.0)]; + let spare = |doc: &str| { + let art = native_with_text(&(layer("Back", true, &square(10, 10)) + &layer("Spare", false, &(text_object(0) + &text_object(1)))), doc); + let r = import(&eps(&page_square(10, 10), &art)).unwrap(); + let texts: Vec = text_of(&r.document.layers[1]).into_iter().cloned().collect(); + (texts, r.warnings) + }; + let (numbered, _) = spare(&text_document(&stories)); + let (named, warnings) = spare(&named_text_document(&stories)); + assert_eq!(numbered.len(), 2); + assert_eq!(named, numbered, "{warnings:?}"); + assert_eq!(named[1].para.justify, vectorcraft_doc::Justify::Center); + let magenta = vectorcraft_color::Paint::solid(vectorcraft_color::Color::cmyk(0.0, 1.0, 0.0, 0.0)); + assert_eq!(named[0].runs[0].style.fill, magenta); +} + +#[test] +fn a_text_document_with_named_keys_takes_a_runs_style_by_its_number() { + // Files saved for version 11 name a run's paragraph and character styles by their numbers in the + // document's style sets, and give point type a curve of four equal points. Styles 1 center the + // paragraph and paint the type magenta; paragraph style 2 sets its direction right to left. + let magenta = "<< /StreamTag /CAITextPaint /Color << /Type 2 /Values [ 1.0 0.0 1.0 0.0 0.0 ] >> >>"; + let story = |text: &str, para: u8, chars: u8, x: f64| { + let n = text.chars().count(); + format!( + "<< /Model << /Text ({text}) /ParagraphRun << /RunArray [ << /RunData << /ParagraphSheet {para} >> /Length {n} >> ] >> \ + /StyleRun << /RunArray [ << /RunData << /StyleSheet {chars} >> /Length {n} >> ] >> >> \ + /View << /Frames [ << /Resource 0 >> ] /Strikes [ << /StreamTag /PathSelectGroupCharacter /Children [ << /StreamTag /FrameStrike \ + /Transform << /Origin [ {x} 8180.0 ] >> /Children [ << /StreamTag /RowColStrike /Children [ << /StreamTag /RowColStrike /Children [ \ + << /StreamTag /LineStrike /Children [ << /StreamTag /Segment /Transform << /Origin [ 0.0 0.0 ] >> >> ] >> ] >> ] >> ] >> ] >> ] >> >>" + ) + }; + let doc = format!( + "/DocumentResources << /FontSet << /Resources [ << /Resource << /StreamTag /CoolTypeFont /Identifier << /Name (Helvetica) >> >> >> ] >> \ + /StyleSheetSet << /Resources [ << /Resource << /Name (Normal) /Features << /Font 0 /FontSize 14.0 >> >> >> \ + << /Resource << /Name (Magenta) /Parent 0 /Features << /FillColor {magenta} >> >> >> ] >> \ + /ParagraphSheetSet << /Resources [ << /Resource << /Name (Normal) /Features << /Justification 0 >> >> >> \ + << /Resource << /Name (Centered) /Parent 0 /Features << /Justification 2 >> >> >> \ + << /Resource << /Name (Leftward) /Parent 0 /Features << /ParagraphDirection 1 >> >> >> ] >> \ + /TextFrameSet << /Resources [ << /Resource << /Bezier << /Points [ 8191.5 8191.5 8191.5 8191.5 8191.5 8191.5 8191.5 8191.5 ] >> \ + /Data << /Type 0 /FrameMatrix [ 1 0 0 1 0 0 ] >> >> >> ] >> >>\n\ + /DocumentObjects << /TextObjects [ {} {} ] /OriginalNormalStyleFeatures << /FontSize 12.0 >> >>\n", + story("Mid\r", 1, 1, 8200.0), + story("Back\r", 2, 0, 8300.0) + ); + let art = native_with_text(&(layer("Back", true, &square(10, 10)) + &layer("Spare", false, &(text_object(0) + &text_object(1)))), &doc); + let r = import(&eps(&page_square(10, 10), &art)).unwrap(); + let texts = text_of(&r.document.layers[1]); + assert_eq!(texts.iter().map(|t| t.plain_text()).collect::>(), ["Mid", "Back"], "{:?}", r.warnings); + assert_eq!(texts[0].para.justify, vectorcraft_doc::Justify::Center); + let magenta = vectorcraft_color::Paint::solid(vectorcraft_color::Color::cmyk(0.0, 1.0, 0.0, 0.0)); + assert_eq!(texts[0].runs[0].style.fill, magenta); + assert_eq!(texts[1].para.direction, Some(vectorcraft_doc::ParaDirection::RightToLeft)); + assert_eq!(texts[1].para.justify, vectorcraft_doc::Justify::Right); + assert_ne!(texts[1].runs[0].style.fill, magenta); +} + #[test] fn a_text_object_written_as_comments_is_read_like_any_other() { let doc = text_document(&[("Hi\r", 0, (8200.0, 8180.0), (0.0, 0.0), 0.0)]); diff --git a/crates/mcp/src/ui_catalog.rs b/crates/mcp/src/ui_catalog.rs index 1ae99d0c25..83d20fc49d 100644 --- a/crates/mcp/src/ui_catalog.rs +++ b/crates/mcp/src/ui_catalog.rs @@ -1737,7 +1737,7 @@ pub const UI_COMMANDS: &[(&str, &str, &str, &str)] = &[ "ui.runInstalledScript", "Run Script", "", - "{path: a script's path as script.listInstalled gives it} run a script of the Scripts folder, as its File › Scripts item does (the script is checked again, then script.run runs it by its path, so its #include lines and $.evalFile read beside it), without the warning Other Script… shows; a file that is no longer a .js or .jsx file in the folder or a subfolder at most 4 levels below it, or that is or lies behind a link or is hidden, is not run, and the menu's listing is read again", + "{path: a script's path as script.listInstalled gives it} run a script of the Scripts folder, as its File › Scripts item does (script.run with installed reads the file once, checks it again and runs that text, so its #include lines and $.evalFile read in the folder that read found), without the warning Other Script… shows; a file that is no longer a .js or .jsx file in the folder or a subfolder at most 4 levels below it, or that is or lies behind a link or is hidden, is not run, and the menu's listing is read again", ), ( "ui.checkScript", diff --git a/crates/pdf/src/export/images.rs b/crates/pdf/src/export/images.rs index af686e1857..04a3a811f3 100644 --- a/crates/pdf/src/export/images.rs +++ b/crates/pdf/src/export/images.rs @@ -139,6 +139,7 @@ impl Exporter<'_> { let doc = self.doc; let out = self.out.clone(); let profiles = !out.standard.cmyk_only(); + let to_srgb = out.image_srgb_intent().map(|intent| crate::images::ToSrgb { intent, sixteen_bit: self.sixteen_bit }); let img = doc.image_blob(im).filter(|_| !self.left_out.contains(key)).and_then(|blob| { let cmyk_image = blob.is_cmyk(); if !cmyk_image && self.convert && doc.color_mode == vectorcraft_doc::ColorMode::Cmyk { @@ -148,12 +149,14 @@ impl Exporter<'_> { let icc = out.cmyk_image_icc(blob); crate::images::recode_cmyk(blob, size, c, interpolate, out.cmyk_pixels(), None, icc.as_deref()) } else { - crate::images::recode(&blob.bytes, size, c, interpolate, out.pixels(), profiles) + crate::images::recode(&blob.bytes, size, c, interpolate, out.pixels(), profiles, to_srgb) }; - if let Some(w) = r.warning { + let img = r.image.or_else(|| embed(blob, interpolate, self.sixteen_bit, profiles)); + // An image left out gets the warning below instead. + for w in [r.warning, r.note].into_iter().flatten().filter(|_| img.is_some()) { self.warn(w); } - r.image.or_else(|| embed(blob, interpolate, self.sixteen_bit, profiles)) + img }); if img.is_none() { self.warn(UNDECODED); diff --git a/crates/pdf/src/images.rs b/crates/pdf/src/images.rs index 5204e4f396..6806aab756 100644 --- a/crates/pdf/src/images.rs +++ b/crates/pdf/src/images.rs @@ -6,6 +6,13 @@ //! monochrome (greyscale that is only black and white). An image already as asked (not resampled, //! a JPEG kept as JPEG or another kept lossless) is embedded as it is; the others are decoded, //! resampled and encoded again. JPEG needs opaque pixels: images with transparency stay lossless. +//! A gray or RGB image that carries a profile the Output settings convert from ([`ToSrgb`]) is +//! always decoded: its pixels are converted to sRGB and written without the profile, at 16 bits a +//! sample when it has them and is otherwise kept as it is; a JPEG kept as JPEG whose samples the +//! conversion leaves unchanged (an sRGB profile) keeps its JPEG data. A black-and-white image whose +//! converted levels are grey is compressed as greyscale. A colour image the Output settings also +//! convert to their destination ([`Pixels`]) goes from its profile to sRGB, then to the +//! destination; a grey one keeps its profile there, as grey pixels are not converted. //! //! CMYK images ([`ImageBlob::cmyk`]) stay CMYK: a CMYK JPEG already as asked is embedded as it is //! when its numbers are kept; the others' ink amounts are resampled, converted when the Output @@ -22,6 +29,7 @@ use image::imageops::FilterType; use image::{DynamicImage, ExtendedColorType, ImageDecoder, ImageFormat, ImageReader}; use krilla::image::{BitsPerComponent, CustomImage, Image, ImageColorspace}; +use vectorcraft_color::cms::{IccProfile, Intent}; use vectorcraft_doc::ImageBlob; use vectorcraft_doc::cmyk::{Inks, jpeg_components}; use vectorcraft_doc::resample::{Resample, premultiply, resized, unpremultiply}; @@ -306,6 +314,100 @@ pub(crate) struct Recoded { pub image: Option, /// Set when the image isn't compressed as asked. pub warning: Option<&'static str>, + /// Set when the image's colors are converted to the destination without its ICC profile, + /// which can't be read. + pub note: Option<&'static str>, +} + +/// How [`recode`] converts a gray or RGB image that carries an ICC profile to sRGB (Convert to +/// Destination, [`crate::output::ColorOut::image_srgb_intent`]): the destination when it is sRGB, +/// else the step before the image's [`Pixels`] conversion. +#[derive(Clone, Copy, Debug)] +pub(crate) struct ToSrgb { + /// The rendering intent of the conversion. + pub intent: Intent, + /// 16-bit samples may be written (PDF 1.5 and later). + pub sixteen_bit: bool, +} + +/// The ICC profile image `bytes` carries ([`source_icc`]), when it is a well-formed profile of +/// colour space `space` (`GRAY` or `RGB `, [`valid_icc`]): `None` without one, `Some(None)` when +/// the CMS can't read it. +fn source_profile(bytes: &[u8], space: [u8; 4]) -> Option> { + let icc = source_icc(bytes).filter(|icc| valid_icc(icc, space))?; + Some(IccProfile::from_bytes(None, &icc).ok()) +} + +const UNCONVERTED: &str = "an image's ICC profile couldn't be read: the image keeps it and is not converted to sRGB"; +const CONVERTED_AS_SRGB: &str = "an image's ICC profile couldn't be read: its colors are converted to the destination as sRGB"; + +const UNDECODED: &str = "an image couldn't be decoded and is not converted to sRGB"; + +/// `img` converted from ICC profile `p` to sRGB with `intent`: at 16 bits a sample when it has +/// more than 8, else at 8, with its alpha; and whether any sample changed. A grey image stays grey, each level as the sRGB grey it converts to. `None` when the +/// pixels can't be converted. +fn into_srgb(img: DynamicImage, p: &IccProfile, intent: Intent) -> Option<(DynamicImage, bool)> { + let kind = img.color(); + let deep = kind.bits_per_pixel() > 8 * u16::from(kind.channel_count()); + let to8 = |v: f32| v.round() as u8; + let to16 = |v: f32| v.round() as u16; + Some(match (kind.has_color(), deep) { + (true, true) => { + let mut s = img.into_rgba16(); + let changed = convert_samples(&mut s, 3, p, intent, 65_535.0, to16)?; + (DynamicImage::ImageRgba16(s), changed) + } + (true, false) => { + let mut s = img.into_rgba8(); + let changed = convert_samples(&mut s, 3, p, intent, 255.0, to8)?; + (DynamicImage::ImageRgba8(s), changed) + } + (false, true) => { + let mut s = img.into_luma_alpha16(); + let changed = convert_samples(&mut s, 1, p, intent, 65_535.0, to16)?; + (DynamicImage::ImageLumaA16(s), changed) + } + (false, false) => { + let mut s = img.into_luma_alpha8(); + let changed = convert_samples(&mut s, 1, p, intent, 255.0, to8)?; + (DynamicImage::ImageLumaA8(s), changed) + } + }) +} + +/// Convert the `n` colour samples (1: grey, 3: RGB) of each pixel of `samples` (followed by an +/// alpha sample each, which is left as it is) from ICC profile `p` to sRGB with `intent`, a run of +/// pixels at a time; `max` is a sample's largest value, `back` turns a value of 0 to `max` into a +/// sample. Grey pixels take the mean of the sRGB values. Whether any sample changed; `None` when +/// the transform fails. +fn convert_samples>( + samples: &mut [T], + n: usize, + p: &IccProfile, + intent: Intent, + max: f32, + back: impl Fn(f32) -> T, +) -> Option { + const RUN: usize = 4096; + let mut src = Vec::with_capacity(RUN * n); + let mut dst = vec![0.0f32; RUN * 3]; + let mut changed = false; + for run in samples.chunks_mut(RUN * (n + 1)) { + src.clear(); + src.extend(run.chunks_exact(n + 1).flat_map(|px| px.iter().take(n).map(|v| (*v).into() / max))); + let out = dst.get_mut(..src.len() / n * 3)?; + p.to_srgb_pixels(&src, out, intent)?; + for (px, rgb) in run.chunks_exact_mut(n + 1).zip(out.as_chunks::<3>().0) { + let grey = [rgb.iter().sum::() / 3.0]; + let values: &[f32] = if n == 1 { &grey } else { rgb }; + for (v, c) in px.iter_mut().zip(values) { + let new = back(c * max); + changed |= new != *v; + *v = new; + } + } + } + Some(changed) } /// Is `img` (a grey image) only black and white? @@ -372,8 +474,11 @@ fn resample_channels(img: DynamicImage, r: &Rule, (w, h): (u32, u32)) -> Dynamic /// Encoded image `bytes` placed `size` points wide and high, as the Compression settings `c` ask: /// resampled when its resolution is above the threshold, compressed with ZIP or JPEG, its colours /// converted by `convert` (colour images). Pixels that keep their colours keep the ICC profile -/// they carry when `profiles` allows it (not in PDF/X-1a, which allows no ICC-based color spaces). -/// `interpolate` lets viewers smooth it. An image that can't be decoded is kept as it is. +/// they carry when `profiles` allows it (not in PDF/X-1a, which allows no ICC-based color spaces); +/// with `to_srgb`, pixels in a gray or RGB profile other than sRGB are converted from it to sRGB +/// instead ([`ToSrgb`]), and colour ones then by `convert`; grey ones that `convert` leaves as +/// they are keep their profile. `interpolate` lets viewers smooth it. An image that can't be +/// decoded is kept as it is, with a warning when its pixels were to be converted to sRGB. pub(crate) fn recode( bytes: &[u8], size: (f64, f64), @@ -381,39 +486,94 @@ pub(crate) fn recode( interpolate: bool, convert: Option<&Pixels>, profiles: bool, + to_srgb: Option, ) -> Recoded { - let keep = Recoded { image: None, warning: None }; + let keep = Recoded { image: None, warning: None, note: None }; let Ok(reader) = ImageReader::new(Cursor::new(bytes)).with_guessed_format() else { return keep }; let jpeg = reader.format() == Some(ImageFormat::Jpeg); let Ok(mut decoder) = reader.into_decoder() else { return keep }; + let dims = decoder.dimensions(); + use ExtendedColorType as T; + let grey = matches!(decoder.original_color_type(), T::L1 | T::La1 | T::L2 | T::La2 | T::L4 | T::La4 | T::L8 | T::La8 | T::L16 | T::La16); + // Pixels in a profile they are converted from are decoded, whatever the Compression settings. + // Grey pixels, which `convert` leaves as they are, keep their profile when it converts. + let to_srgb = to_srgb.filter(|_| !(grey && convert.is_some())); + // Converted to the destination as sRGB, as before, when the profile can't be read. + let mut note = None; + let source = match to_srgb.and_then(|t| Some((source_profile(bytes, if grey { *b"GRAY" } else { *b"RGB " })?, t))) { + Some((Some(p), t)) => Some((p, t)), + Some((None, _)) if convert.is_some() => { + note = Some(CONVERTED_AS_SRGB); + None + } + Some((None, _)) => return Recoded { image: None, warning: Some(UNCONVERTED), note: None }, + None => None, + }; + // Pixels that can't be decoded are kept as they are, with a warning when they were to be + // converted. + let keep = if source.is_some() { Recoded { image: None, warning: Some(UNDECODED), note: None } } else { keep }; // What the header declares is decoded within the image allowance, not whatever it asks for. if decoder.set_limits(limits()).is_err() { return keep; } - let dims = decoder.dimensions(); - use ExtendedColorType as T; - let grey = matches!(decoder.original_color_type(), T::L1 | T::La1 | T::L2 | T::La2 | T::L4 | T::La4 | T::L8 | T::La8 | T::L16 | T::La16); - let (kind, pixels) = if grey { + let (mut kind, pixels) = if grey { // Only the pixels tell monochrome images from greyscale ones: decoded unless both keep it. let (g, m) = (rule(c, Kind::Gray), rule(c, Kind::Mono)); - if kept(&g, dims, size, jpeg) && kept(&m, dims, size, jpeg) && g.instead == m.instead { - return Recoded { image: None, warning: g.instead }; + if source.is_none() && kept(&g, dims, size, jpeg) && kept(&m, dims, size, jpeg) && g.instead == m.instead { + return Recoded { image: None, warning: g.instead, note: None }; } let Ok(img) = DynamicImage::from_decoder(decoder) else { return keep }; (if bilevel(&img) { Kind::Mono } else { Kind::Gray }, Some(img)) } else { (Kind::Color, None) }; - let rule = rule(c, kind); + let mut rule = rule(c, kind); let convert = convert.filter(|_| kind == Kind::Color); - if kept(&rule, dims, size, jpeg) && convert.is_none() { - return Recoded { image: None, warning: rule.instead }; + if kept(&rule, dims, size, jpeg) && convert.is_none() && source.is_none() { + return Recoded { image: None, warning: rule.instead, note: None }; + } + let Some(mut img) = pixels.or_else(|| decoded(bytes)) else { return keep }; + if let Some((p, t)) = &source { + let srgb = match into_srgb(img, p, t.intent) { + Some(s) => Some(s), + None if convert.is_some() => { + // Converted to the destination as sRGB: decoded again (a colour image), as the + // conversion took the pixels. + note = Some(CONVERTED_AS_SRGB); + None + } + None => return Recoded { image: None, warning: Some(UNCONVERTED), note: None }, + }; + img = match srgb { + Some((srgb, changed)) => { + // The converted levels of a black-and-white image may be grey. + if kind == Kind::Mono && !bilevel(&srgb) { + (kind, rule) = (Kind::Gray, self::rule(c, Kind::Gray)); + } + // Written as it is only when nothing converts it further. + let as_is = kept(&rule, dims, size, jpeg) && convert.is_none(); + // A JPEG kept as JPEG whose samples the conversion leaves unchanged (an sRGB + // profile): its JPEG data, without the profile. + if !changed && as_is && jpeg { + let image = Image::from_jpeg_with_icc(bytes.to_vec().into(), None, interpolate).ok(); + return Recoded { image, warning: rule.instead, note: None }; + } + // Not resampled and lossless: written at its own depth. + if as_is && jpeg_quality(&rule, jpeg).is_none() { + return Recoded { image: from_decoded(srgb, None, interpolate, t.sixteen_bit), warning: rule.instead, note: None }; + } + srgb + } + None => { + let Some(img) = decoded(bytes) else { return keep }; + img + } + }; } - let Some(img) = pixels.or_else(|| decoded(bytes)) else { return keep }; // Pixels that keep their colours keep the profile they are in; converted ones are in the // destination's. let want = if kind == Kind::Color { *b"RGB " } else { *b"GRAY" }; - let icc = (profiles && convert.is_none()).then(|| source_icc(bytes)).flatten().filter(|icc| valid_icc(icc, want)); + let icc = (profiles && convert.is_none() && source.is_none()).then(|| source_icc(bytes)).flatten().filter(|icc| valid_icc(icc, want)); let rgb_icc = icc.is_some() && kind == Kind::Color; let mut rgba = resample(img, &rule, sized(&rule, dims, size)).into_rgba8(); if kind == Kind::Mono { @@ -441,7 +601,7 @@ pub(crate) fn recode( to_jpeg(&samples, space, width, height, q, interpolate, icc.as_deref().map(Vec::as_slice)) }); let image = jpeg.or_else(|| from_rgba(rgba.as_raw(), width, height, interpolate, convert, icc)); - Recoded { image, warning: rule.instead } + Recoded { image, warning: rule.instead, note } } /// A colorized image's coverage (one sample a pixel, `w` × `h`, see @@ -492,16 +652,16 @@ pub(crate) fn recode_cmyk( let image = (u64::from(w) * u64::from(h) <= MAX_EMBED_PIXELS) .then(|| Image::from_jpeg_with_icc(blob.bytes.as_ref().clone().into(), icc.map(|i| i.to_vec().into()), interpolate).ok()) .flatten(); - return Recoded { image, warning: rule.instead }; + return Recoded { image, warning: rule.instead, note: None }; } } let Some(inks) = blob.cmyk() else { - return Recoded { image: None, warning: Some(if jpeg { CMYK_KEPT } else { CMYK_RGB }) }; + return Recoded { image: None, warning: Some(if jpeg { CMYK_KEPT } else { CMYK_RGB }), note: None }; }; let (width, height) = sized(&rule, (inks.width, inks.height), size); let (inks, alpha) = match resampled_inks(inks, alpha, &rule, (width, height)) { Ok(r) => r, - Err(warning) => return Recoded { image: None, warning }, + Err(warning) => return Recoded { image: None, warning, note: None }, }; let (samples, space) = match convert { Some(p) => p.convert(&inks), @@ -510,7 +670,7 @@ pub(crate) fn recode_cmyk( // JPEG needs opaque pixels. let image = jpeg_quality(&rule, jpeg).filter(|_| alpha.is_none()).and_then(|q| to_jpeg(&samples, space, width, height, q, interpolate, icc)); let image = image.or_else(|| lossless(samples, alpha, space, width, height, interpolate, icc)); - Recoded { image, warning: rule.instead } + Recoded { image, warning: rule.instead, note: None } } /// Ink amounts (four bytes a pixel) and their alpha (one byte a pixel), if any. diff --git a/crates/pdf/src/output.rs b/crates/pdf/src/output.rs index 8114931bc4..92381e1628 100644 --- a/crates/pdf/src/output.rs +++ b/crates/pdf/src/output.rs @@ -10,8 +10,12 @@ //! colours of the other model (and Lab ones), keeping the numbers of those already in it. //! Grey stays grey. Images (and freeform gradients' images) are converted the same way, pixel //! by pixel (CMYK images too: they keep their numbers unless converted, see [`CmykPixels`]); -//! printer's marks are not. The destination is a profile name; empty, the document's -//! profile for its colour mode. +//! printer's marks are not. With `destination` and an sRGB destination, a gray or RGB image +//! that carries an ICC profile is converted from it to sRGB with the same intent and written +//! without it ([`ColorOut::image_srgb_intent`]); with `destination` and a destination its +//! pixels are converted to (CMYK, another RGB space), a colour image is converted from its +//! profile to sRGB and then to the destination. Without conversion it keeps its profile. The +//! destination is a profile name; empty, the document's profile for its colour mode. //! - Profiles ([`ProfileInclusion`]): with `all`, with `destination`, and with `taggedSource` on a //! document that was assigned profiles, colours are written in ICC-based colour spaces: CMYK //! with the CMYK profile in effect (the destination when converting to CMYK, else the @@ -50,6 +54,8 @@ struct Dest { keep: bool, /// The destination is sRGB (what images are). srgb: bool, + /// Convert to Destination was asked for (not Preserve Numbers, nor PDF/X-1a's own conversion). + destination: bool, } /// How an export writes colours (see the module documentation). @@ -233,11 +239,12 @@ impl ColorOut { }; let cms = Cms::new(&settings).map_err(|e| PdfError::BadSetting(format!("output.destination: {e}")))?; // PDF/X-1a converts without being asked: as Preserve Numbers does. - let keep = set.output.conversion != ColorConversion::Destination || cms::canonical_name(&own) == name; + let destination = set.output.conversion == ColorConversion::Destination; + let keep = !destination || cms::canonical_name(&own) == name; if model == Model::Cmyk { out.cmyk_profile = name.clone(); } - out.dest = Some(Dest { srgb: name == cms::SRGB, cms, model, keep }); + out.dest = Some(Dest { srgb: name == cms::SRGB, cms, model, keep, destination }); } out.tagged = matches!(set.standard, Standard::PdfA2b | Standard::PdfX3 | Standard::PdfX4) || (!set.standard.cmyk_only() @@ -338,6 +345,18 @@ impl ColorOut { .as_ref() } + /// The intent a gray or RGB image that carries an ICC profile is converted from it to sRGB + /// with ([`crate::images::recode`]): the colour settings' intent, with Convert to Destination + /// and an sRGB destination, also when the document's RGB space is sRGB, or a destination + /// colour images' pixels are then converted to ([`Self::pixels`]: CMYK, another RGB space). + /// `None` otherwise, Preserve Numbers and an RGB destination that is the document's own RGB + /// profile (which leaves RGB colours and RGB images as they are) included: such an image + /// keeps its profile, or its numbers are converted as sRGB ones. + pub(crate) fn image_srgb_intent(&self) -> Option { + let d = self.dest.as_ref().filter(|d| d.destination)?; + ((d.model == Model::Rgb && d.srgb) || self.pixels().is_some()).then_some(self.intent) + } + /// The CMYK profile CMYK image `blob` is tagged with ([`crate::images::cmyk_icc`]): the one it /// carries, when its numbers are kept and the Profiles setting or the standard includes source /// profiles. `None` otherwise, and in PDF/X-1a, whose images are untagged CMYK. diff --git a/crates/pdf/src/pages.rs b/crates/pdf/src/pages.rs index 013d09f22d..683a14d1c3 100644 --- a/crates/pdf/src/pages.rs +++ b/crates/pdf/src/pages.rs @@ -297,6 +297,17 @@ impl Opened { bleed, } } + + /// How far page `index`'s (0-based) bleed box reaches past its trim box, `[top, bottom, left, + /// right]` in points, as the page shows (rotated); `None` past the last page, and when the bleed + /// box doesn't hold the trim box. + pub fn page_bleed(&self, index: usize) -> Option<[f64; 4]> { + let page = self.pdf.pages().get(index)?; + let init = Affine::new(page.initial_transform(true).as_coeffs()); + let (trim, bleed) = (init.transform_rect_bbox(page_box(page, CropTo::Trim)), init.transform_rect_bbox(page_box(page, CropTo::Bleed))); + let sides = [trim.y0 - bleed.y0, bleed.y1 - trim.y1, trim.x0 - bleed.x0, bleed.x1 - trim.x1]; + sides.iter().all(|b| b.is_finite() && *b >= 0.0).then_some(sides) + } } /// A PDF's pages as an import on their trim boxes sizes them ([`Opened::trim_pages`]). diff --git a/crates/pdf/src/tests/compression.rs b/crates/pdf/src/tests/compression.rs index 400bc0b0a8..f36cb88e4b 100644 --- a/crates/pdf/src/tests/compression.rs +++ b/crates/pdf/src/tests/compression.rs @@ -266,7 +266,7 @@ fn images_declaring_more_than_the_allowance_are_not_decoded() { assert_eq!(crate::images::mask_alpha(&blob, 60_000, 60_000, false), None); assert_eq!(crate::images::mask_alpha(&blob, 2, 2, false), None, "another size is not decoded at all"); let c: CompressionSettings = serde_json::from_value(json!({"gray": {"downsample": "bicubic", "ppi": 72, "abovePpi": 100}})).unwrap(); - let r = crate::images::recode(&png, (10.0, 10.0), &c, false, None, true); + let r = crate::images::recode(&png, (10.0, 10.0), &c, false, None, true, None); assert!(r.image.is_none(), "kept as it is"); } @@ -529,9 +529,14 @@ fn tagged_tiffs_keep_their_icc_profile() { ); } } - // Converted pixels are in the destination's colours, not the image's profile. + // Converted pixels are in the destination's colours, not the image's profile, which the CMS + // can't read (it has no tags): they are converted as sRGB ones, with a warning. for destination in [vectorcraft_color::cms::DISPLAY_P3, vectorcraft_color::cms::GENERIC_CMYK] { - let spaces = export(&tiff(&colour(), &rgb), json!({"output": {"conversion": "destination", "destination": destination}})); + let mut settings: PdfSettings = serde_json::from_value(json!({"output": {"conversion": "destination", "destination": destination}})).unwrap(); + settings.compression.compress_text = false; + let r = export_with_report(&tiff(&colour(), &rgb), &PdfOptions { settings, ..Default::default() }).unwrap(); + assert!(r.warnings.len() == 1 && r.warnings[0].contains("converted to the destination as sRGB"), "{destination}: {:?}", r.warnings); + let spaces = image_spaces(&r.bytes); assert!(spaces.len() == 1 && spaces[0].icc.as_deref() != Some(rgb.as_slice()), "converted to {destination}: {spaces:?}"); } // PDF/X-1a allows no ICC-based color spaces: a tagged gray TIFF (8 or 16 bits), PNG or JPEG is @@ -555,3 +560,423 @@ fn tagged_tiffs_keep_their_icc_profile() { } } } + +/// A gray ICC profile (version 2.1, display class) with a D50 white point whose tone curve is `trc`: +/// one entry, a gamma (u8Fixed8), or two, the ends of a table. 192 bytes. +fn gray_profile(trc: &[u16]) -> Vec { + let mut icc = vec![0u8; 192]; + icc[..4].copy_from_slice(&192u32.to_be_bytes()); + icc[8..12].copy_from_slice(&[2, 0x10, 0, 0]); + icc[12..16].copy_from_slice(b"mntr"); + icc[16..20].copy_from_slice(b"GRAY"); + icc[20..24].copy_from_slice(b"XYZ "); + icc[36..40].copy_from_slice(b"acsp"); + let d50 = [0x0000_F6D6u32, 0x0001_0000, 0x0000_D32D]; + for (i, v) in d50.iter().enumerate() { + icc[68 + i * 4..72 + i * 4].copy_from_slice(&v.to_be_bytes()); + } + icc[128..132].copy_from_slice(&2u32.to_be_bytes()); + let curve_len = 12 + 2 * trc.len() as u32; + for (i, (sig, at, len)) in [(b"kTRC", 156u32, curve_len), (b"wtpt", 172, 20)].into_iter().enumerate() { + let e = 132 + i * 12; + icc[e..e + 4].copy_from_slice(sig); + icc[e + 4..e + 8].copy_from_slice(&at.to_be_bytes()); + icc[e + 8..e + 12].copy_from_slice(&len.to_be_bytes()); + } + icc[156..160].copy_from_slice(b"curv"); + icc[164..168].copy_from_slice(&(trc.len() as u32).to_be_bytes()); + for (i, v) in trc.iter().enumerate() { + icc[168 + i * 2..170 + i * 2].copy_from_slice(&v.to_be_bytes()); + } + icc[172..176].copy_from_slice(b"XYZ "); + for (i, v) in d50.iter().enumerate() { + icc[180 + i * 4..184 + i * 4].copy_from_slice(&v.to_be_bytes()); + } + icc +} + +/// The samples of `img` converted from ICC profile `icc` to sRGB with `intent`, as an image +/// XObject holds them: RGB (or grey, the mean of the sRGB values, when `img` is grey) at 8 bits a +/// sample, or at 16 big-endian with `deep`. +fn srgb_samples(img: &DynamicImage, icc: &[u8], intent: vectorcraft_color::cms::Intent, deep: bool) -> Vec { + let p = vectorcraft_color::cms::IccProfile::from_bytes(None, icc).unwrap(); + let (max, n) = (if deep { 65_535.0 } else { 255.0 }, if img.color().has_color() { 3 } else { 1 }); + let values: Vec = match (n, deep) { + (3, true) => img.to_rgb16().into_raw().into_iter().map(f32::from).collect(), + (3, false) => img.to_rgb8().into_raw().into_iter().map(f32::from).collect(), + (_, true) => img.to_luma16().into_raw().into_iter().map(f32::from).collect(), + _ => img.to_luma8().into_raw().into_iter().map(f32::from).collect(), + }; + let mut out = vec![]; + for px in values.chunks(n) { + let v: Vec = px.iter().map(|v| v / max).collect(); + let rgb = p.to_srgb(&v, intent).unwrap(); + let rgb = if n == 1 { vec![rgb.iter().sum::() / 3.0] } else { rgb.to_vec() }; + for c in rgb { + let s = (c * max).round(); + if deep { out.extend_from_slice(&(s as u16).to_be_bytes()) } else { out.push(s as u8) } + } + } + out +} + +/// Do samples `a` and `b` (8 bits each, or 16 big-endian with `deep`) differ by at most `tolerance`? +fn samples_close(a: &[u8], b: &[u8], deep: bool, tolerance: i32) -> bool { + let values = |s: &[u8]| -> Vec { + if deep { s.chunks(2).map(|v| i32::from(u16::from_be_bytes([v[0], v[1]]))).collect() } else { s.iter().map(|v| i32::from(*v)).collect() } + }; + let (a, b) = (values(a), values(b)); + a.len() == b.len() && a.iter().zip(&b).all(|(a, b)| (a - b).abs() <= tolerance) +} + +/// The image XObjects of `d` exported with `settings`, which add no warning, and the file. +fn export_images(d: &Document, settings: Value) -> (Vec, Vec) { + let mut settings: PdfSettings = serde_json::from_value(settings).unwrap(); + settings.compression.compress_text = false; + let r = export_with_report(d, &PdfOptions { settings, ..Default::default() }).unwrap(); + assert!(r.warnings.is_empty(), "{:?}", r.warnings); + (image_spaces(&r.bytes), r.bytes) +} + +/// With Convert to Destination and an sRGB destination, a gray or RGB image that carries an ICC +/// profile is converted from it to sRGB and written without it: an 8-bit TIFF, a 16-bit one (at +/// 16 bits a sample from PDF 1.5 on, else at 8), a PNG, a gray TIFF, a resampled image and a JPEG +/// kept as JPEG. Without conversion each keeps its profile. An image in an sRGB profile keeps its +/// samples (a JPEG its JPEG data), and a black-and-white image converted to grays stays gray. +#[test] +fn convert_to_srgb_converts_tagged_images_from_their_profile() { + use vectorcraft_color::cms::{DISPLAY_P3, Intent, SRGB, icc_bytes}; + // Marked version 4.2: PDF 1.7 holds ICC profiles up to that version. + let mut p3 = icc_bytes(DISPLAY_P3).unwrap().to_vec(); + p3[8..12].copy_from_slice(&[4, 0x20, 0, 0]); + // Gamma 1.8. + let gray = gray_profile(&[0x01CD]); + let to_srgb = |more: Value| { + let mut s = json!({"output": {"conversion": "destination", "destination": SRGB}}); + s.as_object_mut().unwrap().extend(more.as_object().unwrap().clone()); + s + }; + let device = |space: &str, bits: u8, filter: &str| vec![Written { space: space.into(), icc: None, bits, filter: filter.into(), smask: false }]; + let flate = "FlateDecode"; + let deep = DynamicImage::ImageRgb16(image::ImageBuffer::from_fn(600, 600, |x, y| { + image::Rgb([((x * 109 + y) % 65_521) as u16, ((y * 257 + 3) ^ 0x5a) as u16, ((x * y) % 65_521) as u16]) + })); + let cases = [ + ("RGB TIFF", colour(), tagged(&colour(), &p3, ImageFormat::Tiff), "image/tiff", &p3, json!({}), device("DeviceRGB", 8, flate), false), + ("RGB PNG", colour(), tagged(&colour(), &p3, ImageFormat::Png), "image/png", &p3, json!({}), device("DeviceRGB", 8, flate), false), + ( + "16-bit RGB TIFF", + deep.clone(), + tagged(&deep, &p3, ImageFormat::Tiff), + "image/tiff", + &p3, + json!({"compatibility": "1.7"}), + device("DeviceRGB", 16, flate), + true, + ), + ( + "16-bit RGB TIFF, PDF 1.4", + deep.clone(), + tagged(&deep, &p3, ImageFormat::Tiff), + "image/tiff", + &p3, + json!({"compatibility": "1.4"}), + device("DeviceRGB", 8, flate), + false, + ), + ( + "gray TIFF", + colour().grayscale(), + tagged(&colour().grayscale(), &gray, ImageFormat::Tiff), + "image/tiff", + &gray, + json!({}), + device("DeviceGray", 8, flate), + false, + ), + ]; + for (what, img, bytes, mime, icc, more, want, sixteen) in cases { + let d = doc(bytes, mime); + let (spaces, pdf) = export_images(&d, to_srgb(more.clone())); + assert_eq!(spaces, want, "{what}"); + let samples = image_samples(&pdf); + // 16-bit samples are converted, then reduced to 8 bits before PDF 1.5. + let deep_source = matches!(img, DynamicImage::ImageRgb16(_)); + let want = srgb_samples(&img, icc, Intent::RelativeColorimetric, deep_source); + let want: Vec = if deep_source && !sixteen { + want.chunks(2).map(|v| (f32::from(u16::from_be_bytes([v[0], v[1]])) / 257.0).round() as u8).collect() + } else { + want + }; + assert!(samples.len() == 1 && samples_close(&samples[0], &want, sixteen, if sixteen { 2 } else { 1 }), "{what}: converted samples"); + let unconverted: Vec = match &img { + DynamicImage::ImageRgb16(s) if sixteen => s.iter().flat_map(|v| v.to_be_bytes()).collect(), + s if s.color().has_color() => s.to_rgb8().into_raw(), + s => s.to_luma8().into_raw(), + }; + assert!(!samples_close(&samples[0], &unconverted, sixteen, 0), "{what}: the samples changed"); + // Without conversion the image keeps its profile (a version 4 profile from PDF 1.5 on). + let mut plain = more; + plain["compatibility"] = json!("1.7"); + let (spaces, _) = export_images(&d, plain); + assert!( + spaces.len() == 1 && spaces[0].space == "ICCBased" && spaces[0].icc.as_deref() == Some(icc.as_slice()), + "{what}, no conversion: {spaces:?}" + ); + } + // Resampled, or kept as a JPEG: converted and written without the profile. + let resampled = to_srgb(json!({"compression": {"color": {"downsample": "bicubic", "ppi": 150, "abovePpi": 225}}})); + let (spaces, _) = export_images(&doc(tagged(&colour(), &p3, ImageFormat::Tiff), "image/tiff"), resampled); + assert_eq!(spaces, device("DeviceRGB", 8, flate), "resampled"); + let (spaces, _) = export_images(&doc(tagged(&colour(), &p3, ImageFormat::Jpeg), "image/jpeg"), to_srgb(json!({}))); + assert_eq!(spaces, device("DeviceRGB", 8, "DCTDecode"), "JPEG"); + // An image in an sRGB profile keeps its samples, and a JPEG its JPEG data, written without the + // profile; so does an image whose samples the conversion leaves unchanged (black in P3). + let srgb = icc_bytes(SRGB).unwrap().to_vec(); + let (spaces, pdf) = export_images(&doc(tagged(&colour(), &srgb, ImageFormat::Png), "image/png"), to_srgb(json!({"compatibility": "1.7"}))); + assert_eq!(spaces, device("DeviceRGB", 8, flate), "sRGB PNG"); + assert!(image_samples(&pdf) == [colour().to_rgb8().into_raw()], "sRGB PNG: the samples unchanged"); + let jpeg = tagged(&colour(), &srgb, ImageFormat::Jpeg); + let (spaces, pdf) = export_images(&doc(jpeg.clone(), "image/jpeg"), to_srgb(json!({"compatibility": "1.7"}))); + assert_eq!(spaces, device("DeviceRGB", 8, "DCTDecode"), "sRGB JPEG"); + assert!(image_data(&pdf) == [jpeg], "sRGB JPEG: the JPEG data unchanged"); + let black = DynamicImage::ImageRgb8(image::RgbImage::new(600, 600)); + let (spaces, _) = export_images(&doc(tagged(&black, &p3, ImageFormat::Png), "image/png"), to_srgb(json!({"compatibility": "1.7"}))); + assert_eq!(spaces, device("DeviceRGB", 8, flate), "black P3 PNG"); + // A black-and-white image whose profile makes black a gray is written in those grays, not + // thresholded back to black and white when it is resampled. + let bilevel = + DynamicImage::ImageLuma8(image::GrayImage::from_fn(600, 600, |x, y| image::Luma([if (x / 10 + y / 10) % 2 == 0 { 0 } else { 255 }]))); + let lifted = gray_profile(&[0x3333, 0xFFFF]); + let resample = json!({"downsample": "bicubic", "ppi": 150, "abovePpi": 225}); + let (spaces, pdf) = export_images( + &doc(tagged(&bilevel, &lifted, ImageFormat::Tiff), "image/tiff"), + to_srgb(json!({"compression": {"gray": resample.clone(), "mono": resample}})), + ); + assert_eq!(spaces, device("DeviceGray", 8, flate), "lifted black and white"); + let samples = image_samples(&pdf); + assert!(samples.len() == 1 && samples[0].iter().all(|v| *v > 0) && samples[0].iter().any(|v| *v > 200), "lifted black and white: grays kept"); + // A well-formed profile the CMS can't read (no tags) is kept, with a warning. + let unread = icc_profile(b"RGB ", 600); + let mut settings: PdfSettings = serde_json::from_value(to_srgb(json!({}))).unwrap(); + settings.compression.compress_text = false; + let r = export_with_report(&doc(tagged(&colour(), &unread, ImageFormat::Tiff), "image/tiff"), &PdfOptions { settings, ..Default::default() }) + .unwrap(); + assert!(r.warnings.iter().any(|w| w.contains("couldn't be read")), "{:?}", r.warnings); + let spaces = image_spaces(&r.bytes); + assert!(spaces.len() == 1 && spaces[0].icc.as_deref() == Some(unread.as_slice()), "unread profile: {spaces:?}"); + // A tagged image whose header declares more pixels than the decoding allowance is kept as it + // is, with a warning when it was to be converted. + let declared = |img: &DynamicImage, icc: &[u8]| { + let mut png = tagged(img, icc, ImageFormat::Png); + png[16..24].copy_from_slice(&[0, 0, 0x75, 0x30, 0, 0, 0x75, 0x30]); + let mut crc = flate2::Crc::new(); + crc.update(&png[12..29]); + png[29..33].copy_from_slice(&crc.sum().to_be_bytes()); + png + }; + let c: CompressionSettings = serde_json::from_value(json!({})).unwrap(); + let srgb_out = Some(crate::images::ToSrgb { intent: Intent::RelativeColorimetric, sixteen_bit: true }); + for (what, png) in [("RGB", declared(&colour(), &p3)), ("gray", declared(&colour().grayscale(), &gray))] { + let r = crate::images::recode(&png, (100.0, 100.0), &c, false, None, true, srgb_out); + assert!(r.image.is_none() && r.warning.is_some_and(|w| w.contains("not converted to sRGB")), "{what}: {:?}", r.warning); + let r = crate::images::recode(&png, (100.0, 100.0), &c, false, None, true, None); + assert!(r.image.is_none() && r.warning.is_none(), "{what}, no conversion: {:?}", r.warning); + // Too large to embed as it is too: the export leaves it out, and says only that. + let mut settings: PdfSettings = serde_json::from_value(to_srgb(json!({}))).unwrap(); + settings.compression.compress_text = false; + let r = export_with_report(&doc(png, "image/png"), &PdfOptions { settings, ..Default::default() }).unwrap(); + assert!(r.warnings.len() == 1 && r.warnings[0].contains("missing from the picture"), "{what}, exported: {:?}", r.warnings); + } +} + +/// An RGB ICC profile (version 2.1, display class, XYZ connection space) of two lookup tables of +/// two grid points each: the colorimetric one (`A2B1`) maps device RGB linearly onto sRGB's +/// primaries adapted to D50, the perceptual one (`A2B0`) onto half of them. 436 bytes. +fn rgb_two_intents() -> Vec { + let mut icc = vec![0u8; 436]; + icc[..4].copy_from_slice(&436u32.to_be_bytes()); + icc[8..12].copy_from_slice(&[2, 0x10, 0, 0]); + icc[12..16].copy_from_slice(b"mntr"); + icc[16..20].copy_from_slice(b"RGB "); + icc[20..24].copy_from_slice(b"XYZ "); + icc[36..40].copy_from_slice(b"acsp"); + let d50 = [0x0000_F6D6u32, 0x0001_0000, 0x0000_D32D]; + for (i, v) in d50.iter().enumerate() { + icc[68 + i * 4..72 + i * 4].copy_from_slice(&v.to_be_bytes()); + } + icc[128..132].copy_from_slice(&3u32.to_be_bytes()); + for (i, (sig, at, len)) in [(b"A2B0", 168u32, 124u32), (b"A2B1", 292, 124), (b"wtpt", 416, 20)].into_iter().enumerate() { + let e = 132 + i * 12; + icc[e..e + 4].copy_from_slice(sig); + icc[e + 4..e + 8].copy_from_slice(&at.to_be_bytes()); + icc[e + 8..e + 12].copy_from_slice(&len.to_be_bytes()); + } + let primaries = [[0.4361f32, 0.2225, 0.0139], [0.3851, 0.7169, 0.0971], [0.1431, 0.0606, 0.7141]]; + for (at, scale) in [(168usize, 0.5f32), (292, 1.0)] { + let mut lut = vec![]; + lut.extend_from_slice(b"mft2"); + lut.extend_from_slice(&[0, 0, 0, 0, 3, 3, 2, 0]); + // The identity matrix, as s15Fixed16 numbers. + for v in [1u32, 0, 0, 0, 1, 0, 0, 0, 1] { + lut.extend_from_slice(&(v << 16).to_be_bytes()); + } + lut.extend_from_slice(&2u16.to_be_bytes()); + lut.extend_from_slice(&2u16.to_be_bytes()); + for _ in 0..3 { + lut.extend_from_slice(&[0, 0, 0xFF, 0xFF]); + } + for corner in 0..8u32 { + let on = [corner >> 2 & 1, corner >> 1 & 1, corner & 1].map(|b| b as f32); + let xyz = primaries.iter().zip(on).fold([0.0f32; 3], |sum, (p, o)| [sum[0] + o * p[0], sum[1] + o * p[1], sum[2] + o * p[2]]); + for v in xyz { + lut.extend_from_slice(&((v * scale * 32_768.0).round() as u16).to_be_bytes()); + } + } + for _ in 0..3 { + lut.extend_from_slice(&[0, 0, 0xFF, 0xFF]); + } + icc[at..at + 124].copy_from_slice(&lut); + } + icc[416..420].copy_from_slice(b"XYZ "); + for (i, v) in d50.iter().enumerate() { + icc[424 + i * 4..428 + i * 4].copy_from_slice(&v.to_be_bytes()); + } + icc +} + +/// The conversion of a tagged image to an sRGB destination uses the colour settings' intent: an +/// RGB profile with a perceptual table and a colorimetric one converts with the table the intent +/// picks. +#[test] +fn convert_to_srgb_converts_images_with_the_color_settings_intent() { + use vectorcraft_color::cms::{self, ColorSettings, Intent, SRGB}; + let icc = rgb_two_intents(); + let img = colour(); + let d = doc(tagged(&img, &icc, ImageFormat::Tiff), "image/tiff"); + let settings = json!({"output": {"conversion": "destination", "destination": SRGB}}); + let mut seen = vec![]; + for intent in [Intent::RelativeColorimetric, Intent::Perceptual] { + let active = std::sync::Arc::new(cms::Cms::new(&ColorSettings { intent, ..ColorSettings::default() }).unwrap()); + let samples = image_samples(&cms::with_active(active, || export_images(&d, settings.clone())).1); + let want = srgb_samples(&img, &icc, intent, false); + assert!(samples.len() == 1 && samples_close(&samples[0], &want, false, 1), "{intent:?}: converted samples"); + seen.push(samples[0].clone()); + } + assert!(seen[0] != seen[1], "the two intents give different samples"); +} + +/// A 600 × 600 image of sixteen blocks of colour, saturated ones (the primaries and secondaries) +/// among them. +fn colour_blocks() -> DynamicImage { + const LEVELS: [u8; 4] = [0, 90, 200, 255]; + image::RgbImage::from_fn(600, 600, |x, y| { + let (i, j) = ((x / 150) as usize, (y / 150) as usize); + image::Rgb([LEVELS[i], LEVELS[j], LEVELS[(i + 2 * j) % 4]]) + }) + .into() +} + +/// The samples (8 bits each) of `img`'s pixels as `convert` gives each one's: each distinct +/// colour converts once. +fn converted_samples(img: &DynamicImage, convert: impl Fn([u8; 3]) -> Vec) -> Vec { + let mut seen: std::collections::HashMap<[u8; 3], Vec> = std::collections::HashMap::new(); + let mut out = vec![]; + for p in img.to_rgb8().pixels() { + out.extend_from_slice(seen.entry(p.0).or_insert_with(|| convert(p.0))); + } + out +} + +/// The largest difference between samples `a` and `b` (8 bits each) and where it is: (difference, +/// sample index, the two samples), or the two lengths when they differ. +fn max_diff(a: &[u8], b: &[u8]) -> String { + if a.len() != b.len() { + return format!("lengths {} and {}", a.len(), b.len()); + } + let worst = a.iter().zip(b).enumerate().max_by_key(|(_, (a, b))| a.abs_diff(**b)); + worst.map_or_else(String::new, |(i, (x, y))| format!("largest difference {} at sample {i}: {x} vs {y}", x.abs_diff(*y))) +} + +/// With Convert to Destination and a CMYK destination, or an RGB one other than sRGB, a colour +/// image that carries an ICC profile is converted from that profile (to sRGB, then to the +/// destination) rather than from its numbers read as sRGB ones; an untagged image is converted +/// from sRGB as before. A gray image keeps its profile (gray stays gray), and an RGB profile the +/// CMS can't read leaves the numbers converted as sRGB ones, with a warning. An RGB destination +/// other than sRGB that is the document's own RGB profile leaves RGB images as they are, so the image keeps its +/// profile. +#[test] +fn convert_to_destination_converts_tagged_images_from_their_profile() { + use vectorcraft_color::cms::{self, ColorSettings, DISPLAY_P3, GENERIC_CMYK, Intent, WIDE_GAMUT_RGB, icc_bytes}; + let intent = Intent::RelativeColorimetric; + let active = std::sync::Arc::new(cms::Cms::new(&ColorSettings { intent, ..ColorSettings::default() }).unwrap()); + let mut p3 = icc_bytes(DISPLAY_P3).unwrap().to_vec(); + p3[8..12].copy_from_slice(&[4, 0x20, 0, 0]); + let p3_profile = cms::IccProfile::from_bytes(None, &p3).unwrap(); + // A P3 pixel's sRGB samples. + let from_p3 = |rgb: [u8; 3]| p3_profile.to_srgb(&rgb.map(|v| f32::from(v) / 255.0), intent).unwrap().map(|v| (v * 255.0).round() as u8); + let img = colour_blocks(); + let tagged_png = doc(tagged(&img, &p3, ImageFormat::Png), "image/png"); + let untagged_png = doc(encoded(img.clone(), ImageFormat::Png), "image/png"); + let device = |space: &str| vec![Written { space: space.into(), icc: None, bits: 8, filter: "FlateDecode".into(), smask: false }]; + // Each destination: the space images are written in, and how an sRGB pixel's samples convert + // to it (the tables the export builds from the destination's color settings). + let cmyk = cms::Cms::new(&ColorSettings { intent, cmyk: GENERIC_CMYK.into(), ..ColorSettings::default() }).unwrap(); + let wide = cms::Cms::new(&ColorSettings { intent, rgb: WIDE_GAMUT_RGB.into(), ..ColorSettings::default() }).unwrap(); + let cmy = cms::ProofLut::build(|rgb| { + let v = cmyk.srgb_to_cmyk(rgb, intent); + [v[0], v[1], v[2]] + }); + let black = cms::ProofLut::build(|rgb| [cmyk.srgb_to_cmyk(rgb, intent)[3], 0.0, 0.0]); + let wide_lut = cms::ProofLut::build(|rgb| wide.srgb_to_rgb(rgb)); + let to_cmyk = |rgb: [u8; 3]| [cmy.apply8(rgb).as_slice(), &black.apply8(rgb)[..1]].concat(); + let to_wide = |rgb: [u8; 3]| wide_lut.apply8(rgb).to_vec(); + type ToDest<'a> = &'a dyn Fn([u8; 3]) -> Vec; + let destinations: [(&str, &str, ToDest<'_>); 2] = [(GENERIC_CMYK, "DeviceCMYK", &to_cmyk), (WIDE_GAMUT_RGB, "DeviceRGB", &to_wide)]; + for (name, space, to_dest) in destinations { + let settings = json!({"output": {"conversion": "destination", "destination": name}}); + let export = |d: &Document| cms::with_active(active.clone(), || export_images(d, settings.clone())); + let (spaces, pdf) = export(&tagged_png); + assert_eq!(spaces, device(space), "{name}: tagged"); + let samples = image_samples(&pdf); + let want = converted_samples(&img, |rgb| to_dest(from_p3(rgb))); + let as_srgb = converted_samples(&img, to_dest); + assert!( + samples.len() == 1 && samples_close(&samples[0], &want, false, 1), + "{name}: converted from the profile, {}", + max_diff(&samples[0], &want) + ); + assert!(!samples_close(&samples[0], &as_srgb, false, 8), "{name}: not converted as sRGB"); + // An untagged image is converted from sRGB, as before. + let (spaces, pdf) = export(&untagged_png); + assert_eq!(spaces, device(space), "{name}: untagged"); + let samples = image_samples(&pdf); + assert!( + samples.len() == 1 && samples_close(&samples[0], &as_srgb, false, 1), + "{name}: untagged converted as sRGB, {}", + max_diff(&samples[0], &as_srgb) + ); + } + // A destination that is the document's own RGB profile leaves RGB images as they are: the + // image keeps its profile, as untagged images keep their numbers. + let own = std::sync::Arc::new(cms::Cms::new(&ColorSettings { intent, rgb: WIDE_GAMUT_RGB.into(), ..ColorSettings::default() }).unwrap()); + let settings = json!({"compatibility": "1.7", "output": {"conversion": "destination", "destination": WIDE_GAMUT_RGB}}); + let (spaces, _) = cms::with_active(own, || export_images(&tagged_png, settings)); + assert!(spaces.len() == 1 && spaces[0].space == "ICCBased" && spaces[0].icc.as_deref() == Some(p3.as_slice()), "own RGB profile: {spaces:?}"); + // A gray image keeps its gray profile in a CMYK destination. + let gray = gray_profile(&[0x01CD]); + let settings = json!({"output": {"conversion": "destination", "destination": GENERIC_CMYK}}); + let gray_doc = doc(tagged(&colour().grayscale(), &gray, ImageFormat::Tiff), "image/tiff"); + let (spaces, _) = cms::with_active(active.clone(), || export_images(&gray_doc, settings.clone())); + assert!(spaces.len() == 1 && spaces[0].space == "ICCBased" && spaces[0].icc.as_deref() == Some(gray.as_slice()), "gray: {spaces:?}"); + // A profile the CMS can't read: converted as sRGB, with a warning. + let unread = icc_profile(b"RGB ", 600); + let mut s: PdfSettings = serde_json::from_value(settings).unwrap(); + s.compression.compress_text = false; + let unread_doc = doc(tagged(&img, &unread, ImageFormat::Png), "image/png"); + let r = cms::with_active(active, || export_with_report(&unread_doc, &PdfOptions { settings: s, ..Default::default() })).unwrap(); + assert!(r.warnings.iter().any(|w| w.contains("converted to the destination as sRGB")), "{:?}", r.warnings); + assert_eq!(image_spaces(&r.bytes), device("DeviceCMYK"), "unread profile"); + let as_srgb = converted_samples(&img, to_cmyk); + assert!(image_samples(&r.bytes).first().is_some_and(|s| samples_close(s, &as_srgb, false, 1)), "unread profile: converted as sRGB"); +} diff --git a/crates/svg/Cargo.toml b/crates/svg/Cargo.toml index 36744c8de5..e7d869e6f6 100644 --- a/crates/svg/Cargo.toml +++ b/crates/svg/Cargo.toml @@ -28,6 +28,8 @@ serde_json = { workspace = true } # SVGZ (gzip, pure-Rust backend: builds for wasm). flate2 = { workspace = true } image = { workspace = true } +# Image data URLs decoded as usvg decodes them, within a byte limit (MIT OR Apache-2.0; already used by usvg). +data-url = "0.3.2" # Colour / transform / length parsing for the fallback (MIT OR Apache-2.0; already used by usvg). svgtypes = "0.16" simplecss = "0.2.2" diff --git a/crates/svg/src/css.rs b/crates/svg/src/css.rs index 5b0b921e32..2bfa7c5e5c 100644 --- a/crates/svg/src/css.rs +++ b/crates/svg/src/css.rs @@ -168,8 +168,8 @@ pub(crate) fn type_props(st: &CharStyle, len: &dyn Fn(f64) -> String) -> Props { if spacing != 0.0 { p.push(("letter-spacing", len(spacing / 1000.0 * st.size * st.v_scale / 100.0))); } - if st.kerning.is_some() { - // Manual kerning replaces the font's pair kerning. + if st.kerning.is_some() || st.glyph_form.is_width() { + // Manual kerning replaces the font's pair kerning, and a monospaced width form turns it off. p.push(("font-kerning", "none".into())); } let mut v: Vec = st @@ -190,8 +190,9 @@ pub(crate) fn type_props(st: &CharStyle, len: &dyn Fn(f64) -> String) -> Props { .filter(|(tag, _)| tag.len() == 4 && tag.bytes().all(|b| b.is_ascii_alphanumeric())) .map(|(tag, value)| format!("\"{tag}\" {value}")) .collect(); - // Proportional Metrics: `palt` (SVG writes vertical type as outlines). - if st.proportional_metrics { + // Proportional Metrics: `palt` (SVG writes vertical type as outlines), not with a monospaced + // width form, which sets the characters on a fixed width. + if st.proportional_metrics && !st.glyph_form.is_width() { v.push("\"palt\" 1".into()); } // The East Asian glyph form: its one feature. @@ -876,4 +877,24 @@ mod tests { assert_eq!(css_string("w\r\""), r#""w\d \"\3c /style\3e ""#); assert_eq!(css_string("wght"), "\"wght\""); } + + /// A monospaced width form sets the characters on a fixed width: Proportional Metrics' `palt` + /// is not written with it and the font's pair kerning is turned off, and with the other forms + /// `palt` is written and kerning stays. + #[test] + fn width_forms_leave_out_proportional_metrics() { + use vectorcraft_doc::GlyphForm; + let prop = |form, key: &str| { + let st = CharStyle { proportional_metrics: true, glyph_form: form, ..CharStyle::default() }; + type_props(&st, &|v| format!("{v}px")).into_iter().find(|(k, _)| *k == key).map(|(_, v)| v) + }; + assert_eq!(prop(GlyphForm::Default, "font-feature-settings").as_deref(), Some("\"palt\" 1")); + assert_eq!(prop(GlyphForm::Jis78, "font-feature-settings").as_deref(), Some("\"palt\" 1, \"jp78\" 1")); + assert_eq!(prop(GlyphForm::Jis78, "font-kerning"), None); + for form in [GlyphForm::FullWidth, GlyphForm::HalfWidth, GlyphForm::ThirdWidth, GlyphForm::QuarterWidth] { + let tag = std::str::from_utf8(form.feature().unwrap()).unwrap(); + assert_eq!(prop(form, "font-feature-settings"), Some(format!("\"{tag}\" 1")), "{form:?}"); + assert_eq!(prop(form, "font-kerning").as_deref(), Some("none"), "{form:?}"); + } + } } diff --git a/crates/svg/src/import.rs b/crates/svg/src/import.rs index 5267acd279..287569b859 100644 --- a/crates/svg/src/import.rs +++ b/crates/svg/src/import.rs @@ -20,7 +20,7 @@ use text::TextSlots; pub use depth::MAX_ELEMENT_DEPTH; pub(crate) use depth::xml_too_deep; -pub use files::{MAX_NESTED_SVG_IMAGES, MAX_NESTED_SVG_READS, MAX_NESTED_SVG_TEXT}; +pub use files::{MAX_DATA_URL_BYTES, MAX_NESTED_SVG_IMAGES, MAX_NESTED_SVG_READS, MAX_NESTED_SVG_TEXT}; pub(crate) use fx::BLEND; /// Most objects an imported SVG may expand to: a guard against `` and nested-image @@ -205,7 +205,7 @@ pub(crate) fn import_finding( symbol_suffixes: HashMap::new(), pattern_suffixes: HashMap::new(), blends: fx::blends(&xml, &scan.markers), - filter_sources: fx::sources(&xml, &scan.markers), + filter_sources: fx::sources(&xml, &scan.markers, |h| files.data_href(h)), files, non_scaling: found.non_scaling, data: found.data, diff --git a/crates/svg/src/import/files.rs b/crates/svg/src/import/files.rs index fe3335fb4e..c60d22f983 100644 --- a/crates/svg/src/import/files.rs +++ b/crates/svg/src/import/files.rs @@ -11,6 +11,12 @@ //! A raster image, linked or a data URL, whose orientation (EXIF, or a TIFF's Orientation tag) //! turns or flips its pixels is turned before usvg gets it, so it is sized, fitted and stored as //! it is shown. +//! +//! usvg decodes a data URL each time it converts the element holding it, a `` copy +//! included, before the resolver sees it. So before usvg reads an SVG (the imported one, and +//! each SVG image), each `` and `` href that is a data URL is changed to a key, +//! and the resolver decodes the URL once, the first time a key comes, within what the data URLs +//! may decode to together ([`MAX_DATA_URL_BYTES`]); every later use of it gets the same image. use std::collections::HashMap; use std::path::Path; @@ -34,6 +40,13 @@ pub const MAX_NESTED_SVG_READS: usize = 1024; /// Most bytes of nested SVG image text an imported SVG may read, decoded or not, all reads together. pub const MAX_NESTED_SVG_TEXT: u64 = 512 << 20; +/// Most bytes the image data URLs an imported SVG shows may decode to, those in its SVG images +/// included, all together; each URL is decoded once, however often it is shown. +pub const MAX_DATA_URL_BYTES: u64 = 512 << 20; + +/// What a data URL's key starts with, the URL's number following it. +const DATA_URL_KEY: &str = "vectorcraft-data-url-"; + /// The files an SVG's images link to. #[derive(Default)] pub(in crate::import) struct Files { @@ -74,6 +87,12 @@ pub(in crate::import) struct Files { /// The CMYK TIFF stored in place of a turned CMYK image, by the address of the PNG usvg got /// (kept here too, so the address stays its own). cmyk: Mutex>, + /// The data URLs changed to keys before usvg read them. + data: Mutex, + /// The bytes the image data URLs decoded to so far, whether or not they are images. + data_bytes: AtomicU64, + /// Images were left out for their data URLs decoding past [`MAX_DATA_URL_BYTES`]. + too_much_data: AtomicBool, /// What usvg makes of the SVG and of the SVG images read so far, all together ([`budget`]): /// elements, bytes of copied style values and selector tests. spent: Mutex, @@ -82,6 +101,34 @@ pub(in crate::import) struct Files { refused: Mutex>, } +/// The hrefs changed to keys, by number: an href's key is [`DATA_URL_KEY`] and its number. +#[derive(Default)] +struct DataUrls { + /// The number of each href. + numbers: HashMap, usize>, + /// Each href: a data URL, then what it decoded to, or a link. + urls: Vec, + /// Each href as it was written, for the filters kept as written ([`Files::data_href`]). + hrefs: Vec>, +} + +enum DataUrl { + Encoded(Arc), + Decoded(Decoded), + /// A link that starts as a key does, looked up as it is. + Link(Arc), +} + +/// What a data URL decoded to, as usvg gets it. +#[derive(Clone)] +enum Decoded { + Raster(RasterKind, Bytes), + /// SVG text (unpacked when it was SVGZ), read where it is shown ([`Files::svg`]). + Svg(Arc), + /// Not an image VectorCraft reads, or left out. + Nothing, +} + /// Encoded image bytes, shared. type Bytes = Arc>; @@ -254,7 +301,7 @@ fn key(bytes: &Arc>) -> usize { impl Files { /// Read the files the `` elements of `xml` (the text `svg`) link to. Placeholders /// stretch to the image's box (`preserveAspectRatio="none"`), which the file fills once - /// relinked. + /// relinked. The data URLs of its images are changed to keys ([`Files::take_data_urls`]). pub(in crate::import) fn read(svg: &str, xml: &roxmltree::Document, opts: &ImportOptions, edits: &mut Edits, warnings: &mut Vec) -> Self { let mut files = Files::default(); let mut reading = Reading { @@ -269,9 +316,41 @@ impl Files { measured: budget::Cost::default(), }; files.sources = files.links_of(svg, xml, opts.folder, None, edits, &mut reading); + files.take_data_urls(xml, edits); files } + /// Change each href of the `` and `` elements of `xml` that usvg would decode + /// as a data URL to that URL's key, numbering the URLs not seen before: usvg gives a key to + /// the resolver as it is ([`Files::data_url`]). An href that starts as a key does, a link, + /// gets a key too, which the resolver reads as that link. + fn take_data_urls(&self, xml: &roxmltree::Document, edits: &mut Edits) { + let Ok(mut data) = self.data.lock() else { return }; + let hrefs = xml + .descendants() + .filter(|n| n.is_element() && matches!(n.tag_name().name(), "image" | "feImage")) + .flat_map(|n| n.attributes()) + .filter(|a| a.name() == "href" && matches!(a.namespace(), None | Some("http://www.w3.org/1999/xlink"))); + for a in hrefs { + let url = data_url::DataUrl::process(a.value()).is_ok(); + if !url && !a.value().starts_with(DATA_URL_KEY) { + continue; + } + let n = match data.numbers.get(a.value()) { + Some(n) => *n, + None => { + let href: Arc = Arc::from(a.value()); + let n = data.urls.len(); + data.numbers.insert(href.clone(), n); + data.hrefs.push(href.clone()); + data.urls.push(if url { DataUrl::Encoded(href) } else { DataUrl::Link(href) }); + n + } + }; + edits.set_value(a, &format!("{DATA_URL_KEY}{n}")); + } + } + /// What usvg gets for each href the `` elements of `xml` (the text `svg`) link to, /// relative paths taken from `folder`. `inside` is how deep the SVG images `xml` shows nest in /// it and the SVGs around it, measured when an SVG file is read (`None` for `xml` itself). @@ -507,6 +586,9 @@ impl Files { MAX_NESTED_SVG_TEXT >> 20 )); } + if self.too_much_data.load(Ordering::Relaxed) { + notes.push(format!("images were left out: together their data URLs decode to more than {} MB", MAX_DATA_URL_BYTES >> 20)); + } notes } @@ -537,47 +619,145 @@ impl Files { } } - /// usvg's image resolver, for an SVG whose elements nest `height` deep: data URLs as usvg - /// reads them, other hrefs only from the files read. SVG images are read here - /// ([`Files::svg_data`], [`Files::svg`]), never by usvg's own resolver, which caps neither how - /// deep they nest nor what they unpack to. + /// usvg's image resolver, for an SVG whose elements nest `height` deep: data URLs by their + /// keys ([`Files::data_url`]), and those usvg decoded itself as it reads them, within + /// [`MAX_DATA_URL_BYTES`]; other hrefs only from the files read. SVG images are read here + /// ([`Files::svg`]), never by usvg's own resolver, which caps neither how deep they nest nor + /// what they unpack to. pub(in crate::import) fn resolver(&self, height: usize) -> ImageHrefResolver<'_> { self.depth.store(height, Ordering::Relaxed); ImageHrefResolver { - // By type as usvg reads them, and TIFF; one without a type (`text/plain`) by its - // bytes: one of those rasters, else SVG. A raster as [`shown`] gives it. resolve_data: Box::new(move |mime, bytes, opts| { - use image::ImageFormat as F; - let format = match mime { - "image/jpg" | "image/jpeg" => F::Jpeg, - "image/png" => F::Png, - "image/gif" => F::Gif, - "image/webp" => F::WebP, - "image/tiff" => F::Tiff, - "image/svg+xml" => return self.svg_data(&bytes, opts), - "text/plain" => match image::guess_format(&bytes).ok().filter(|f| raster(*f).is_some() || *f == F::Tiff) { - Some(format) => format, - None => return self.svg_data(&bytes, opts), - }, - _ => return None, - }; - let (kind, bytes) = self.keep(shown(bytes, format)?); - Some(kind(bytes)) + let spent = self.data_bytes.load(Ordering::Relaxed).saturating_add(bytes.len() as u64); + if spent > MAX_DATA_URL_BYTES { + return leave_out(&self.too_much_data, true); + } + self.data_bytes.store(spent, Ordering::Relaxed); + self.show(self.decoded(mime, bytes), opts) }), - resolve_string: Box::new(move |h, opts| match self.find(h)? { - Source::Raster(kind, bytes) => Some(kind(bytes)), - Source::Svg(file) => self.svg(&file.text.clone(), Some(file), opts), + resolve_string: Box::new(move |h, opts| { + let link = match self.data_number(h).map(|n| self.data_url(n, opts)) { + Some(Ok(image)) => return image, + Some(Err(link)) => link, + None => Arc::from(h), + }; + match self.find(&link)? { + Source::Raster(kind, bytes) => Some(kind(bytes)), + Source::Svg(file) => self.svg(&file.text.clone(), Some(file), opts), + } }), } } - /// The SVG image of data `bytes` (SVG or SVGZ), as [`Files::svg`] reads it. Unpacked here, - /// within what the SVGZ images have left, so usvg unpacks nothing itself; past it, `None`, - /// noted. - fn svg_data(&self, bytes: &[u8], opts: &usvg::Options) -> Option { + /// The number of the href whose key is `h`. + fn data_number(&self, h: &str) -> Option { + let n: usize = h.strip_prefix(DATA_URL_KEY)?.parse().ok()?; + let known = n < self.data.lock().ok()?.urls.len(); + (known && h.len() == DATA_URL_KEY.len() + n.to_string().len()).then_some(n) + } + + /// The href whose key is `h`, as it was written: a filter kept as an effect holds its + /// `` hrefs as written, not their keys ([`super::fx::sources`]). + pub(in crate::import) fn data_href(&self, h: &str) -> Option> { + let n = self.data_number(h)?; + self.data.lock().ok()?.hrefs.get(n).cloned() + } + + /// The image of data URL `n`: decoded the first time ([`Files::decode`]), then kept; the link + /// when href `n` is one. + fn data_url(&self, n: usize, opts: &usvg::Options) -> Result, Arc> { + // The lock is let go before the image is shown: an SVG image takes it again. + let state = match self.data.lock().ok().as_deref().and_then(|d| d.urls.get(n)) { + Some(DataUrl::Decoded(decoded)) => Ok(decoded.clone()), + Some(DataUrl::Encoded(url)) => Err(url.clone()), + Some(DataUrl::Link(link)) => return Err(link.clone()), + None => return Ok(None), + }; + let url = match state { + Ok(decoded) => return Ok(self.show(decoded, opts)), + Err(url) => url, + }; + let decoded = self.decode(&url); + if let Ok(mut data) = self.data.lock() + && let Some(slot) = data.urls.get_mut(n) + { + *slot = DataUrl::Decoded(decoded.clone()); + } + Ok(self.show(decoded, opts)) + } + + /// What data URL `url` decodes to, as usvg decodes it, in what is left of + /// [`MAX_DATA_URL_BYTES`]: decoding stops where it would go past, the image left out, noted. + fn decode(&self, url: &str) -> Decoded { + let Ok(parsed) = data_url::DataUrl::process(url) else { return Decoded::Nothing }; + let spent = self.data_bytes.load(Ordering::Relaxed); + let room = MAX_DATA_URL_BYTES.saturating_sub(spent); + let mut body = Vec::new(); + let decoded = parsed.decode(|bytes| { + if (body.len() as u64).saturating_add(bytes.len() as u64) > room { + return Err(()); + } + body.extend_from_slice(bytes); + Ok(()) + }); + self.data_bytes.store(spent.saturating_add(body.len() as u64), Ordering::Relaxed); + match decoded { + Ok(_) => {} + Err(data_url::forgiving_base64::DecodeError::WriteError(())) => { + self.too_much_data.store(true, Ordering::Relaxed); + return Decoded::Nothing; + } + Err(_) => return Decoded::Nothing, + } + let mime = format!("{}/{}", parsed.mime_type().type_, parsed.mime_type().subtype); + self.decoded(&mime, Arc::new(body)) + } + + /// What usvg gets for data `bytes` of type `mime`: by type as usvg reads them, and TIFF; one + /// without a type (`text/plain`) by its bytes: one of those rasters, else SVG. A raster as + /// [`shown`] gives it; SVG or SVGZ as [`Files::svg_text`] gives it. + fn decoded(&self, mime: &str, bytes: Bytes) -> Decoded { + use image::ImageFormat as F; + let format = match mime { + "image/jpg" | "image/jpeg" => F::Jpeg, + "image/png" => F::Png, + "image/gif" => F::Gif, + "image/webp" => F::WebP, + "image/tiff" => F::Tiff, + "image/svg+xml" => return self.svg_text(&bytes), + "text/plain" => match image::guess_format(&bytes).ok().filter(|f| raster(*f).is_some() || *f == F::Tiff) { + Some(format) => format, + None => return self.svg_text(&bytes), + }, + _ => return Decoded::Nothing, + }; + match shown(bytes, format) { + Some(shown) => { + let (kind, bytes) = self.keep(shown); + Decoded::Raster(kind, bytes) + } + None => Decoded::Nothing, + } + } + + /// The text of SVG image data `bytes` (SVG or SVGZ). Unpacked here, within what the SVGZ + /// images have left, so usvg unpacks nothing itself; past it, nothing, noted. + fn svg_text(&self, bytes: &[u8]) -> Decoded { match crate::text_within(bytes, &self.unpacked) { - Ok(text) => self.svg(&text, None, opts), - Err(e) => leave_out(&self.too_big, matches!(e, SvgError::TooBig(_))), + Ok(text) => Decoded::Svg(Arc::from(text.as_ref())), + Err(e) => { + leave_out(&self.too_big, matches!(e, SvgError::TooBig(_))); + Decoded::Nothing + } + } + } + + /// The image usvg gets for `decoded`: an SVG image read here, where it is shown. + fn show(&self, decoded: Decoded, opts: &usvg::Options) -> Option { + match decoded { + Decoded::Raster(kind, bytes) => Some(kind(bytes)), + Decoded::Svg(text) => self.svg(&text, None, opts), + Decoded::Nothing => None, } } @@ -641,6 +821,9 @@ impl Files { // usvg's parser would overflow on it, as on the SVG holding it (`import`). super::convolve_matrices_ok(&xml).ok()?; self.set_spent(spent.add(cost)); + let mut edits = Edits::default(); + self.take_data_urls(&xml, &mut edits); + let keyed = edits.apply(text); self.depth.store(used + height, Ordering::Relaxed); let within = self.within.lock().ok().map(|mut w| w.push(file)).is_some(); // usvg's `Tree::from_data_nested`, except that hrefs to files go to the resolver. @@ -659,7 +842,7 @@ impl Files { }, ..usvg::Options::default() }; - let tree = usvg::Tree::from_str(text, &nested); + let tree = usvg::Tree::from_str(&keyed, &nested); if within && let Ok(mut w) = self.within.lock() { w.pop(); } @@ -1096,6 +1279,178 @@ mod tests { assert_eq!(files.left_out().len(), 2, "{:?}", files.left_out()); } + /// A base64 data URL of `bytes` of type `mime`. + fn data(mime: &str, bytes: &[u8]) -> String { + format!("data:{mime};base64,{}", crate::base64_encode(bytes)) + } + + /// A PNG of `w` × 1. + fn png_of(w: u32) -> Vec { + let mut out = vec![]; + image::RgbaImage::new(w, 1).write_to(&mut std::io::Cursor::new(&mut out), image::ImageFormat::Png).unwrap(); + out + } + + /// SVG text of 2 × 2 holding `body`. + fn doc(body: &str) -> String { + format!(r#"{body}"#) + } + + /// `svg` read as the import reads it, its data URLs changed to keys first, `spent` bytes of + /// [`MAX_DATA_URL_BYTES`] already decoded: the files, and the PNG bytes of the images usvg + /// made, filters' included, in order. + fn read_keyed(svg: &str, spent: u64) -> (Files, Vec>>) { + let xml = super::super::parse(svg).unwrap(); + let mut edits = Edits::default(); + let files = Files::read(svg, &xml, &ImportOptions::default(), &mut edits, &mut vec![]); + files.data_bytes.store(spent, Ordering::Relaxed); + let text = edits.apply(svg).into_owned(); + let tree = { + let height = depth::height(&xml, MAX_ELEMENT_DEPTH).unwrap(); + let opts = usvg::Options { image_href_resolver: files.resolver(height), ..usvg::Options::default() }; + usvg::Tree::from_str(&text, &opts).unwrap() + }; + // Filters' images and SVG images' trees are subroots. + fn pngs(g: &usvg::Group, out: &mut Vec>>) { + for n in g.children() { + match n { + usvg::Node::Group(g) => pngs(g, out), + usvg::Node::Image(im) => { + if let ImageKind::PNG(b) = im.kind() { + out.push(b.clone()); + } + } + _ => {} + } + n.subroots(|g| pngs(g, out)); + } + } + let mut out = vec![]; + pngs(tree.root(), &mut out); + (files, out) + } + + /// A data URL shown by many `` elements is decoded once, and every use gets the same + /// image, where usvg decoded it again for each. + #[test] + fn a_data_url_is_decoded_once_however_often_it_is_used() { + let png = png(); + let uses = r##""##.repeat(64); + let svg = doc(&format!(r#"{uses}"#, data("image/png", &png))); + let (files, shown) = read_keyed(&svg, 0); + assert_eq!(shown.len(), 64); + assert!(shown.iter().all(|b| Arc::ptr_eq(b, &shown[0]) && **b == png)); + assert_eq!(files.data_bytes.load(Ordering::Relaxed), png.len() as u64); + assert!(files.left_out().is_empty(), "{:?}", files.left_out()); + } + + /// The data URLs inside an SVG image get keys too: each is decoded once, together with the + /// SVG image's own URL. + #[test] + fn data_urls_in_svg_images_are_decoded_once() { + let png = png(); + let uses = r##""##.repeat(16); + let inner = doc(&format!(r#"{uses}"#, data("image/png", &png))); + let svg = doc(&format!(r#""#, data("image/svg+xml", inner.as_bytes()))); + let (files, shown) = read_keyed(&svg, 0); + assert_eq!(shown.len(), 16); + assert_eq!(files.data_bytes.load(Ordering::Relaxed), (inner.len() + png.len()) as u64); + } + + /// An ``'s data URL gets a key too. + #[test] + fn fe_image_data_urls_are_decoded_once() { + let png = png(); + let rects: String = (1..=8).map(|i| format!(r#""#)).collect(); + let svg = doc(&format!(r#"{rects}"#, data("image/png", &png))); + let (files, shown) = read_keyed(&svg, 0); + assert_eq!(shown.len(), 8); + assert_eq!(files.data_bytes.load(Ordering::Relaxed), png.len() as u64); + } + + /// Both hrefs get keys, and usvg still takes the plain `href` over `xlink:href`, in either + /// order: only its URL is decoded. + #[test] + fn the_plain_href_still_wins_over_xlink_href() { + let (one, two) = (png_of(1), png_of(2)); + let (one_url, two_url) = (data("image/png", &one), data("image/png", &two)); + for image in [ + format!(r#""#), + format!(r#""#), + ] { + let (files, shown) = read_keyed(&doc(&image), 0); + assert_eq!(shown.len(), 1); + assert_eq!(*shown[0], one); + assert_eq!(files.data_bytes.load(Ordering::Relaxed), one.len() as u64); + } + } + + /// An image whose data URL is written in an entity, and so in every element the entity + /// makes, gets a key too. + #[test] + fn data_urls_written_in_entities_are_decoded_once() { + let png = png(); + let uses = r##""##.repeat(16); + let svg = format!( + r#"">]>{}"#, + data("image/png", &png), + doc(&format!("&img;{uses}")) + ); + let (files, shown) = read_keyed(&svg, 0); + assert_eq!(shown.len(), 16); + assert_eq!(files.data_bytes.load(Ordering::Relaxed), png.len() as u64); + } + + /// The data URLs decode to at most [`MAX_DATA_URL_BYTES`] together: a URL that fits what is + /// left is decoded, and decoding stops where a URL would go past it, that image left out and + /// noted. + #[test] + fn data_urls_past_the_limit_are_left_out() { + let (small, big) = (png(), png_of(64)); + assert!(small.len() < big.len()); + let svg = doc(&format!( + r#""#, + data("image/png", &small), + data("image/png", &big) + )); + let spent = MAX_DATA_URL_BYTES - (small.len() + big.len()) as u64 + 1; + let (files, shown) = read_keyed(&svg, spent); + assert_eq!(shown.len(), 1); + assert_eq!(*shown[0], small); + assert!(files.data_bytes.load(Ordering::Relaxed) <= MAX_DATA_URL_BYTES); + assert_eq!(files.left_out(), [format!("images were left out: together their data URLs decode to more than {} MB", MAX_DATA_URL_BYTES >> 20)]); + // A data URL usvg decodes itself counts too. + let files = Files::default(); + files.data_bytes.store(MAX_DATA_URL_BYTES, Ordering::Relaxed); + let resolver = files.resolver(1); + assert!((resolver.resolve_data)("image/png", Arc::new(small.clone()), &usvg::Options::default()).is_none()); + drop(resolver); + assert_eq!(files.left_out().len(), 1); + } + + /// A link that looks like a key, spelled plainly or with character references, in the + /// imported SVG or in an SVG image, stays that link: it never shows a data URL's image. + #[test] + fn links_that_look_like_keys_stay_links() { + let png = png(); + let url = data("image/png", &png); + let inner = doc(&format!(r#""#)); + let svg = doc(&format!( + r#""#, + &DATA_URL_KEY[1..], + data("image/svg+xml", inner.as_bytes()) + )); + let (files, shown) = read_keyed(&svg, 0); + // The data URL's image, then the placeholder of the missing file, twice; the SVG image's + // link names no file there, so it shows nothing. + assert_eq!(shown.len(), 3); + assert_eq!(*shown[0], png); + assert!(shown[1..].iter().all(|b| **b != png && files.links.get(&key(b)).is_some_and(|l| l.missing))); + assert_eq!(files.data_number(&format!("{DATA_URL_KEY}00")), None); + assert_eq!(files.data_number(&format!("{DATA_URL_KEY}+0")), None); + assert_eq!(files.data_number(&format!("{DATA_URL_KEY}9")), None); + } + /// The nested SVG images read, decoded or not, take at most [`MAX_NESTED_SVG_TEXT`] bytes of /// text together: an image whose text goes past it is left out and noted. A broken image's /// text counts too, and its decoded place is given back. diff --git a/crates/svg/src/import/fx.rs b/crates/svg/src/import/fx.rs index b760b1b119..4a549552d4 100644 --- a/crates/svg/src/import/fx.rs +++ b/crates/svg/src/import/fx.rs @@ -61,8 +61,10 @@ impl Source { /// Each `` element with an id, as written, by id, with the document's style sheets read /// once for all of them. A source is checked and read by usvg only when art uses it, once. -/// `markers` is the text with its data URLs left out ([`super::depth::prescan`]). -pub(super) fn sources(xml: &roxmltree::Document, markers: &str) -> HashMap { +/// `markers` is the text with its data URLs left out ([`super::depth::prescan`]). `href` gives the +/// href, as written, of each key the prepass wrote in place of an `` href +/// ([`super::files::Files::data_href`]): the source holds that href. +pub(super) fn sources(xml: &roxmltree::Document, markers: &str, href: impl Fn(&str) -> Option>) -> HashMap { if !markers.contains(" HashMap` href that `href` gives a written href for put back as written, XML-escaped. +fn hrefs_as_written(n: roxmltree::Node, mut xml: String, href: &impl Fn(&str) -> Option>) -> String { + // `element_xml` adds namespace declarations to the start tag only, ahead of every child. + let (start, end) = (n.range().start, n.range().end); + let Some(shift) = xml.len().checked_sub(end - start) else { return xml }; + let mut written: Vec<_> = n + .descendants() + .filter(|p| p.is_element() && p.tag_name().name() == "feImage") + .flat_map(|p| p.attributes()) + .filter(|a| a.name() == "href" && matches!(a.namespace(), None | Some("http://www.w3.org/1999/xlink"))) + .filter_map(|a| { + let r = a.range_value(); + // A value written through an entity lies outside the element's text: it stays. + (r.start > start && r.end < end).then_some(())?; + Some((r.start - start + shift..r.end - start + shift, a.value(), href(a.value())?)) + }) + .collect(); + written.sort_by_key(|(r, ..)| std::cmp::Reverse(r.start)); + for (r, key, h) in written { + if xml.get(r.clone()) == Some(key) { + xml.replace_range(r, &crate::xml_escape(&h)); + } + } + xml +} + /// The element name of primitive `k` (`feGaussianBlur`). fn element_name(k: &Kind) -> String { let debug = format!("{k:?}"); diff --git a/crates/svg/src/import/prepass.rs b/crates/svg/src/import/prepass.rs index 3d1b04ad60..e541963561 100644 --- a/crates/svg/src/import/prepass.rs +++ b/crates/svg/src/import/prepass.rs @@ -95,6 +95,11 @@ impl Edits { } } + /// Set the value of attribute `a` to `value` (XML-escaped already). + pub(crate) fn set_value(&mut self, a: roxmltree::Attribute, value: &str) { + self.list.push((a.range_value(), value.to_string())); + } + /// Add the declaration `decl` (XML-escaped already) to the `style` of element `n` (in `svg`), /// after the ones already there (so it wins over them and over style sheet rules). fn style(&mut self, svg: &str, n: XNode, decl: &str) { @@ -163,7 +168,8 @@ fn named_made_up_ids(xml: &roxmltree::Document) -> HashSet { /// * so do `` and `` elements, recorded: usvg makes their arcs into a varying /// number of curves, and they come back as the ellipse VectorCraft draws (not in a file whose /// style sheet has an attribute selector); -/// * linked files are read ([`files`]); +/// * linked files are read, and the data URLs of `` and `` elements are each +/// changed to a key that the resolver decodes once ([`files`]); /// * percentage lengths in a `` with a `viewBox` become numbers ([`symbol_units`]); /// * what style sheet rules with a `:not()` set goes in `style` attributes ([`not_rules`]). /// @@ -190,7 +196,7 @@ pub(super) fn prepass<'s>( && !events && !foreign_attrs && !ellipses - && ![" bool) -> Resu } pub use glyph::glyph_art; -pub use import::{MAX_ELEMENT_DEPTH, MAX_FOREIGN, MAX_FOREIGN_VALUE, MAX_NESTED_SVG_IMAGES, MAX_NESTED_SVG_READS, MAX_NESTED_SVG_TEXT, MAX_NODES}; +pub use import::{ + MAX_DATA_URL_BYTES, MAX_ELEMENT_DEPTH, MAX_FOREIGN, MAX_FOREIGN_VALUE, MAX_NESTED_SVG_IMAGES, MAX_NESTED_SVG_READS, MAX_NESTED_SVG_TEXT, + MAX_NODES, +}; /// Import an SVG document reading the files it links to as `opts` says, also returning warnings. pub fn import_with(svg: &str, opts: &ImportOptions) -> Result<(Document, Vec), SvgError> { diff --git a/crates/svg/tests/it/import_details.rs b/crates/svg/tests/it/import_details.rs index 4d6606eaa5..3a598f5ca1 100644 --- a/crates/svg/tests/it/import_details.rs +++ b/crates/svg/tests/it/import_details.rs @@ -949,6 +949,21 @@ fn nested_svg_images_past_count_cap_are_left_out() { assert!(warnings.iter().any(|w| w.contains(&format!("more than {MAX_NESTED_SVG_IMAGES} nested SVG images")))); } +/// A data URL shown by `` elements, in the SVG and in an SVG image of it, imports as one +/// image shown at each use. +#[test] +fn data_url_images_shown_by_uses_import_at_each_use() { + let png = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR4nGNgYGD4DwABBAEAwS2OUAAAAABJRU5ErkJggg=="; + let image = format!(r#""#); + let uses = r##""##.repeat(3); + let inner = format!(r#"{image}{uses}"#); + let (d, w) = import_with_report(&svg(&format!("{image}{uses}{}", image_of("image/svg+xml", &inner)))).unwrap(); + assert!(w.is_empty(), "{w:?}"); + let found = images(&d); + assert_eq!(found.len(), 6); + assert!(found.iter().all(|(_, im)| im.key == found[0].1.key && (im.width, im.height) == (1, 1))); +} + /// Nested SVG images that can't be read: text that isn't XML, and an SVG without a size. const BROKEN_SVG_IMAGES: [&str; 2] = [r#""#]; diff --git a/crates/text/src/features.rs b/crates/text/src/features.rs index 4aabae7b1a..11f174e1a4 100644 --- a/crates/text/src/features.rs +++ b/crates/text/src/features.rs @@ -295,7 +295,8 @@ impl OtFeatures { let own = self.with_tags(listed.iter().map(String::as_str)); let s = &own; let mut v = Vec::with_capacity(8); - if st.kerning.is_some() { + // A monospaced width form turns the font's pair kerning off as well. + if st.kerning.is_some() || st.glyph_form.is_width() { v.push(f(b"kern", false)); } let liga = s.ligatures && (!ligatures_suppressed_by(st.tracking) || explicit_ligatures(listed)); @@ -342,15 +343,20 @@ impl OtFeatures { // Kerning for the glyphs `palt` makes proportional (OpenType `apkn`), with automatic // kerning only. v.push(f(b"apkn", st.kerning.is_none())); + } else if st.glyph_form.is_width() { + // Listed as off, not left out: in runs shaped together with proportional text, a + // feature left out is set back to the shaper's default, which for `apkn` is on. + v.push(f(b"apkn", false)); } v } /// Does text in style `st` take proportional widths (`palt`)? Proportional Metrics in - /// horizontal type; vertical type takes proportional heights instead + /// horizontal type, except with a monospaced width form ([`vectorcraft_doc::GlyphForm::is_width`]), + /// which sets the characters on a fixed width; vertical type takes proportional heights instead /// ([`Self::proportional_vertical`]). pub(crate) fn proportional(&self, st: &CharStyle) -> bool { - st.proportional_metrics && !self.vertical + st.proportional_metrics && !self.vertical && !st.glyph_form.is_width() } /// Does text in style `st` take proportional heights (`vpal`)? Proportional Metrics in @@ -630,4 +636,44 @@ mod tests { assert!(serbian.iter().zip(&russian).all(|(s, r)| s != r), "every letter in its Serbian form"); assert_eq!(outlines("sr-Latn"), serbian); } + + /// A monospaced width form (`fwid`, `hwid`, `twid`, `qwid`) and Proportional Metrics' `palt` + /// are mutually exclusive: with a width form `palt` is left out and the font's pair kerning + /// (`kern`, and `apkn` for `palt`'s glyphs) is turned off, with automatic or manual kerning, + /// and with the other forms they stay. + /// + #[test] + fn width_forms_turn_off_proportional_metrics() { + use vectorcraft_doc::GlyphForm; + let tags = |form, kerning, value| { + let st = CharStyle { proportional_metrics: true, glyph_form: form, kerning, ..CharStyle::default() }; + OtFeatures::default().resolve(&st).iter().filter(|f| f.value == value).map(|f| f.tag.to_string()).collect::>() + }; + for form in [GlyphForm::Default, GlyphForm::Traditional, GlyphForm::Jis78] { + assert!(tags(form, None, 1).contains(&"palt".to_string()), "{form:?}: {:?}", tags(form, None, 1)); + assert!(!tags(form, None, 0).contains(&"kern".to_string()), "{form:?}: {:?}", tags(form, None, 0)); + } + for form in [GlyphForm::FullWidth, GlyphForm::HalfWidth, GlyphForm::ThirdWidth, GlyphForm::QuarterWidth] { + for kerning in [None, Some(0.0)] { + let on = tags(form, kerning, 1); + assert!(!on.contains(&"palt".to_string()) && !on.contains(&"apkn".to_string()), "{form:?}: {on:?}"); + assert!(on.iter().any(|t| t.as_bytes() == form.feature().unwrap()), "{form:?}: {on:?}"); + // Listed as off, so that runs shaped together with proportional text keep them off. + let off = tags(form, kerning, 0); + assert!(off.contains(&"kern".to_string()) && off.contains(&"apkn".to_string()), "{form:?} {kerning:?}: {off:?}"); + } + } + } + + /// A face's `GPOS` features are found apart from its `GSUB` ones: Source Sans 3 kerns with + /// `GPOS` and makes ligatures with `GSUB`. + #[test] + fn positioning_and_substitution_features_are_found_in_their_own_tables() { + let face = FontDb::global().face("Source Sans 3", "Regular").unwrap(); + assert!(face.has_positioning_feature(*b"kern")); + assert!(!face.has_substitution_feature(*b"kern")); + assert!(face.has_substitution_feature(*b"liga")); + assert!(!face.has_positioning_feature(*b"liga")); + assert!(!face.has_positioning_feature(*b"fwid")); + } } diff --git a/crates/text/src/fontdb/gsub.rs b/crates/text/src/fontdb/gsub.rs index bcc19f74b0..a245efeea2 100644 --- a/crates/text/src/fontdb/gsub.rs +++ b/crates/text/src/fontdb/gsub.rs @@ -158,6 +158,17 @@ fn ligature_glyphs(t: &LigatureSubstFormat1<'_>, room: usize) -> Vec { out } +/// Does `features` (a `GSUB` or `GPOS` feature list) have feature `tag` with at least one lookup? +/// At most [`MAX_FEATURES`] feature records are read. +fn has_feature(features: &skrifa::raw::tables::layout::FeatureList<'_>, tag: [u8; 4]) -> bool { + features + .feature_records() + .iter() + .take(MAX_FEATURES) + .filter(|r| r.feature_tag().to_be_bytes() == tag) + .any(|r| r.feature(features.offset_data()).is_ok_and(|f| !f.lookup_list_indices().is_empty())) +} + impl GsubIndex { /// Read `face`'s `GSUB` table. Empty when the face has none or it cannot be read; glyph ids past /// the face's glyph count are left out; the caps above bound what a damaged table makes it read. @@ -309,13 +320,15 @@ impl FontFace { /// cannot be read. pub fn has_substitution_feature(&self, tag: [u8; 4]) -> bool { let Some(font) = self.skrifa() else { return false }; - let Some(features) = font.gsub().ok().and_then(|g| g.feature_list().ok()) else { return false }; - features - .feature_records() - .iter() - .take(MAX_FEATURES) - .filter(|r| r.feature_tag().to_be_bytes() == tag) - .any(|r| r.feature(features.offset_data()).is_ok_and(|f| !f.lookup_list_indices().is_empty())) + font.gsub().ok().and_then(|g| g.feature_list().ok()).is_some_and(|features| has_feature(&features, tag)) + } + + /// Does the face's `GPOS` table have feature `tag` with at least one lookup? At most + /// [`MAX_FEATURES`] feature records are read; false when the face has no `GPOS` table or it + /// cannot be read. + pub fn has_positioning_feature(&self, tag: [u8; 4]) -> bool { + let Some(font) = self.skrifa() else { return false }; + font.gpos().ok().and_then(|g| g.feature_list().ok()).is_some_and(|features| has_feature(&features, tag)) } /// What the features `aalt`, `salt`, `ss01` to `ss20`, `swsh`, `cv01` to `cv99`, `hist`, diff --git a/crates/text/src/shape.rs b/crates/text/src/shape.rs index 9d36eed28b..4c320a2bb4 100644 --- a/crates/text/src/shape.rs +++ b/crates/text/src/shape.rs @@ -1545,7 +1545,8 @@ fn shape_segment(text: &str, context: &Range, seg: &Segment, feats: &OtFe char_level.set(false); *chars = crate::harakat::own_chars(raw, &by_char, range.end, text, |gid| classes.get(skrifa::GlyphId::new(gid)) == 3); } - if feats.proportional(st) { + // Runs shaped together may differ in their glyph form, and a width form turns `palt` off. + if feats.proportional(st) || runs.get(source_runs.clone()).unwrap_or_default().iter().any(|(_, s)| feats.proportional(s)) { // `palt` is positioning only: the same glyphs set on their full widths tell which // advances it changed (kerning and the rest change both alike). let full = shape(&with_more(&|s| feats.resolve_fixed_width(s)), along); diff --git a/crates/tools/src/draw2.rs b/crates/tools/src/draw2.rs index d85ff4fffa..46e77314b6 100644 --- a/crates/tools/src/draw2.rs +++ b/crates/tools/src/draw2.rs @@ -14,7 +14,7 @@ use vectorcraft_geom::{Affine, Anchor, AnchorKind, BezPath, PathData, Point, Sub use vectorcraft_pathops as po; use vectorcraft_pathops::{Cap, Join}; -use crate::{Action, Tool, ToolContext}; +use crate::{Action, PenPose, Tool, ToolContext}; pub use anchor::AnchorTool; pub use curvature::{ @@ -87,6 +87,34 @@ pub fn pressure_size(size: f64, variation: f64, pressure: f64) -> f64 { /// The narrowest a Blob Brush or Eraser stroke gets, in points (their Size field's minimum). pub const MIN_BRUSH_SIZE: f64 = 0.1; +/// A pen's tilt as a lean on the page (each tilt's tangent) and its angle from upright in degrees, +/// as Pointer Events' tiltX and tiltY convert to an altitude and an azimuth. +fn pen_lean(tilt: [f32; 2]) -> ([f64; 2], f64) { + let [x, y] = tilt.map(|d| f64::from(d).to_radians().tan()); + ([x, y], x.hypot(y).atan().to_degrees()) +} + +/// Which way pose `q` leans, in degrees counter-clockwise on the page from leaning to the right, +/// when it leans 1° or more from upright (`path.eraseRegion` keeps the bearing before a pen +/// nearer upright than that). +pub(crate) fn pen_bearing(q: PenPose) -> Option { + let ([x, y], from_upright) = pen_lean(q.tilt?); + (from_upright >= 1.0).then(|| (-y).atan2(x).to_degrees()) +} + +/// The 0..1 that each of the Eraser's variation modes Pressure, Tilt, Bearing and Rotation reads +/// from a pen at `pressure` (0..1) held as `pose`, leaning toward `bearing` (degrees, from +/// [`pen_bearing`]): 0 gives the value less its variation and 1 the value plus it, as +/// `path.eraseRegion` reads a stroke's points. Tilt reads 1 upright and 0 lying flat; Bearing and +/// Rotation read ½ plus the angle over 360°, the rotation turned counter-clockwise. What the pen +/// does not give reads ½, the value itself. +pub(crate) fn pen_controls(pressure: f64, pose: Option, bearing: Option) -> [f64; 4] { + let tilt = pose.and_then(|q| q.tilt).map_or(0.5, |t| 1.0 - pen_lean(t).1 / 90.0); + let rotation = pose.and_then(|q| q.rotation).map_or(0.5, |r| 0.5 + vectorcraft_geom::normalize_deg(-f64::from(r)) / 360.0); + let bearing = bearing.map_or(0.5, |b| 0.5 + b / 360.0); + [pressure, tilt, bearing, rotation].map(|c| if c.is_finite() { c.clamp(0.0, 1.0) } else { 0.5 }) +} + /// Create a drawing tool by id (None = not one of ours). pub fn create(id: &str) -> Option> { Some(match id { diff --git a/crates/tools/src/draw2/gesture.rs b/crates/tools/src/draw2/gesture.rs index 562654c3c4..75b71ec8df 100644 --- a/crates/tools/src/draw2/gesture.rs +++ b/crates/tools/src/draw2/gesture.rs @@ -12,7 +12,10 @@ //! pressed less than fully on sends `[x, y, pressure]` points) for pressure-sensitive brushes, and //! the pen's tilt and barrel rotation where the pen reads them (`[x, y, pressure, tiltX, tiltY, //! rotation]` points, `null` for what a point lacks) for brushes that vary with them. The Blob -//! Brush and Eraser also record pressure when their Size varies with Pressure. The other tools +//! Brush and Eraser also record pressure when their Size varies with Pressure, and the Eraser +//! records the pressure, tilt and rotation that its Angle, Roundness and Size variations follow; +//! while it erases, its tip outline at the pointer takes the angle, roundness and size the pen +//! gives there. The other tools //! collect the drag polyline (shown as an overlay) and run one command on release. With Shift //! held, the Blob Brush's points stay on the horizontal or //! vertical line through the press point. Alt held at the press, or before the Blob Brush's drag @@ -29,6 +32,7 @@ use std::cell::Cell; use std::collections::{BTreeMap, BTreeSet}; use serde_json::{Value, json}; +use vectorcraft_brush::{Calligraphic, Variation}; use vectorcraft_doc::{NodeId, NodeKind}; use vectorcraft_geom::{Affine, BezPath, FillRule, ParamCurve, PathData, PathSeg, Point, Rect, Vec2, shapes}; use vectorcraft_pathops as po; @@ -88,10 +92,11 @@ pub struct GestureTool { previewed: usize, id: &'static str, points: Vec, - /// The pen pressure of each of `points` (the Paintbrush, and the Blob Brush and the Eraser - /// with `size_pressure`). + /// The pen pressure of each of `points` (the Paintbrush, the Blob Brush with Size on Pressure, + /// and the Eraser with any of its variations on Pressure). pressures: Vec, - /// The pen's tilt and rotation at each of `points` (the Paintbrush only). + /// The pen's tilt and rotation at each of `points` (the Paintbrush, and the Eraser with any of + /// its variations on Tilt, Bearing or Rotation). poses: Vec>, active: bool, began: bool, @@ -117,16 +122,22 @@ pub struct GestureTool { pub fidelity: f64, /// Brush / eraser diameter in points (the Eraser's across its tip's long axis). pub size: f64, - /// Blob Brush and Eraser Tool Options' Size set to Pressure (else Fixed): the tip is `size` − - /// `size_variation` wide at the lightest pen pressure and `size` + `size_variation` at the - /// heaviest. - pub size_pressure: bool, + /// Blob Brush and Eraser Tool Options' Size mode: Fixed or Pressure for the Blob Brush (with + /// Pressure the tip is `size` − `size_variation` wide at the lightest pen pressure and `size` + + /// `size_variation` at the heaviest), and any [`Variation`] for the Eraser. + pub size_mode: Variation, /// Blob Brush and Eraser Tool Options' Size Variation, in points. pub size_variation: f64, /// The angle of the Blob Brush or Eraser tip's long axis, in degrees counter-clockwise from horizontal. pub angle: f64, /// The Blob Brush or Eraser tip's short axis in percent of its long one (100: round). pub roundness: f64, + /// Eraser Tool Options' Angle mode and its Variation in degrees (0 to 180). + pub angle_mode: Variation, + pub angle_variation: f64, + /// Eraser Tool Options' Roundness mode and its Variation in percent (0 to 100). + pub roundness_mode: Variation, + pub roundness_variation: f64, /// Fill new pencil/brush strokes. pub fill: bool, /// "Keep selected": select the path a Pencil, Paintbrush or Blob Brush stroke draws, continues @@ -176,6 +187,9 @@ pub struct GestureTool { tip_at: Option, /// A pen pressed less than fully at a point of the stroke whose pressure is kept. light: bool, + /// Which way the pen leaned at the last point of the stroke where it leaned 1° or more from + /// upright ([`super::pen_bearing`]), for the Eraser's tip outline. + bearing: Option, /// The length of the stroke, in points. length: f64, /// The outline of the area a Blob Brush or Eraser drag covers, as far as it is built @@ -829,10 +843,14 @@ impl GestureTool { redraw: None, fidelity, size: 10.0, - size_pressure: false, + size_mode: Variation::Fixed, size_variation: 0.0, angle: 0.0, roundness: 100.0, + angle_mode: Variation::Fixed, + angle_variation: 0.0, + roundness_mode: Variation::Fixed, + roundness_variation: 0.0, fill: false, keep_selected: id != "blobBrush", // Off, as Pencil Tool Options ships it: Alt keeps drawing a closed path until it is on. @@ -852,6 +870,7 @@ impl GestureTool { pressed: Point::ZERO, tip_at: None, light: false, + bearing: None, length: 0.0, area: Cell::new(None), area_pending: Cell::new(false), @@ -908,15 +927,40 @@ impl GestureTool { self.length += last.distance(p); } self.points.push(p); - if self.id == "paintbrush" || (self.varies_size() && self.size_pressure) { + let modes = self.tip_modes(); + // The poses go out beside the pressures (`stroke_json`), so a stroke that keeps one keeps both. + let posed = self.id == "paintbrush" || modes.iter().any(|m| matches!(m, Variation::Tilt | Variation::Bearing | Variation::Rotation)); + if posed || modes.contains(&Variation::Pressure) { self.pressures.push(ev.pressure); self.light |= ev.pressure < 1.0; } - if self.id == "paintbrush" { - self.poses.push(ev.pen.map(PenPose::sanitized)); + if posed { + let pose = ev.pen.map(PenPose::sanitized); + self.bearing = pose.and_then(super::pen_bearing).or(self.bearing); + self.poses.push(pose); } } + /// Give the Eraser's last stroke sample the pen's tilt and rotation of the event `ev`, a drag + /// within a pixel of that sample that adds none: a pen turned or tilted in place turns its tip + /// there, in the outline and in what the release erases. The sample keeps its pressure, which + /// the area outline of the drag was built with, and its pose when `ev` reads none. + fn refresh_last_pose(&mut self, ev: &PointerEvent) { + if self.id != "eraser" || self.poses.len() != self.points.len() || ev.pen.is_none() { + return; + } + let pose = ev.pen.map(PenPose::sanitized); + let Some(last) = self.poses.last_mut() else { return }; + let had_bearing = last.and_then(super::pen_bearing).is_some(); + *last = pose; + // The bearing is that of the last sample the pen leaned at, as `push` keeps it. + self.bearing = match pose.and_then(super::pen_bearing) { + Some(b) => Some(b), + None if had_bearing => self.poses.iter().rev().find_map(|q| q.and_then(super::pen_bearing)), + None => self.bearing, + }; + } + /// Would a press with `m` held draw a straight Pencil line? Shift or Alt held, unless Alt lends /// the Smooth tool: the rule `PointerKind::Down` sets `straight` by. fn straight_at_press(&self, m: Mods) -> bool { @@ -975,12 +1019,14 @@ impl GestureTool { } /// The Blob Brush's or the Eraser's tip at the pointer, before and while painting: its outline - /// `size` across at its angle and roundness, at the zoom, black on a white halo so it reads on + /// `size` across at its angle and roundness (the Eraser's as the pen varies them while it + /// erases, [`Self::tip_now`]), at the zoom, black on a white halo so it reads on /// any art. Precise cursors show only the crosshair, and smoothing shows no tip. fn tip_outline(&self, cx: &ToolContext) -> Vec { let Some(at) = self.tip_at.filter(|_| !self.smoothing && !cx.precise_cursors) else { return vec![] }; - let tip = if self.id == "blobBrush" { blob_tip(self.angle, self.roundness) } else { eraser_tip(self.angle, self.roundness) }; - let r = self.size / 2.0; + let [angle, roundness, size] = self.tip_now(); + let tip = if self.id == "blobBrush" { blob_tip(angle, roundness) } else { eraser_tip(angle, roundness) }; + let r = size / 2.0; // A tolerance relative to the radius: four arcs at any size or zoom. let circle = kurbo::Shape::to_path(&kurbo::Circle::new(Point::ORIGIN, r), 0.1 * r.max(1e-6)); let path = Affine::translate(at.to_vec2()) * tip * circle; @@ -999,6 +1045,7 @@ impl GestureTool { self.previewed = 0; self.shift_line = None; self.light = false; + self.bearing = None; self.length = 0.0; self.area.set(None); self.area_pending.set(false); @@ -1064,7 +1111,53 @@ impl GestureTool { /// keeps its Size). fn size_pressures(&self) -> Option<&[f32]> { let pressed = self.pressures.len() == self.points.len() && self.light; - (self.varies_size() && self.size_pressure && !self.smoothing && pressed).then_some(self.pressures.as_slice()) + (self.tip_modes()[2] == Variation::Pressure && !self.smoothing && pressed).then_some(self.pressures.as_slice()) + } + + /// How the tip's angle, roundness and size vary: the Eraser's three modes, the Blob Brush's + /// Size mode (its angle and roundness stay fixed), and Fixed for the other tools. A mode whose + /// Variation is 0 is Fixed. + fn tip_modes(&self) -> [Variation; 3] { + let fixed_at_zero = |mode: Variation, v: f64| if v > 0.0 { mode } else { Variation::Fixed }; + match self.id { + "eraser" => [ + fixed_at_zero(self.angle_mode, self.angle_variation), + fixed_at_zero(self.roundness_mode, self.roundness_variation), + fixed_at_zero(self.size_mode, self.size_variation), + ], + "blobBrush" => [Variation::Fixed, Variation::Fixed, fixed_at_zero(self.size_mode, self.size_variation)], + _ => [Variation::Fixed; 3], + } + } + + /// The Eraser's angle, roundness and size where its tip outline shows: while it erases, each + /// value on a mode that follows the pen goes from the value less its Variation to the value + /// plus it as the pen reads now (the pressure once the pen has pressed less than fully, the + /// tilt and rotation of the last point, and the bearing of the last point the pen leaned 1° or + /// more at), as `path.eraseRegion` reads the stroke; before a press, and for Random, Fixed and a + /// pen that gives no reading, the values themselves. The Blob Brush's are its own values. + fn tip_now(&self) -> [f64; 3] { + let base = [self.angle, self.roundness, self.size]; + if self.id != "eraser" || !self.active { + return base; + } + let pressure = if self.light { self.pressures.last().map_or(0.5, |p| f64::from(*p)) } else { 0.5 }; + let controls = super::pen_controls(pressure, self.poses.last().copied().flatten(), self.bearing); + let variations = [self.angle_variation, self.roundness_variation, self.size_variation]; + let mut out = base; + for (i, (v, mode)) in out.iter_mut().zip(self.tip_modes()).enumerate() { + let c = match mode { + Variation::Pressure => controls[0], + Variation::Tilt => controls[1], + Variation::Bearing => controls[2], + Variation::Rotation => controls[3], + Variation::Fixed | Variation::Random => continue, + }; + // The size goes past a nib's 1296 pt, as `path.eraseRegion` sweeps it. + *v = if i == 2 { super::pressure_size(*v, variations[i], c) } else { Calligraphic::moved(i, *v, variations[i] * (2.0 * c - 1.0)) }; + } + let [angle, roundness, size] = out; + [angle, roundness.clamp(1.0, 100.0), size.max(super::MIN_BRUSH_SIZE)] } /// The stroke's points: `[x, y, pressure, tiltX, tiltY, rotation]` when a pen read its tilt or @@ -1254,7 +1347,7 @@ impl GestureTool { json!({ "points": self.stroke_json(), "size": self.size, - "sizeMode": if self.size_pressure { "pressure" } else { "fixed" }, + "sizeMode": self.size_mode.id(), "sizeVariation": self.size_variation, "fidelity": self.fidelity, "angle": self.angle, @@ -1268,10 +1361,14 @@ impl GestureTool { json!({ "points": self.stroke_json(), "size": self.size, - "sizeMode": if self.size_pressure { "pressure" } else { "fixed" }, + "sizeMode": self.size_mode.id(), "sizeVariation": self.size_variation, "angle": self.angle, - "roundness": self.roundness + "angleMode": self.angle_mode.id(), + "angleVariation": self.angle_variation, + "roundness": self.roundness, + "roundnessMode": self.roundness_mode.id(), + "roundnessVariation": self.roundness_variation }), )], "knife" if pts.len() >= 2 => vec![Action::Exec("path.knife".into(), json!({"points": points_json(pts)}))], @@ -1385,6 +1482,9 @@ impl Tool for GestureTool { let release_end = kept.unwrap_or(end); self.near_start = self.release_closes_near_start(cx, release_end); self.connects = self.connects_at(cx, release_end).is_some(); + if self.points.last().is_some_and(|l| l.distance(p) < cx.tol(1.0)) { + self.refresh_last_pose(ev); + } return vec![]; } self.push(p, ev); @@ -1438,13 +1538,17 @@ impl Tool for GestureTool { } } else if self.points.last().is_some_and(|l| l.distance(p) >= cx.tol(1.0)) { self.push(end, ev); - } else if self.grid_snaps(cx) - && !self.smoothing - && self.points.len() > 1 - && let Some(last) = self.points.last_mut() - { - // The last step was where the button went up: it moves where that snaps. - *last = end; + } else { + // A release within a pixel of the last step gives it the pen's pose there. + self.refresh_last_pose(ev); + if self.grid_snaps(cx) + && !self.smoothing + && self.points.len() > 1 + && let Some(last) = self.points.last_mut() + { + // The last step was where the button went up: it moves where that snaps. + *last = end; + } } let out = if cancel { vec![Action::Cancel] } else { self.finish(cx, ev.mods) }; self.active = false; @@ -1666,7 +1770,7 @@ impl Tool for GestureTool { json!({ "fidelity": self.fidelity, "size": self.size, - "sizeMode": if self.size_pressure { "pressure" } else { "fixed" }, + "sizeMode": self.size_mode.id(), "sizeVariation": self.size_variation, "angle": self.angle, "roundness": self.roundness, @@ -1676,10 +1780,14 @@ impl Tool for GestureTool { } "eraser" => json!({ "size": self.size, - "sizeMode": if self.size_pressure { "pressure" } else { "fixed" }, + "sizeMode": self.size_mode.id(), "sizeVariation": self.size_variation, "angle": self.angle, - "roundness": self.roundness + "angleMode": self.angle_mode.id(), + "angleVariation": self.angle_variation, + "roundness": self.roundness, + "roundnessMode": self.roundness_mode.id(), + "roundnessVariation": self.roundness_variation }), _ => Value::Null, } @@ -1688,12 +1796,19 @@ impl Tool for GestureTool { match key { "fidelity" => self.fidelity = v.as_f64().unwrap_or(self.fidelity).clamp(super::min_fidelity(self.id), 100.0), "size" => self.size = v.as_f64().unwrap_or(self.size).clamp(0.1, 1000.0), - "sizeMode" if self.varies_size() => match v.as_str() { - Some("pressure") => self.size_pressure = true, - Some("fixed") => self.size_pressure = false, + // The Blob Brush's Size is Fixed or Pressure; the Eraser's Angle, Roundness and Size + // take any mode. + "sizeMode" if self.varies_size() => match v.as_str().and_then(Variation::parse) { + Some(m) if self.id == "eraser" || matches!(m, Variation::Fixed | Variation::Pressure) => self.size_mode = m, _ => {} }, + "angleMode" if self.id == "eraser" => self.angle_mode = v.as_str().and_then(Variation::parse).unwrap_or(self.angle_mode), + "roundnessMode" if self.id == "eraser" => self.roundness_mode = v.as_str().and_then(Variation::parse).unwrap_or(self.roundness_mode), "sizeVariation" if self.varies_size() => self.size_variation = v.as_f64().unwrap_or(self.size_variation).clamp(0.0, 1000.0), + "angleVariation" if self.id == "eraser" => self.angle_variation = v.as_f64().unwrap_or(self.angle_variation).clamp(0.0, 180.0), + "roundnessVariation" if self.id == "eraser" => { + self.roundness_variation = v.as_f64().unwrap_or(self.roundness_variation).clamp(0.0, 100.0) + } "angle" => self.angle = v.as_f64().unwrap_or(self.angle).clamp(-180.0, 180.0), "roundness" => self.roundness = v.as_f64().unwrap_or(self.roundness).clamp(if self.id == "blobBrush" { 0.0 } else { 1.0 }, 100.0), "fill" => self.fill = v.as_bool().unwrap_or(self.fill), diff --git a/crates/tools/src/draw2/gesture/tests.rs b/crates/tools/src/draw2/gesture/tests.rs index 4db9310c79..9dfbdba2de 100644 --- a/crates/tools/src/draw2/gesture/tests.rs +++ b/crates/tools/src/draw2/gesture/tests.rs @@ -527,10 +527,20 @@ fn blob_brush_sends_keep_selected_and_merge_only_with_selection() { "{acts:?}" ); } - // The Eraser keeps Size (Fixed by default), Angle and Roundness: a round tip by default. + // The Eraser keeps Size, Angle and Roundness, each Fixed by default: a round tip by default. assert_eq!( GestureTool::new("eraser").options(), - json!({"size": 10.0, "sizeMode": "fixed", "sizeVariation": 0.0, "angle": 0.0, "roundness": 100.0}) + json!({ + "size": 10.0, + "sizeMode": "fixed", + "sizeVariation": 0.0, + "angle": 0.0, + "angleMode": "fixed", + "angleVariation": 0.0, + "roundness": 100.0, + "roundnessMode": "fixed", + "roundnessVariation": 0.0 + }) ); } @@ -2768,6 +2778,161 @@ fn the_blob_brush_and_the_eraser_show_their_tip_at_the_pointer() { } } +/// Eraser Tool Options' Angle, Roundness and Size variation menus: the Eraser keeps each mode and +/// Variation, the release sends them with the pen's readings along the drag, and while it erases +/// its tip outline at the pointer takes the angle, roundness and size the pen gives there. The Blob +/// Brush's Size takes Fixed and Pressure only, and it has no Angle or Roundness menus. +#[test] +fn the_eraser_tip_follows_its_variation_modes() { + let (d, _) = doc_with_rect(); + let s = Selection::default(); + let p = paint(); + let cx = cx(&d, &s, &p); + // The bounds of the tip outline, the last two overlays (its halo and its line). + let tip = |t: &GestureTool| match &t.overlays(&cx)[..] { + [.., Overlay::Path { path: halo, width: 3.0, .. }, Overlay::Path { path, color: [0, 0, 0], width: 1.0, .. }] => { + assert_eq!(halo, path); + vectorcraft_geom::Shape::bounding_box(path) + } + o => panic!("{o:?}"), + }; + let near = |b: vectorcraft_geom::Rect, want: (f64, f64, f64, f64)| { + assert!([b.x0 - want.0, b.y0 - want.1, b.x1 - want.2, b.y1 - want.3].iter().all(|e| e.abs() < 0.01), "{b:?} vs {want:?}"); + }; + let mut t = GestureTool::new("eraser"); + assert_eq!( + (t.options()["angleMode"].clone(), t.options()["roundnessMode"].clone(), t.options()["sizeMode"].clone()), + (json!("fixed"), json!("fixed"), json!("fixed")), + "Fixed by default" + ); + for (k, v) in [ + ("size", json!(20)), + ("roundness", json!(25)), + ("angleMode", json!("rotation")), + ("angleVariation", json!(90)), + ("roundnessMode", json!("random")), + ("roundnessVariation", json!(10)), + ("sizeMode", json!("tilt")), + ("sizeVariation", json!(8)), + ] { + t.set_option(k, &v); + } + let o = t.options(); + assert_eq!( + [&o["angleMode"], &o["angleVariation"], &o["roundnessMode"], &o["roundnessVariation"], &o["sizeMode"], &o["sizeVariation"]], + [&json!("rotation"), &json!(90.0), &json!("random"), &json!(10.0), &json!("tilt"), &json!(8.0)] + ); + // A mode it does not have leaves the mode as it was; a Variation is kept within its range. + t.set_option("angleMode", &json!("wheel")); + t.set_option("roundnessVariation", &json!(500)); + assert_eq!((t.options()["angleMode"].clone(), t.options()["roundnessVariation"].clone()), (json!("rotation"), json!(100.0))); + t.set_option("roundnessVariation", &json!(10)); + // Before a press the outline is the set tip: 20 by 5 pt, lying level. + t.pointer(&cx, &PointerEvent::new(PointerKind::Move, 100.0, 50.0)); + near(tip(&t), (90.0, 47.5, 110.0, 52.5)); + // Erasing with the barrel turned half way round and the pen upright, the tip stands upright and + // is 28 by 7 pt; Random leaves the roundness as set. + let pen = Some(PenPose { tilt: Some([0.0, 0.0]), rotation: Some(180.0) }); + t.pointer(&cx, &PointerEvent { pen, pressure: 0.5, ..PointerEvent::new(PointerKind::Down, 100.0, 50.0) }); + t.pointer(&cx, &PointerEvent { pen, pressure: 0.5, ..PointerEvent::new(PointerKind::Drag, 200.0, 50.0) }); + near(tip(&t), (196.5, 36.0, 203.5, 64.0)); + let acts = t.pointer(&cx, &PointerEvent { pen, pressure: 0.5, ..PointerEvent::new(PointerKind::Up, 200.0, 50.0) }); + let Some(Action::Exec(c, v)) = acts.first() else { panic!("{acts:?}") }; + assert_eq!(c, "path.eraseRegion"); + assert_eq!( + [&v["angleMode"], &v["angleVariation"], &v["roundnessMode"], &v["roundnessVariation"], &v["sizeMode"], &v["sizeVariation"]], + [&json!("rotation"), &json!(90.0), &json!("random"), &json!(10.0), &json!("tilt"), &json!(8.0)] + ); + assert_eq!( + v["points"], + json!([[100.0, 50.0, 0.5, 0.0, 0.0, 180.0], [200.0, 50.0, 0.5, 0.0, 0.0, 180.0]]), + "the pen's readings go with the points" + ); + // A mouse drag, which reads no tilt or rotation, keeps the set tip. + t.pointer(&cx, &PointerEvent::new(PointerKind::Down, 100.0, 50.0)); + t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, 200.0, 50.0)); + near(tip(&t), (190.0, 47.5, 210.0, 52.5)); + t.pointer(&cx, &PointerEvent::new(PointerKind::Up, 200.0, 50.0)); + // A point without a reading after one with it goes back to the set tip there, as the release + // erases it. + t.pointer(&cx, &PointerEvent { pen, pressure: 0.5, ..PointerEvent::new(PointerKind::Down, 100.0, 50.0) }); + t.pointer(&cx, &PointerEvent { pressure: 0.5, ..PointerEvent::new(PointerKind::Drag, 200.0, 50.0) }); + near(tip(&t), (190.0, 47.5, 210.0, 52.5)); + t.pointer(&cx, &PointerEvent { pressure: 0.5, ..PointerEvent::new(PointerKind::Up, 200.0, 50.0) }); + // The Blob Brush's Size is Fixed or Pressure, and its Angle and Roundness have no menus. + let mut b = GestureTool::new("blobBrush"); + b.set_option("sizeMode", &json!("tilt")); + b.set_option("angleMode", &json!("rotation")); + assert_eq!(b.options()["sizeMode"], json!("fixed")); + assert!(b.options().get("angleMode").is_none() && b.options().get("roundnessVariation").is_none()); + b.set_option("sizeMode", &json!("pressure")); + assert_eq!(b.options()["sizeMode"], json!("pressure")); +} + +/// The Eraser's tip outline follows a pen turned in place, which adds no stroke point, and the +/// release erases with that reading; and its size passes 1296 pt as `path.eraseRegion` sweeps it. +#[test] +fn the_eraser_tip_turns_in_place_and_passes_a_nib_size() { + let (d, _) = doc_with_rect(); + let s = Selection::default(); + let p = paint(); + let cx = cx(&d, &s, &p); + let tip = |t: &GestureTool| match &t.overlays(&cx)[..] { + [.., Overlay::Path { path, color: [0, 0, 0], width: 1.0, .. }] => vectorcraft_geom::Shape::bounding_box(path), + o => panic!("{o:?}"), + }; + let near = |b: vectorcraft_geom::Rect, want: (f64, f64, f64, f64)| { + assert!([b.x0 - want.0, b.y0 - want.1, b.x1 - want.2, b.y1 - want.3].iter().all(|e| e.abs() < 0.01), "{b:?} vs {want:?}"); + }; + let mut t = GestureTool::new("eraser"); + for (k, v) in [("size", json!(20)), ("roundness", json!(25)), ("angleMode", json!("rotation")), ("angleVariation", json!(90))] { + t.set_option(k, &v); + } + let level = Some(PenPose { tilt: None, rotation: Some(0.0) }); + let turned = Some(PenPose { tilt: None, rotation: Some(180.0) }); + t.pointer(&cx, &PointerEvent { pen: level, pressure: 0.5, ..PointerEvent::new(PointerKind::Down, 100.0, 50.0) }); + t.pointer(&cx, &PointerEvent { pen: level, pressure: 0.5, ..PointerEvent::new(PointerKind::Drag, 200.0, 50.0) }); + near(tip(&t), (190.0, 47.5, 210.0, 52.5)); + // The barrel turns half way round with the pointer still: the tip stands upright. + t.pointer(&cx, &PointerEvent { pen: turned, pressure: 0.5, ..PointerEvent::new(PointerKind::Drag, 200.0, 50.0) }); + near(tip(&t), (197.5, 40.0, 202.5, 60.0)); + let acts = t.pointer(&cx, &PointerEvent { pen: turned, pressure: 0.5, ..PointerEvent::new(PointerKind::Up, 200.0, 50.0) }); + let Some(Action::Exec(_, v)) = acts.first() else { panic!("{acts:?}") }; + assert_eq!(v["points"], json!([[100.0, 50.0, 0.5, null, null, 0.0], [200.0, 50.0, 0.5, null, null, 180.0]])); + // A release at the last point erases with the pose the pen has as it lifts. + t.pointer(&cx, &PointerEvent { pen: level, pressure: 0.5, ..PointerEvent::new(PointerKind::Down, 100.0, 50.0) }); + t.pointer(&cx, &PointerEvent { pen: level, pressure: 0.5, ..PointerEvent::new(PointerKind::Drag, 200.0, 50.0) }); + let acts = t.pointer(&cx, &PointerEvent { pen: turned, pressure: 0.5, ..PointerEvent::new(PointerKind::Up, 200.0, 50.0) }); + let Some(Action::Exec(_, v)) = acts.first() else { panic!("{acts:?}") }; + assert_eq!(v["points"], json!([[100.0, 50.0, 0.5, null, null, 0.0], [200.0, 50.0, 0.5, null, null, 180.0]])); + // A release there that reads no pose keeps the last one. + t.pointer(&cx, &PointerEvent { pen: level, pressure: 0.5, ..PointerEvent::new(PointerKind::Down, 100.0, 50.0) }); + t.pointer(&cx, &PointerEvent { pen: turned, pressure: 0.5, ..PointerEvent::new(PointerKind::Drag, 200.0, 50.0) }); + let acts = t.pointer(&cx, &PointerEvent { pressure: 0.5, ..PointerEvent::new(PointerKind::Up, 200.0, 50.0) }); + let Some(Action::Exec(_, v)) = acts.first() else { panic!("{acts:?}") }; + assert_eq!(v["points"], json!([[100.0, 50.0, 0.5, null, null, 0.0], [200.0, 50.0, 0.5, null, null, 180.0]])); + // With Angle on Bearing, a pen that leaned only at the last point and stands up there again + // leaves no bearing: the tip lies level, as set. + t.set_option("angleMode", &json!("bearing")); + let upright = Some(PenPose { tilt: Some([0.0, 0.0]), rotation: None }); + let leaning = Some(PenPose { tilt: Some([0.0, 45.0]), rotation: None }); + t.pointer(&cx, &PointerEvent { pen: upright, pressure: 0.5, ..PointerEvent::new(PointerKind::Down, 100.0, 50.0) }); + t.pointer(&cx, &PointerEvent { pen: leaning, pressure: 0.5, ..PointerEvent::new(PointerKind::Drag, 200.0, 50.0) }); + assert!(tip(&t).height() > 6.0, "{:?}", tip(&t)); + t.pointer(&cx, &PointerEvent { pen: upright, pressure: 0.5, ..PointerEvent::new(PointerKind::Drag, 200.0, 50.0) }); + near(tip(&t), (190.0, 47.5, 210.0, 52.5)); + t.pointer(&cx, &PointerEvent { pen: upright, pressure: 0.5, ..PointerEvent::new(PointerKind::Up, 200.0, 50.0) }); + // Size 1000 with a Variation of 1000 on Tilt, the pen upright: a tip 2000 pt across. + let mut t = GestureTool::new("eraser"); + for (k, v) in [("size", json!(1000)), ("sizeMode", json!("tilt")), ("sizeVariation", json!(1000))] { + t.set_option(k, &v); + } + let upright = Some(PenPose { tilt: Some([0.0, 0.0]), rotation: None }); + t.pointer(&cx, &PointerEvent { pen: upright, pressure: 0.5, ..PointerEvent::new(PointerKind::Down, 100.0, 50.0) }); + t.pointer(&cx, &PointerEvent { pen: upright, pressure: 0.5, ..PointerEvent::new(PointerKind::Drag, 200.0, 50.0) }); + near(tip(&t), (-800.0, -950.0, 1200.0, 1050.0)); +} + #[test] fn smooth_needs_selection_and_brackets_resize() { let (d, _) = doc_with_rect(); diff --git a/crates/tools/src/settings.rs b/crates/tools/src/settings.rs index a4d1b1a64f..a5c92b00b0 100644 --- a/crates/tools/src/settings.rs +++ b/crates/tools/src/settings.rs @@ -43,6 +43,8 @@ const ROWS: &[Row] = &[ (&["blobBrush"], None, &["fidelity", "keepSelected", "mergeOnlySelection"]), // Eraser Tool Options' tip shape. (&["blobBrush", "eraser"], None, &["angle", "roundness"]), + // Eraser Tool Options' Angle and Roundness variation menus and fields. + (&["eraser"], None, &["angleMode", "angleVariation", "roundnessMode", "roundnessVariation"]), // Live Paint Selection Tool Options (`builder::LivePaintSelectionTool`). (&["livePaintSelection"], None, &["selectFills", "selectStrokes", "color", "width"]), // Live Paint Bucket Options (`builder::LivePaintBucketTool`). diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index 7095e6a9ed..847b60fd87 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -66,7 +66,7 @@ use overlays::selected_anchor; use overlays::{HandleLook, blend_widgets_overlay, draw_inside_overlay, draw_overlays, hover_highlight, ime_geometry, selection_overlay}; pub use panes::Panes; pub(crate) use panes::{consolidate, pane, tile, tiled_area}; -use raster::{changed_pixels, pixel_region, upload}; +use raster::{changed_pixels, frame_ppp, pixel_region, upload}; pub(crate) use raster::{color_image, color_image_owned, render_job}; pub use selection_marks::SelectionMarks; #[cfg(test)] @@ -326,7 +326,9 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) { } // Artwork raster. - let ppp = ui.ctx().pixels_per_point(); + let device_ppp = ui.ctx().pixels_per_point(); + let max_side = ui.ctx().input(|i| i.max_texture_side); + let ppp = frame_ppp(rect.size(), device_ppp, max_side); // Match Renderer::render_as before recording the cache extent or building its view. let (w, h) = ( ((rect.width() * ppp).round().max(1.0) as u32).min(u32::from(u16::MAX)), @@ -334,7 +336,7 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) { ); // View › Pixel Preview: the art as it rasterizes, one pixel per point, while a document pixel // is bigger than a screen pixel (below that the screen render already shows it). - let pixel = (app.ui.view.pixel_preview && xf.zoom * ppp as f64 > 1.0).then(|| pixel_region(&xf)).flatten(); + let pixel = (app.ui.view.pixel_preview && xf.zoom * device_ppp as f64 > 1.0).then(|| pixel_region(&xf, max_side)).flatten(); // File Handling › Display Bitmaps as Anti-aliased Images in Pixel Preview: off, images show // their pixels as they rasterize, hard-edged. let smooth_images = pixel.is_none() || app.session.prefs.anti_aliased_bitmaps; diff --git a/crates/ui-egui/src/canvas/input.rs b/crates/ui-egui/src/canvas/input.rs index 88bb262844..69849ebe11 100644 --- a/crates/ui-egui/src/canvas/input.rs +++ b/crates/ui-egui/src/canvas/input.rs @@ -5,8 +5,8 @@ use super::drag::{ tool_drag_moved, tool_frame_moves, tool_holds, tool_press_doc_id, }; use super::view::{ - HOLD_DELAY, HOLD_ZOOM, SCRUB_SLOP, SCRUB_ZOOM, WheelTurn, animated_zoom, pan_pixels, rotate_about, scroll_view, touch_gestures, trackpad_turn, - wheel, whole_pixels, zoom_about, + HOLD_DELAY, HOLD_ZOOM, SCRUB_SLOP, SCRUB_ZOOM, WheelTurn, animated_zoom, pan_pixels, pan_ppp, rotate_about, scroll_view, touch_gestures, + trackpad_turn, wheel, whole_pixels, zoom_about, }; use super::{Color32, Drag, Mods, Point, PointerEvent, PointerKind, Pos2, Shape, Stroke, Ui, VectorcraftApp, View, Xf, json}; @@ -367,7 +367,7 @@ fn drag_held(app: &mut VectorcraftApp, ui: &Ui, f: &Pointing, d: Drag, p: Pos2) let ui_zoom = app.canvas_ui_zoom; match d { Drag::Pan { start, center, .. } => { - let ppp = ui.ctx().pixels_per_point(); + let ppp = pan_ppp(app, ui); if let Some(vm) = app.view_mut() { vm.center = center; pan_pixels(vm, (ppp, ui_zoom), whole_pixels(p - start, ppp)); diff --git a/crates/ui-egui/src/canvas/panes.rs b/crates/ui-egui/src/canvas/panes.rs index 50fd2c9583..bf070af076 100644 --- a/crates/ui-egui/src/canvas/panes.rs +++ b/crates/ui-egui/src/canvas/panes.rs @@ -12,7 +12,7 @@ use super::outline::{c32, node_outline, stroke_path, too_many_anchors}; use super::view::fit_zoom; use super::{ Affine, CacheKey, Point, Rect, RulerScale, Sense, Stroke, StrokeKind, Tokens, Ui, VectorcraftApp, View, Xf, artboard_edges, backdrop, cache_key, - dim_on_canvas, frame_options, frame_view, grid, guides_of, json, paint_paper, pixel_region, placed_key, plugin_key, pos2, rulers, + dim_on_canvas, frame_options, frame_ppp, frame_view, grid, guides_of, json, paint_paper, pixel_region, placed_key, plugin_key, pos2, rulers, shows_artboards, textured_quad, vec2, view_rects, }; use crate::doc_windows::Win; @@ -339,10 +339,13 @@ fn draw_pane(app: &mut VectorcraftApp, ui: &Ui, w: Win, cell: egui::Rect) -> boo if let Some(look) = grid_look.filter(|_| grids_in_back) { grid(&painter, &xf, st.doc.grid.spacing, st.doc.grid.subdivisions, look); } + // View › Pixel Preview, as the canvas draws it. + let max_side = ui.ctx().input(|i| i.max_texture_side); + let pixel = (app.ui.view.pixel_preview && xf.zoom * ppp as f64 > 1.0).then(|| pixel_region(&xf, max_side)).flatten(); + // The pane's frame fits the largest texture side the display supports, as the canvas's does. + let ppp = frame_ppp(rect.size(), ppp, max_side); let size = |l: f32| ((l * ppp).round().max(1.0) as u32).min(u32::from(u16::MAX)); let (pw, ph) = (size(rect.width()), size(rect.height())); - // View › Pixel Preview, as the canvas draws it. - let pixel = (app.ui.view.pixel_preview && xf.zoom * ppp as f64 > 1.0).then(|| pixel_region(&xf)).flatten(); let smooth_images = pixel.is_none() || app.session.prefs.anti_aliased_bitmaps; let plugins = plugin_key(&mut app.canvas.plugin_keys, &app.session, st); let placed = placed_key(&mut app.canvas.placed_keys, &app.session, st); diff --git a/crates/ui-egui/src/canvas/panes/tests.rs b/crates/ui-egui/src/canvas/panes/tests.rs index 005b930d01..3a45d90157 100644 --- a/crates/ui-egui/src/canvas/panes/tests.rs +++ b/crates/ui-egui/src/canvas/panes/tests.rs @@ -323,6 +323,40 @@ fn other_panes_show_pixel_preview() { assert!(x1 > x0 && y1 > y0); } +/// A pane more device pixels across than the largest texture side the display supports renders +/// its document within that size, as the canvas does, and the frame covers the pane. +#[test] +fn other_panes_render_within_the_largest_texture_side() { + let mut app = app_with(2); + app.canvas.panes.worker_started = true; // every pane renders on this thread + let ctx = egui::Context::default(); + ctx.set_pixels_per_point(2.0); + app.run("window.arrange.tile", json!({})).unwrap(); + // The first frame sets the pixels a point, the second draws at them. + for _ in 0..2 { + let raw = egui::RawInput { + screen_rect: Some(egui::Rect::from_min_size(Pos2::ZERO, vec2(1200.0, 800.0))), + max_texture_side: Some(1024), + ..Default::default() + }; + ctx.run_ui(raw, |ui| app.canvas_area(ui)).textures_delta.clear(); + } + let uid = (app.session.documents()[0].uid, 1); + let (key, tex) = app.canvas.panes.shown.get(&uid).map(|(k, t)| (k.clone(), t.size())).expect("the other document's pane rendered"); + assert_eq!(tex[0].max(tex[1]), 1024, "the pane's frame is {tex:?} pixels"); + let (docs, cells) = panes(&app); + let pane = docs.iter().zip(cells).find(|(i, _)| **i == 0).map(|(_, r)| r).unwrap(); + let rect = view_rects(&app, pane, ctx.pixels_per_point()).1; + assert!(rect.width().max(rect.height()) * ctx.pixels_per_point() > 1024.0, "the pane is {rect:?} at {} pixels a point", ctx.pixels_per_point()); + assert!( + (key.w as f32 / key.ppp - rect.width()).abs() < 1.0 && (key.h as f32 / key.ppp - rect.height()).abs() < 1.0, + "the frame covers the pane {rect:?}: {} × {} pixels at {} a point", + key.w, + key.h, + key.ppp + ); +} + /// A pane's render key takes its document's [`CacheKey::placed`] value ([`placed_key`]), which a /// bitmap made for a test running beside this one does not move: the document holds no images. #[test] diff --git a/crates/ui-egui/src/canvas/raster.rs b/crates/ui-egui/src/canvas/raster.rs index 2f9a4f981f..8f167bdfa2 100644 --- a/crates/ui-egui/src/canvas/raster.rs +++ b/crates/ui-egui/src/canvas/raster.rs @@ -242,9 +242,20 @@ pub(super) fn upload(app: &mut VectorcraftApp, ctx: &egui::Context, img: vectorc } } +/// The device pixels a point that the art's frame of a canvas `size` points across renders at: +/// the display's `ppp`, lowered when a side of the frame would be more than `max_side` pixels, the +/// largest texture side the display supports (egui's `max_texture_side`). The frame is then drawn +/// scaled up over the canvas, at a lower resolution. +pub(super) fn frame_ppp(size: egui::Vec2, ppp: f32, max_side: usize) -> f32 { + let longest = size.x.max(size.y) * ppp; + let max = max_side.clamp(1, usize::from(u16::MAX)) as f32; + if longest.round() <= max { ppp } else { ppp * (max / longest) } +} + /// Pixel Preview: the whole document pixels (x0, y0, x1, y1) under the canvas, rotated view -/// included. None when the view is degenerate. -pub(super) fn pixel_region(xf: &Xf) -> Option<[i64; 4]> { +/// included. None when the view is degenerate, or when a side of the region is more than +/// `max_side` pixels, the largest texture side the display supports. +pub(super) fn pixel_region(xf: &Xf, max_side: usize) -> Option<[i64; 4]> { let r = xf.rect; let corners = [r.left_top(), r.right_top(), r.right_bottom(), r.left_bottom()].map(|p| xf.to_doc(p)); let (mut x0, mut y0, mut x1, mut y1) = (f64::INFINITY, f64::INFINITY, f64::NEG_INFINITY, f64::NEG_INFINITY); @@ -253,6 +264,7 @@ pub(super) fn pixel_region(xf: &Xf) -> Option<[i64; 4]> { } let (x0, y0, x1, y1) = (x0.floor(), y0.floor(), x1.ceil(), y1.ceil()); // A document pixel is bigger than a screen pixel here, so the region is about canvas-sized. - let ok = [x0, y0, x1, y1].iter().all(|c| c.is_finite() && c.abs() < 1e9) && x1 > x0 && y1 > y0 && x1 - x0 <= 16384.0 && y1 - y0 <= 16384.0; + let max = max_side.min(16384) as f64; + let ok = [x0, y0, x1, y1].iter().all(|c| c.is_finite() && c.abs() < 1e9) && x1 > x0 && y1 > y0 && x1 - x0 <= max && y1 - y0 <= max; ok.then_some([x0 as i64, y0 as i64, x1 as i64, y1 as i64]) } diff --git a/crates/ui-egui/src/canvas/tests.rs b/crates/ui-egui/src/canvas/tests.rs index 6e48b0a357..35acda02b7 100644 --- a/crates/ui-egui/src/canvas/tests.rs +++ b/crates/ui-egui/src/canvas/tests.rs @@ -2550,6 +2550,45 @@ fn an_edit_renders_only_its_part_of_the_canvas() { assert!(canvas(&app, delta).is_some_and(|(pos, _)| pos.is_none()), "a new view renders all of it"); } +/// A canvas more device pixels across than the largest texture side the display supports renders +/// its art at fewer pixels a point, within that size, and draws it over the whole canvas. Pixel +/// Preview's region of document pixels stays within it too. +#[test] +fn the_art_renders_within_the_largest_texture_side() { + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + app.run("file.new", json!({"width":400,"height":300})).unwrap(); + app.canvas.worker_started = true; // every frame on this thread, however long it takes + let ctx = egui::Context::default(); + ctx.set_pixels_per_point(2.0); + // The first frame sets the pixels a point, the second draws at them. + for _ in 0..2 { + let raw = egui::RawInput { + screen_rect: Some(egui::Rect::from_min_size(Pos2::ZERO, vec2(800.0, 600.0))), + max_texture_side: Some(1024), + ..Default::default() + }; + let mut out = ctx.run_ui(raw, |ui| show(&mut app, ui)); + out.textures_delta.clear(); + } + let rect = app.canvas_rect.unwrap(); + assert!(rect.width() * ctx.pixels_per_point() > 1024.0, "the canvas is {rect:?} at {} pixels a point", ctx.pixels_per_point()); + let size = app.canvas.texture.as_ref().map(|t| t.size()).unwrap(); + assert_eq!(size[0].max(size[1]), 1024, "the frame is {size:?} pixels"); + let k = app.canvas.key.clone().unwrap(); + assert!( + (k.w as f32 / k.ppp - rect.width()).abs() < 1.0 && (k.h as f32 / k.ppp - rect.height()).abs() < 1.0, + "the frame covers the canvas {rect:?}: {} × {} pixels at {} a point", + k.w, + k.h, + k.ppp + ); + + let xf = Xf { rect: egui::Rect::from_min_size(Pos2::ZERO, vec2(1000.0, 500.0)), zoom: 0.5, center: Point::new(0.0, 0.0), rot: 0.0 }; + let [x0, _, x1, _] = pixel_region(&xf, 4096).expect("a region 2,000 document pixels wide"); + assert_eq!(x1 - x0, 2000); + assert_eq!(pixel_region(&xf, 1024), None, "more than the display's largest texture"); +} + /// A placed document's bitmap made in the background renders the canvas again only where the /// objects showing its file are: both copies of the file are in the part rendered again. #[test] @@ -3425,7 +3464,8 @@ fn a_drag_back_to_its_start_renders_again_after_it() { assert!(!app.canvas.moved); } -/// Cache metadata, worker requests and view transforms use the renderer's bounded raster size. +/// Cache metadata, worker requests and view transforms use the renderer's bounded raster size, +/// on a display that reports a larger texture side than that. /// Keep the worker controlled so huge virtual viewports never allocate huge test images. #[test] fn canvas_render_jobs_use_renderer_limited_dimensions() { @@ -3436,7 +3476,11 @@ fn canvas_render_jobs_use_renderer_limited_dimensions() { app.canvas.worker_started = true; app.ui.view.rulers = false; let frame = |app: &mut VectorcraftApp, size, ppp| { - let mut input = egui::RawInput { screen_rect: Some(egui::Rect::from_min_size(pos2(0.0, 0.0), size)), ..Default::default() }; + let mut input = egui::RawInput { + screen_rect: Some(egui::Rect::from_min_size(pos2(0.0, 0.0), size)), + max_texture_side: Some(1 << 20), + ..Default::default() + }; input.viewports.get_mut(&egui::ViewportId::ROOT).unwrap().native_pixels_per_point = Some(ppp); let mut output = ctx.run_ui(input, |ui| show(app, ui)); output.textures_delta.clear(); @@ -5017,6 +5061,67 @@ fn scrolls_and_pans_move_the_view_by_whole_device_pixels() { frame(&mut app, vec![primary(p0 + vec2(-5.55, 22.71), false)]); } +/// On a canvas whose frame renders at fewer pixels a point than the display's ([`frame_ppp`]), +/// the wheel and the Hand tool move the view by whole pixels of that frame. +#[test] +fn scrolls_and_pans_move_a_lowered_frame_by_whole_pixels() { + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + app.session.execute("file.new", &json!({"width": 400, "height": 300})).unwrap(); + app.canvas.worker_started = true; // every frame on this thread + let ctx = egui::Context::default(); + ctx.set_pixels_per_point(2.0); + let screen = egui::Rect::from_min_size(Pos2::ZERO, vec2(800.0, 600.0)); + let mut time = 0.0; + let mut frame = |app: &mut VectorcraftApp, events: Vec| { + time += 0.016; + let input = egui::RawInput { time: Some(time), screen_rect: Some(screen), max_texture_side: Some(1024), events, ..Default::default() }; + ctx.run_ui(input, |ui| show(app, ui)).textures_delta.clear(); + }; + frame(&mut app, vec![]); + app.view_mut().unwrap().center = Point::new(200.3, 150.7); + frame(&mut app, vec![]); + let ppp = app.canvas.key.as_ref().map(|k| k.ppp).unwrap(); + assert!(ppp < 2.0, "the frame renders at {ppp} pixels a point"); + // The pan of the canvas's render view, in pixels of its frame. + let pan = |app: &VectorcraftApp| { + let v = app.view().unwrap(); + (Affine::scale(v.zoom * f64::from(ppp)) * Affine::translate(-v.center.to_vec2())).translation() + }; + let whole = |by: vectorcraft_geom::Vec2| (by - vectorcraft_geom::Vec2::new(by.x.round(), by.y.round())).hypot() < 1e-6; + let wheel = |d: egui::Vec2| egui::Event::MouseWheel { + unit: egui::MouseWheelUnit::Point, + delta: d, + phase: egui::TouchPhase::Move, + modifiers: Default::default(), + source: egui::MouseWheelSource::Unknown, + }; + let rect = app.canvas_rect.unwrap(); + frame(&mut app, vec![egui::Event::PointerMoved(rect.center())]); + let start = pan(&app); + for (k, d) in [vec2(0.37, 11.13), vec2(-12.71, 0.0), vec2(21.9, -3.83)].into_iter().enumerate() { + frame(&mut app, vec![wheel(d)]); + let by = pan(&app) - start; + assert!(whole(by), "scroll {k}: the view moved {by:?} pixels of the frame"); + } + for k in 0..60 { + frame(&mut app, vec![]); + let by = pan(&app) - start; + assert!(whole(by), "frame {k} after the scrolls: the view moved {by:?} pixels of the frame"); + } + assert!((pan(&app) - start).hypot() > 10.0, "the wheel moved the view"); + app.select_tool("hand"); + let p0 = rect.center() + vec2(0.3, 0.1); + frame(&mut app, vec![egui::Event::PointerMoved(p0)]); + let start = pan(&app); + frame(&mut app, vec![primary(p0, true)]); + for (k, d) in [vec2(13.37, -7.61), vec2(40.19, 3.33)].into_iter().enumerate() { + frame(&mut app, vec![egui::Event::PointerMoved(p0 + d)]); + let by = pan(&app) - start; + assert!(whole(by), "drag {k}: the view moved {by:?} pixels of the frame"); + } + frame(&mut app, vec![primary(p0 + vec2(40.19, 3.33), false)]); +} + /// The part of a device pixel a scroll leaves is added to the next scroll of the same view, and /// dropped when the view was zoomed, turned or moved in between, when the next scroll is in /// another document, or when the device pixels a point changed. diff --git a/crates/ui-egui/src/canvas/tests_ui_scaling.rs b/crates/ui-egui/src/canvas/tests_ui_scaling.rs index d9e2a96edf..335f332529 100644 --- a/crates/ui-egui/src/canvas/tests_ui_scaling.rs +++ b/crates/ui-egui/src/canvas/tests_ui_scaling.rs @@ -10,10 +10,15 @@ use vectorcraft_engine::Session; const WINDOW: egui::Vec2 = vec2(1200.0, 900.0); /// One canvas frame in a `WINDOW` window at `native` device pixels a point, with the interface -/// zoomed by `ui_zoom`. +/// zoomed by `ui_zoom`, on a display whose textures are up to 8,192 pixels a side. fn frame(app: &mut VectorcraftApp, ctx: &egui::Context, (native, ui_zoom): (f32, f32), events: Vec) { ctx.set_zoom_factor(ui_zoom); - let mut input = egui::RawInput { screen_rect: Some(egui::Rect::from_min_size(Pos2::ZERO, WINDOW / ui_zoom)), events, ..Default::default() }; + let mut input = egui::RawInput { + screen_rect: Some(egui::Rect::from_min_size(Pos2::ZERO, WINDOW / ui_zoom)), + max_texture_side: Some(8192), + events, + ..Default::default() + }; input.viewports.entry(egui::ViewportId::ROOT).or_default().native_pixels_per_point = Some(native); let mut out = ctx.run_ui(input, |ui| show(app, ui)); out.textures_delta.clear(); diff --git a/crates/ui-egui/src/canvas/view.rs b/crates/ui-egui/src/canvas/view.rs index 0e43e6a545..4443f12997 100644 --- a/crates/ui-egui/src/canvas/view.rs +++ b/crates/ui-egui/src/canvas/view.rs @@ -139,10 +139,18 @@ pub(super) fn whole_pixels(d: egui::Vec2, ppp: f32) -> (f64, f64) { ((f64::from(d.x) * f64::from(ppp)).round(), (f64::from(d.y) * f64::from(ppp)).round()) } +/// The pixels a point that the canvas's art frame renders at ([`super::frame_ppp`]): the display's, +/// or fewer on a canvas wider than the largest texture side. Pans and scrolls move the view by +/// whole pixels of that frame. +pub(super) fn pan_ppp(app: &VectorcraftApp, ui: &Ui) -> f32 { + let ppp = ui.ctx().pixels_per_point(); + app.canvas_rect.map_or(ppp, |r| super::frame_ppp(r.size(), ppp, ui.ctx().input(|i| i.max_texture_side))) +} + /// What the scrolls of the view have left to move ([`scroll_view`]). #[derive(Clone, Copy, Debug, Default, PartialEq)] pub(super) struct ScrollRest { - /// Device pixels along the screen's axes, at most half a pixel each. + /// Pixels of the canvas's frame along the screen's axes, at most half a pixel each. pub(super) x: f64, pub(super) y: f64, /// The document, the view's center, zoom and rotation, and the device pixels a point that the @@ -151,11 +159,11 @@ pub(super) struct ScrollRest { } /// Scroll the active view so that its content moves `by` screen points (the wheel, a trackpad, a -/// touch screen, a scroll bar), in whole device pixels: the part of a pixel left over is added to -/// the next scroll. It is dropped when the view changed in between (a zoom, a turn, Fit, a pan -/// of another kind, another document) or the device pixels a point did. +/// touch screen, a scroll bar), in whole pixels of the canvas's frame ([`pan_ppp`]): the part of a +/// pixel left over is added to the next scroll. It is dropped when the view changed in between (a +/// zoom, a turn, Fit, a pan of another kind, another document) or the pixels a point did. pub(super) fn scroll_view(app: &mut VectorcraftApp, ui: &Ui, by: egui::Vec2) { - let ppp = ui.ctx().pixels_per_point(); + let ppp = pan_ppp(app, ui); let ui_zoom = app.canvas_ui_zoom; let Some(doc) = app.session.active().map(|st| st.uid) else { return }; let Some(vm) = app.view_mut() else { return }; @@ -344,7 +352,7 @@ pub(super) fn scroll_bars(app: &mut VectorcraftApp, ui: &Ui, rect: egui::Rect, o let hot = drag.is_some() || hover.is_some_and(|p| thumb.contains(p)); painter.rect_filled(thumb, CornerRadius::same(3), if hot { t.text_dim } else { t.text_disabled }); } - let ppp = ui.ctx().pixels_per_point(); + let ppp = pan_ppp(app, ui); if let Some((c, by)) = center && let Some(vm) = app.view_mut() { diff --git a/crates/ui-egui/src/chrome/control_bar.rs b/crates/ui-egui/src/chrome/control_bar.rs index fc06926247..cd31acd8c9 100644 --- a/crates/ui-egui/src/chrome/control_bar.rs +++ b/crates/ui-egui/src/chrome/control_bar.rs @@ -573,7 +573,7 @@ fn dock(app: &mut VectorcraftApp, edge: &str) { fn panel_menu(app: &mut VectorcraftApp, ui: &mut Ui, rect: egui::Rect) { let resp = crate::panels::menu_button(ui, "control", rect); egui::Popup::menu(&resp).show(|ui| { - ui.set_min_width(160.0); + widgets::menu_min_width(ui, 160.0); widgets::menu_scroll(ui, |ui| { for group in CONTROL_BAR_GROUPS { if widgets::menu_item(ui, group_label(group), true, group_on(app, group)) { diff --git a/crates/ui-egui/src/custom_toolbars.rs b/crates/ui-egui/src/custom_toolbars.rs index 31f83c8bba..f8c3033a5e 100644 --- a/crates/ui-egui/src/custom_toolbars.rs +++ b/crates/ui-egui/src/custom_toolbars.rs @@ -1377,7 +1377,7 @@ pub(crate) fn add_menu_id(name: &str) -> egui::Id { /// bottom bars, as other long menus do ([`widgets::menu_scroll`]). fn add_menu(button: &egui::Response, t: &Tokens, bar: &CustomToolbar, actions: &mut Vec) { egui::Popup::menu(button).id(add_menu_id(&bar.name)).show(|ui| { - ui.set_min_width(200.0); + widgets::menu_min_width(ui, 200.0); widgets::menu_scroll(ui, |ui| { let held: Vec<&'static str> = bar.slots.iter().flatten().filter_map(|id| tool_info(id).map(|t| t.id)).collect(); let heading = |ui: &mut egui::Ui, text: &str| ui.label(egui::RichText::new(text).size(12.0).color(t.text_strong)); diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index 4ac6da2fbe..d958f18c1b 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -105,6 +105,7 @@ pub mod relink; pub mod rename_artboards; pub(crate) mod revolve; pub(crate) mod revolve_gizmo; +pub(crate) mod revolve_lights; pub mod safe_mode; pub mod saturate; pub mod save_brush_library; diff --git a/crates/ui-egui/src/dialogs/freehand.rs b/crates/ui-egui/src/dialogs/freehand.rs index 48f31731cc..bc7dd47212 100644 --- a/crates/ui-egui/src/dialogs/freehand.rs +++ b/crates/ui-egui/src/dialogs/freehand.rs @@ -9,11 +9,16 @@ //! Brush), `closeWithin` and `editWithin` (screen pixels, 0 turns it off; Pencil and Paintbrush), //! `angle` (degrees) and `roundness` (percent; Blob Brush and Eraser), `livePreview` (Pencil: the stroke shows //! with its fill and stroke while it is drawn, or as a thin line until the release), `size` -//! (points; Blob Brush and Eraser), and `sizeMode` (`fixed` or `pressure`) and `sizeVariation` -//! (points; Blob Brush and Eraser: with Pressure the tip is Size − Variation wide at the lightest -//! pen pressure and Size + Variation at the heaviest). +//! (points; Blob Brush and Eraser), and `sizeMode` and `sizeVariation` (points; Blob Brush and +//! Eraser: with Pressure the tip is Size − Variation wide at the lightest pen pressure and Size + +//! Variation at the heaviest). The Blob Brush's `sizeMode` is `fixed` or `pressure`. The Eraser's +//! Angle, Roundness and Size each have a menu (`angleMode`, `roundnessMode`, `sizeMode`: `fixed`, +//! `random`, `pressure`, `tilt`, `bearing` or `rotation`) before the field and a Variation field +//! under it (`angleVariation` in degrees, `roundnessVariation` in percent, `sizeVariation`), +//! greyed while the menu is Fixed. use serde_json::{Map, Value, json}; +use vectorcraft_brush::Variation; use super::document_setup::check; use super::{DialogSpec, form, run_and_close}; @@ -30,10 +35,14 @@ pub(super) const SPEC: DialogSpec = DialogSpec { heading, body, confirm, min_wid pub const TOOLS: [&str; 5] = ["pencil", "paintbrush", "smooth", "blobBrush", "eraser"]; /// The options the dialog edits, in the order it shows them (a tool has those it reports). -const KEYS: [&str; 14] = [ +const KEYS: [&str; 18] = [ "fidelity", "angle", + "angleMode", + "angleVariation", "roundness", + "roundnessMode", + "roundnessVariation", "size", "sizeMode", "sizeVariation", @@ -140,26 +149,58 @@ fn number(ui: &mut egui::Ui, d: &mut Dialog, (key, label): (&str, &str), suffix: }); } -/// Width of the Blob Brush's and the Eraser's Size mode menu. +/// Width of the Blob Brush's and the Eraser's variation menus. const MODE_W: f32 = 96.0; -/// The Blob Brush's and the Eraser's Size rows: Size with its Fixed or Pressure menu before the -/// field, then the Variation that Pressure adds and takes (greyed while Size is Fixed). -fn blob_size(ui: &mut egui::Ui, d: &mut Dialog) { - const MODES: [&str; 2] = ["fixed", "pressure"]; - let pressure = d.str("sizeMode") == "pressure"; - let labels = ["Fixed", "Pressure"]; - let label_w = label_w(ui) - MODE_W - ui.spacing().item_spacing.x; - let size = d.f64("size", 10.0); - widgets::label_row(ui, tl!("Size:"), label_w, |ui| { - if let Some(m) = widgets::dropdown(ui, "freehand-size-mode", labels[usize::from(pressure)], &labels, MODE_W).and_then(|i| MODES.get(i)) { - d.fields.insert("sizeMode".into(), json!(m)); +/// A tip value with a variation menu: its field. +struct Varied<'a> { + /// The value's key; the menu's is `Mode` and the Variation's `Variation`. + key: &'a str, + label: &'a str, + unit: &'a str, + decimals: usize, + range: (f64, f64), + /// The value's default. + default: f64, + /// The Variation field's range. + variation: (f64, f64), +} + +/// The rows of a tip value with a variation menu: the value with its menu of `modes` before the +/// field, then the Variation field the menu's modes other than Fixed take (greyed while the +/// menu is Fixed). The Blob Brush's Size offers Fixed and Pressure, and the Eraser's Angle, +/// Roundness and Size every mode. +fn varied(ui: &mut egui::Ui, d: &mut Dialog, v: &Varied, modes: &[Variation]) { + let (mode_key, var_key) = (format!("{}Mode", v.key), format!("{}Variation", v.key)); + let mode = Variation::parse(&d.str(&mode_key)).filter(|m| modes.contains(m)).unwrap_or_default(); + let labels: Vec<&str> = modes.iter().map(|m| m.label()).collect(); + let menu_label_w = label_w(ui) - MODE_W - ui.spacing().item_spacing.x; + let value = d.f64(v.key, v.default); + widgets::label_row(ui, v.label, menu_label_w, |ui| { + if let Some(m) = widgets::dropdown(ui, ("freehand-mode", v.key), mode.label(), &labels, MODE_W).and_then(|i| modes.get(i)) { + d.fields.insert(mode_key.clone(), json!(m.id())); } - if let Some(x) = widgets::range_field(ui, ("freehand-field", "size"), size, 0.1..=1000.0, "pt", 1, FIELD_W) { - d.fields.insert("size".into(), json!(x)); + if let Some(x) = widgets::range_field(ui, ("freehand-field", v.key), value, v.range.0..=v.range.1, v.unit, v.decimals, FIELD_W) { + d.fields.insert(v.key.into(), json!(x)); } }); - ui.add_enabled_ui(pressure, |ui| number(ui, d, ("sizeVariation", tl!("Variation:")), "pt", 1, (0.0, 1000.0))); + ui.add_enabled_ui(mode != Variation::Fixed, |ui| { + let label_w = label_w(ui); + let x = d.f64(&var_key, 0.0); + widgets::label_row(ui, tl!("Variation:"), label_w, |ui| { + if let Some(x) = + widgets::range_field(ui, ("freehand-field", var_key.as_str()), x, v.variation.0..=v.variation.1, v.unit, v.decimals, FIELD_W) + { + d.fields.insert(var_key.clone(), json!(x)); + } + }); + }); +} + +/// The Blob Brush's and the Eraser's Size: [`varied`] in points. +fn size_rows(ui: &mut egui::Ui, d: &mut Dialog, modes: &[Variation]) { + let size = Varied { key: "size", label: tl!("Size:"), unit: "pt", decimals: 1, range: (0.1, 1000.0), default: 10.0, variation: (0.0, 1000.0) }; + varied(ui, d, &size, modes); } /// The Fidelity rows: the label on a line of its own, then a logarithmic slider from the tool's @@ -208,19 +249,39 @@ fn body(_app: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) -> bool { fidelity(ui, d); ui.add_space(10.0); } - if d.fields.contains_key("angle") { - number(ui, d, ("angle", tl!("Angle:")), "°", 0, (-180.0, 180.0)); - } - if d.fields.contains_key("roundness") { - let min = if d.str("tool") == "blobBrush" { 0.0 } else { 1.0 }; - number(ui, d, ("roundness", tl!("Roundness:")), "%", 0, (min, 100.0)); + let eraser = d.fields.contains_key("angleMode"); + if eraser { + // The Eraser: Angle, Roundness and Size each with every variation mode. + let angle = + Varied { key: "angle", label: tl!("Angle:"), unit: "°", decimals: 0, range: (-180.0, 180.0), default: 0.0, variation: (0.0, 180.0) }; + varied(ui, d, &angle, &Variation::ALL); + let roundness = Varied { + key: "roundness", + label: tl!("Roundness:"), + unit: "%", + decimals: 0, + range: (1.0, 100.0), + default: 100.0, + variation: (0.0, 100.0), + }; + varied(ui, d, &roundness, &Variation::ALL); + size_rows(ui, d, &Variation::ALL); + } else { + if d.fields.contains_key("angle") { + number(ui, d, ("angle", tl!("Angle:")), "°", 0, (-180.0, 180.0)); + } + if d.fields.contains_key("roundness") { + let min = if d.str("tool") == "blobBrush" { 0.0 } else { 1.0 }; + number(ui, d, ("roundness", tl!("Roundness:")), "%", 0, (min, 100.0)); + } } if d.str("tool") == "blobBrush" { blob_tip_preview(ui, d); } - if d.fields.contains_key("sizeMode") { - blob_size(ui, d); - } else if d.fields.contains_key("size") { + // The Eraser's Size rows are above. + if !eraser && d.fields.contains_key("sizeMode") { + size_rows(ui, d, &[Variation::Fixed, Variation::Pressure]); + } else if !eraser && d.fields.contains_key("size") { number(ui, d, ("size", tl!("Size:")), "pt", 1, (0.1, 1000.0)); } if d.fields.contains_key("fill") { diff --git a/crates/ui-egui/src/dialogs/freehand/tests.rs b/crates/ui-egui/src/dialogs/freehand/tests.rs index 35d216bc77..1b41fc0886 100644 --- a/crates/ui-egui/src/dialogs/freehand/tests.rs +++ b/crates/ui-egui/src/dialogs/freehand/tests.rs @@ -366,6 +366,38 @@ fn the_blob_brush_and_eraser_size_can_follow_the_pen_pressure() { assert!(!text.contains("Variation:") && !text.contains("Fixed"), "{text}"); } +/// Eraser Tool Options gives Angle, Roundness and Size each a variation menu with a Variation +/// field; OK sets the modes and Variations. The Blob Brush keeps one menu, on Size. +#[test] +fn the_eraser_angle_roundness_and_size_each_have_a_variation_menu() { + let mut app = app(); + app.run("tool.options", json!({"tool": "eraser"})).unwrap(); + let text = painted(&mut app); + assert_eq!((text.matches("Variation:").count(), text.matches("Fixed").count()), (3, 3), "{text}"); + let d = app.ui.dialog.as_mut().unwrap(); + for (k, v) in [ + ("angleMode", json!("bearing")), + ("angleVariation", json!(30)), + ("roundnessMode", json!("random")), + ("roundnessVariation", json!(20)), + ("sizeMode", json!("rotation")), + ("sizeVariation", json!(5)), + ] { + d.fields.insert(k.into(), v); + } + let text = painted(&mut app); + assert!(text.contains("Bearing") && text.contains("Random") && text.contains("Rotation") && !text.contains("Fixed"), "{text}"); + super::super::confirm(&mut app).unwrap(); + let o = app.session.tool_options_of("eraser"); + assert_eq!( + [&o["angleMode"], &o["angleVariation"], &o["roundnessMode"], &o["roundnessVariation"], &o["sizeMode"], &o["sizeVariation"]], + [&json!("bearing"), &json!(30.0), &json!("random"), &json!(20.0), &json!("rotation"), &json!(5.0)] + ); + app.run("tool.options", json!({"tool": "blobBrush"})).unwrap(); + let text = painted(&mut app); + assert_eq!(text.matches("Variation:").count(), 1, "{text}"); +} + #[test] fn the_eraser_options_set_the_tips_angle_and_roundness() { let mut app = app(); diff --git a/crates/ui-egui/src/dialogs/revolve.rs b/crates/ui-egui/src/dialogs/revolve.rs index de21c368bc..a547c8fbb7 100644 --- a/crates/ui-egui/src/dialogs/revolve.rs +++ b/crates/ui-egui/src/dialogs/revolve.rs @@ -362,6 +362,10 @@ fn lighting_controls(ui: &mut egui::Ui, d: &mut Dialog) { // A mixed Shading leaves the light settings on, for the effects that shade. ui.add_enabled_ui(flag(d, "shade", true) != Some(false), |ui| { lighting_presets(ui, d); + // The panel's lighting widget; the dialog moves lights with its rings on the artboard. + if d.bool("__panel") { + super::revolve_lights::show(ui, d); + } light_list(ui, d); let mut selected = selected_light(d); let before = light_record(&selected); @@ -429,7 +433,7 @@ const LIGHT_KEYS: [(&str, &str); 6] = [ ("behind", "lightBehind"), ]; -fn explicit_lights(d: &Dialog) -> Option<&Vec> { +pub(super) fn explicit_lights(d: &Dialog) -> Option<&Vec> { d.fields.get("lights").and_then(Value::as_array).filter(|v| !v.is_empty() && v.len() <= vectorcraft_three_d::MAX_LIGHTS) } diff --git a/crates/ui-egui/src/dialogs/revolve_gizmo.rs b/crates/ui-egui/src/dialogs/revolve_gizmo.rs index 4595447ac2..c99e7ab83e 100644 --- a/crates/ui-egui/src/dialogs/revolve_gizmo.rs +++ b/crates/ui-egui/src/dialogs/revolve_gizmo.rs @@ -186,13 +186,13 @@ struct Drag { fields: serde_json::Map, } -fn wrap(n: f64, snap: bool) -> f64 { +pub(super) fn wrap(n: f64, snap: bool) -> f64 { let n = if snap { (n / 15.0).round() * 15.0 } else { n }; (n + 180.0).rem_euclid(360.0) - 180.0 } /// A Height, 0° to 90°, on 15° steps with Shift. -fn snap_height(height: f64, snap: bool) -> f64 { +pub(super) fn snap_height(height: f64, snap: bool) -> f64 { (if snap { (height / 15.0).round() * 15.0 } else { height }).clamp(0.0, 90.0) } diff --git a/crates/ui-egui/src/dialogs/revolve_lights.rs b/crates/ui-egui/src/dialogs/revolve_lights.rs new file mode 100644 index 0000000000..1a4116b7ee --- /dev/null +++ b/crates/ui-egui/src/dialogs/revolve_lights.rs @@ -0,0 +1,261 @@ +//! The lighting widget on the 3D and Materials panel's Lighting tab for Extrude & Bevel, Revolve, +//! Inflate and Rotate: a sphere that shows each of the effect's lights +//! at its Rotation and Height, a filled sun in front of the object and a hollow one behind it. +//! Dragging a light moves it on its side of the object, a press selects it in the list of lights, +//! and the button below the sphere moves the selected light to the back or the front of the object. +//! Each light takes the keyboard focus, where the arrow keys move it, and has a name for screen +//! readers. The widget edits the panel's fields as the light settings do, so the panel previews a +//! drag and records it as one undo step when the pointer is released. + +use egui::{Id, Key, Modifiers, Rect, Sense, Stroke, Vec2, vec2}; +use serde_json::{Value, json}; +use vectorcraft_geom::Point; +use vectorcraft_three_d::{DEFAULT_LIGHT, Revolve}; + +use super::revolve::{c3, explicit_lights, light_record, set_light_value}; +use super::revolve_gizmo::{snap_height, wrap}; +use crate::state::Dialog; +use crate::theme::Tokens; + +/// The sphere's largest radius, in points. +pub(crate) const RADIUS: f32 = 56.0; +/// The radius of a light's sun. +const SUN: f32 = 7.0; +/// The arrow keys' steps in degrees, without and with Shift. +const STEP: f64 = 1.0; +const SHIFT_STEP: f64 = 15.0; +/// The keys that move the focused light. +const ARROWS: [Key; 4] = [Key::ArrowLeft, Key::ArrowRight, Key::ArrowUp, Key::ArrowDown]; + +/// One light as the widget shows it. +#[derive(Clone, Copy, Debug, PartialEq)] +pub(crate) struct Light { + pub rotation: f64, + pub height: f64, + pub behind: bool, + pub color: egui::Color32, +} + +impl Light { + fn options(self) -> Revolve { + Revolve { light_rotation: self.rotation, light_height: self.height, light_behind: self.behind, ..Default::default() } + } + + /// Where the light sits on a sphere of radius 1 around the origin, y down: the direction the + /// shading uses, seen from the front. + pub(crate) fn offset(self) -> Vec2 { + let [x, y, _] = self.options().light_direction(); + vec2(x as f32, y as f32) + } + + /// The Rotation and Height, rounded to 0.1°, of the light moved to `offset` (radius 1, y down) + /// on its side of the object: the farther out, the lower. Shift (`snap`) snaps both to 15°. + pub(crate) fn moved_to(self, offset: Vec2, snap: bool) -> (f64, f64) { + let [rotation, height] = self.options().light_at_on_side(Point::new(f64::from(offset.x), f64::from(offset.y))); + (round(wrap(rotation, snap)), round(snap_height(height, snap))) + } + + /// The Rotation and Height after one press of `key` (Left and Right turn the light around the + /// object counterclockwise and clockwise, Up and Down raise and lower it). Only the setting the + /// key moves changes, rounded to 0.1°. `None` for another key. + fn stepped(self, key: Key, step: f64) -> Option<(f64, f64)> { + let turn = |by: f64| round(wrap(self.rotation + by, false)); + let raise = |by: f64| round(snap_height(self.height + by, false)); + match key { + Key::ArrowLeft => Some((turn(step), self.height)), + Key::ArrowRight => Some((turn(-step), self.height)), + Key::ArrowUp => Some((self.rotation, raise(step))), + Key::ArrowDown => Some((self.rotation, raise(-step))), + _ => None, + } + } + + /// The name screen readers give the light at `index`: its number, Rotation, Height and side. + fn name(self, index: usize) -> String { + let side = if self.behind { format!(", {}", tl!("Light behind the object")) } else { String::new() }; + format!("{} {}: {} {:.1}°, {} {:.1}°{side}", c3("Light"), index + 1, tl!("Rotation"), self.rotation, tl!("Height"), self.height) + } +} + +fn round(n: f64) -> f64 { + (n * 10.0).round() / 10.0 +} + +/// The effect's lights: each light of its list, or its single light, with the effect's defaults for +/// the settings a light leaves out or the selected effects hold with different values (null). +pub(crate) fn lights(d: &Dialog) -> Vec { + let defaults = vectorcraft_effects::default_params(&d.str("__effect")).unwrap_or(json!({})); + let list = explicit_lights(d).cloned().unwrap_or_else(|| vec![light_record(d)]); + list.iter() + .map(|light| { + let value = + |key: &str, field: &str| light.get(key).filter(|v| !v.is_null()).or_else(|| defaults.get(field)).cloned().unwrap_or(Value::Null); + let [r, g, b] = value("color", "lightColor") + .as_str() + .filter(|s| s.len() == 7 && s.starts_with('#')) + .and_then(vectorcraft_color::Color::from_hex) + .unwrap_or(vectorcraft_color::Color::WHITE) + .to_rgb_uncalibrated() + .map(|c| (c * 255.0).round() as u8); + Light { + rotation: value("rotation", "lightRotation").as_f64().unwrap_or(DEFAULT_LIGHT.0), + height: value("height", "lightHeight").as_f64().unwrap_or(DEFAULT_LIGHT.1), + behind: value("behind", "lightBehind").as_bool().unwrap_or(false), + color: egui::Color32::from_rgb(r, g, b), + } + }) + .collect() +} + +/// The index of the selected light among `count` lights. +fn selected(d: &Dialog, count: usize) -> usize { + d.fields.get("__light").and_then(Value::as_u64).and_then(|v| usize::try_from(v).ok()).unwrap_or(0).min(count.saturating_sub(1)) +} + +/// Makes light `index` the selected one, as a choice in the list of lights does. +fn select(ui: &egui::Ui, d: &mut Dialog, index: usize) { + if d.fields.get("__light").and_then(Value::as_u64) != Some(index as u64) { + ui.memory_mut(|m| m.stop_text_input()); + super::revolve_gizmo::clear(ui.ctx()); + } + d.fields.insert("__light".into(), json!(index)); +} + +/// Draws the sphere with the lights and the button that moves the selected light to the back or +/// the front, editing `d.fields`. +pub(super) fn show(ui: &mut egui::Ui, d: &mut Dialog) { + let lights = lights(d); + let Some(&first) = lights.first() else { return }; + let current = selected(d, lights.len()); + let t = Tokens::get(ui.ctx()); + let radius = RADIUS.min((ui.available_width() * 0.5 - SUN - 8.0).max(24.0)); + let (rect, _) = ui.allocate_exact_size(vec2(ui.available_width(), (radius + SUN + 6.0) * 2.0), Sense::hover()); + let center = rect.center(); + let painter = ui.painter_at(rect); + // The sphere: its rim is Height 0, its center Height 90°, and the ring between them 45°. + painter.circle(center, radius, t.input, Stroke::new(1.0, t.input_border)); + painter.circle_stroke(center, radius * std::f32::consts::FRAC_1_SQRT_2, Stroke::new(1.0, t.input_border.gamma_multiply(0.5))); + for axis in [vec2(radius, 0.0), vec2(0.0, radius)] { + painter.line_segment([center - axis, center + axis], Stroke::new(1.0, t.input_border.gamma_multiply(0.5))); + } + painter.circle(center, 4.0, t.panel, Stroke::new(1.0, t.text_dim)); + // The selected light last, so it is drawn above the others and a press there takes it. + let order = (0..lights.len()).filter(|&i| i != current).chain([current]); + let mut moved = None; + let enabled = ui.is_enabled(); + for index in order { + let light = lights.get(index).copied().unwrap_or(first); + let at = center + light.offset() * radius; + let id = Id::unique(("three-d-lighting-widget", index)); + let response = ui.interact(Rect::from_center_size(at, Vec2::splat((SUN + 4.0) * 2.0)), id, Sense::click_and_drag()); + response.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Slider, enabled, light.name(index))); + // A light the pointer took, or that the keyboard focus reached, becomes the selected light and + // keeps the focus, so the arrow keys move it next. Selecting another light stops the editor + // that had the focus, which clears the focus, so the light asks for it after that. + if response.drag_started() || response.clicked() || response.gained_focus() { + select(ui, d, index); + response.request_focus(); + } + let origin = ui.input(|i| i.pointer.press_origin()); + if response.drag_started() + && let Some(origin) = origin + { + // The offset from the press to the light's center, kept for the drag, and whether the + // pointer has left the press yet. + ui.data_mut(|m| m.insert_temp(id, (at - origin, false))); + } + // The frame that releases the pointer can also move it: the light goes where it was dropped, + // the point of the press included once the pointer has left it. + if (response.dragged() || response.drag_stopped()) + && let Some(p) = response.interact_pointer_pos().or_else(|| ui.input(|i| i.pointer.latest_pos())) + { + let (grip, left): (Vec2, bool) = ui.data(|m| m.get_temp(id)).unwrap_or_default(); + let left = left || origin != Some(p); + ui.data_mut(|m| m.insert_temp(id, (grip, left))); + if left { + let snap = ui.input(|i| i.modifiers.shift); + moved = Some((index, light.moved_to((p + grip - center) / radius, snap))); + } + } + if response.has_focus() { + ui.memory_mut(|m| { + m.set_focus_lock_filter(id, egui::EventFilter { horizontal_arrows: true, vertical_arrows: true, ..Default::default() }) + }); + // Every arrow-key press in the frame moves the light in turn, in the order it came. + let presses = ui.input_mut(|i| { + let mut presses = vec![]; + i.events.retain(|e| match e { + egui::Event::Key { key, pressed: true, modifiers, .. } + if ARROWS.contains(key) && (modifiers.matches_exact(Modifiers::NONE) || modifiers.matches_exact(Modifiers::SHIFT)) => + { + presses.push((*key, if modifiers.shift { SHIFT_STEP } else { STEP })); + false + } + _ => true, + }); + presses + }); + let mut next = light; + for (key, step) in presses { + if let Some((rotation, height)) = next.stepped(key, step) { + (next.rotation, next.height) = (rotation, height); + } + } + if next != light { + moved = Some((index, (next.rotation, next.height))); + } + } + paint_light(ui, at, light, index == current, response.has_focus(), response.hovered() || response.dragged()); + if response.hovered() || response.dragged() { + ui.ctx().set_cursor_icon(if response.dragged() { egui::CursorIcon::Grabbing } else { egui::CursorIcon::Grab }); + } + response.on_hover_text(tl!("Drag a light to move it. The arrow keys move the focused light; Shift steps 15°.")); + } + // Only a setting that moved is written: a press that leaves a light where it is, or a key at a + // limit, changes nothing, and a key leaves the other setting as each selected effect has it. + if let Some((index, (rotation, height))) = moved + && let Some(light) = lights.get(index) + { + for (key, value, was) in [("lightRotation", rotation, light.rotation), ("lightHeight", height, light.height)] { + if (value - was).abs() > 1e-9 { + select(ui, d, index); + set_light_value(d, key, json!(value)); + } + } + } + let index = selected(d, lights.len()); + let behind = lights.get(index).is_some_and(|l| l.behind); + ui.horizontal(|ui| { + let text = if behind { tl!("Move Light to Front") } else { tl!("Move Light to Back") }; + if ui.button(text).clicked() { + select(ui, d, index); + set_light_value(d, "lightBehind", json!(!behind)); + } + }); + ui.add_space(4.0); +} + +/// A light's sun: filled with its color in front of the object, hollow behind it, larger and +/// ringed when it is the selected light, with a second ring while it has the keyboard focus. +fn paint_light(ui: &egui::Ui, at: egui::Pos2, light: Light, selected: bool, focused: bool, hot: bool) { + let t = Tokens::get(ui.ctx()); + let painter = ui.painter(); + let r = if selected { SUN } else { SUN - 2.0 }; + let outline = Stroke::new(if hot { 2.5 } else { 1.5 }, if selected { t.warning } else { t.warning.gamma_multiply(0.7) }); + if light.behind { + painter.circle(at, r, t.panel, outline); + painter.circle_stroke(at, r * 0.5, Stroke::new(1.5, light.color)); + } else { + painter.circle(at, r, light.color, outline); + } + if selected { + for i in 0..8 { + let angle = i as f32 * std::f32::consts::TAU / 8.0; + let direction = vec2(angle.cos(), angle.sin()); + painter.line_segment([at + direction * (r + 2.0), at + direction * (r + 4.0)], Stroke::new(1.25, t.warning)); + } + } + if focused { + painter.circle_stroke(at, r + 6.0, Stroke::new(1.0, t.accent)); + } +} diff --git a/crates/ui-egui/src/i18n/bg.tsv b/crates/ui-egui/src/i18n/bg.tsv index 948b295e17..2f43999f7c 100644 --- a/crates/ui-egui/src/i18n/bg.tsv +++ b/crates/ui-egui/src/i18n/bg.tsv @@ -3327,6 +3327,7 @@ digits Malayalam Малаялам Sixth Space Шесто разстояние Size Размер Size Tile to Art Оразмеряване на плочката според обектите + Move Tile with Art Преместване на плочката заедно с обектите Size/Leading Размер/междуредие Size: Размер: Ridges per segment: Изпъкналости на сегмент: @@ -3955,6 +3956,9 @@ digits Malayalam Малаялам Working CMYK Работен CMYK Working CMYK (don't tag) Работен CMYK (без етикет) Working RGB (don't tag) Работен RGB (без етикет) + Don't Color Manage This Document Без управление на цветовете за този документ + Working RGB: {name} Работен RGB: {name} + Working CMYK: {name} Работен CMYK: {name} Workspace Работно пространство Workspace {i} Работно пространство {i} Wrinkle Tool Инструмент „Набръчкване“ @@ -5711,6 +5715,9 @@ theme Appearance Външен вид Softness Мекота Ambient Light Околна светлина Light behind the object Светлина зад обекта + Move Light to Back Премести светлината отзад + Move Light to Front Премести светлината отпред + Drag a light to move it. The arrow keys move the focused light; Shift steps 15°. Плъзнете светлина, за да я преместите. Клавишите със стрелки местят фокусираната светлина; Shift я мести с 15°. Shadows Сенки Distance from Object Разстояние от обекта Shadow Bounds Граници на сянката diff --git a/crates/ui-egui/src/i18n/bs.tsv b/crates/ui-egui/src/i18n/bs.tsv index f8d6fa263b..60c5705497 100644 --- a/crates/ui-egui/src/i18n/bs.tsv +++ b/crates/ui-egui/src/i18n/bs.tsv @@ -2103,6 +2103,7 @@ digits Malayalam Malajalam Show Swatch Bounds Prikaži granice uzorka boje Show Tile Edge Prikaži ivicu pločice Size Tile to Art Prilagodi pločicu crtežu + Move Tile with Art Pomjeri pločicu s crtežom Tile Type: Vrsta pločice: Top in Front Vrh naprijed V Spacing: Okomiti razmak: @@ -2145,6 +2146,9 @@ digits Malayalam Malajalam Use Black Point Compensation Koristi kompenzaciju crne tačke Working CMYK (don't tag) Radni CMYK (bez oznake) Working RGB (don't tag) Radni RGB (bez oznake) + Don't Color Manage This Document Ne upravljaj bojama ovog dokumenta + Working RGB: {name} Radni RGB: {name} + Working CMYK: {name} Radni CMYK: {name} {name} (spot) {name} (spot) Add to Profiles… Dodaj u profile… Align Stroke to Center Poravnaj potez po sredini @@ -5797,6 +5801,9 @@ mark-style Japanese Japanski Softness Mekoća Ambient Light Ambijentalno svjetlo Light behind the object Svjetlo iza objekta + Move Light to Back Pomjeri svjetlo iza + Move Light to Front Pomjeri svjetlo naprijed + Drag a light to move it. The arrow keys move the focused light; Shift steps 15°. Prevucite svjetlo da ga pomjerite. Tipke sa strelicama pomjeraju fokusirano svjetlo; Shift ga pomjera za 15°. Shadows Sjene Distance from Object Udaljenost od objekta Shadow Bounds Granice sjene diff --git a/crates/ui-egui/src/i18n/ca.tsv b/crates/ui-egui/src/i18n/ca.tsv index 3ed3ca8331..db5f5b0aee 100644 --- a/crates/ui-egui/src/i18n/ca.tsv +++ b/crates/ui-egui/src/i18n/ca.tsv @@ -2339,6 +2339,9 @@ digits Malayalam Malaiàlam Ligatures are off while the text is tracked. Les lligadures estan desactivades mentre el text té espaiat. Light Clar Light behind the object Llum darrere de l’objecte + Move Light to Back Mou la llum al darrere + Move Light to Front Mou la llum al davant + Drag a light to move it. The arrow keys move the focused light; Shift steps 15°. Arrossegueu una llum per moure-la. Les tecles de fletxa mouen la llum amb el focus; Maj la mou de 15° en 15°. Shadows Ombres Distance from Object Distància de l’objecte Shadow Bounds Límits de l’ombra @@ -4349,6 +4352,7 @@ digits Malayalam Malaiàlam Sixth Space Espai de sisena part Size Mida Size Tile to Art Ajusta la mida del mosaic a l’art + Move Tile with Art Mou el mosaic amb l’art Size/Leading Mida/Interlineat Size: Mida: Size: {size} Tracking: {tracking} Mida: {size} Espaiat: {tracking} @@ -5161,6 +5165,9 @@ digits Malayalam Malaiàlam Working CMYK CMYK de treball Working CMYK (don't tag) CMYK de treball (sense etiquetar) Working RGB (don't tag) RGB de treball (sense etiquetar) + Don't Color Manage This Document No gestionis el color d'aquest document + Working RGB: {name} RGB de treball: {name} + Working CMYK: {name} CMYK de treball: {name} Working RGB RGB de treball Workspace Espai de treball Workspace {i} Espai de treball {i} @@ -5440,6 +5447,9 @@ digits Malayalam Malaiàlam @msg automation path rejected: cannot resolve {path}: {e} camí d’automatització rebutjat: no es pot resoldre {path}: {e} @msg automation path rejected: outside the read root {root}: {path} camí d’automatització rebutjat: fora de l’arrel de lectura {root}: {path} @msg automation path rejected: outside the read roots {roots}: {path} camí d’automatització rebutjat: fora de les arrels de lectura {roots}: {path} +@msg a linked JavaScript file is outside the read roots un fitxer JavaScript enllaçat és fora de les arrels de lectura +@msg the library's file is outside the read roots el fitxer de la biblioteca és fora de les arrels de lectura +@msg {file} names more than one linked JavaScript file {file} correspon a més d’un fitxer JavaScript enllaçat @msg automation path rejected: outside the write root {root}: {path} camí d’automatització rebutjat: fora de l’arrel d’escriptura {root}: {path} @msg automation path rejected: outside the write roots {roots}: {path} camí d’automatització rebutjat: fora de les arrels d’escriptura {roots}: {path} @msg automation path rejected: parent traversal below a folder that doesn't exist: {path} camí d’automatització rebutjat: recorregut ascendent per sota d’una carpeta que no existeix: {path} @@ -5865,6 +5875,7 @@ digits Malayalam Malaiàlam @msg no library `{key}` (see {list}) no hi ha la biblioteca `{key}` (vegeu {list}) @msg No linked files to look for No hi ha fitxers enllaçats per buscar @msg The link is inside a symbol, graph design, pattern or opacity mask and has no place on the canvas L’enllaç és dins d’un símbol, un disseny de gràfic, un motiu o una màscara d’opacitat i no té lloc al llenç +@msg the art of a graph design in a graph series is drawn again from the design: edit the design to change it la il·lustració d’un disseny de gràfic en una sèrie d’un gràfic es torna a dibuixar a partir del disseny: editeu el disseny per canviar-la @msg no Live Paint edge near that point no hi ha cap aresta de pintura interactiva a prop d’aquell punt @msg no Live Paint face at that point no hi ha cap cara de pintura interactiva en aquell punt @msg no native format no hi ha format natiu diff --git a/crates/ui-egui/src/i18n/cs.tsv b/crates/ui-egui/src/i18n/cs.tsv index 161e235c56..435ec1e1aa 100644 --- a/crates/ui-egui/src/i18n/cs.tsv +++ b/crates/ui-egui/src/i18n/cs.tsv @@ -2134,6 +2134,7 @@ digits Malayalam Malajálamské Show Swatch Bounds Zobrazit hranice položky vzorníku Show Tile Edge Zobrazit okraj dlaždice Size Tile to Art Velikost dlaždice podle kresby + Move Tile with Art Přesouvat dlaždici s kresbou Tile Type: Typ dlaždice: Top in Front Horní vpředu V Spacing: Svislá mezera: @@ -2176,6 +2177,9 @@ digits Malayalam Malajálamské Use Black Point Compensation Použít kompenzaci černého bodu Working CMYK (don't tag) Pracovní CMYK (neoznačovat) Working RGB (don't tag) Pracovní RGB (neoznačovat) + Don't Color Manage This Document Nespravovat barvy tohoto dokumentu + Working RGB: {name} Pracovní RGB: {name} + Working CMYK: {name} Pracovní CMYK: {name} {name} (spot) {name} (přímá) Add to Profiles… Přidat do profilů… Align Stroke to Center Zarovnat tah na střed @@ -5520,6 +5524,7 @@ imageTrace Outline Obrys @msg every numbered name for {name} is taken in the document's folder ve složce dokumentu jsou obsazena všechna číslovaná jména pro {name} @msg No linked files to look for Žádné propojené soubory k vyhledání @msg The link is inside a symbol, graph design, pattern or opacity mask and has no place on the canvas Propojení je uvnitř symbolu, návrhu grafu, vzorku nebo masky krytí a nemá na plátně žádné místo +@msg the art of a graph design in a graph series is drawn again from the design: edit the design to change it kresba návrhu grafu v datové řadě grafu se znovu vykresluje podle návrhu: chcete-li ji změnit, upravte návrh @msg `id` is the step reference {r}, which only `run` and `command_batch` resolve `id` je odkaz na krok {r}, který vyhodnocují jen `run` a `command_batch` @msg `ids` must be an array of object ids, not {v} `ids` musí být pole ID objektů, ne {v} @msg `colors` must be an array of colors, not {v} `colors` musí být pole barev, ne {v} @@ -5539,6 +5544,7 @@ imageTrace Outline Obrys @msg automation filesystem access is not granted: write authority is absent: {path} přístup automatizace k systému souborů není udělen: chybí oprávnění k zápisu: {path} @msg automation path rejected: outside the read root {root}: {path} cesta automatizace odmítnuta: mimo kořen pro čtení {root}: {path} @msg automation path rejected: outside the read roots {roots}: {path} cesta automatizace odmítnuta: mimo kořeny pro čtení {roots}: {path} +@msg a linked JavaScript file is outside the read roots propojený soubor JavaScript je mimo kořeny pro čtení @msg automation path rejected: outside the write root {root}: {path} cesta automatizace odmítnuta: mimo kořen pro zápis {root}: {path} @msg automation path rejected: outside the write roots {roots}: {path} cesta automatizace odmítnuta: mimo kořeny pro zápis {roots}: {path} @msg automation path rejected: path is empty cesta automatizace odmítnuta: cesta je prázdná @@ -6100,6 +6106,9 @@ mark-style Japanese Japonské Softness Měkkost Ambient Light Okolní světlo Light behind the object Světlo za objektem + Move Light to Back Přesunout světlo dozadu + Move Light to Front Přesunout světlo dopředu + Drag a light to move it. The arrow keys move the focused light; Shift steps 15°. Přetažením světlo přesunete. Šipky posouvají světlo s fokusem; se Shiftem po 15°. Shadows Stíny Distance from Object Vzdálenost od objektu Shadow Bounds Hranice stínu diff --git a/crates/ui-egui/src/i18n/da.tsv b/crates/ui-egui/src/i18n/da.tsv index fd0b7c042e..0d62adc6a3 100644 --- a/crates/ui-egui/src/i18n/da.tsv +++ b/crates/ui-egui/src/i18n/da.tsv @@ -2543,6 +2543,7 @@ digits Malayalam Malayalam Show Swatch Bounds Vis farveprøvegrænser Show Tile Edge Vis flisekant Size Tile to Art Tilpas flise til grafik + Move Tile with Art Flyt flise med grafik Tile Type: Flisetype: Top in Front Toppen forrest V Spacing: Lodret afstand: @@ -2585,6 +2586,9 @@ digits Malayalam Malayalam Use Black Point Compensation Brug sortpunktskompensation Working CMYK (don't tag) Arbejds-CMYK (mærk ikke) Working RGB (don't tag) Arbejds-RGB (mærk ikke) + Don't Color Manage This Document Farvestyr ikke dette dokument + Working RGB: {name} Arbejds-RGB: {name} + Working CMYK: {name} Arbejds-CMYK: {name} {name} (spot) {name} (spotfarve) Add to Profiles… Tilføj til profiler… Align Stroke to Center Justér kontur til midten @@ -6054,6 +6058,9 @@ mark-style Japanese Japansk Softness Blødhed Ambient Light Omgivelseslys Light behind the object Lys bag objektet + Move Light to Back Flyt lys bagud + Move Light to Front Flyt lys frem + Drag a light to move it. The arrow keys move the focused light; Shift steps 15°. Træk et lys for at flytte det. Piletasterne flytter lyset med fokus; Skift flytter det 15° ad gangen. Shadows Skygger Distance from Object Afstand fra objektet Shadow Bounds Skyggegrænser diff --git a/crates/ui-egui/src/i18n/de.tsv b/crates/ui-egui/src/i18n/de.tsv index 2a5f32a5d9..c48259f6f1 100644 --- a/crates/ui-egui/src/i18n/de.tsv +++ b/crates/ui-egui/src/i18n/de.tsv @@ -3389,6 +3389,7 @@ digits Malayalam Malayalam Sixth Space Sechstelgeviert-Leerzeichen Size Größe Size Tile to Art Kachelgröße an Grafik anpassen + Move Tile with Art Kachel mit Grafik verschieben Size/Leading Größe/Zeilenabstand Size: Größe: Ridges per segment: Zacken pro Segment: @@ -3965,6 +3966,9 @@ effect Points Punkte Working CMYK CMYK-Arbeitsfarbraum Working CMYK (don't tag) CMYK-Arbeitsfarbraum (nicht kennzeichnen) Working RGB (don't tag) RGB-Arbeitsfarbraum (nicht kennzeichnen) + Don't Color Manage This Document Kein Farbmanagement für dieses Dokument + Working RGB: {name} RGB-Arbeitsfarbraum: {name} + Working CMYK: {name} CMYK-Arbeitsfarbraum: {name} Workspace Arbeitsbereich Workspace {i} Arbeitsbereich {i} Wrinkle Tool Zerknittern-Werkzeug @@ -5682,6 +5686,9 @@ effect Soft Weich Softness Weichheit Ambient Light Umgebungslicht Light behind the object Licht hinter dem Objekt + Move Light to Back Licht nach hinten + Move Light to Front Licht nach vorne + Drag a light to move it. The arrow keys move the focused light; Shift steps 15°. Ziehen Sie ein Licht, um es zu verschieben. Die Pfeiltasten verschieben das fokussierte Licht; mit Umschalt in Schritten von 15°. Shadows Schatten Distance from Object Abstand vom Objekt Shadow Bounds Schattenbegrenzung diff --git a/crates/ui-egui/src/i18n/el.tsv b/crates/ui-egui/src/i18n/el.tsv index b1b7b4d039..95b41e7739 100644 --- a/crates/ui-egui/src/i18n/el.tsv +++ b/crates/ui-egui/src/i18n/el.tsv @@ -3972,6 +3972,7 @@ digits Malayalam Μαλαγιάλαμ Toggle Line Composer Εναλλαγή σύνθεσης γραμμών Sixth Space Διάστημα ενός έκτου Size Tile to Art Προσαρμογή μεγέθους πλακιδίου στο σχέδιο + Move Tile with Art Μετακίνηση πλακιδίου μαζί με το σχέδιο Size/Leading Μέγεθος/διάστιχο Ridges per segment: Ράχες ανά τμήμα: Size: {size} Tracking: {tracking} Μέγεθος: {size} Απόσταση χαρακτήρων: {tracking} @@ -4433,6 +4434,9 @@ digits Malayalam Μαλαγιάλαμ Width Tool Πλάτος (Width Tool) Working CMYK (don't tag) Χώρος εργασίας CMYK (χωρίς ετικέτα) Working RGB (don't tag) Χώρος εργασίας RGB (χωρίς ετικέτα) + Don't Color Manage This Document Χωρίς διαχείριση χρωμάτων για αυτό το έγγραφο + Working RGB: {name} Χώρος εργασίας RGB: {name} + Working CMYK: {name} Χώρος εργασίας CMYK: {name} Workspace {i} Χώρος εργασίας {i} Wrinkle Tool Πτυχώσεις (Wrinkle Tool) Write Layers Εγγραφή επιπέδων @@ -5162,6 +5166,7 @@ digits Malayalam Μαλαγιάλαμ @msg every numbered name for {name} is taken in the document's folder όλα τα αριθμημένα ονόματα για το {name} χρησιμοποιούνται ήδη στον φάκελο εγγράφου @msg No linked files to look for Δεν υπάρχουν συνδεδεμένα αρχεία για αναζήτηση @msg The link is inside a symbol, graph design, pattern or opacity mask and has no place on the canvas Ο σύνδεσμος βρίσκεται μέσα σε σύμβολο, σχεδιασμό γραφήματος, μοτίβο ή μάσκα αδιαφάνειας και δεν έχει θέση στον καμβά +@msg the art of a graph design in a graph series is drawn again from the design: edit the design to change it το έργο ενός σχεδιασμού γραφήματος σε μια σειρά γραφήματος σχεδιάζεται ξανά από τον σχεδιασμό: επεξεργαστείτε τον σχεδιασμό για να το αλλάξετε Hide VectorCraft Απόκρυψη VectorCraft Bring All to Front Μεταφορά όλων στο προσκήνιο Break Link Κατάργηση σύνδεσης @@ -5219,6 +5224,9 @@ digits Malayalam Μαλαγιάλαμ @msg automation filesystem access is not granted: write authority is absent: {path} δεν έχει επιτραπεί πρόσβαση αυτοματισμού στο σύστημα αρχείων: δεν υπάρχει δικαίωμα εγγραφής: {path} @msg automation path rejected: outside the read root {root}: {path} απορρίφθηκε η διαδρομή αυτοματισμού: εκτός ρίζας ανάγνωσης {root}: {path} @msg automation path rejected: outside the read roots {roots}: {path} απορρίφθηκε η διαδρομή αυτοματισμού: εκτός των ριζών ανάγνωσης {roots}: {path} +@msg a linked JavaScript file is outside the read roots ένα συνδεδεμένο αρχείο JavaScript είναι εκτός των ριζών ανάγνωσης +@msg the library's file is outside the read roots το αρχείο της βιβλιοθήκης είναι εκτός των ριζών ανάγνωσης +@msg {file} names more than one linked JavaScript file το {file} αντιστοιχεί σε περισσότερα από ένα συνδεδεμένα αρχεία JavaScript @msg automation path rejected: outside the write root {root}: {path} απορρίφθηκε η διαδρομή αυτοματισμού: εκτός ρίζας εγγραφής {root}: {path} @msg automation path rejected: outside the write roots {roots}: {path} απορρίφθηκε η διαδρομή αυτοματισμού: εκτός των ριζών εγγραφής {roots}: {path} @msg automation path rejected: path is empty απορρίφθηκε η διαδρομή αυτοματισμού: η διαδρομή είναι κενή @@ -5779,6 +5787,9 @@ digits Malayalam Μαλαγιάλαμ to set spacing and orientation για ορισμό απόστασης και προσανατολισμού : : Light behind the object Φως πίσω από το αντικείμενο + Move Light to Back Μετακίνηση φωτός πίσω + Move Light to Front Μετακίνηση φωτός μπροστά + Drag a light to move it. The arrow keys move the focused light; Shift steps 15°. Σύρετε ένα φως για να το μετακινήσετε. Τα πλήκτρα βέλους μετακινούν το εστιασμένο φως· με Shift ανά 15°. Shadows Σκιές Distance from Object Απόσταση από το αντικείμενο Shadow Bounds Όρια σκιάς diff --git a/crates/ui-egui/src/i18n/es.tsv b/crates/ui-egui/src/i18n/es.tsv index 5b3c675f7b..e99d4d8d3d 100644 --- a/crates/ui-egui/src/i18n/es.tsv +++ b/crates/ui-egui/src/i18n/es.tsv @@ -3420,6 +3420,7 @@ digits Malayalam Malayálam Sixth Space Sexto de espacio Size Tamaño Size Tile to Art Ajustar tamaño del mosaico a la ilustración + Move Tile with Art Mover mosaico con la ilustración Size/Leading Tamaño/interlineado Size: Tamaño: Ridges per segment: Crestas por segmento: @@ -4047,6 +4048,9 @@ digits Malayalam Malayálam Working CMYK CMYK de trabajo Working CMYK (don't tag) CMYK de trabajo (sin etiquetar) Working RGB (don't tag) RGB de trabajo (sin etiquetar) + Don't Color Manage This Document No gestionar el color de este documento + Working RGB: {name} RGB de trabajo: {name} + Working CMYK: {name} CMYK de trabajo: {name} Workspace Espacio de trabajo Workspace {i} Espacio de trabajo {i} Wrinkle Tool Herramienta Arrugar @@ -5213,6 +5217,7 @@ axis Both Ambos @msg every numbered name for {name} is taken in the document's folder todos los nombres numerados para {name} ya existen en la carpeta del documento @msg No linked files to look for No hay archivos vinculados que buscar @msg The link is inside a symbol, graph design, pattern or opacity mask and has no place on the canvas El vínculo está dentro de un símbolo, un diseño de gráfico, un motivo o una máscara de opacidad y no tiene lugar en el lienzo +@msg the art of a graph design in a graph series is drawn again from the design: edit the design to change it la ilustración de un diseño de gráfico en una serie de un gráfico se vuelve a dibujar a partir del diseño: edita el diseño para cambiarla # The macOS menu bar (native_menu): the app and Window menus' system items, Settings pages. Services Servicios @@ -5258,6 +5263,7 @@ axis Both Ambos @msg automation filesystem access is not granted: write authority is absent: {path} la automatización no tiene acceso al sistema de archivos: falta el permiso de escritura: {path} @msg automation path rejected: outside the read root {root}: {path} ruta de automatización rechazada: fuera de la carpeta raíz de lectura {root}: {path} @msg automation path rejected: outside the read roots {roots}: {path} ruta de automatización rechazada: fuera de las carpetas raíz de lectura {roots}: {path} +@msg a linked JavaScript file is outside the read roots un archivo JavaScript vinculado está fuera de las carpetas raíz de lectura @msg automation path rejected: outside the write root {root}: {path} ruta de automatización rechazada: fuera de la carpeta raíz de escritura {root}: {path} @msg automation path rejected: outside the write roots {roots}: {path} ruta de automatización rechazada: fuera de las carpetas raíz de escritura {roots}: {path} @msg automation path rejected: path is empty ruta de automatización rechazada: la ruta está vacía @@ -6044,6 +6050,9 @@ mark-style Japanese Japonesas Softness Suavidad Ambient Light Luz ambiental Light behind the object Luz detrás del objeto + Move Light to Back Mover luz detrás + Move Light to Front Mover luz delante + Drag a light to move it. The arrow keys move the focused light; Shift steps 15°. Arrastre una luz para moverla. Las teclas de flecha mueven la luz con el foco; Mayús la mueve de 15° en 15°. Shadows Sombras Distance from Object Distancia del objeto Shadow Bounds Límites de la sombra diff --git a/crates/ui-egui/src/i18n/fi.tsv b/crates/ui-egui/src/i18n/fi.tsv index e77a617c52..e32cb7fb02 100644 --- a/crates/ui-egui/src/i18n/fi.tsv +++ b/crates/ui-egui/src/i18n/fi.tsv @@ -2095,6 +2095,7 @@ digits Malayalam Malajalam Show Swatch Bounds Näytä värimallin rajat Show Tile Edge Näytä ruudun reuna Size Tile to Art Sovita ruudun koko kuvitukseen + Move Tile with Art Siirrä ruutua kuvituksen mukana Tile Type: Ruudun tyyppi: Top in Front Ylin edessä V Spacing: Pystyväli: @@ -2137,6 +2138,9 @@ digits Malayalam Malajalam Use Black Point Compensation Käytä mustan pisteen kompensointia Working CMYK (don't tag) Työ-CMYK (ei tunnistetta) Working RGB (don't tag) Työ-RGB (ei tunnistetta) + Don't Color Manage This Document Älä hallitse tämän asiakirjan värejä + Working RGB: {name} Työ-RGB: {name} + Working CMYK: {name} Työ-CMYK: {name} {name} (spot) {name} (spottiväri) Add to Profiles… Lisää profiileihin… Align Stroke to Center Tasaa viiva keskelle @@ -5421,6 +5425,7 @@ imageTrace Outline Ääriviiva @msg every numbered name for {name} is taken in the document's folder kaikki kohteen {name} numeroidut nimet ovat jo käytössä asiakirjan kansiossa @msg No linked files to look for Ei etsittäviä linkitettyjä tiedostoja @msg The link is inside a symbol, graph design, pattern or opacity mask and has no place on the canvas Linkki on symbolin, kaaviomallin, kuvion tai peittävyysmaskin sisällä, eikä sillä ole paikkaa piirtoalueella +@msg the art of a graph design in a graph series is drawn again from the design: edit the design to change it kaavion sarjassa olevan kaaviomallin piirros piirretään uudelleen mallista: muokkaa mallia muuttaaksesi sitä @msg `id` is the step reference {r}, which only `run` and `command_batch` resolve `id` on vaiheviittaus {r}, jonka vain `run` ja `command_batch` ratkaisevat @msg `ids` must be an array of object ids, not {v} `ids` on oltava objektitunnusten taulukko, ei {v} @msg `colors` must be an array of colors, not {v} `colors` on oltava värien taulukko, ei {v} @@ -5440,6 +5445,9 @@ imageTrace Outline Ääriviiva @msg automation filesystem access is not granted: write authority is absent: {path} automaation tiedostojärjestelmäkäyttöä ei ole myönnetty: kirjoitusoikeus puuttuu: {path} @msg automation path rejected: outside the read root {root}: {path} automaation polku hylättiin: lukujuuren {root} ulkopuolella: {path} @msg automation path rejected: outside the read roots {roots}: {path} automaation polku hylättiin: lukujuurten {roots} ulkopuolella: {path} +@msg a linked JavaScript file is outside the read roots linkitetty JavaScript-tiedosto on lukujuurten ulkopuolella +@msg the library's file is outside the read roots kirjaston tiedosto on lukujuurten ulkopuolella +@msg {file} names more than one linked JavaScript file {file} vastaa useampaa kuin yhtä linkitettyä JavaScript-tiedostoa @msg automation path rejected: outside the write root {root}: {path} automaation polku hylättiin: kirjoitusjuuren {root} ulkopuolella: {path} @msg automation path rejected: outside the write roots {roots}: {path} automaation polku hylättiin: kirjoitusjuurten {roots} ulkopuolella: {path} @msg automation path rejected: path is empty automaation polku hylättiin: polku on tyhjä @@ -5981,6 +5989,9 @@ mark-style Japanese Japanilainen Softness Pehmeys Ambient Light Ympäristövalo Light behind the object Valo objektin takana + Move Light to Back Siirrä valo taakse + Move Light to Front Siirrä valo eteen + Drag a light to move it. The arrow keys move the focused light; Shift steps 15°. Siirrä valoa vetämällä. Nuolinäppäimet siirtävät kohdistettua valoa; Vaihto siirtää 15° kerrallaan. Shadows Varjot Distance from Object Etäisyys objektista Shadow Bounds Varjon rajat diff --git a/crates/ui-egui/src/i18n/fr.tsv b/crates/ui-egui/src/i18n/fr.tsv index 2762d2a954..08fcc4b393 100644 --- a/crates/ui-egui/src/i18n/fr.tsv +++ b/crates/ui-egui/src/i18n/fr.tsv @@ -3412,6 +3412,7 @@ digits Malayalam Malayalam Sixth Space Espace d’un sixième Size Taille Size Tile to Art Ajuster le carreau à l’illustration + Move Tile with Art Déplacer le carreau avec l’illustration Size/Leading Taille/interlignage Size: Taille : Ridges per segment: Crêtes par segment : @@ -4038,6 +4039,9 @@ digits Malayalam Malayalam Working CMYK CMJN de travail Working CMYK (don't tag) CMJN de travail (sans balise) Working RGB (don't tag) RVB de travail (sans balise) + Don't Color Manage This Document Ne pas gérer les couleurs de ce document + Working RGB: {name} RVB de travail : {name} + Working CMYK: {name} CMJN de travail : {name} Workspace Espace de travail Workspace {i} Espace de travail {i} Wrinkle Tool Outil Fronces @@ -5198,6 +5202,7 @@ axis Both Les deux @msg every numbered name for {name} is taken in the document's folder tous les noms numérotés pour {name} sont déjà pris dans le dossier du document @msg No linked files to look for Aucun fichier lié à rechercher @msg The link is inside a symbol, graph design, pattern or opacity mask and has no place on the canvas Le lien se trouve dans un symbole, une conception de graphe, un motif ou un masque d’opacité et n’a pas de place sur le canevas +@msg the art of a graph design in a graph series is drawn again from the design: edit the design to change it l’illustration d’une conception de graphe dans une série de graphe est redessinée à partir de la conception, qu’il faut modifier pour la changer # The macOS menu bar (native_menu): the app and Window menus' system items, Settings pages. Services Services @@ -5263,6 +5268,7 @@ axis Both Les deux @msg automation filesystem access is not granted: write authority is absent: {path} l’automatisation n’a pas accès au système de fichiers : aucune autorisation d’écriture : {path} @msg automation path rejected: outside the read root {root}: {path} chemin d’automatisation refusé : hors du dossier racine de lecture {root} : {path} @msg automation path rejected: outside the read roots {roots}: {path} chemin d’automatisation refusé : hors des dossiers racines de lecture {roots} : {path} +@msg a linked JavaScript file is outside the read roots un fichier JavaScript lié est hors des dossiers racines de lecture @msg automation path rejected: outside the write root {root}: {path} chemin d’automatisation refusé : hors du dossier racine d’écriture {root} : {path} @msg automation path rejected: outside the write roots {roots}: {path} chemin d’automatisation refusé : hors des dossiers racines d’écriture {roots} : {path} @msg automation path rejected: path is empty chemin d’automatisation refusé : le chemin est vide @@ -6096,6 +6102,9 @@ theme Appearance Apparence Softness Douceur Ambient Light Lumière ambiante Light behind the object Lumière derrière l'objet + Move Light to Back Placer la lumière derrière + Move Light to Front Placer la lumière devant + Drag a light to move it. The arrow keys move the focused light; Shift steps 15°. Faites glisser une lumière pour la déplacer. Les touches fléchées déplacent la lumière active ; Maj par pas de 15°. Shadows Ombres Distance from Object Distance de l'objet Shadow Bounds Limites de l'ombre diff --git a/crates/ui-egui/src/i18n/gu.tsv b/crates/ui-egui/src/i18n/gu.tsv index 1c96900b2b..e2d299ce76 100644 --- a/crates/ui-egui/src/i18n/gu.tsv +++ b/crates/ui-egui/src/i18n/gu.tsv @@ -1029,6 +1029,7 @@ Document model દસ્તાવેજ મોડેલ Document open password દસ્તાવેજ ખોલવાનો પાસવર્ડ Documents you create appear here. તમે બનાવેલા દસ્તાવેજો અહીં દેખાશે. + Don't Color Manage This Document આ દસ્તાવેજનું રંગ સંચાલન કરશો નહીં Don't Relink ફરીથી લિંક કરશો નહીં Don't Save સાચવશો નહીં Don't Show Center સેન્ટર બતાવશો નહીં @@ -1910,6 +1911,9 @@ Light Intensity લાઇટ તીવ્રતા Light Rough લાઇટ રફ Light behind the object ઑબ્જેક્ટની પાછળ લાઇટ + Move Light to Back લાઇટ પાછળ ખસેડો + Move Light to Front લાઇટ આગળ ખસેડો + Drag a light to move it. The arrow keys move the focused light; Shift steps 15°. લાઇટ ખસેડવા માટે તેને ખેંચો. ઍરો કી ફોકસ થયેલી લાઇટ ખસેડે છે; Shift સાથે 15° ના પગલાં. Lighten હળવું કરો Lighting લાઇટિંગ Lights લાઇટ્સ @@ -3536,6 +3540,7 @@ Sixth Space છઠ્ઠી જગ્યા Size કદ Size Tile to Art ટાઇલનું કદ આર્ટ પ્રમાણે કરો + Move Tile with Art ટાઇલને આર્ટ સાથે ખસેડો Size/Leading કદ/લીડિંગ Size: કદ: Size: {size} Tracking: {tracking} કદ: {size} ટ્રેકિંગ: {tracking} @@ -4177,6 +4182,8 @@ Word Spacing: શબ્દ અંતર: Words Longer Than: આનાથી લાંબા શબ્દો: Working CMYK કાર્યકારી CMYK + Working CMYK: {name} કાર્યકારી CMYK: {name} + Working RGB: {name} કાર્યકારી RGB: {name} Workspace વર્કસ્પેસ Wrinkle કરચલી Wrinkle Tool કરચલી ટૂલ diff --git a/crates/ui-egui/src/i18n/hi.tsv b/crates/ui-egui/src/i18n/hi.tsv index 548f961f51..9e7c1fa6fa 100644 --- a/crates/ui-egui/src/i18n/hi.tsv +++ b/crates/ui-egui/src/i18n/hi.tsv @@ -2587,6 +2587,9 @@ antiAlias Sharp शार्प Ligatures are off while the text is tracked. टेक्स्ट पर ट्रैकिंग लागू होने पर लिगेचर ऑफ़ रहते हैं। Light लाइट Light behind the object ऑब्जेक्ट के पीछे प्रकाश + Move Light to Back प्रकाश को पीछे ले जाएँ + Move Light to Front प्रकाश को आगे ले जाएँ + Drag a light to move it. The arrow keys move the focused light; Shift steps 15°. प्रकाश को हिलाने के लिए उसे खींचें। तीर कुंजियाँ फ़ोकस वाले प्रकाश को हिलाती हैं; Shift के साथ 15° के चरण। Shadows छायाएँ Distance from Object ऑब्जेक्ट से दूरी Shadow Bounds छाया सीमाएँ @@ -4696,6 +4699,7 @@ antiAlias Sharp शार्प Sixth Space छठे हिस्से जितना स्पेस Size आकार Size Tile to Art आकार टाइल कला के लिए + Move Tile with Art टाइल को कला के साथ ले जाएँ Size: साइज़: Size: {size} Tracking: {tracking} साइज़: {size} ट्रैकिंग: {tracking} Size/Leading साइज़/लीडिंग @@ -5573,6 +5577,9 @@ antiAlias Sharp शार्प Working CMYK वर्किंग CMYK Working CMYK (don't tag) कार्य CMYK (टैग नहीं) Working RGB (don't tag) कार्य RGB (टैग नहीं) + Don't Color Manage This Document इस दस्तावेज़ का रंग प्रबंधन न करें + Working RGB: {name} कार्य RGB: {name} + Working CMYK: {name} कार्य CMYK: {name} Working RGB वर्किंग RGB Workspace वर्कस्पेस Workspace {i} वर्कस्पेस {i} @@ -6464,6 +6471,9 @@ antiAlias Sharp शार्प @msg automation path rejected: cannot resolve {path}: {e} स्वचालन पाथ खारिज कर दिया: {path} को हल नहीं कर सकता: {e} @msg automation path rejected: outside the read root {root}: {path} स्वचालन पाथ खारिज: पढ़ने की जड़ के बाहर {root}: {path} @msg automation path rejected: outside the read roots {roots}: {path} स्वचालन पाथ खारिज: पढ़ने की जड़ों के बाहर {roots}: {path} +@msg a linked JavaScript file is outside the read roots एक लिंक की गई जावास्क्रिप्ट फ़ाइल पढ़ने की जड़ों के बाहर है +@msg the library's file is outside the read roots लाइब्रेरी की फ़ाइल पढ़ने की जड़ों के बाहर है +@msg {file} names more than one linked JavaScript file {file} एक से अधिक लिंक की गई जावास्क्रिप्ट फ़ाइलों से मेल खाता है @msg automation path rejected: outside the write root {root}: {path} स्वचालन पाथ खारिज: लिखने की जड़ के बाहर {root}: {path} @msg automation path rejected: outside the write roots {roots}: {path} स्वचालन पाथ खारिज: लिखने की जड़ों के बाहर {roots}: {path} @msg automation path rejected: parent traversal below a folder that doesn't exist: {path} स्वचालन पाथ खारिज कर दिया: एक फ़ोल्डर के नीचे माता-पिता traversal जो मौजूद नहीं है: {path} @@ -7376,6 +7386,7 @@ antiAlias Sharp शार्प @msg No line breaks to remove हटाने के लिए कोई लाइन ब्रेक नहीं @msg No linked files to look for देखने के लिए कोई लिंक्ड फ़ाइल नहीं @msg The link is inside a symbol, graph design, pattern or opacity mask and has no place on the canvas लिंक किसी सिंबल, ग्राफ़ डिज़ाइन, पैटर्न या ओपेसिटी मास्क के अंदर है और कैनवास पर इसकी कोई जगह नहीं है +@msg the art of a graph design in a graph series is drawn again from the design: edit the design to change it ग्राफ़ श्रृंखला में ग्राफ़ डिज़ाइन की कला डिज़ाइन से फिर से बनाई जाती है: इसे बदलने के लिए डिज़ाइन संपादित करें @msg no Live Paint edge near that point उस बिंदु के पास कोई लाइव पेंट एज नहीं @msg no Live Paint face at that point उस बिंदु पर कोई लाइव पेंट फेस नहीं @msg no metafile format कोई मेटाफ़ाइल फ़ॉर्मैट diff --git a/crates/ui-egui/src/i18n/hu.tsv b/crates/ui-egui/src/i18n/hu.tsv index 200b45d1b1..f12923ee31 100644 --- a/crates/ui-egui/src/i18n/hu.tsv +++ b/crates/ui-egui/src/i18n/hu.tsv @@ -4069,6 +4069,7 @@ mark-style Japanese Japán Sixth Space Hatodkvirt-szóköz Size Méret Size Tile to Art Csempe méretezése a grafikához + Move Tile with Art Csempe mozgatása a grafikával Size/Leading Méret/sorköz Size: Méret: Size: {size} Tracking: {tracking} Méret: {size} Betűköz: {tracking} @@ -4788,6 +4789,9 @@ mark-style Japanese Japán Working CMYK Munka-CMYK Working CMYK (don't tag) Munka-CMYK (címke nélkül) Working RGB (don't tag) Munka-RGB (címke nélkül) + Don't Color Manage This Document Ne legyen színkezelés ebben a dokumentumban + Working RGB: {name} Munka-RGB: {name} + Working CMYK: {name} Munka-CMYK: {name} Working RGB Munka-RGB Workspace Munkaterület Workspace {i} Munkaterület {i} @@ -5044,6 +5048,7 @@ imageTrace Outline Körvonal @msg automation path rejected: cannot resolve {path}: {e} az automatizálás útvonala elutasítva: nem oldható fel: {path}: {e} @msg automation path rejected: outside the read root {root}: {path} az automatizálás útvonala elutasítva: kívül esik az olvasási gyökéren ({root}): {path} @msg automation path rejected: outside the read roots {roots}: {path} az automatizálás útvonala elutasítva: kívül esik az olvasási gyökereken ({roots}): {path} +@msg a linked JavaScript file is outside the read roots egy csatolt JavaScript-fájl kívül esik az olvasási gyökereken @msg automation path rejected: outside the write root {root}: {path} az automatizálás útvonala elutasítva: kívül esik az írási gyökéren ({root}): {path} @msg automation path rejected: outside the write roots {roots}: {path} az automatizálás útvonala elutasítva: kívül esik az írási gyökereken ({roots}): {path} @msg automation path rejected: parent traversal below a folder that doesn't exist: {path} az automatizálás útvonala elutasítva: szülőmappára lépés egy nem létező mappa alatt: {path} @@ -5252,6 +5257,7 @@ imageTrace Outline Körvonal @msg every numbered name for {name} is taken in the document's folder a dokumentum mappájában minden sorszámozott név foglalt: {name} @msg No linked files to look for Nincs keresendő csatolt fájl @msg The link is inside a symbol, graph design, pattern or opacity mask and has no place on the canvas A csatolás egy szimbólumban, diagrammintában, mintázatban vagy átlátszósági maszkban van, és nincs helye a vásznon +@msg the art of a graph design in a graph series is drawn again from the design: edit the design to change it egy diagram adatsorában a diagramminta grafikája a mintából rajzolódik újra: a módosításához szerkeszd a mintát @msg every search thread stopped on an internal error (please report this bug) minden keresési szál belső hibával leállt (kérjük, jelentsd ezt a hibát) @msg Expand Appearance: the selection has a basic appearance Megjelenés kibontása: a kijelölésnek alapmegjelenése van @msg Expand: no brush strokes selected Kibontás: nincs kijelölt ecsetvonás @@ -6083,6 +6089,9 @@ imageTrace Outline Körvonal Softness Lágyság Ambient Light Környezeti fény Light behind the object Fény az objektum mögött + Move Light to Back Fény hátra + Move Light to Front Fény előre + Drag a light to move it. The arrow keys move the focused light; Shift steps 15°. Húzzon egy fényt az áthelyezéséhez. A nyílbillentyűk a fókuszban lévő fényt mozgatják; Shifttel 15°-onként. Shadows Árnyékok Distance from Object Távolság az objektumtól Shadow Bounds Árnyék határai diff --git a/crates/ui-egui/src/i18n/id.tsv b/crates/ui-egui/src/i18n/id.tsv index f3dcca3874..7fc6760e3d 100644 --- a/crates/ui-egui/src/i18n/id.tsv +++ b/crates/ui-egui/src/i18n/id.tsv @@ -1934,6 +1934,7 @@ digits Malayalam Malayalam Show Swatch Bounds Tampilkan batas swatch Show Tile Edge Tampilkan tepi ubin Size Tile to Art Sesuaikan ukuran ubin dengan karya seni + Move Tile with Art Pindahkan ubin bersama karya seni Tile Type: Jenis ubin: Top in Front Atas di depan V Spacing: Jarak V: @@ -1976,6 +1977,9 @@ digits Malayalam Malayalam Use Black Point Compensation Gunakan kompensasi titik hitam Working CMYK (don't tag) CMYK kerja (tanpa tag) Working RGB (don't tag) RGB kerja (tanpa tag) + Don't Color Manage This Document Jangan kelola warna dokumen ini + Working RGB: {name} RGB kerja: {name} + Working CMYK: {name} CMYK kerja: {name} {name} (spot) {name} (spot) Add to Profiles… Tambahkan ke profil… Align Stroke to Center Ratakan stroke ke tengah @@ -5444,6 +5448,9 @@ mark-style Japanese Jepang Softness Kelembutan Ambient Light Cahaya ambient Light behind the object Cahaya di belakang objek + Move Light to Back Pindahkan Cahaya ke Belakang + Move Light to Front Pindahkan Cahaya ke Depan + Drag a light to move it. The arrow keys move the focused light; Shift steps 15°. Seret cahaya untuk memindahkannya. Tombol panah memindahkan cahaya yang difokuskan; Shift memindahkannya 15°. Shadows Bayangan Distance from Object Jarak dari objek Shadow Bounds Batas bayangan diff --git a/crates/ui-egui/src/i18n/it.tsv b/crates/ui-egui/src/i18n/it.tsv index 90ec79404c..0d33fe8b64 100644 --- a/crates/ui-egui/src/i18n/it.tsv +++ b/crates/ui-egui/src/i18n/it.tsv @@ -3465,6 +3465,7 @@ digits Malayalam Malayalam Sixth Space Spazio di un sesto Size Dimensione Size Tile to Art Adatta tassello alla grafica + Move Tile with Art Sposta tassello con la grafica Size/Leading Dimensione/interlinea Size: Dimensione: Ridges per segment: Creste per segmento: @@ -4094,6 +4095,9 @@ digits Malayalam Malayalam Working CMYK CMYK di lavoro Working CMYK (don't tag) CMYK di lavoro (senza tag) Working RGB (don't tag) RGB di lavoro (senza tag) + Don't Color Manage This Document Non gestire il colore di questo documento + Working RGB: {name} RGB di lavoro: {name} + Working CMYK: {name} CMYK di lavoro: {name} Workspace Area di lavoro Workspace {i} Area di lavoro {i} Wrinkle Tool Strumento Grinza @@ -5263,6 +5267,7 @@ axis Both Entrambi @msg every numbered name for {name} is taken in the document's folder tutti i nomi numerati per {name} sono già usati nella cartella del documento @msg No linked files to look for Nessun file collegato da cercare @msg The link is inside a symbol, graph design, pattern or opacity mask and has no place on the canvas Il collegamento si trova in un simbolo, un design di grafico, un motivo o una maschera di opacità e non ha una posizione nell’area di disegno +@msg the art of a graph design in a graph series is drawn again from the design: edit the design to change it la grafica di un design di grafico in una serie del grafico viene ridisegnata dal design: modifica il design per cambiarla # The macOS menu bar (native_menu): the app and Window menus' system items, Settings pages. Services Servizi @@ -5308,6 +5313,7 @@ axis Both Entrambi @msg automation filesystem access is not granted: write authority is absent: {path} l'automazione non ha accesso al file system: manca l'autorizzazione di scrittura: {path} @msg automation path rejected: outside the read root {root}: {path} percorso di automazione rifiutato: fuori dalla cartella radice di lettura {root}: {path} @msg automation path rejected: outside the read roots {roots}: {path} percorso di automazione rifiutato: fuori dalle cartelle radice di lettura {roots}: {path} +@msg a linked JavaScript file is outside the read roots un file JavaScript collegato è fuori dalle cartelle radice di lettura @msg automation path rejected: outside the write root {root}: {path} percorso di automazione rifiutato: fuori dalla cartella radice di scrittura {root}: {path} @msg automation path rejected: outside the write roots {roots}: {path} percorso di automazione rifiutato: fuori dalle cartelle radice di scrittura {roots}: {path} @msg automation path rejected: path is empty percorso di automazione rifiutato: il percorso è vuoto @@ -5717,6 +5723,9 @@ lighting Light Luce Softness Morbidezza Ambient Light Luce ambientale Light behind the object Luce dietro l'oggetto + Move Light to Back Sposta luce dietro + Move Light to Front Sposta luce davanti + Drag a light to move it. The arrow keys move the focused light; Shift steps 15°. Trascina una luce per spostarla. I tasti freccia spostano la luce attiva; Maiusc a passi di 15°. Shadows Ombre Distance from Object Distanza dall'oggetto Shadow Bounds Limiti dell'ombra diff --git a/crates/ui-egui/src/i18n/ja.tsv b/crates/ui-egui/src/i18n/ja.tsv index d98acfa22d..0f1231d5d4 100644 --- a/crates/ui-egui/src/i18n/ja.tsv +++ b/crates/ui-egui/src/i18n/ja.tsv @@ -3281,6 +3281,7 @@ mark-style Japanese 日本式 Single-line Composer 1 行ずつ組む Toggle Line Composer 行の組版方式を切り替え Size Tile to Art タイルをアートワークのサイズに合わせる + Move Tile with Art タイルをアートワークと一緒に移動 Size/Leading サイズ/行送り Size: サイズ: Ridges per segment: セグメントごとの山数: @@ -4122,6 +4123,9 @@ file-filter Templates テンプレート Windows Windows Working CMYK (don't tag) 作業用 CMYK (タグなし) Working RGB (don't tag) 作業用 RGB (タグなし) + Don't Color Manage This Document このドキュメントにカラーマネジメントを適用しない + Working RGB: {name} 作業用 RGB: {name} + Working CMYK: {name} 作業用 CMYK: {name} Workspace {i} ワークスペース {i} Yellow イエロー [Normal Character Style] [標準文字スタイル] @@ -5330,6 +5334,7 @@ swatch Bright Violet ビビッドバイオレット @msg every numbered name for {name} is taken in the document's folder ドキュメントのフォルダーでは、{name} の番号付きの名前がすべて使われています @msg No linked files to look for 検索するリンクファイルがありません @msg The link is inside a symbol, graph design, pattern or opacity mask and has no place on the canvas このリンクはシンボル、グラフデザイン、パターン、または不透明マスクの中にあり、カンバス上に位置がありません +@msg the art of a graph design in a graph series is drawn again from the design: edit the design to change it グラフの系列にあるグラフデザインのアートはデザインから描き直されます: 変更するにはデザインを編集してください @msg `id` is the step reference {r}, which only `run` and `command_batch` resolve `id` はステップ参照 {r} です。ステップ参照を解決できるのは `run` と `command_batch` だけです @msg `ids` must be an array of object ids, not {v} `ids` はオブジェクト ID の配列にしてください ({v} は使えません) @msg `colors` must be an array of colors, not {v} `colors` は色の配列にしてください ({v} は使えません) @@ -5349,6 +5354,7 @@ swatch Bright Violet ビビッドバイオレット @msg automation filesystem access is not granted: write authority is absent: {path} 自動化にはファイルシステムへのアクセスが許可されていません: 書き込み権限がありません: {path} @msg automation path rejected: outside the read root {root}: {path} 自動化のパスを拒否しました: 読み込みルート {root} の外です: {path} @msg automation path rejected: outside the read roots {roots}: {path} 自動化のパスを拒否しました: 読み込みルート {roots} のいずれの中でもありません: {path} +@msg a linked JavaScript file is outside the read roots リンクされた JavaScript ファイルが読み込みルートの外にあります @msg automation path rejected: outside the write root {root}: {path} 自動化のパスを拒否しました: 書き込みルート {root} の外です: {path} @msg automation path rejected: outside the write roots {roots}: {path} 自動化のパスを拒否しました: 書き込みルート {roots} のいずれの中でもありません: {path} @msg automation path rejected: path is empty 自動化のパスを拒否しました: パスが空です @@ -5760,6 +5766,9 @@ lighting Light ライト Softness 柔らかさ Ambient Light 環境光 Light behind the object オブジェクトの背後のライト + Move Light to Back ライトを背面へ + Move Light to Front ライトを前面へ + Drag a light to move it. The arrow keys move the focused light; Shift steps 15°. ライトをドラッグして移動します。矢印キーでフォーカスのあるライトを移動し、Shift キーで 15° ずつ移動します。 Shadows シャドウ Distance from Object オブジェクトからの距離 Shadow Bounds シャドウの範囲 diff --git a/crates/ui-egui/src/i18n/ka.tsv b/crates/ui-egui/src/i18n/ka.tsv index 794e5454a9..5f49f73675 100644 --- a/crates/ui-egui/src/i18n/ka.tsv +++ b/crates/ui-egui/src/i18n/ka.tsv @@ -1863,6 +1863,7 @@ digits Malayalam მალაიალამური Show Swatch Bounds ფერის ნიმუშის საზღვრების ჩვენება Show Tile Edge ფილის კიდის ჩვენება Size Tile to Art ფილის ზომის ნახატზე მორგება + Move Tile with Art ფილის ნახატთან ერთად გადატანა Tile Type: ფილის ტიპი: Top in Front ზედა წინ V Spacing: ვერტ. დაშორება: @@ -1905,6 +1906,9 @@ digits Malayalam მალაიალამური Use Black Point Compensation შავი წერტილის კომპენსაციის გამოყენება Working CMYK (don't tag) სამუშაო CMYK (ტეგის გარეშე) Working RGB (don't tag) სამუშაო RGB (ტეგის გარეშე) + Don't Color Manage This Document ამ დოკუმენტის ფერების მართვის გარეშე + Working RGB: {name} სამუშაო RGB: {name} + Working CMYK: {name} სამუშაო CMYK: {name} {name} (spot) {name} (სპოტ) Add to Profiles… პროფილებში დამატება… Align Stroke to Center კონტურის ცენტრზე სწორება @@ -4864,6 +4868,9 @@ font-technology Variable ცვლადი @msg automation filesystem access is not granted: write authority is absent: {path} ავტომატიზაციას ფაილურ სისტემაზე წვდომა არ აქვს: ჩაწერის უფლება არ არის: {path} @msg automation path rejected: outside the read root {root}: {path} ავტომატიზაციის გზა უარყოფილია: წაკითხვის ძირის {root} გარეთაა: {path} @msg automation path rejected: outside the read roots {roots}: {path} ავტომატიზაციის გზა უარყოფილია: წაკითხვის ძირების {roots} გარეთაა: {path} +@msg a linked JavaScript file is outside the read roots დაკავშირებული JavaScript ფაილი წაკითხვის ძირების გარეთაა +@msg the library's file is outside the read roots ბიბლიოთეკის ფაილი წაკითხვის ძირების გარეთაა +@msg {file} names more than one linked JavaScript file {file} ერთზე მეტ დაკავშირებულ JavaScript ფაილს შეესაბამება @msg automation path rejected: outside the write root {root}: {path} ავტომატიზაციის გზა უარყოფილია: ჩაწერის ძირის {root} გარეთაა: {path} @msg automation path rejected: outside the write roots {roots}: {path} ავტომატიზაციის გზა უარყოფილია: ჩაწერის ძირების {roots} გარეთაა: {path} @msg automation path rejected: path is empty ავტომატიზაციის გზა უარყოფილია: გზა ცარიელია @@ -5225,6 +5232,7 @@ font-technology Variable ცვლადი @msg every numbered name for {name} is taken in the document's folder დოკუმენტის საქაღალდეში {name}-ის ყველა დანომრილი სახელი დაკავებულია @msg No linked files to look for მოსაძებნი დაკავშირებული ფაილები არ არის @msg The link is inside a symbol, graph design, pattern or opacity mask and has no place on the canvas ბმული სიმბოლოში, დიაგრამის დიზაინში, პატერნში ან გაუმჭვირვალობის ნიღაბშია და ტილოზე ადგილი არ აქვს +@msg the art of a graph design in a graph series is drawn again from the design: edit the design to change it დიაგრამის სერიაში დიაგრამის დიზაინის გრაფიკა დიზაინიდან თავიდან იხატება: მის შესაცვლელად დაარედაქტირეთ დიზაინი Ruler Origin სახაზავების სათავე Reset Ruler Origin სახაზავების სათავის ჩამოყრა Artistic მხატვრული @@ -5653,6 +5661,9 @@ imageTrace Outline კონტური In the File ფაილში Keys კლავიშები Light behind the object სინათლე ობიექტის უკან + Move Light to Back სინათლის უკან გადატანა + Move Light to Front სინათლის წინ გადატანა + Drag a light to move it. The arrow keys move the focused light; Shift steps 15°. გადაათრიეთ სინათლე მის გადასაადგილებლად. ისრიანი ღილაკები ფოკუსირებულ სინათლეს ამოძრავებს; Shift-ით 15°-იანი ბიჯით. Shadows ჩრდილები Distance from Object მანძილი ობიექტიდან Shadow Bounds ჩრდილის საზღვრები diff --git a/crates/ui-egui/src/i18n/ko.tsv b/crates/ui-egui/src/i18n/ko.tsv index bb8676df71..2ff5d2b148 100644 --- a/crates/ui-egui/src/i18n/ko.tsv +++ b/crates/ui-egui/src/i18n/ko.tsv @@ -3270,6 +3270,7 @@ mark-style Japanese 일본식 Single-line Composer 한 줄 조판 Toggle Line Composer 줄 조판 방식 전환 Size Tile to Art 타일을 아트웍 크기에 맞춤 + Move Tile with Art 아트웍과 함께 타일 이동 Size/Leading 크기/행간 Size: 크기: Ridges per segment: 세그먼트당 융기 수: @@ -4110,6 +4111,9 @@ file-filter Templates 템플릿 Windows Windows Working CMYK (don't tag) 작업용 CMYK (태그 안 함) Working RGB (don't tag) 작업용 RGB (태그 안 함) + Don't Color Manage This Document 이 문서에 색상 관리 안 함 + Working RGB: {name} 작업용 RGB: {name} + Working CMYK: {name} 작업용 CMYK: {name} Workspace {i} 작업 영역 {i} Yellow 노랑 [Normal Character Style] [기본 문자 스타일] @@ -5257,6 +5261,7 @@ theme Appearance 외관 @msg every numbered name for {name} is taken in the document's folder 문서 폴더에서 {name}을(를) 위한 번호 이름이 모두 사용 중입니다 @msg No linked files to look for 찾을 링크된 파일이 없습니다 @msg The link is inside a symbol, graph design, pattern or opacity mask and has no place on the canvas 링크가 심볼, 그래프 디자인, 패턴 또는 불투명 마스크 안에 있어 캔버스에 위치가 없습니다 +@msg the art of a graph design in a graph series is drawn again from the design: edit the design to change it 그래프 계열에 있는 그래프 디자인의 아트는 디자인에서 다시 그려집니다: 변경하려면 디자인을 편집하세요 @msg `id` is the step reference {r}, which only `run` and `command_batch` resolve `id`는 단계 참조 {r}이며, `run`과 `command_batch`만 이를 해석합니다 @msg `ids` must be an array of object ids, not {v} `ids`는 {v}이(가) 아니라 개체 id 배열이어야 합니다 @msg `colors` must be an array of colors, not {v} `colors`는 {v}이(가) 아니라 색상 배열이어야 합니다 @@ -5276,6 +5281,7 @@ theme Appearance 외관 @msg automation filesystem access is not granted: write authority is absent: {path} 자동화 파일 시스템 접근이 허용되지 않았습니다: 쓰기 권한이 없습니다: {path} @msg automation path rejected: outside the read root {root}: {path} 자동화 경로가 거부되었습니다: 읽기 루트 {root} 밖입니다: {path} @msg automation path rejected: outside the read roots {roots}: {path} 자동화 경로가 거부되었습니다: 읽기 루트 {roots} 밖입니다: {path} +@msg a linked JavaScript file is outside the read roots 연결된 JavaScript 파일이 읽기 루트 밖에 있습니다 @msg automation path rejected: outside the write root {root}: {path} 자동화 경로가 거부되었습니다: 쓰기 루트 {root} 밖입니다: {path} @msg automation path rejected: outside the write roots {roots}: {path} 자동화 경로가 거부되었습니다: 쓰기 루트 {roots} 밖입니다: {path} @msg automation path rejected: path is empty 자동화 경로가 거부되었습니다: 경로가 비어 있습니다 @@ -5678,6 +5684,9 @@ lighting Light 광원 Softness 부드러움 Ambient Light 주변광 Light behind the object 개체 뒤의 조명 + Move Light to Back 조명을 뒤로 이동 + Move Light to Front 조명을 앞으로 이동 + Drag a light to move it. The arrow keys move the focused light; Shift steps 15°. 조명을 드래그하여 이동합니다. 화살표 키로 포커스된 조명을 이동하고, Shift 키를 누르면 15°씩 이동합니다. Shadows 그림자 Distance from Object 개체로부터의 거리 Shadow Bounds 그림자 범위 diff --git a/crates/ui-egui/src/i18n/nl.tsv b/crates/ui-egui/src/i18n/nl.tsv index a597a03fd0..b05beb7627 100644 --- a/crates/ui-egui/src/i18n/nl.tsv +++ b/crates/ui-egui/src/i18n/nl.tsv @@ -2452,6 +2452,7 @@ digits Malayalam Malayalam Show Swatch Bounds Staalgrenzen weergeven Show Tile Edge Tegelrand weergeven Size Tile to Art Tegel aan artwork aanpassen + Move Tile with Art Tegel met artwork verplaatsen Tile Type: Tegeltype: Top in Front Bovenkant voorop V Spacing: V-spatiëring: @@ -2494,6 +2495,9 @@ digits Malayalam Malayalam Use Black Point Compensation Zwartpuntcompensatie gebruiken Working CMYK (don't tag) Werk-CMYK (niet taggen) Working RGB (don't tag) Werk-RGB (niet taggen) + Don't Color Manage This Document Geen kleurbeheer voor dit document + Working RGB: {name} Werk-RGB: {name} + Working CMYK: {name} Werk-CMYK: {name} {name} (spot) {name} (spotkleur) Add to Profiles… Toevoegen aan profielen… Align Stroke to Center Lijn in het midden uitlijnen @@ -7847,6 +7851,9 @@ mark-style Japanese Japans Softness Zachtheid Ambient Light Omgevingslicht Light behind the object Licht achter het object + Move Light to Back Licht naar achteren + Move Light to Front Licht naar voren + Drag a light to move it. The arrow keys move the focused light; Shift steps 15°. Sleep een licht om het te verplaatsen. De pijltoetsen verplaatsen het licht met de focus; Shift verplaatst het per 15°. Shadows Schaduwen Distance from Object Afstand tot object Shadow Bounds Schaduwgrenzen diff --git a/crates/ui-egui/src/i18n/pl.tsv b/crates/ui-egui/src/i18n/pl.tsv index 99ee2080b8..0f2922af89 100644 --- a/crates/ui-egui/src/i18n/pl.tsv +++ b/crates/ui-egui/src/i18n/pl.tsv @@ -2052,6 +2052,7 @@ digits Malayalam Malajalam Show Swatch Bounds Pokaż granice próbki Show Tile Edge Pokaż krawędź kafelka Size Tile to Art Dopasuj kafelek do kompozycji + Move Tile with Art Przesuwaj kafelek z kompozycją Tile Type: Typ kafelka: Top in Front Górny na wierzchu V Spacing: Odstęp pion.: @@ -2094,6 +2095,9 @@ digits Malayalam Malajalam Use Black Point Compensation Użyj kompensacji punktu czerni Working CMYK (don't tag) Roboczy CMYK (bez znacznika) Working RGB (don't tag) Roboczy RGB (bez znacznika) + Don't Color Manage This Document Nie zarządzaj kolorem tego dokumentu + Working RGB: {name} Roboczy RGB: {name} + Working CMYK: {name} Roboczy CMYK: {name} {name} (spot) {name} (dodatkowy) Add to Profiles… Dodaj do profili… Align Stroke to Center Wyrównaj obrys do środka @@ -5347,6 +5351,7 @@ imageTrace Outline Kontur @msg every numbered name for {name} is taken in the document's folder wszystkie numerowane nazwy dla {name} są zajęte w folderze dokumentu @msg No linked files to look for Brak połączonych plików do wyszukania @msg The link is inside a symbol, graph design, pattern or opacity mask and has no place on the canvas Łącze znajduje się w symbolu, wzorze wykresu, wzorku lub masce krycia i nie ma miejsca na obszarze roboczym +@msg the art of a graph design in a graph series is drawn again from the design: edit the design to change it grafika wzoru wykresu w serii danych wykresu jest rysowana od nowa na podstawie wzoru: aby ją zmienić, edytuj wzór @msg `id` is the step reference {r}, which only `run` and `command_batch` resolve `id` to odwołanie do kroku {r}, które rozwiązują tylko `run` i `command_batch` @msg `ids` must be an array of object ids, not {v} `ids` musi być tablicą identyfikatorów obiektów, nie {v} @msg `colors` must be an array of colors, not {v} `colors` musi być tablicą kolorów, nie {v} @@ -5366,6 +5371,7 @@ imageTrace Outline Kontur @msg automation filesystem access is not granted: write authority is absent: {path} nie przyznano dostępu automatyzacji do systemu plików: brak uprawnień do zapisu: {path} @msg automation path rejected: outside the read root {root}: {path} odrzucono ścieżkę automatyzacji: poza katalogiem głównym odczytu {root}: {path} @msg automation path rejected: outside the read roots {roots}: {path} odrzucono ścieżkę automatyzacji: poza katalogami głównymi odczytu {roots}: {path} +@msg a linked JavaScript file is outside the read roots połączony plik JavaScript jest poza katalogami głównymi odczytu @msg automation path rejected: outside the write root {root}: {path} odrzucono ścieżkę automatyzacji: poza katalogiem głównym zapisu {root}: {path} @msg automation path rejected: outside the write roots {roots}: {path} odrzucono ścieżkę automatyzacji: poza katalogami głównymi zapisu {roots}: {path} @msg automation path rejected: path is empty odrzucono ścieżkę automatyzacji: ścieżka jest pusta @@ -5940,6 +5946,9 @@ mark-style Japanese Japońskie Softness Miękkość Ambient Light Światło otoczenia Light behind the object Światło za obiektem + Move Light to Back Przenieś światło do tyłu + Move Light to Front Przenieś światło do przodu + Drag a light to move it. The arrow keys move the focused light; Shift steps 15°. Przeciągnij światło, aby je przesunąć. Klawisze strzałek przesuwają aktywne światło; z Shift o 15°. Shadows Cienie Distance from Object Odległość od obiektu Shadow Bounds Granice cienia diff --git a/crates/ui-egui/src/i18n/pt-br.tsv b/crates/ui-egui/src/i18n/pt-br.tsv index 677c8e460f..1f129dd5e6 100644 --- a/crates/ui-egui/src/i18n/pt-br.tsv +++ b/crates/ui-egui/src/i18n/pt-br.tsv @@ -2892,6 +2892,7 @@ digits Malayalam Malaiala Sixth Space Espaço de Sexto Size Tamanho Size Tile to Art Dimensionar Bloco para a Arte + Move Tile with Art Mover Bloco com a Arte Size/Leading Tamanho/Entrelinhamento Size: Tamanho: Size: {size} Tracking: {tracking} Tamanho: {size} Rastreamento: {tracking} @@ -4668,6 +4669,7 @@ axis Both Ambos @msg automation filesystem access is not granted: write authority is absent: {path} o acesso da automação ao sistema de arquivos não foi concedido: falta autorização de gravação: {path} @msg automation path rejected: outside the read root {root}: {path} caminho da automação rejeitado: fora da raiz de leitura {root}: {path} @msg automation path rejected: outside the read roots {roots}: {path} caminho da automação rejeitado: fora das raízes de leitura {roots}: {path} +@msg a linked JavaScript file is outside the read roots um arquivo JavaScript vinculado está fora das raízes de leitura @msg automation path rejected: outside the write root {root}: {path} caminho da automação rejeitado: fora da raiz de gravação {root}: {path} @msg automation path rejected: outside the write roots {roots}: {path} caminho da automação rejeitado: fora das raízes de gravação {roots}: {path} @msg automation path rejected: path is empty caminho da automação rejeitado: o caminho está vazio @@ -4801,6 +4803,9 @@ axis Both Ambos Softness Suavidade Ambient Light Luz ambiente Light behind the object Luz atrás do objeto + Move Light to Back Mover luz para trás + Move Light to Front Mover luz para a frente + Drag a light to move it. The arrow keys move the focused light; Shift steps 15°. Arraste uma luz para movê-la. As teclas de seta movem a luz em foco; Shift a move de 15° em 15°. Shadows Sombras Distance from Object Distância do objeto Shadow Bounds Limites da sombra @@ -5317,6 +5322,9 @@ axis Both Ambos Windows Windows Working CMYK (don't tag) CMYK de Trabalho (não marcar) Working RGB (don't tag) RGB de Trabalho (não marcar) + Don't Color Manage This Document Não gerenciar as cores deste documento + Working RGB: {name} RGB de Trabalho: {name} + Working CMYK: {name} CMYK de Trabalho: {name} Workspace {i} Espaço de Trabalho {i} Yellow Amarelo [Normal Character Style] [Estilo de Caractere Normal] @@ -5448,6 +5456,7 @@ axis Both Ambos @msg every numbered name for {name} is taken in the document's folder todos os nomes numerados para {name} já existem na pasta do documento @msg No linked files to look for Nenhum arquivo vinculado para procurar @msg The link is inside a symbol, graph design, pattern or opacity mask and has no place on the canvas O link está dentro de um símbolo, projeto de gráfico, padrão ou máscara de opacidade e não tem lugar na tela +@msg the art of a graph design in a graph series is drawn again from the design: edit the design to change it a arte de um projeto de gráfico em uma série do gráfico é desenhada de novo a partir do projeto: edite o projeto para alterá-la De-Interlace Desentrelaçar Eliminate Eliminar Create New Fields by Criar Novos Campos por diff --git a/crates/ui-egui/src/i18n/ru.tsv b/crates/ui-egui/src/i18n/ru.tsv index 106343cddd..b267f7cb09 100644 --- a/crates/ui-egui/src/i18n/ru.tsv +++ b/crates/ui-egui/src/i18n/ru.tsv @@ -3452,6 +3452,7 @@ digits Malayalam Малаялам Sixth Space Шестая шпация Size Размер Size Tile to Art Подогнать плитку по рисунку + Move Tile with Art Перемещать плитку вместе с рисунком Size/Leading Размер/интерлиньяж Size: Размер: Ridges per segment: Гребней на сегмент: @@ -4079,6 +4080,9 @@ digits Malayalam Малаялам Working CMYK Рабочий CMYK Working CMYK (don't tag) Рабочий CMYK (без пометки) Working RGB (don't tag) Рабочий RGB (без пометки) + Don't Color Manage This Document Не управлять цветом этого документа + Working RGB: {name} Рабочий RGB: {name} + Working CMYK: {name} Рабочий CMYK: {name} Workspace Рабочая среда Workspace {i} Рабочая среда {i} Wrinkle Tool Инструмент «Морщины» @@ -5250,6 +5254,7 @@ axis Both Оба @msg every numbered name for {name} is taken in the document's folder все нумерованные имена для {name} уже заняты в папке документа @msg No linked files to look for Нет связанных файлов для поиска @msg The link is inside a symbol, graph design, pattern or opacity mask and has no place on the canvas Связь находится внутри символа, дизайна диаграммы, узора или непрозрачной маски и не имеет места на холсте +@msg the art of a graph design in a graph series is drawn again from the design: edit the design to change it графика дизайна диаграммы в ряду данных диаграммы перерисовывается по дизайну: чтобы изменить её, отредактируйте дизайн # The macOS menu bar (native_menu): the app and Window menus' system items, Settings pages. Services Службы @@ -5297,6 +5302,7 @@ axis Both Оба @msg automation filesystem access is not granted: write authority is absent: {path} у автоматизации нет доступа к файловой системе: нет права на запись: {path} @msg automation path rejected: outside the read root {root}: {path} путь автоматизации отклонён: вне корневой папки для чтения {root}: {path} @msg automation path rejected: outside the read roots {roots}: {path} путь автоматизации отклонён: вне корневых папок для чтения {roots}: {path} +@msg a linked JavaScript file is outside the read roots связанный файл JavaScript находится вне корневых папок для чтения @msg automation path rejected: outside the write root {root}: {path} путь автоматизации отклонён: вне корневой папки для записи {root}: {path} @msg automation path rejected: outside the write roots {roots}: {path} путь автоматизации отклонён: вне корневых папок для записи {roots}: {path} @msg automation path rejected: path is empty путь автоматизации отклонён: путь пуст @@ -5704,6 +5710,9 @@ lighting Light Свет Softness Мягкость Ambient Light Окружающий свет Light behind the object Свет позади объекта + Move Light to Back Переместить свет назад + Move Light to Front Переместить свет вперёд + Drag a light to move it. The arrow keys move the focused light; Shift steps 15°. Перетащите источник света, чтобы переместить его. Клавиши со стрелками перемещают выбранный источник; с Shift — шагами по 15°. Shadows Тени Distance from Object Расстояние от объекта Shadow Bounds Границы тени diff --git a/crates/ui-egui/src/i18n/sv.tsv b/crates/ui-egui/src/i18n/sv.tsv index cc854aa362..b6ca9a9716 100644 --- a/crates/ui-egui/src/i18n/sv.tsv +++ b/crates/ui-egui/src/i18n/sv.tsv @@ -3342,6 +3342,7 @@ digits Malayalam Malayalam Sixth Space Sjättedelsblanksteg Size Storlek Size Tile to Art Anpassa rutans storlek efter grafiken + Move Tile with Art Flytta rutan med grafiken Size/Leading Storlek/radavstånd Size: Storlek: Ridges per segment: Åsar per segment: @@ -3968,6 +3969,9 @@ digits Malayalam Malayalam Working CMYK Arbets-CMYK Working CMYK (don't tag) Arbets-CMYK (tagga inte) Working RGB (don't tag) Arbets-RGB (tagga inte) + Don't Color Manage This Document Färghantera inte det här dokumentet + Working RGB: {name} Arbets-RGB: {name} + Working CMYK: {name} Arbets-CMYK: {name} Workspace Arbetslayout Workspace {i} Arbetslayout {i} Wrinkle Tool Skrynkelverktyg @@ -5993,6 +5997,9 @@ theme Appearance Utseende Softness Mjukhet Ambient Light Omgivande ljus Light behind the object Ljus bakom objektet + Move Light to Back Flytta ljus bakåt + Move Light to Front Flytta ljus framåt + Drag a light to move it. The arrow keys move the focused light; Shift steps 15°. Dra ett ljus för att flytta det. Piltangenterna flyttar ljuset med fokus; Skift flyttar det 15° i taget. Shadows Skuggor Distance from Object Avstånd från objektet Shadow Bounds Skuggans gränser diff --git a/crates/ui-egui/src/i18n/tests.rs b/crates/ui-egui/src/i18n/tests.rs index 02fb1fbc9b..7f3c9b0f2f 100644 --- a/crates/ui-egui/src/i18n/tests.rs +++ b/crates/ui-egui/src/i18n/tests.rs @@ -3146,6 +3146,48 @@ fn selected_path_reason_is_translated() { } } +/// The refusals of a linked JavaScript file or a library's file outside the read roots, of a file +/// name that fits more than one linked JavaScript file, and of an appearance edit of the art a +/// graph design draws in a graph series have translated rows in languages that translate every +/// message. Each row keeps `{file}` and shows translated both alone and inside the +/// invalid-parameters error of the command that returns it. +#[test] +fn read_root_script_and_graph_design_reasons_are_translated() { + use vectorcraft_engine::EngineError; + let reasons = [ + ("document.export", "a linked JavaScript file is outside the read roots"), + ("swatch.library.copyToUser", "the library's file is outside the read roots"), + ("svgInteractivity.removeFile", "{file} names more than one linked JavaScript file"), + ("appearance", "the art of a graph design in a graph series is drawn again from the design: edit the design to change it"), + ]; + let literals = message_literals(); + for (_, reason) in reasons { + assert!(literals.contains(reason), "{reason}"); + } + let mut rows = [0; 4]; + for code in COMPLETE_MESSAGES { + let lang = Lang::from_code(code).expect("registered"); + let (entries, _) = parse_entries(lang.0.source); + for (i, (cmd, reason)) in reasons.into_iter().enumerate() { + let row = entries.iter().find(|(ctx, src, _)| ctx == "@msg" && src == reason).map(|(_, _, tr)| tr.as_str()); + let Some(row) = row else { continue }; + rows[i] += 1; + assert_ne!(row, reason, "{code}"); + assert_eq!(row.matches("{file}").count(), reason.matches("{file}").count(), "{code}: {row}"); + let sample = reason.replace("{file}", "\"a.js\""); + let translated = row.replace("{file}", "\"a.js\""); + assert_eq!(message(lang, &sample), translated, "{code}"); + let error = EngineError::BadParams { cmd: cmd.into(), msg: sample.clone() }.to_string(); + assert_eq!(message(Lang::EN, &error), error); + let shown = message(lang, &error); + assert!(shown.contains(&translated) && !shown.contains(&sample) && shown.contains(&format!("`{cmd}`")), "{code}: {shown}"); + } + } + for ((_, reason), count) in reasons.into_iter().zip(rows) { + assert!(count > 0, "no complete-message catalog has a `@msg` row for {reason:?}"); + } +} + /// The errors a script's `File` and `Folder` objects, `vc.run` and `$.evalFile` raise are /// translated in every language of [`COMPLETE_MESSAGES`], and so is the status bar's line for a run /// that ends with one: the script's name, its place, the command ids and what the script itself diff --git a/crates/ui-egui/src/i18n/tr.tsv b/crates/ui-egui/src/i18n/tr.tsv index f2888a30f0..eb1affa964 100644 --- a/crates/ui-egui/src/i18n/tr.tsv +++ b/crates/ui-egui/src/i18n/tr.tsv @@ -3218,6 +3218,7 @@ digits Malayalam Malayalam Sixth Space Altıncı Uzay Size Boyut Size Tile to Art Sanata Boyut Döşeme + Move Tile with Art Döşemeyi Sanatla Taşı Size/Leading Boyut/Satır Aralığı Size: Boyut: Ridges per segment: Segment başına sırtlar: @@ -3827,6 +3828,9 @@ digits Malayalam Malayalam Working CMYK CMYK'de çalışıyor Working CMYK (don't tag) CMYK çalışıyor (etiketlemeyin) Working RGB (don't tag) Çalışan RGB (etiketlemeyin) + Don't Color Manage This Document Bu belgede renk yönetimi yapma + Working RGB: {name} Çalışma RGB'si: {name} + Working CMYK: {name} Çalışma CMYK'sı: {name} Workspace Çalışma Alanı Workspace {i} Çalışma Alanı {i} Wrinkle Tool Kırışıklık Aracı @@ -5877,6 +5881,9 @@ theme Appearance Görünüm Softness Yumuşaklık Ambient Light Ortam Işığı Light behind the object Nesnenin arkasındaki ışık + Move Light to Back Işığı Arkaya Taşı + Move Light to Front Işığı Öne Taşı + Drag a light to move it. The arrow keys move the focused light; Shift steps 15°. Işığı taşımak için sürükleyin. Ok tuşları odaktaki ışığı taşır; Shift ile 15° adımlarla. Shadows Gölgeler Distance from Object Nesneden uzaklık Shadow Bounds Gölge sınırları diff --git a/crates/ui-egui/src/i18n/uk.tsv b/crates/ui-egui/src/i18n/uk.tsv index 411b2c3baf..d0a68e7fa9 100644 --- a/crates/ui-egui/src/i18n/uk.tsv +++ b/crates/ui-egui/src/i18n/uk.tsv @@ -3457,6 +3457,7 @@ digits Malayalam Малаялам Sixth Space Шоста шпація Size Розмір Size Tile to Art Підігнати плитку за малюнком + Move Tile with Art Переміщувати плитку разом із малюнком Size/Leading Розмір/інтерліньяж Size: Розмір: Ridges per segment: Гребенів на сегмент: @@ -4087,6 +4088,9 @@ effect Points Точки Working CMYK Робочий CMYK Working CMYK (don't tag) Робочий CMYK (без позначки) Working RGB (don't tag) Робочий RGB (без позначки) + Don't Color Manage This Document Не керувати кольором цього документа + Working RGB: {name} Робочий RGB: {name} + Working CMYK: {name} Робочий CMYK: {name} Workspace Робоче середовище Workspace {i} Робоче середовище {i} Wrinkle Tool Інструмент «Зморшки» @@ -5247,6 +5251,7 @@ axis Both Обидві @msg every numbered name for {name} is taken in the document's folder усі нумеровані імена для {name} уже зайняті в папці документа @msg No linked files to look for Немає пов’язаних файлів для пошуку @msg The link is inside a symbol, graph design, pattern or opacity mask and has no place on the canvas Зв’язок міститься в символі, дизайні діаграми, візерунку або масці непрозорості й не має місця на полотні +@msg the art of a graph design in a graph series is drawn again from the design: edit the design to change it графіка дизайну діаграми в ряді даних діаграми перемальовується за дизайном: щоб змінити її, відредагуйте дизайн Services Служби Hide VectorCraft Приховати VectorCraft Zoom Масштаб @@ -5292,6 +5297,7 @@ axis Both Обидві @msg automation filesystem access is not granted: write authority is absent: {path} автоматизація не має доступу до файлової системи: немає права на запис: {path} @msg automation path rejected: outside the read root {root}: {path} шлях автоматизації відхилено: поза кореневою папкою для читання {root}: {path} @msg automation path rejected: outside the read roots {roots}: {path} шлях автоматизації відхилено: поза кореневими папками для читання {roots}: {path} +@msg a linked JavaScript file is outside the read roots зв’язаний файл JavaScript перебуває поза кореневими папками для читання @msg automation path rejected: outside the write root {root}: {path} шлях автоматизації відхилено: поза кореневою папкою для запису {root}: {path} @msg automation path rejected: outside the write roots {roots}: {path} шлях автоматизації відхилено: поза кореневими папками для запису {roots}: {path} @msg automation path rejected: path is empty шлях автоматизації відхилено: шлях порожній @@ -5697,6 +5703,9 @@ lighting Light Світло Softness М'якість Ambient Light Навколишнє світло Light behind the object Світло позаду об'єкта + Move Light to Back Перемістити світло назад + Move Light to Front Перемістити світло вперед + Drag a light to move it. The arrow keys move the focused light; Shift steps 15°. Перетягніть світло, щоб перемістити його. Клавіші зі стрілками переміщують вибране світло; із Shift — кроками по 15°. Shadows Тіні Distance from Object Відстань від об'єкта Shadow Bounds Межі тіні diff --git a/crates/ui-egui/src/i18n/vi.tsv b/crates/ui-egui/src/i18n/vi.tsv index 1460910197..e5d15ded92 100644 --- a/crates/ui-egui/src/i18n/vi.tsv +++ b/crates/ui-egui/src/i18n/vi.tsv @@ -3812,6 +3812,7 @@ mark-style Japanese Kiểu Nhật Sixth Space Dấu cách 1/6 Size Kích thước Size Tile to Art Kích thước ô theo đối tượng + Move Tile with Art Di chuyển ô cùng đối tượng Size/Leading Cỡ chữ/Giãn dòng Size: Kích thước: Size: {size} Tracking: {tracking} Kích thước: {size} Giãn chữ: {tracking} @@ -4462,6 +4463,9 @@ mark-style Japanese Kiểu Nhật Working CMYK CMYK làm việc Working CMYK (don't tag) CMYK làm việc (không gắn thẻ) Working RGB (don't tag) RGB làm việc (không gắn thẻ) + Don't Color Manage This Document Không quản lý màu cho tài liệu này + Working RGB: {name} RGB làm việc: {name} + Working CMYK: {name} CMYK làm việc: {name} Workspace Không gian làm việc Workspace {i} Không gian làm việc {i} Wrinkle Tool Công cụ nhăn @@ -4698,6 +4702,9 @@ theme Appearance Giao diện @msg automation path rejected: cannot resolve {path}: {e} đường dẫn tự động bị từ chối: không thể phân giải {path}: {e} @msg automation path rejected: outside the read root {root}: {path} đường dẫn tự động bị từ chối: ngoài thư mục gốc đọc {root}: {path} @msg automation path rejected: outside the read roots {roots}: {path} đường dẫn tự động bị từ chối: ngoài các thư mục gốc đọc {roots}: {path} +@msg a linked JavaScript file is outside the read roots một tệp JavaScript được liên kết nằm ngoài các thư mục gốc đọc +@msg the library's file is outside the read roots tệp của thư viện nằm ngoài các thư mục gốc đọc +@msg {file} names more than one linked JavaScript file {file} khớp với nhiều hơn một tệp JavaScript được liên kết @msg automation path rejected: outside the write root {root}: {path} đường dẫn tự động bị từ chối: ngoài thư mục gốc ghi {root}: {path} @msg automation path rejected: outside the write roots {roots}: {path} đường dẫn tự động bị từ chối: ngoài các thư mục gốc ghi {roots}: {path} @msg automation path rejected: parent traversal below a folder that doesn't exist: {path} đường dẫn tự động bị từ chối: duyệt cha bên dưới thư mục không tồn tại: {path} @@ -5080,6 +5087,7 @@ theme Appearance Giao diện @msg no library `{key}` (see {list}) không có thư viện `{key}` (xem {list}) @msg No linked files to look for Không có tệp liên kết để tìm kiếm @msg The link is inside a symbol, graph design, pattern or opacity mask and has no place on the canvas Liên kết nằm trong ký hiệu, thiết kế biểu đồ, họa tiết hoặc mặt nạ độ mờ và không có vị trí trên canvas +@msg the art of a graph design in a graph series is drawn again from the design: edit the design to change it hình vẽ của một thiết kế biểu đồ trong một chuỗi dữ liệu của biểu đồ được vẽ lại từ thiết kế: hãy sửa thiết kế để thay đổi nó @msg no Live Paint edge near that point không có cạnh tô trực tiếp gần điểm đó @msg no Live Paint face at that point không có mặt tô trực tiếp tại điểm đó @msg no native format không có định dạng gốc @@ -5702,6 +5710,9 @@ theme Appearance Giao diện Softness Độ mềm Ambient Light Ánh sáng môi trường Light behind the object Ánh sáng phía sau đối tượng + Move Light to Back Đưa ánh sáng ra sau + Move Light to Front Đưa ánh sáng ra trước + Drag a light to move it. The arrow keys move the focused light; Shift steps 15°. Kéo một nguồn sáng để di chuyển nó. Phím mũi tên di chuyển nguồn sáng đang có tiêu điểm; Shift di chuyển theo bước 15°. Shadows Bóng đổ Distance from Object Khoảng cách từ đối tượng Shadow Bounds Giới hạn bóng diff --git a/crates/ui-egui/src/i18n/zh-hans.tsv b/crates/ui-egui/src/i18n/zh-hans.tsv index 3fe2f9e009..a081c23d5c 100644 --- a/crates/ui-egui/src/i18n/zh-hans.tsv +++ b/crates/ui-egui/src/i18n/zh-hans.tsv @@ -3280,6 +3280,7 @@ digits Malayalam 马拉雅拉姆数字 Sixth Space 六分之一空格 Size 大小 Size Tile to Art 使拼贴适合图稿 + Move Tile with Art 拼贴随图稿移动 Size/Leading 字号/行距 Size: 尺寸: Ridges per segment: 每段起伏数: @@ -3926,6 +3927,9 @@ digits Malayalam 马拉雅拉姆数字 Working CMYK 工作 CMYK Working CMYK (don't tag) 工作 CMYK (不加标签) Working RGB (don't tag) 工作 RGB (不加标签) + Don't Color Manage This Document 不对此文档进行色彩管理 + Working RGB: {name} 工作 RGB:{name} + Working CMYK: {name} 工作 CMYK:{name} Workspace 工作区 Workspace {i} 工作区 {i} Wrinkle Tool 褶皱工具 @@ -5006,6 +5010,7 @@ imageTrace Outline 轮廓 @msg every numbered name for {name} is taken in the document's folder 文档所在文件夹中 {name} 的所有带编号的名称均已被占用 @msg No linked files to look for 没有要查找的链接文件 @msg The link is inside a symbol, graph design, pattern or opacity mask and has no place on the canvas 该链接位于符号、图表设计、图案或不透明度蒙版中,在画布上没有位置 +@msg the art of a graph design in a graph series is drawn again from the design: edit the design to change it 图表系列中图表设计的图稿会根据设计重新绘制:要更改它,请编辑设计 @msg `id` is the step reference {r}, which only `run` and `command_batch` resolve `id` 是步骤引用 {r},只有 `run` 和 `command_batch` 能解析它 @msg `ids` must be an array of object ids, not {v} `ids` 必须是由对象 id 组成的数组,而不是 {v} @msg `colors` must be an array of colors, not {v} `colors` 必须是由颜色组成的数组,而不是 {v} @@ -5025,6 +5030,7 @@ imageTrace Outline 轮廓 @msg automation filesystem access is not granted: write authority is absent: {path} 未授予自动化文件系统访问权限:缺少写入权限:{path} @msg automation path rejected: outside the read root {root}: {path} 已拒绝自动化路径:位于读取根目录 {root} 之外:{path} @msg automation path rejected: outside the read roots {roots}: {path} 已拒绝自动化路径:位于所有读取根目录 {roots} 之外:{path} +@msg a linked JavaScript file is outside the read roots 链接的 JavaScript 文件位于所有读取根目录之外 @msg automation path rejected: outside the write root {root}: {path} 已拒绝自动化路径:位于写入根目录 {root} 之外:{path} @msg automation path rejected: outside the write roots {roots}: {path} 已拒绝自动化路径:位于所有写入根目录 {roots} 之外:{path} @msg automation path rejected: path is empty 已拒绝自动化路径:路径为空 @@ -5970,6 +5976,9 @@ lighting Light 光源 Softness 柔和度 Ambient Light 环境光 Light behind the object 光源位于对象后方 + Move Light to Back 将光源移到后方 + Move Light to Front 将光源移到前方 + Drag a light to move it. The arrow keys move the focused light; Shift steps 15°. 拖动光源可移动它。方向键移动具有焦点的光源;按住 Shift 以 15° 为步长。 Shadows 阴影 Distance from Object 与对象的距离 Shadow Bounds 阴影边界 diff --git a/crates/ui-egui/src/i18n/zh-hant.tsv b/crates/ui-egui/src/i18n/zh-hant.tsv index ab7f9d9dd2..e34ccf9b5c 100644 --- a/crates/ui-egui/src/i18n/zh-hant.tsv +++ b/crates/ui-egui/src/i18n/zh-hant.tsv @@ -2705,6 +2705,7 @@ file-filter Templates 範本 @msg every numbered name for {name} is taken in the document's folder 文件所在資料夾中 {name} 的所有編號名稱都已被使用 @msg No linked files to look for 沒有要尋找的連結檔案 @msg The link is inside a symbol, graph design, pattern or opacity mask and has no place on the canvas 此連結位於符號、圖表設計、圖樣或不透明度遮色片中,在畫布上沒有位置 +@msg the art of a graph design in a graph series is drawn again from the design: edit the design to change it 圖表數列中圖表設計的圖稿會依設計重新繪製:若要變更,請編輯設計 @msg `id` is the step reference {r}, which only `run` and `command_batch` resolve `id` 是步驟參照 {r},只有 `run` 和 `command_batch` 能解析它 @msg `ids` must be an array of object ids, not {v} `ids` 必須是由物件 id 組成的陣列,而非 {v} @msg `colors` must be an array of colors, not {v} `colors` 必須是由顏色組成的陣列,而非 {v} @@ -2716,6 +2717,7 @@ file-filter Templates 範本 @msg automation filesystem access is not granted: write authority is absent: {path} 未授予自動化檔案系統存取權:沒有寫入權限:{path} @msg automation path rejected: outside the read root {root}: {path} 已拒絕自動化路徑:位於讀取根目錄 {root} 之外:{path} @msg automation path rejected: outside the read roots {roots}: {path} 已拒絕自動化路徑:位於所有讀取根目錄 {roots} 之外:{path} +@msg a linked JavaScript file is outside the read roots 連結的 JavaScript 檔案位於所有讀取根目錄之外 @msg automation path rejected: outside the write root {root}: {path} 已拒絕自動化路徑:位於寫入根目錄 {root} 之外:{path} @msg automation path rejected: outside the write roots {roots}: {path} 已拒絕自動化路徑:位於所有寫入根目錄 {roots} 之外:{path} @msg automation path rejected: path is empty 已拒絕自動化路徑:路徑是空的 @@ -4538,6 +4540,7 @@ digits Malayalam 馬來亞拉姆數字 Sixth Space 六分之一空格 Size 大小 Size Tile to Art 拼貼符合圖稿大小 + Move Tile with Art 拼貼隨圖稿移動 Size/Leading 大小/行距 Size: 大小: Ridges per segment: 每段起伏數: @@ -5189,6 +5192,9 @@ digits Malayalam 馬來亞拉姆數字 Working CMYK 使用中的 CMYK Working CMYK (don't tag) 工作 CMYK (不加標記) Working RGB (don't tag) 工作 RGB (不加標記) + Don't Color Manage This Document 不對此文件進行色彩管理 + Working RGB: {name} 工作 RGB:{name} + Working CMYK: {name} 工作 CMYK:{name} Workspace 工作區 Workspace {i} 工作區 {i} Wrinkle Tool 皺摺工具 @@ -5802,6 +5808,9 @@ lighting Light 光源 Softness 柔和度 Ambient Light 環境光 Light behind the object 光源位於物件後方 + Move Light to Back 將光源移到後方 + Move Light to Front 將光源移到前方 + Drag a light to move it. The arrow keys move the focused light; Shift steps 15°. 拖曳光源即可移動。方向鍵會移動具有焦點的光源;按住 Shift 以 15° 為間距。 Shadows 陰影 Distance from Object 與物件的距離 Shadow Bounds 陰影邊界 diff --git a/crates/ui-egui/src/menus/bar.rs b/crates/ui-egui/src/menus/bar.rs index 575f11f74f..85588620e8 100644 --- a/crates/ui-egui/src/menus/bar.rs +++ b/crates/ui-egui/src/menus/bar.rs @@ -117,7 +117,7 @@ fn switch_on_hover(ctx: &egui::Context, titles: &[egui::Response]) -> bool { /// it scrolls when it is taller than the window. pub(super) fn menu_body(app: &VectorcraftApp, ui: &mut egui::Ui, items: &[Item], clicked: &mut Option<(String, Value)>) { widgets::menu_scroll(ui, |ui| { - ui.set_min_width(230.0); + widgets::menu_min_width(ui, 230.0); render_items(app, ui, items, true, clicked); }); } @@ -137,7 +137,7 @@ pub(super) fn render_items(app: &VectorcraftApp, ui: &mut egui::Ui, items: &[Ite Entry::Sub(label, children) => { ui.menu_button(label, |ui| { widgets::menu_scroll(ui, |ui| { - ui.set_min_width(200.0); + widgets::menu_min_width(ui, 200.0); render_items(app, ui, children, checks || only_toggles(app, children), clicked); }); }); diff --git a/crates/ui-egui/src/menus/context.rs b/crates/ui-egui/src/menus/context.rs index bc11e703e0..68b2a93574 100644 --- a/crates/ui-egui/src/menus/context.rs +++ b/crates/ui-egui/src/menus/context.rs @@ -272,7 +272,7 @@ fn prune(app: &VectorcraftApp, items: Vec) -> Vec { /// The canvas context menu's popup; the item clicked goes in `clicked`, for [`invoke`]. pub fn context_menu_body(app: &VectorcraftApp, ui: &mut egui::Ui, clicked: &mut Option<(String, Value)>) { widgets::menu_scroll(ui, |ui| { - ui.set_min_width(200.0); + widgets::menu_min_width(ui, 200.0); // Its toggles already say what they do (Show Rulers, Hide Guides): no check-mark gutter, // except in a submenu of toggles alone (Spelling › Auto Spell Check). render_items(app, ui, &context_items(app), false, clicked); @@ -289,7 +289,7 @@ pub fn ruler_unit_items() -> Vec { /// `clicked`, for [`invoke`]. pub fn ruler_menu_body(app: &VectorcraftApp, ui: &mut egui::Ui, clicked: &mut Option<(String, Value)>) { widgets::menu_scroll(ui, |ui| { - ui.set_min_width(160.0); + widgets::menu_min_width(ui, 160.0); render_items(app, ui, &ruler_unit_items(), true, clicked); }); } diff --git a/crates/ui-egui/src/menus/ui_commands.rs b/crates/ui-egui/src/menus/ui_commands.rs index d83f03d376..be062c4e73 100644 --- a/crates/ui-egui/src/menus/ui_commands.rs +++ b/crates/ui-egui/src/menus/ui_commands.rs @@ -1738,7 +1738,7 @@ pub const UI_COMMANDS: &[(&str, &str, &str, &str)] = &[ crate::scripts_menu::COMMAND, "Run Script", "", - "{path: a script's path as script.listInstalled gives it} run a script of the Scripts folder, as its File › Scripts item does (the script is checked again, then script.run runs it by its path, so its #include lines and $.evalFile read beside it), without the warning Other Script… shows; a file that is no longer a .js or .jsx file in the folder or a subfolder at most 4 levels below it, or that is or lies behind a link or is hidden, is not run, and the menu's listing is read again", + "{path: a script's path as script.listInstalled gives it} run a script of the Scripts folder, as its File › Scripts item does (script.run with installed reads the file once, checks it again and runs that text, so its #include lines and $.evalFile read in the folder that read found), without the warning Other Script… shows; a file that is no longer a .js or .jsx file in the folder or a subfolder at most 4 levels below it, or that is or lies behind a link or is hidden, is not run, and the menu's listing is read again", ), ( "ui.checkScript", diff --git a/crates/ui-egui/src/panels.rs b/crates/ui-egui/src/panels.rs index a56bcd7128..a36e9c245d 100644 --- a/crates/ui-egui/src/panels.rs +++ b/crates/ui-egui/src/panels.rs @@ -265,7 +265,7 @@ pub fn panel_menu(app: &mut VectorcraftApp, ui: &mut Ui, id: &str, rect: Rect) { let resp = menu_button(ui, id, rect); egui::Popup::menu(&resp).show(|ui| { crate::widgets::menu_scroll(ui, |ui| { - ui.set_min_width(220.0); + crate::widgets::menu_min_width(ui, 220.0); if panel_menu_items(app, ui, id) { ui.separator(); } diff --git a/crates/ui-egui/src/panels/color_guide.rs b/crates/ui-egui/src/panels/color_guide.rs index 3ef2fc75e4..510b87cde5 100644 --- a/crates/ui-egui/src/panels/color_guide.rs +++ b/crates/ui-egui/src/panels/color_guide.rs @@ -99,7 +99,7 @@ pub(crate) fn set_limit(app: &mut VectorcraftApp, p: &Value) -> Result Vec { ids } +/// The value `f` gives every character [`glyph_form_items`] acts on: the Type tool's selected +/// range, all the text of the object at a caret (the menu formats all of it there), else every +/// frame of the selected stories. `None` where they differ, or with no type. +fn selection_shared(app: &VectorcraftApp, f: impl Fn(&CharStyle) -> T) -> Option { + match super::character::text_editing(app) { + Some((id, a, b)) if b <= a && super::character::text_story(app).is_none() => { + let NodeKind::Text(t) = &app.session.active()?.doc.node(id)?.kind else { return None }; + let mut acc = super::character::Shared::None; + for r in &t.runs { + acc.add(f(&r.style)); + } + acc.value() + } + _ => super::character::shared(app, f), + } +} + +/// The East Asian glyph form every character [`glyph_form_items`] acts on has ([`selection_shared`]). +fn selection_glyph_form(app: &VectorcraftApp) -> Option { + selection_shared(app, |s| s.glyph_form) +} + +/// Does choosing `form` change the characters [`glyph_form_items`] acts on? Not when they all have +/// it already, unless one of them has a glyph choice, which a form other than the default takes +/// away. +fn glyph_form_changes(app: &VectorcraftApp, form: GlyphForm) -> bool { + selection_glyph_form(app) != Some(form) || (form != GlyphForm::Default && selection_shared(app, |s| s.glyph_alternate.is_none()) != Some(true)) +} + /// The East Asian glyph forms of the panel menu, with Show East Asian Options: each draws the /// selected characters (all the text of the selected type objects without a Type tool range) in /// one form of their font, and is checked when they all have it; a form none of the faces that @@ -989,10 +1018,12 @@ fn glyph_form_items(app: &mut VectorcraftApp, ui: &mut Ui) { let has = super::character::text_style(app).is_some(); let db = FontDb::global(); let faces: Vec<_> = selection_faces(app, ui).into_iter().filter_map(|id| db.face_by_id(id)).collect(); - let current = super::character::shared(app, |s| s.glyph_form); + let current = selection_glyph_form(app); for form in GlyphForm::ALL { + // A width form may be drawn with other widths for the same glyphs (`GPOS`) instead of + // other glyphs. let enabled = match form.feature() { - Some(tag) => faces.iter().any(|f| f.has_substitution_feature(*tag)), + Some(tag) => faces.iter().any(|f| f.has_substitution_feature(*tag) || (form.is_width() && f.has_positioning_feature(*tag))), None => has, }; let checked = form != GlyphForm::Default && current == Some(form); @@ -1000,7 +1031,7 @@ fn glyph_form_items(app: &mut VectorcraftApp, ui: &mut Ui) { ui.separator(); } // Choosing the form every selected character already has changes nothing. - if menu_item(ui, tl!(glyph_form_label(form)), enabled, checked) && current != Some(form) { + if menu_item(ui, tl!(glyph_form_label(form)), enabled, checked) && glyph_form_changes(app, form) { super::character::format(app, json!({"glyphForm": form.id()})); } } @@ -1261,6 +1292,51 @@ mod tests { assert!(forms(&app).iter().all(|(_, f)| *f == GlyphForm::Default), "{:?}", forms(&app)); } + /// At a caret the glyph form items act on all the text of the object, so they are checked, and + /// skipped as changing nothing, by every character's form, not the caret's: with the caret in + /// text of the default form and other text in JIS 78 forms, Revert To Default Forms reverts + /// that text. + #[test] + fn glyph_form_items_at_a_caret_follow_all_the_text() { + let (mut app, id) = editing("亜唖"); + app.session.prefs.show_east_asian_options = true; + let ctx = egui::Context::default(); + let node = NodeId(id.as_u64().unwrap_or(0)); + let forms = |app: &VectorcraftApp| match &app.session.active().unwrap().doc.node(node).unwrap().kind { + NodeKind::Text(t) => t.runs.iter().map(|r| r.style.glyph_form).collect::>(), + _ => panic!("text"), + }; + app.run("text.setRangeStyle", json!({"id": id, "start": 0, "end": 3, "glyphForm": "jis78"})).unwrap(); + select(&mut app, 6, 6); + assert_eq!(selection_glyph_form(&app), None, "the caret's form is not every character's"); + choose(&ctx, &mut app, "Revert To Default Forms"); + assert!(forms(&app).iter().all(|f| *f == GlyphForm::Default), "{:?}", forms(&app)); + app.run("text.setRangeStyle", json!({"id": id, "glyphForm": "jis78"})).unwrap(); + select(&mut app, 6, 6); + assert_eq!(selection_glyph_form(&app), Some(GlyphForm::Jis78)); + } + + /// Choosing the form every character already has is skipped as changing nothing, unless a + /// character has a glyph choice: a form other than the default takes it away, and Revert To + /// Default Forms keeps it. + #[test] + fn a_glyph_choice_lets_the_current_form_be_chosen_again() { + use vectorcraft_text::test_fonts::{alternates_family, alternates_font, unencoded_glyphs}; + FontDb::global().add_font(alternates_font(false).expect("the test font builds")); + let [u1, ..] = unencoded_glyphs().expect("unencoded glyphs"); + let (mut app, id) = editing("xay"); + app.run("text.setRangeStyle", json!({"id": id, "font": alternates_family(false), "glyphForm": "jis78"})).unwrap(); + select(&mut app, 3, 3); + assert!(!glyph_form_changes(&app, GlyphForm::Jis78), "every character has it"); + assert!(glyph_form_changes(&app, GlyphForm::Default)); + app.run("text.setGlyphAlternate", json!({"id": id, "start": 1, "gid": u1})).unwrap(); + select(&mut app, 3, 3); + assert!(glyph_form_changes(&app, GlyphForm::Jis78), "it takes away the glyph choice"); + app.run("text.setRangeStyle", json!({"id": id, "glyphForm": "default"})).unwrap(); + select(&mut app, 3, 3); + assert!(!glyph_form_changes(&app, GlyphForm::Default), "Revert To Default Forms keeps the glyph choice"); + } + /// Show › Alternates for Current Selection lists the selected character's alternates from the /// font of its text, whatever font the panel browses; a double-click draws the character with /// one. A character without alternates, no selected character and a missing font each show a diff --git a/crates/ui-egui/src/panels/gradient.rs b/crates/ui-egui/src/panels/gradient.rs index 777db05811..e0e032c086 100644 --- a/crates/ui-egui/src/panels/gradient.rs +++ b/crates/ui-egui/src/panels/gradient.rs @@ -601,7 +601,7 @@ fn thumbnail(app: &mut VectorcraftApp, ui: &mut Ui, g: &GradientPaint, is_grad: let dresp = dresp.on_hover_text(tl!("Gradient swatches")); egui::Popup::menu(&dresp).show(|ui| { widgets::menu_scroll(ui, |ui| { - ui.set_min_width(200.0); + widgets::menu_min_width(ui, 200.0); let mut chosen = None; let swatches = gradient_swatches(app); if swatches.is_empty() { diff --git a/crates/ui-egui/src/panels/pattern_options.rs b/crates/ui-egui/src/panels/pattern_options.rs index a84f0042f2..0c19c0721c 100644 --- a/crates/ui-egui/src/panels/pattern_options.rs +++ b/crates/ui-egui/src/panels/pattern_options.rs @@ -1,6 +1,6 @@ //! Pattern Options panel: active in pattern editing mode (Object → Pattern → Make / Edit -//! Pattern). Name, Tile Type, Brick Offset, Width/Height, Size Tile to Art with H/V spacing, -//! Overlap, Copies, Dim Copies, Show Tile Edge, Show Swatch Bounds; Save a Copy / Done / Cancel. Outside editing +//! Pattern). Name, Tile Type, Brick Offset, Width/Height, Size Tile to Art with H/V spacing, Move +//! Tile with Art, Overlap, Copies, Dim Copies, Show Tile Edge, Show Swatch Bounds; Save a Copy / Done / Cancel. Outside editing //! mode it lists the document's patterns with an Edit button. use egui::Ui; @@ -94,6 +94,9 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) { if widgets::check(ui, tl!("Size Tile to Art"), sized, true) { set(app, &name, json!({"sizeTileToArt": !sized})); } + if widgets::check(ui, tl!("Move Tile with Art"), def.move_tile_with_art, true) { + set(app, &name, json!({"moveTileWithArt": !def.move_tile_with_art})); + } row(ui, tl!("H Spacing:"), |ui| { ui.add_enabled_ui(sized, |ui| { if let Some(v) = widgets::num_field(ui, "po-hs", Some(def.h_spacing), unit, 90.0) { @@ -277,4 +280,29 @@ mod tests { frame(&mut app, &ctx, vec![click(false)]); assert!(app.session.active().unwrap().doc.pattern("Dots").unwrap().show_swatch_bounds); } + + /// The Move Tile with Art checkbox sits under Size Tile to Art and turns the option on and off. + #[test] + fn move_tile_with_art_toggles_the_pattern_option() { + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + app.run("file.new", json!({"width": 200, "height": 200})).unwrap(); + app.run("shape.rectangle", json!({"x": 0, "y": 0, "width": 10, "height": 10})).unwrap(); + app.run("object.pattern.make", json!({"name": "Dots"})).unwrap(); + let ctx = egui::Context::default(); + crate::theme::install_fonts(&ctx); + let on = |app: &VectorcraftApp| app.session.active().unwrap().doc.pattern("Dots").unwrap().move_tile_with_art; + let texts = frame(&mut app, &ctx, vec![]); + let find = |t: &str| texts.iter().find(|(s, _)| s == t).map(|(_, r)| *r); + let (sized, at) = (find("Size Tile to Art").expect("Size Tile to Art"), find("Move Tile with Art").expect("the checkbox")); + assert!(at.top() > sized.top(), "under Size Tile to Art"); + let at = at.center(); + let click = |pressed| egui::Event::PointerButton { pos: at, button: egui::PointerButton::Primary, pressed, modifiers: Default::default() }; + assert!(!on(&app)); + frame(&mut app, &ctx, vec![egui::Event::PointerMoved(at), click(true)]); + frame(&mut app, &ctx, vec![click(false)]); + assert!(on(&app)); + frame(&mut app, &ctx, vec![egui::Event::PointerMoved(at), click(true)]); + frame(&mut app, &ctx, vec![click(false)]); + assert!(!on(&app)); + } } diff --git a/crates/ui-egui/src/panels/separations.rs b/crates/ui-egui/src/panels/separations.rs index 0cb6c726d4..d3cf595666 100644 --- a/crates/ui-egui/src/panels/separations.rs +++ b/crates/ui-egui/src/panels/separations.rs @@ -147,16 +147,99 @@ fn settings_rows(app: &mut VectorcraftApp, ui: &mut Ui) { } } }); - if let Some(d) = app.session.active() { - let (_, cmyk) = vectorcraft_engine::cmd::colormgmt::doc_profiles(&d.doc); - const WORKING: &str = "Working CMYK (don't tag)"; - ui.horizontal(|ui| { - ui.label(tl!("Assign:")); - if let Some(n) = profile_dropdown(ui, "cms-assign", cmyk.as_deref().unwrap_or(WORKING), ProfileKind::Cmyk, Some(WORKING)) { - let v = if n == WORKING { Value::Null } else { Value::String(n) }; - run(app, "edit.assignProfile", json!({ "cmyk": v })); + if let Some(row) = app.session.active().map(|d| AssignRow::of(&d.doc)) { + assign_rows(app, ui, &row); + } +} + +/// Edit › Assign Profile…'s three choices for the active document's color mode. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum AssignChoice { + /// Don't Color Manage This Document: no profile, so the colors show through the working space. + None, + /// The working space of the mode, assigned to the document as its profile. + Working, + /// Another profile of the mode's kind. + Profile, +} + +/// What the Assign rows show for a document: the profile of its color mode (`key` is the +/// `edit.assignProfile` param), the working space of that mode, and the other profiles of that kind. +#[derive(Debug)] +struct AssignRow { + key: &'static str, + mode: vectorcraft_doc::ColorMode, + assigned: Option, + working: String, + others: Vec, +} + +impl AssignRow { + fn of(doc: &vectorcraft_doc::Document) -> Self { + let (rgb, cmyk) = vectorcraft_engine::cmd::colormgmt::doc_profiles(doc); + let st = cms::working_settings(); + let (key, kind, assigned, working) = match doc.color_mode { + vectorcraft_doc::ColorMode::Rgb => ("rgb", ProfileKind::Rgb, rgb, st.rgb), + vectorcraft_doc::ColorMode::Cmyk => ("cmyk", ProfileKind::Cmyk, cmyk, st.cmyk), + }; + let working = cms::canonical_name(&working).to_string(); + let others = cms::profiles().into_iter().filter(|p| p.kind == kind && p.name != working).map(|p| p.name).collect(); + AssignRow { key, mode: doc.color_mode, assigned, working, others } + } + + fn choice(&self) -> AssignChoice { + match &self.assigned { + None => AssignChoice::None, + Some(n) if *n == self.working => AssignChoice::Working, + Some(_) => AssignChoice::Profile, + } + } + + /// The `edit.assignProfile` params for `choice` (with `profile`, the profile picked for + /// [`AssignChoice::Profile`], else the first of the list), or `None` when the document already + /// has it. + fn params(&self, choice: AssignChoice, profile: Option<&str>) -> Option { + let v = match choice { + AssignChoice::None => Value::Null, + AssignChoice::Working => json!(self.working), + AssignChoice::Profile => json!(profile.or(self.others.first().map(String::as_str))?), + }; + (v != json!(self.assigned)).then(|| json!({ self.key: v })) + } +} + +/// The Assign rows: Don't Color Manage This Document, the working space of the document's color +/// mode, and Profile with a list of the other profiles of that kind. A pick runs `edit.assignProfile`. +fn assign_rows(app: &mut VectorcraftApp, ui: &mut Ui, row: &AssignRow) { + let choice = row.choice(); + let mut pick = None; + ui.label(tl!("Assign:")); + if widgets::radio(ui, tl!("Don't Color Manage This Document"), choice == AssignChoice::None, true) { + pick = row.params(AssignChoice::None, None); + } + let working = match row.mode { + vectorcraft_doc::ColorMode::Rgb => tl!("Working RGB: {name}"), + vectorcraft_doc::ColorMode::Cmyk => tl!("Working CMYK: {name}"), + }; + let working = crate::i18n::fmt(working, &[("name", &row.working)]); + if widgets::radio(ui, &working, choice == AssignChoice::Working, true) { + pick = row.params(AssignChoice::Working, None); + } + ui.horizontal(|ui| { + let on = choice == AssignChoice::Profile; + let shown = if on { row.assigned.as_deref() } else { row.others.first().map(String::as_str) }; + if widgets::radio(ui, tl!("Profile:"), on, !row.others.is_empty()) { + pick = row.params(AssignChoice::Profile, shown); + } + let refs: Vec<&str> = row.others.iter().map(String::as_str).collect(); + ui.add_enabled_ui(!refs.is_empty(), |ui| { + if let Some(i) = widgets::dropdown_names(ui, "cms-assign", shown.unwrap_or_default(), &refs, 210.0) { + pick = row.params(AssignChoice::Profile, refs.get(i).copied()); } }); + }); + if let Some(p) = pick { + run(app, "edit.assignProfile", p); } } @@ -235,6 +318,67 @@ mod tests { assert!(panel.iter().all(|(id, _)| cms::ProofTarget::parse(id).is_some_and(|t| t.id() == *id))); } + /// Assign Profile's three choices: no profile, the working space assigned as the profile, or + /// another profile; a choice the document already has runs nothing. + #[test] + fn assign_choices_give_the_profile_params_of_the_color_mode() { + let row = |assigned: Option<&str>| AssignRow { + key: "rgb", + mode: vectorcraft_doc::ColorMode::Rgb, + assigned: assigned.map(str::to_string), + working: "W".into(), + others: vec!["A".into(), "B".into()], + }; + assert_eq!(row(None).choice(), AssignChoice::None); + assert_eq!(row(Some("W")).choice(), AssignChoice::Working); + assert_eq!(row(Some("B")).choice(), AssignChoice::Profile); + assert_eq!(row(None).params(AssignChoice::None, None), None); + assert_eq!(row(Some("B")).params(AssignChoice::None, None), Some(json!({"rgb": null}))); + assert_eq!(row(None).params(AssignChoice::Working, None), Some(json!({"rgb": "W"}))); + assert_eq!(row(Some("W")).params(AssignChoice::Working, None), None); + assert_eq!(row(None).params(AssignChoice::Profile, None), Some(json!({"rgb": "A"}))); + assert_eq!(row(Some("W")).params(AssignChoice::Profile, Some("B")), Some(json!({"rgb": "B"}))); + assert_eq!(row(Some("B")).params(AssignChoice::Profile, Some("B")), None); + let none = AssignRow { others: vec![], ..row(None) }; + assert_eq!(none.params(AssignChoice::Profile, None), None, "no other profile to pick"); + } + + /// The Assign rows follow the document's color mode: an RGB document lists the RGB working space + /// and the RGB profiles and assigns its RGB profile, a CMYK document the CMYK ones, and the other + /// model's profile stays as it was. + #[test] + fn assign_rows_follow_the_document_color_mode() { + fn texts(s: &egui::Shape, out: &mut Vec) { + match s { + egui::Shape::Text(t) => out.push(t.galley.text().to_string()), + egui::Shape::Vec(v) => v.iter().for_each(|s| texts(s, out)), + _ => {} + } + } + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + app.run("file.new", json!({})).unwrap(); + for (mode, key, kind, other) in [("rgb", "rgb", ProfileKind::Rgb, "cmyk"), ("cmyk", "cmyk", ProfileKind::Cmyk, "rgb")] { + app.run("edit.assignProfile", json!({"rgb": null, "cmyk": null})).unwrap(); + app.run("file.documentColorMode", json!({"mode": mode, "convert": false})).unwrap(); + let row = AssignRow::of(&app.session.active().unwrap().doc); + assert_eq!((row.key, row.choice()), (key, AssignChoice::None)); + assert!(!row.others.is_empty() && !row.others.contains(&row.working)); + assert!(row.others.iter().chain([&row.working]).all(|n| cms::profile(n).is_some_and(|p| p.kind == kind)), "{row:?}"); + let ctx = egui::Context::default(); + let mut out = ctx.run_ui(egui::RawInput::default(), |ui| settings_rows(&mut app, ui)); + out.textures_delta.clear(); + let mut shown = vec![]; + out.shapes.iter().for_each(|c| texts(&c.shape, &mut shown)); + let working = format!("Working {}: {}", mode.to_uppercase(), row.working); + assert!(shown.contains(&working) && shown.iter().any(|t| t == "Don't Color Manage This Document"), "{shown:?}"); + app.run("edit.assignProfile", row.params(AssignChoice::Profile, None).unwrap()).unwrap(); + let doc = &app.session.active().unwrap().doc; + let (rgb, cmyk) = vectorcraft_engine::cmd::colormgmt::doc_profiles(doc); + let (mine, theirs) = if key == "rgb" { (rgb, cmyk) } else { (cmyk, rgb) }; + assert_eq!((mine.as_deref(), theirs), (row.others.first().map(String::as_str), None), "{other} stays untagged"); + } + } + /// #6419: Edit › Assign Profile… and Color Settings… show the panel scrolled to its Color /// Settings section, which a window shorter than the panel (a floated panel stops at 320 /// points) otherwise shows only after scrolling past the plates and Proof Setup. Once: a diff --git a/crates/ui-egui/src/panels/tests_long_popups.rs b/crates/ui-egui/src/panels/tests_long_popups.rs index c562d3540f..966fef1f84 100644 --- a/crates/ui-egui/src/panels/tests_long_popups.rs +++ b/crates/ui-egui/src/panels/tests_long_popups.rs @@ -25,13 +25,17 @@ pub(super) struct PopupWindow<'a> { height: f32, draw: Draw<'a>, texts: Vec<(String, Rect)>, + /// The full texts of the last frame's texts drawn ending in an ellipsis. + shortened: Vec, + /// The last frame's screen-reader nodes, when the window's context has them turned on. + nodes: Vec, } impl<'a> PopupWindow<'a> { pub(super) fn new(app: &mut VectorcraftApp, height: f32, draw: impl FnMut(&mut VectorcraftApp, &mut Ui) + 'a) -> Self { let ctx = egui::Context::default(); crate::theme::install_fonts(&ctx); - let mut window = Self { ctx, time: 0.0, width: WIDTH, height, draw: Box::new(draw), texts: vec![] }; + let mut window = Self { ctx, time: 0.0, width: WIDTH, height, draw: Box::new(draw), texts: vec![], shortened: vec![], nodes: vec![] }; window.frames(app, 2); window } @@ -45,6 +49,15 @@ impl<'a> PopupWindow<'a> { let mut out = self.ctx.run_ui(input, |ui| draw(app, ui)); out.textures_delta.clear(); self.texts = crate::tests_removeanchors::shapes_text(&out.shapes.iter().map(|c| c.shape.clone()).collect::>()); + self.shortened = out + .shapes + .iter() + .filter_map(|c| match &c.shape { + egui::Shape::Text(text) if text.galley.elided => Some(text.galley.text().to_string()), + _ => None, + }) + .collect(); + self.nodes = out.platform_output.accesskit_update.map(|u| u.nodes.into_iter().map(|(_, n)| n).collect()).unwrap_or_default(); } /// Makes the window `width` pt wide and draws the frames that takes. @@ -214,7 +227,7 @@ pub(super) fn assert_last_row_scrolls_into_view( /// scroll bar's column, whether or not its list scrolls. The row `widest` is drawn the widest; the /// wheel brings it into view when the popup opens without it. The popup and that row keep their /// place and size while the popup stays open. After the 200 pt window becomes `narrow` pt wide, -/// the popup and its rows in view stay inside the window. +/// the popup, its scroll bar and its rows in view stay inside the window. pub(super) fn assert_rows_stay_on_one_line( app: &mut VectorcraftApp, mut draw: impl FnMut(&mut VectorcraftApp, &mut Ui), @@ -228,6 +241,7 @@ pub(super) fn assert_rows_stay_on_one_line( |window: &PopupWindow| -> Vec<(String, Rect)> { rows.iter().filter_map(|row| window.text(row).map(|rect| (row.clone(), rect))).collect() }; let assert_inside = |window: &PopupWindow, drawn: &[(String, Rect)], name: &str| { let popup = window.popup().unwrap_or_else(|| panic!("the popup is open in the {name} window")); + assert!(popup.right() <= window.width + 0.5, "the popup at {popup:?} ends inside the {name} window, at x = {}", window.width); for (row, rect) in drawn { assert!( rect.left() >= popup.left() && rect.right() <= popup.right() - bar, @@ -333,6 +347,68 @@ fn app() -> VectorcraftApp { VectorcraftApp::new(vectorcraft_engine::Session::new(), Default::default()) } +/// A row's name too long for the narrow window in [`a_shortened_menu_row_keeps_its_full_text`]. +const LONG_ROW: &str = "A row whose name is far too long for a narrow window"; +/// A dimmed row's name too long for the same window. +const DIMMED_ROW: &str = "A dimmed row whose name is also far too long"; + +/// In a window too narrow for a menu's row, the menu stays inside the window and the row ends in +/// an ellipsis. Screen readers get the row's full text, a click still chooses it, and resting the +/// pointer on it shows the full text in a tooltip inside the window, as it does on a dimmed row. A +/// row that fits is drawn in full and shows no tooltip. +#[test] +fn a_shortened_menu_row_keeps_its_full_text() { + let clicked = std::cell::Cell::new(false); + let draw = |_: &mut VectorcraftApp, ui: &mut Ui| { + let button = ui.button("Open"); + egui::Popup::menu(&button).show(|ui| { + crate::widgets::menu_scroll(ui, |ui| { + // More than the window has, as the menu bar's menus ask for. + crate::widgets::menu_min_width(ui, 230.0); + if ui.button(LONG_ROW).clicked() { + clicked.set(true); + } + let _ = ui.button("Short"); + let _ = ui.add_enabled(false, egui::Button::new(DIMMED_ROW)); + }) + }); + }; + let mut app = app(); + let mut window = PopupWindow::new(&mut app, 300.0, draw); + window.ctx.enable_accesskit(); + window.resize(&mut app, 160.0); + let at = window.text("Open").expect("the button").center(); + window.click(&mut app, at); + let popup = window.popup().expect("the menu is open"); + assert!(popup.left() >= 0.0 && popup.right() <= 160.5, "the menu at {popup:?} is inside the 160 pt wide window"); + let row = window.text(LONG_ROW).expect("the long row"); + assert!(row.right() <= popup.right(), "the long row at {row:?} ends inside the menu at {popup:?}"); + assert!(window.shortened.iter().any(|text| text == LONG_ROW), "the long row ends in an ellipsis: {:?}", window.shortened); + assert!(!window.shortened.iter().any(|text| text == "Short"), "the short row is drawn in full"); + assert!(window.nodes.iter().any(|node| node.label() == Some(LONG_ROW)), "screen readers get the long row's full text"); + let drawn = |window: &PopupWindow, wanted: &str| window.texts.iter().filter(|(text, _)| text == wanted).count(); + assert_eq!(drawn(&window, LONG_ROW), 1, "no tooltip shows before the pointer rests on the row"); + + window.frame(&mut app, vec![Event::PointerMoved(window.text("Short").expect("the short row").center())]); + window.frames(&mut app, 60); + assert_eq!(drawn(&window, "Short"), 1, "the short row shows no tooltip"); + + window.frame(&mut app, vec![Event::PointerMoved(row.center())]); + window.frames(&mut app, 60); + assert_eq!(drawn(&window, LONG_ROW), 2, "resting the pointer on the long row shows its full text in a tooltip"); + let tip = window.texts.iter().filter(|(text, _)| text == LONG_ROW).map(|(_, rect)| *rect).find(|rect| *rect != row); + assert!(tip.is_some_and(|tip| tip.left() >= 0.0 && tip.right() <= 160.5), "the tooltip at {tip:?} is inside the window"); + + assert!(window.shortened.iter().any(|text| text == DIMMED_ROW), "the dimmed row ends in an ellipsis"); + let dimmed = window.text(DIMMED_ROW).expect("the dimmed row"); + window.frame(&mut app, vec![Event::PointerMoved(dimmed.center())]); + window.frames(&mut app, 60); + assert_eq!(drawn(&window, DIMMED_ROW), 2, "resting the pointer on the dimmed row shows its full text in a tooltip"); + + window.click(&mut app, row.center()); + assert!(clicked.get(), "a click on the shortened row chooses it"); +} + /// A long menu opened from a button `y` pt down a window stays at one place on every frame, and so /// does its submenu, whether it opens below the button, above it, or in a window shorter than it. #[test] diff --git a/crates/ui-egui/src/panels/tests_three_d.rs b/crates/ui-egui/src/panels/tests_three_d.rs index 72ce8004b5..fd3a30c7bf 100644 --- a/crates/ui-egui/src/panels/tests_three_d.rs +++ b/crates/ui-egui/src/panels/tests_three_d.rs @@ -794,6 +794,196 @@ fn quick_actions_show_on_the_object_tab_only() { assert!(super::quick_actions_shown(&d)); } +/// An app with an Extrude & Bevel square given `params`, and the panel on its Lighting tab with the +/// accessibility tree on. +fn lighting_app(params: Value) -> (VectorcraftApp, egui::Context) { + use crate::panels::tests_appearance::{click, frame_events, text_rect}; + let mut app = app(); + app.run("effect.apply", json!({"effect": "threeD.extrude", "item": null, "params": params})).unwrap(); + let ctx = egui::Context::default(); + crate::theme::install_fonts(&ctx); + ctx.enable_accesskit(); + let texts = frame_events(&ctx, &mut app, vec![], show); + click(&ctx, &mut app, text_rect(&texts, "Lighting").center(), show); + (app, ctx) +} + +/// The lighting widget's node for light `n` (from 1): its screen-reader name and its center. +fn light_node(app: &mut VectorcraftApp, ctx: &egui::Context, n: usize) -> (String, egui::Pos2) { + let nodes = controls(app, ctx); + let prefix = format!("Light {n}:"); + let node = nodes + .iter() + .find(|x| x.role() == egui::accesskit::Role::Slider && x.label().is_some_and(|l| l.starts_with(&prefix))) + .unwrap_or_else(|| panic!("no lighting widget node for light {n}")); + let r = node.bounds().unwrap(); + (node.label().unwrap_or_default().to_string(), egui::pos2(((r.x0 + r.x1) / 2.0) as f32, ((r.y0 + r.y1) / 2.0) as f32)) +} + +fn pointer(pos: egui::Pos2, pressed: Option) -> egui::Event { + match pressed { + Some(pressed) => egui::Event::PointerButton { pos, button: egui::PointerButton::Primary, pressed, modifiers: Default::default() }, + None => egui::Event::PointerMoved(pos), + } +} + +fn merged_light(app: &VectorcraftApp) -> (f64, f64) { + let (id, params) = effect(app); + let merged = vectorcraft_effects::merged_params(&id, ¶ms); + (merged["lightRotation"].as_f64().unwrap(), merged["lightHeight"].as_f64().unwrap()) +} + +/// Dragging a light in the lighting widget previews its new Rotation and Height while the pointer +/// is down and records one undo step when it is released, at the point of release; the rim of the +/// sphere is Height 0. +#[test] +fn the_lighting_widget_drags_a_light_in_one_undo_step() { + use crate::dialogs::revolve_lights::RADIUS; + use crate::panels::tests_appearance::frame_events; + let (mut app, ctx) = lighting_app(json!({})); + let (name, from) = light_node(&mut app, &ctx, 1); + assert_eq!(name, "Light 1: Rotation 125.3°, Height 30.0°"); + let light = crate::dialogs::revolve_lights::lights(&crate::state::Dialog::new( + "x", + json!({"__effect": "threeD.extrude", "lightRotation": vectorcraft_three_d::DEFAULT_LIGHT.0, "lightHeight": 30.0}), + ))[0]; + let center = from - light.offset() * RADIUS; + let history = undo_len(&app); + frame_events(&ctx, &mut app, vec![pointer(from, None)], show); + frame_events(&ctx, &mut app, vec![pointer(from, Some(true))], show); + // Halfway out along the light's own direction, then onto the rim straight right of the center. + frame_events(&ctx, &mut app, vec![pointer(center + egui::vec2(0.0, -RADIUS * 0.5), None)], show); + assert_eq!(merged_light(&app), (90.0, 60.0), "the light follows the pointer while it is down"); + let rim = center + egui::vec2(RADIUS, 0.0); + frame_events(&ctx, &mut app, vec![pointer(rim, None)], show); + assert_eq!(merged_light(&app), (0.0, 0.0)); + assert_eq!(undo_len(&app), history, "nothing is recorded while the pointer is down"); + // A frame that moves the pointer below the center and releases it there drops the light there. + let below = center + egui::vec2(0.0, RADIUS); + frame_events(&ctx, &mut app, vec![pointer(below, None), pointer(below, Some(false))], show); + frame_events(&ctx, &mut app, vec![], show); + assert_eq!(undo_len(&app), history + 1); + assert_eq!(merged_light(&app), (-90.0, 0.0)); + app.run("edit.undo", json!({})).unwrap(); + assert_eq!(merged_light(&app), (vectorcraft_three_d::DEFAULT_LIGHT.0, 30.0)); + // A drag that comes back to the press drops the light there, not where it last was. + for event in [pointer(from, None), pointer(from, Some(true)), pointer(center + egui::vec2(0.0, -RADIUS * 0.5), None), pointer(from, None)] { + frame_events(&ctx, &mut app, vec![event], show); + } + frame_events(&ctx, &mut app, vec![pointer(from, Some(false))], show); + frame_events(&ctx, &mut app, vec![], show); + assert_eq!(merged_light(&app), (125.3, 30.0)); +} + +/// With objects whose lights differ in Height, an arrow key that turns the light changes only +/// the Rotation, and each object keeps its own Height. +#[test] +fn a_key_leaves_the_other_setting_as_each_selected_effect_has_it() { + use crate::panels::tests_appearance::{click, frame_events, text_rect}; + let mut app = app(); + let a = app.session.active().unwrap().selection.objects[0].0; + app.run("effect.apply", json!({"effect": "threeD.extrude", "item": null, "params": {"lightRotation": 0.0, "lightHeight": 20.0}})).unwrap(); + let b = app.run("shape.rectangle", json!({"x": 250, "y": 100, "width": 100, "height": 100})).unwrap()["id"].as_u64().unwrap(); + app.run("effect.apply", json!({"effect": "threeD.extrude", "item": null, "params": {"lightRotation": 0.0, "lightHeight": 40.0}})).unwrap(); + app.run("select.set", json!({"ids": [a, b]})).unwrap(); + let ctx = egui::Context::default(); + crate::theme::install_fonts(&ctx); + ctx.enable_accesskit(); + let texts = frame_events(&ctx, &mut app, vec![], show); + click(&ctx, &mut app, text_rect(&texts, "Lighting").center(), show); + let (_, at) = light_node(&mut app, &ctx, 1); + click(&ctx, &mut app, at, show); + let left = egui::Event::Key { key: egui::Key::ArrowLeft, physical_key: None, pressed: true, repeat: false, modifiers: egui::Modifiers::NONE }; + frame_events(&ctx, &mut app, vec![left], show); + let light = |id: u64| { + let n = app.session.active().unwrap().doc.node(vectorcraft_doc::NodeId(id)).unwrap(); + let merged = vectorcraft_effects::merged_params("threeD.extrude", &n.appearance.effects[0].params); + (merged["lightRotation"].as_f64().unwrap(), merged["lightHeight"].as_f64().unwrap()) + }; + assert_eq!((light(a), light(b)), ((1.0, 20.0), (1.0, 40.0))); +} + +/// A press on a light selects it in the list without an undo step, and the button below the sphere +/// moves only the selected light to the back of the object, then back to the front. +#[test] +fn the_lighting_widget_selects_a_light_and_moves_it_behind_the_object() { + use crate::panels::tests_appearance::{click, frame_events, text_rect}; + let light = + |rotation: f64| json!({"color": "#ffffff", "intensity": 80.0, "rotation": rotation, "height": 30.0, "softness": 0.0, "behind": false}); + let (mut app, ctx) = lighting_app(json!({"lights": [light(0.0), light(180.0)]})); + let history = undo_len(&app); + let (_, second) = light_node(&mut app, &ctx, 2); + click(&ctx, &mut app, second, show); + assert_eq!(undo_len(&app), history, "a press on a light changes nothing"); + let texts = frame_events(&ctx, &mut app, vec![], show); + assert!(texts.iter().any(|(s, _)| s == "Light 2"), "the list shows the pressed light: {texts:?}"); + click(&ctx, &mut app, text_rect(&texts, "Move Light to Back").center(), show); + assert_eq!(undo_len(&app), history + 1); + let behind = |app: &VectorcraftApp| effect(app).1["lights"].as_array().unwrap().iter().map(|l| l["behind"].clone()).collect::>(); + assert_eq!(behind(&app), [json!(false), json!(true)]); + let (name, _) = light_node(&mut app, &ctx, 2); + assert_eq!(name, "Light 2: Rotation 180.0°, Height 30.0°, Light behind the object"); + let texts = frame_events(&ctx, &mut app, vec![], show); + click(&ctx, &mut app, text_rect(&texts, "Move Light to Front").center(), show); + assert_eq!(behind(&app), [json!(false), json!(false)]); + assert_eq!(undo_len(&app), history + 2); +} + +/// A light the pointer took has the keyboard focus: the arrow keys raise and lower it and turn it +/// around the object, 1° a press or 15° with Shift, changing only the setting each press moves, one +/// undo step a frame, and a press at a limit records nothing. +#[test] +fn the_arrow_keys_move_the_focused_light() { + use crate::panels::tests_appearance::{click, frame_events}; + let (mut app, ctx) = lighting_app(json!({"lightRotation": 10.04, "lightHeight": 89.5})); + let (_, at) = light_node(&mut app, &ctx, 1); + click(&ctx, &mut app, at, show); + let history = undo_len(&app); + let key = |key: egui::Key, modifiers: egui::Modifiers| egui::Event::Key { key, physical_key: None, pressed: true, repeat: false, modifiers }; + frame_events(&ctx, &mut app, vec![key(egui::Key::ArrowUp, egui::Modifiers::NONE)], show); + frame_events(&ctx, &mut app, vec![], show); + assert_eq!(merged_light(&app), (10.04, 90.0), "Up leaves the Rotation as it is"); + assert_eq!(undo_len(&app), history + 1); + frame_events(&ctx, &mut app, vec![key(egui::Key::ArrowUp, egui::Modifiers::NONE)], show); + frame_events(&ctx, &mut app, vec![], show); + assert_eq!(merged_light(&app), (10.04, 90.0)); + assert_eq!(undo_len(&app), history + 1, "Height stays at 90° and nothing is recorded"); + // Down then Up in one frame, in that order: back at 90°. + frame_events(&ctx, &mut app, vec![key(egui::Key::ArrowDown, egui::Modifiers::NONE), key(egui::Key::ArrowUp, egui::Modifiers::NONE)], show); + frame_events(&ctx, &mut app, vec![], show); + assert_eq!(merged_light(&app), (10.04, 90.0)); + assert_eq!(undo_len(&app), history + 1); + // Presses that arrive in one frame all move the light, as one undo step. + let presses = vec![ + key(egui::Key::ArrowLeft, egui::Modifiers::SHIFT), + key(egui::Key::ArrowDown, egui::Modifiers::NONE), + key(egui::Key::ArrowDown, egui::Modifiers::NONE), + ]; + frame_events(&ctx, &mut app, presses, show); + frame_events(&ctx, &mut app, vec![], show); + assert_eq!(merged_light(&app), (25.0, 88.0)); + assert_eq!(undo_len(&app), history + 2); +} + +/// A press on another light selects it and gives it the keyboard focus, so the next arrow key +/// moves that light and leaves the one selected before where it is. +#[test] +fn the_arrow_keys_move_the_light_pressed_last() { + use crate::panels::tests_appearance::{click, frame_events}; + let light = + |rotation: f64| json!({"color": "#ffffff", "intensity": 80.0, "rotation": rotation, "height": 30.0, "softness": 0.0, "behind": false}); + let (mut app, ctx) = lighting_app(json!({"lights": [light(0.0), light(180.0)]})); + let (_, first) = light_node(&mut app, &ctx, 1); + click(&ctx, &mut app, first, show); + let (_, second) = light_node(&mut app, &ctx, 2); + click(&ctx, &mut app, second, show); + let left = egui::Event::Key { key: egui::Key::ArrowLeft, physical_key: None, pressed: true, repeat: false, modifiers: egui::Modifiers::NONE }; + frame_events(&ctx, &mut app, vec![left], show); + frame_events(&ctx, &mut app, vec![], show); + let rotations = effect(&app).1["lights"].as_array().unwrap().iter().map(|l| l["rotation"].clone()).collect::>(); + assert_eq!(rotations, [json!(0.0), json!(-179.0)]); +} + #[test] fn changed_settings_lists_only_new_values() { let before = json!({"depth": 50.0, "cap": true, "bevel": "none"}); diff --git a/crates/ui-egui/src/scripts_menu.rs b/crates/ui-egui/src/scripts_menu.rs index 9c890d5c39..c951aac8cb 100644 --- a/crates/ui-egui/src/scripts_menu.rs +++ b/crates/ui-egui/src/scripts_menu.rs @@ -16,6 +16,7 @@ use std::sync::mpsc::{Receiver, TryRecvError}; use std::sync::{Arc, Mutex, MutexGuard}; use serde_json::{Value, json}; +use vectorcraft_engine::cmd::scripts::NOT_LISTED; use vectorcraft_engine::script::folder::{Entry, Listing}; use crate::VectorcraftApp; @@ -241,15 +242,16 @@ pub fn readable(p: &Value) -> bool { p.get("path").and_then(Value::as_str).is_some_and(|path| vectorcraft_engine::file_access::check_read(path).is_ok()) } -/// `ui.runInstalledScript {path}`: run the listed script at `path`. A script that is no longer in -/// the folder is not run, and the folder is read again. The script runs from its file, so its -/// `#include` lines and `$.evalFile` read beside it. +/// `ui.runInstalledScript {path}`: run the listed script at `path`. `script.run` reads the file +/// once, checks it as a listed script and runs that text, so its `#include` lines and `$.evalFile` +/// read in the folder that read found. A script that is no longer in the folder is not run, and the +/// folder is read again. pub fn run(app: &mut VectorcraftApp, p: &Value) -> Result { let path = p.get("path").and_then(Value::as_str).ok_or("missing `path`")?; - app.session.installed_script(path).map_err(|e| { - let ctx = app.ui_ctx.clone(); - refresh(app, ctx.as_ref()); - e.to_string() - })?; - app.run("script.run", json!({ "path": path })) + app.run("script.run", json!({ "path": path, "installed": true })).inspect_err(|e| { + if e == NOT_LISTED { + let ctx = app.ui_ctx.clone(); + refresh(app, ctx.as_ref()); + } + }) } diff --git a/crates/ui-egui/src/widgets.rs b/crates/ui-egui/src/widgets.rs index 36145224d5..0738144ade 100644 --- a/crates/ui-egui/src/widgets.rs +++ b/crates/ui-egui/src/widgets.rs @@ -45,7 +45,7 @@ pub use drag::{ }; #[cfg(test)] use dropdowns::combo_button_id; -pub(crate) use dropdowns::{combo, combo_menu, combo_menu_field_named, combo_named, field_focused_in, popover_is_open}; +pub(crate) use dropdowns::{combo, combo_menu, combo_menu_field_named, combo_named, field_focused_in, menu_min_width, popover_is_open}; pub use dropdowns::{ dropdown, dropdown_icon_names, dropdown_named, dropdown_names, dropdown_names_named, dropdown_with, dropdown_with_named, list_scroll_style, menu_room, menu_scroll, panel_scroll, popover, set_menu_floor, strip_scroll, diff --git a/crates/ui-egui/src/widgets/dropdowns.rs b/crates/ui-egui/src/widgets/dropdowns.rs index 1539be8700..7f088f25ef 100644 --- a/crates/ui-egui/src/widgets/dropdowns.rs +++ b/crates/ui-egui/src/widgets/dropdowns.rs @@ -443,7 +443,9 @@ fn popup_anchor(ui: &Ui) -> Option<(Rect, bool)> { /// reachable on small windows and in a floating panel's short OS window. Its scroll bar has a column /// of its own, so it never covers the shortcuts or the submenu arrows (#2086, #5347). When the list /// scrolls, that column widens the popup instead of narrowing the items, so their labels stay on one -/// line. +/// line. In a window too narrow for the items, the list is as wide as the window allows, and a row +/// that does not fit ends in an ellipsis and shows its full text in a tooltip. Screen readers and +/// keyboard selection still use the full text. /// /// The list's height comes from the room below and above the button, row or point the popup opens /// from. A popup with an id of its own measures from its own top instead. @@ -463,14 +465,13 @@ pub fn menu_scroll(ui: &mut Ui, add_contents: impl FnOnce(&mut Ui) -> R) -> R menu_room(floor, top, tallest) }; // The items' width on the first frame of this showing, when the scroll area does not yet show - // its bar. Later frames lay the items out at least that wide, and the bar's column is added to - // the popup's width. The width is kept until the popup closes. + // its bar. Later frames lay the items out that wide, up to the widest the window allows, and + // the bar's column is added to the popup's width. The width is kept until the popup closes. let key = ui.next_auto_id().with("menu-scroll-width"); let frame = ctx.cumulative_frame_nr(); let first = ctx.data(|d| d.get_temp::<(u64, f32)>(key)).filter(|(shown, _)| shown + 1 >= frame).map(|(_, width)| width); let scroll = list_scroll_style(); - // The widest the items can be with the popup's margins and the bar's column inside the window. - let widest = (ctx.content_rect().width() - margins.x - scroll.allocated_width()).max(0.0); + let widest = menu_list_widest(ui); ui.scope(|ui| { ui.spacing_mut().scroll = scroll; // The minimum lets the list grow past a popup's default 400 pt size; it still shrinks to @@ -479,9 +480,20 @@ pub fn menu_scroll(ui: &mut Ui, add_contents: impl FnOnce(&mut Ui) -> R) -> R let out = area.show(ui, |ui| { // Always the same child, so the items' ids stay the same on the frame the width changes. let max = ui.max_rect(); - let width = first.map_or(max.width(), |first| first.min(widest).max(max.width())); + let width = first.map_or(max.width(), |first| first.max(max.width()).min(widest)); let rect = egui::Rect::from_min_size(max.min, egui::vec2(width, max.height())); - ui.scope_builder(egui::UiBuilder::new().max_rect(rect).layout(*ui.layout()), add_contents).inner + let painted = ctx.graphics(|g| g.get(ui.layer_id()).map_or(0, |list| list.all_entries().len())); + let inner = ui + .scope_builder(egui::UiBuilder::new().max_rect(rect).layout(*ui.layout()), |ui| { + // The first frame measures the items at their full width. + if first.is_some() { + ui.style_mut().wrap_mode = Some(egui::TextWrapMode::Truncate); + } + add_contents(ui) + }) + .inner; + show_shortened_rows(ui, painted); + inner }); let width = first.unwrap_or(out.content_size.x); ctx.data_mut(|d| d.insert_temp(key, (frame, width))); @@ -490,6 +502,64 @@ pub fn menu_scroll(ui: &mut Ui, add_contents: impl FnOnce(&mut Ui) -> R) -> R .inner } +/// The widest the items of a [`menu_scroll`] list can be with the popup's margins and the scroll +/// bar's column inside the window. +fn menu_list_widest(ui: &Ui) -> f32 { + let margins = egui::Frame::popup(ui.style()).total_margin().sum(); + (ui.ctx().content_rect().width() - margins.x - list_scroll_style().allocated_width()).max(0.0) +} + +/// Makes a menu at least `width` wide, or as wide as the window allows the items of a +/// [`menu_scroll`] list when that is less, so the menu stays inside a narrow window. +pub(crate) fn menu_min_width(ui: &mut Ui, width: f32) { + ui.set_min_width(width.min(menu_list_widest(ui))); +} + +/// For each text that `ui`'s layer drew after its first `painted` shapes and that ends in an +/// ellipsis, the clickable row it is drawn in shows the full text in a tooltip that wraps to fit +/// inside the window. +fn show_shortened_rows(ui: &Ui, painted: usize) { + let ctx = ui.ctx(); + let layer = ui.layer_id(); + let shortened: Vec<(egui::Pos2, egui::text::LayoutJob)> = ctx.graphics(|g| { + let Some(list) = g.get(layer) else { return vec![] }; + list.all_entries() + .skip(painted) + .filter_map(|clipped| match &clipped.shape { + egui::Shape::Text(text) if text.galley.elided => { + let job = egui::text::LayoutJob { + sections: text.galley.job.sections.clone(), + text: text.galley.job.text.clone(), + language: text.galley.job.language.clone(), + ..Default::default() + }; + Some((Rect::from_min_size(text.pos, text.galley.size()).center(), job)) + } + _ => None, + }) + .collect() + }); + if shortened.is_empty() { + return; + } + let rows: Vec<(egui::Id, Rect)> = + ctx.viewport(|vp| vp.this_pass.widgets.get_layer(layer).filter(|w| w.sense.senses_click()).map(|w| (w.id, w.rect)).collect()); + for (at, job) in shortened { + let Some(response) = rows.iter().rfind(|(_, rect)| rect.contains(at)).and_then(|(id, _)| ctx.read_response(*id)) else { continue }; + let tip = |ui: &mut Ui| { + let margins = ui.spacing().menu_margin.sum().x + 2.0 * ui.spacing().item_spacing.x; + ui.set_max_width(ui.spacing().tooltip_width.min(ui.ctx().content_rect().width() - margins).max(0.0)); + ui.add(egui::Label::new(job).wrap()); + }; + // A dimmed row shows its full text too. + if response.enabled() { + response.on_hover_ui(tip); + } else { + response.on_disabled_hover_ui(tip); + } + } +} + /// A column of tool or panel buttons (the toolbar, the dock's icon column) that scrolls when the /// window is too short for it: with the mouse wheel, or the scroll bar shown while the pointer is /// over it. diff --git a/docs/control-protocol.md b/docs/control-protocol.md index e6f3621e8e..d52720f29e 100644 --- a/docs/control-protocol.md +++ b/docs/control-protocol.md @@ -973,7 +973,10 @@ the path a stroke draws, continues or merges into is not selected afterwards), ` off by default, and while it is on a stroke merges only with selected blobs), `closeWithin` and `editWithin` (screen pixels, 0 turns it off; Pencil, Paintbrush), `size` (pt; Blob Brush, Eraser), and `angle` (°, −180 to 180; 0) and `roundness` (%, 0 to 100 for the Blob Brush, where 0 is drawn as 0.1, and 1 to 100 for -the Eraser; 100) of the tip (Blob Brush, Eraser); the tip keeps its angle on the page along a stroke. `ui.dialog.confirm` runs +the Eraser; 100) of the tip (Blob Brush, Eraser); the tip keeps its angle on the page along a stroke. `sizeMode` and +`sizeVariation` (pt) vary the size (Blob Brush: `fixed` or `pressure`). The Eraser also has `angleMode`, +`angleVariation` (°, 0 to 180), `roundnessMode` and `roundnessVariation` (%, 0 to 100), and its three modes are each +`fixed`, `random`, `pressure`, `tilt`, `bearing` or `rotation`, as `path.eraseRegion` takes them. `ui.dialog.confirm` runs `tool.setOption {tool, values}`. `ui.pointer` events also take `holdMs` (0..60000): the pointer then holds still that long, button down, before the diff --git a/docs/mcp.md b/docs/mcp.md index 8ca4121032..ceb56138fc 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -1060,6 +1060,11 @@ styles and brushes. `color.removeProfile`, `view.proofSetup`, `view.proofColors`, `view.overprintPreview`, `view.separationsPreview` and `swatch.setSpot` reject a param of the wrong type, such as `"bpc": "no"`, `"on": 1`, `"to": 5` or `"colors": "#fff"`, and change nothing. Omitting a param or giving null keeps its default. +The Separations panel's Assign rows (where Edit › Assign Profile… goes) act on the profile of the document's color +mode: Don't Color Manage This Document runs `edit.assignProfile` with null for that model (no profile, so the colors +show through the working space), Working RGB or Working CMYK with the working space's name (that profile stays +assigned when the working space changes), and Profile with another profile of that kind. A choice the document +already has runs nothing, and the other model's profile stays as it is. SVG import keeps what the canvas edits live. `` becomes a pattern swatch. A `` with `` becomes a symbol (named after its `data-name`, else its id) with an instance per ``; a `` that shows it differently @@ -1129,7 +1134,8 @@ gradient meshes whose every point is at 0% opacity, and paths without effects wh An image or a mesh with a Crop Marks effect, or with a Vector Halftone effect that makes dots, is measured. Such objects in the art of a symbol are left out of its instances too, and an instance whose symbol holds nothing else is left out. In the art of a dynamic symbol, an object that an instance's fill, stroke or opacity makes -the plot draw is measured. +the plot draw is measured. The art of an instance that goes through 9-slice guides is placed through them first, and +such objects are then left out of the placed art. A document whose only visible art is left out is refused, and Export As refuses an HP-GL plot of it before the save dialog. A PDF of that page, from either command, carries no Preserve Editing data; when Preserve Editing is on (as it is in VectorCraft Default), the export comes back with a warning that says so. A `.ai` export with @@ -1297,8 +1303,10 @@ opens at its size in points, the file's numbers times the `UserUnit` (one above such a page opens from that data at the page's size. `linkPages: true` (File › Open's Import PDF dialog: Import PDF pages as links) opens each page `pages` picks as a placed document linked to that page of the file, as `file.place` with `link` places one, instead of as editable art. -Each page gets an artboard of its `cropTo` box (`bounding`: its art's bounds), in a row from the left 36 pt apart, and -a layer named after the page (`Page 2`). The document opens in CMYK when most of those pages are painted mostly in +Each page gets an artboard of its `cropTo` box (`bounding`: its art's bounds; a blank page gets its crop box), in a row from the left 36 pt apart, and +a layer named after the page (`Page 2`). A PDF-compatible `.ai` with `cropTo: "crop"` gets its trim boxes, as it does +when it opens as art. A page on its trim box shows its bleed box round the artboard, and the pages give the document +their bleed; the artboards are then 36 pt apart plus the bleed on both sides. The document opens in CMYK when most of those pages are painted mostly in CMYK, unless `colorMode` names a mode. All the pages' objects share one copy of the file. The Links panel lists each page, and `links.embed` makes a page editable art. `document.open` refuses `linkPages` for a file that is not a PDF or a PDF-compatible `.ai`, for a file given as @@ -2571,6 +2579,9 @@ The rest of this section is VectorCraft's own rules: `The Scripts folder cannot be read`. - A listed file is checked again when it is chosen. A file that is gone, is no longer a `.js` or `.jsx` file under the folder, or is or lies behind a link ends with `the script is no longer in the Scripts folder`, and nothing runs. + The file is read once for that check, and the text that read checked is the text that runs; its `#include` lines + and `$.evalFile` read in the folder that read found. `script.run {path, installed: true}` runs a listed script the + same way. - The web build has no Scripts folder, and neither has a run with `VECTORCRAFT_NO_PREFS` set. `script.listInstalled {}` returns the same listing: `{folder, scripts: [{path, label, folders}], truncated, @@ -4862,6 +4873,14 @@ and `height`, so a new `hSpacing` changes the tile's width. `pattern.options` st `object.pattern.edit` edits the pattern that fills or strokes a selected object, and `pattern.options` changes the pattern being edited; each returns an error when there is no such pattern. `pattern.delete` requires `name`. +With `pattern.options {moveTileWithArt: true}` (Move Tile with Art), a move of all of the tile art in pattern editing +mode moves the tile by the same offset: `object.move`, `object.nudge`, `object.transform` with a translation, and drags +with the Selection tool. A move of only some of the art, a move of direct-selected anchors, a copy, a rotation and a +scale leave the tile where it is. The option is off for a new pattern and works the same with `sizeTileToArt` on. These +details are VectorCraft's own rules; the help page says only that the option moves the tile along with the art +(). +`pattern.list` reports it as `moveTileWithArt`. A `pattern.options` call that changes nothing records no undo step. + ```json {"name":"run_command","arguments":{"command":"pattern.options","params":{"showSwatchBounds":true}}} {"name":"run_command","arguments":{"command":"prefs.set","params":{"key":"patternTileEdgeColor","value":"#ff4f4f"}}} @@ -5519,11 +5538,28 @@ moves that point alone as the focal point, with or without Shift (Shift keeps th focal points stay highlighted where it left them; Escape during a drag puts the path back and keeps them, and switching to another tool clears them. +`path.eraseRegion` (the Eraser) takes a variation mode for each of its tip's values, as the variation menus of +Eraser Tool Options set them ([Erase paths using the Eraser +tool](https://helpx.adobe.com/illustrator/desktop/draw-shapes-and-paths/modify-paths/erase-paths-using-eraser-tool.html)): +`angleMode` with `angleVariation` (degrees, 0 to 180), `roundnessMode` with `roundnessVariation` (percent, 0 to 100) +and `sizeMode` with `sizeVariation` (points, 0 or more), each `"fixed"` (the default), `"random"`, `"pressure"`, +`"tilt"`, `"bearing"` or `"rotation"`. A value that varies takes up to its variation either way. Random draws one +value for the drag. Pressure goes from the value less the variation where the pen presses lightest to the value plus +it where the pen presses hardest. Tilt goes from less with the pen lying flat to more with it upright. Bearing and +Rotation go from less at −180° to more at 180° of the way the pen leans or its barrel turns. The tip at each point of +the drag takes what the pen read there (the points' pressure, tilt and rotation, as `path.freehand` takes them); a +drag without that reading, such as a mouse drag, erases at the value itself. One Random value per drag, which way +Tilt, Bearing and Rotation run, and the value itself without a reading are VectorCraft's own rules. +The command refuses another mode, and a variation below 0 or past its range, and erases nothing. In the desktop app +the Eraser's tip outline at the pointer takes the angle, roundness and size the pen gives while it erases. +`tool.setOption {tool: "eraser"}` takes the same keys. + These commands refuse a string or another value that is not a number with `` `dy` must be a number, not "10" ``, naming the key; the document does not change and no undo step is recorded: `t`, `dx` and `dy` in `path.reshapeSegment` and `object.blend.spine.reshapeSegment`; `x`, `y` and `t` in `path.curvatureEdit`; `x` and `y` in `path.convertAnchor`; `t` in `path.insertAnchor`; `fidelity` and the `within` of `redraw` in `path.freehand` (and in -`brush.freehand`, which runs it); `size`, `sizeVariation`, `angle` and `roundness` in `path.eraseRegion`; +`brush.freehand`, which runs it); `size`, `sizeVariation`, `angle`, `angleVariation`, `roundness` and +`roundnessVariation` in `path.eraseRegion`; `size`, `sizeVariation`, `angle`, `roundness` and `fidelity` in `path.blob`; `width` in `path.eraseSegments`; `radius` and `fidelity` in `path.smoothRegion`; `tolerance` in `path.joinScrub`; `dx` and `dy` in `path.moveAnchors`; and `dx`, `dy`, `tol` and `radius` in `path.reshape`. A null is read as a key left out. `path.convertAnchor` refuses @@ -5686,7 +5722,17 @@ the colour bars are process, spot and black-tint patches. Layers whose Print opt The Output and Advanced sections convert colours and write real text. `output: {conversion, destination}` converts every colour into the destination profile's model (`destination`; CMYK of another profile too) or only the colours of the other model (`preserveNumbers`: colours already in it keep their numbers); grey stays grey, images are converted -too and printer's marks are not. The destination is any profile `edit.colorSettings` lists (default: the document's +too and printer's marks are not. With `destination` and an sRGB destination, a gray or RGB image that carries an +ICC profile is converted from that profile to sRGB with the color settings' intent and written without +it, at 16 bits a sample when it has them, is not resampled or recompressed and the file is PDF 1.5 or later (an image +whose profile the color engine can't read, or whose pixels can't be decoded, keeps its profile, with a warning); without +conversion it keeps its profile. With `destination` and a CMYK destination, or an RGB one other than sRGB that images +are converted to, an RGB image that carries an ICC profile is converted from that profile to sRGB with the same intent +and then to the destination, instead of having its numbers read as sRGB (a profile the color engine can't read leaves +them read as sRGB, with a warning); a gray image keeps its profile, as gray is not converted. An RGB destination other +than sRGB that is the document's own RGB profile leaves RGB colors and RGB images as they are, so a tagged RGB image keeps its profile +there. +`preserveNumbers` still reads an image's numbers as sRGB. The destination is any profile `edit.colorSettings` lists (default: the document's for its colour mode). `profiles` (`all`, `destination`, or `taggedSource` for a document assigned profiles with `edit.assignProfile`) writes colours in ICC-based spaces with their profiles embedded: CMYK with the destination's or the document's CMYK profile, RGB as sRGB, grey with the sRGB tone curve (PDF/A, PDF/X-3 and PDF/X-4 files are @@ -9264,13 +9310,20 @@ as outlines. `fwid`, `hwid`, `twid` or `qwid` ([feature list](https://learn.microsoft.com/en-us/typography/opentype/spec/featurelist)), or `"default"`, the font's default glyphs. A character holds one form at a time, so setting one replaces another. The text keeps its characters, and a character the font has no glyph of that form for keeps its own. Runs that differ only -in their form are shaped as one piece of text. The form is saved only when it is not `"default"`, character styles -carry it, PDF draws the glyphs the canvas draws, and live SVG text writes it in `font-feature-settings`. Another value -is refused with `` `glyphForm` must be one of "traditional", … ``. One undo step, and a `text.setFormat` call that -gives the form the characters already have records none. With Show East Asian Options on, the Glyphs panel menu lists -the ten forms and Revert To Default Forms: a form is checked when every selected character has it, choosing it again -does nothing, and it is disabled when none of the faces that draw the selected text, fallback faces included, has a -feature for it. +in their form are shaped as one piece of text. A form other than `"default"` also takes away the characters' glyph +choice (`text.setGlyphAlternate`), which would otherwise keep drawing its own glyph; `"default"` keeps it. The four +width forms set the characters on a fixed width, so Proportional Metrics (`palt`) is not applied with them and the +font's pair kerning (`kern`, `apkn`) is turned off, in shaping and in SVG output (`font-kerning: none`); +`proportionalMetrics` and `kerning` keep their values. The form is saved only when it is not `"default"`, character +styles carry it, PDF draws the glyphs the canvas draws, and live SVG text writes it in `font-feature-settings`. Another +value is refused with `` `glyphForm` must be one of "traditional", … ``. One undo step, and a `text.setFormat` call +that gives the form the characters already have records none. With Show East Asian Options on, the Glyphs panel menu +lists the ten forms and Revert To Default Forms. They act on the Type tool's selected range, all the text of the object +at a caret, or all the text of the selected type objects. A form is checked when every one of those characters has +it, and choosing it again does nothing unless one of them has a glyph choice, which the form takes away. A form is +disabled when none of the faces that draw the selected text, fallback faces included, has a feature for it: a `GSUB` feature, or for the width +forms a `GSUB` or `GPOS` feature +([fwid](https://learn.microsoft.com/en-us/typography/opentype/spec/features_fj#tag-fwid)). ```json {"name":"run_command","arguments":{"command":"text.setRangeStyle","params":{"id":12,"start":0,"end":3,"glyphForm":"jis78"}}} diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index d0f70f0196..ef1d56afcd 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -586,6 +586,7 @@ since 2026-10-01. - **Panel collapse controls (#5925, in part):** The small » in a floating tab-group header moves that group into the side icon rail, reusing the existing rail or creating one. An opened rail panel has the same control to fold its flyout back into the rail, including after a tab switch. Both controls preserve the tab group and its menu; other floating groups and full-width docked headers stay as they were. Pointer regressions in `crates/ui-egui/src/panel_docking/tests.rs` cover grouped tabs, reopening, separate groups, a missing rail, the menu and flyout closure after switching tabs; `native_tests.rs` covers the native-window click and removal of the empty palette. Scores and estimates unchanged. - 3D lighting presets: `crates/effects/src/lighting_presets.rs`, `crates/engine/src/cmd/effectcmd/lighting_presets_tests.rs` and the pointer tests in `crates/ui-egui/src/dialogs/revolve/tests.rs` cover recognition, atomic targeting, native round trips, preview, Cancel, reset and Undo. +- 3D lighting widget: the Lighting tab of the 3D and Materials panel shows every light on a sphere at its Rotation and Height; a drag moves a light in one undo step, a press selects it, Move Light to Back and Move Light to Front change its side, and the arrow keys move the focused light ([three-d.md](three-d.md#lighting-widget)). `crates/ui-egui/src/panels/tests_three_d.rs` covers the drag, selection, side and keyboard. - 3D shadows: Revolve, Extrude & Bevel, Inflate and Rotate take a Shadows switch with Position (Behind Object or Below Object), Distance from Object (0–100 %) and Shadow Bounds (10–400 %) on the Lighting tab ([three-d.md](three-d.md#shadows)); the first light casts one hard-edged vector shadow, drawn behind the solids, in exports and kept by Expand Appearance. Evidence: `crates/three-d/src/shadow/tests.rs`, `crates/effects/src/solids/tests_shadow.rs`, `effectcmd::tests::a_3d_shadow_is_one_undo_step_saved_exported_and_expanded`. Softness and the shadows of lights after the first are not built. Scores unchanged. - 3D shadows: with Shadows on, Revolve, Extrude & Bevel, Inflate and Rotate cast the shadow from the faces kept by the build that draws each solid, so drawing an object builds each of its solids once. The art and the shadow are unchanged. Evidence: `crates/three-d/src/shadow/tests.rs` (`a_solid_and_its_shadow_are_built_once` and the tests that compare each solid and its shadow with the two built apart), `solids::tests_shadow::a_shadowed_3d_object_builds_each_solid_once`, `solids::tests_shadow::the_shadow_cast_from_the_solids_drawn_is_the_shadow_of_solids_built_for_it`, `revolve::tests::the_shadow_cast_from_the_surfaces_drawn_is_the_shadow_of_surfaces_swept_for_it`. Scores unchanged. - Window › 3D and Materials panel: `crates/ui-egui/src/panels/three_d.rs` applies a 3D effect by object type (Plane, Extrude, Revolve, Inflate), changes an applied one in place (`effect.setParams` `threeDType`) and edits it with the options dialog's Object, Rotation and Lighting controls, one undo step per change; `crates/engine/src/cmd/effectcmd/three_d_type_tests.rs` and `crates/ui-egui/src/panels/tests_three_d.rs` cover it. No Materials tab. @@ -747,6 +748,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur - **Document Setup modal handoff:** Document Setup → Edit Artboards applies valid settings, closes the dialog and activates the Artboard tool. Pointer-click tests cover successful handoff, invalid settings, OK, Cancel and Undo in `dialogs/document_setup.rs`. - **Document Setup › Color:** a Color tab shows the document color mode (RGB Color or CMYK Color), a Convert to CMYK or Convert to RGB box for a mode change (cleared: the colors keep their numbers), and the RGB and CMYK profiles assigned to the document, with a working-space entry for none. OK runs `edit.assignProfile` and then `file.documentColorMode` with the dialog's other changes in one `command.batch`, and the document's changes are one undo step. An unchanged Color tab adds no color commands, and Cancel, or OK with nothing changed, records no undo step. A Color tab field with a value of the wrong kind is an error, and the dialog stays open. The tab is VectorCraft's own layout; File › Document Color Mode and Edit › Assign Profile… stay as they are. Tests in `dialogs/document_setup.rs`. +- **Assign Profile's three choices:** the Assign rows of Edit › Assign Profile… (the Separations panel's Color Settings section) offer Don't Color Manage This Document, the working space of the document's color mode (Working RGB or Working CMYK, with its name) and Profile with the other profiles of that kind, as [Assign or remove a color profile](https://help.adobe.com/archive/en_US/illustrator/cs3/illustrator_cs3_help.pdf#page=150) lists them. They act on the profile of the document's color mode: an RGB document's RGB profile, a CMYK document's CMYK profile; the other model's profile stays as it is (VectorCraft's own rule; Document Setup › Color sets both). Picking the working space assigns its profile, which stays when the working space changes later. A choice the document already has runs nothing. Tests in `panels/separations.rs`. - **Canvas through the display's profile on Windows and X11:** Preferences › Performance › Use Monitor Profile for Canvas (on by default) converts the canvas's art, artboard paper and transparency grid from sRGB into the ICC profile of the display that shows most of the main window, after any proof, as the help page describes the monitor's profile translating the document's colors for the monitor ([the help page](https://help.adobe.com/archive/en_US/illustrator/cs4/illustrator_cs4_help.pdf#page=151)). Windows gives the display's default profile, and a display with Advanced Color on is left to Windows ([Microsoft Learn](https://learn.microsoft.com/en-us/windows/win32/wcs/advanced-color-icc-profiles)); X11 gives colord's profile for the output or the `_ICC_PROFILE` property. macOS converts the window itself, and a Wayland session stays sRGB. `ui.inspect` reports `displayProfile`. Tests in `color/src/cms/display.rs`, `ui-egui/src/display_profile.rs` and `canvas::tests::the_canvas_is_drawn_through_the_display_profile_while_the_preference_is_on`. - **Translucent art blended before the display profile:** with Use Monitor Profile for Canvas converting, translucent art and the anti-aliased edges of art over an artboard's paper, a colored artboard or the transparency grid are blended with it in sRGB and the blend is converted, so the monitor's profile translates the document's colors as they are composited ([the help page](https://help.adobe.com/archive/en_US/illustrator/cs4/illustrator_cs4_help.pdf#page=151)). The frame rendered whole, the part an edit renders again, the render worker's frame, the pan fill and the other windows' panes do the same; over the pasteboard and in Outline the art is converted on its own, and with no profile to convert into nothing changes. Tests `display_profile::tests::translucent_pixels_over_the_paper_are_blended_before_they_are_converted`, `the_transparency_grid_is_blended_cell_by_cell`, `the_backdrop_is_found_through_the_view` and `canvas::tests::translucent_art_over_the_paper_is_blended_before_the_display_profile`. - **Out-of-gamut warning after a profile change:** the out-of-gamut warning and its closest-color correction in the Color panel and Color Picker ([the help page](https://helpx.adobe.com/illustrator/using/adjusting-colors.html)) check the color against the document's assigned CMYK profile, else the working CMYK space, and update when Edit › Assign Profile… or Edit › Color Settings… changes that space or a loaded profile changes, without a change of color. Which CMYK space the warning uses is VectorCraft's own rule. Tests: `gamut_warning_follows_a_profile_change` and `gamut_warning_follows_a_profile_loaded_again` in `dialogs/color_picker/tests.rs`. @@ -758,6 +760,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur - **Repeated brush names in a file:** a document or brush library file whose brushes repeat a name opens with the first brush of that name as stored and each later one numbered, as "Name 2", "Name 3" and so on, skipping names the file already has. Strokes that name the brush keep drawing the first one, and `brush.list`, `brush.options`, `brush.delete`, `brush.library` and the Brushes panel then reach each brush by its own name. This numbering is VectorCraft's own rule. Tests in `crates/brush/src/tests.rs`, `tests/brushsym.rs` and `tests/brushlib.rs` cover it. - **Pattern names and swatch names:** `pattern.options` refuses a new name that another pattern, a swatch or a color group has. It takes a name that only the pattern's own swatches have. The reference page does not say what happens when the new name is taken; the refusal follows VectorCraft's rule that swatches and color groups share one namespace (`Document::swatch_name_taken`), which `swatch.new` and `swatch.edit` also follow. A rename gives the new name only to the pattern's swatches that had the old one; any other swatch with the old name keeps it, and art linked to that swatch stays linked. `object.pattern.make` and `object.pattern.saveCopy` number a color group's name as they number a swatch's. Tests in `tests/pattern.rs` cover each command. Public reference: [Create and edit patterns](https://web.archive.org/web/20211031180358/https://helpx.adobe.com/illustrator/using/create-edit-patterns.html) (archived copy). - **Cancel, Delete and rename leave no paint of a missing pattern:** the help page lists Save a Copy, Done and Cancel for leaving pattern editing (https://helpx.adobe.com/illustrator/using/create-edit-patterns.html). Canceling a pattern made for the edit removes it with every swatch of it, a color group's included. Canceling a new pattern, `pattern.delete` and a `pattern.options` rename reach every paint of the pattern: art outside the editing layer, opacity mask art, type runs, symbol definitions, graph designs, the series and label paints a graph keeps for its next redraw, other patterns' tiles (a copy Save a Copy made included), graphic and character styles, brush art, and the fill, stroke and appearance new art takes. Canceling a new pattern and Delete make those paints no paint, a VectorCraft rule; a rename renames them. Canceling the edit of an existing pattern puts its definition back as it was when editing started; after a rename in Pattern Options, it also gives the pattern its old name back with its swatches and paints, unless another pattern, swatch or color group has taken that name. Undo and Redo restore new art's paints with the document. Tests in `tests/pattern.rs` cover each command. +- **Move Tile with Art:** Pattern Options has the Move Tile with Art checkbox under Size Tile to Art, and with it on, moving the tile art in pattern editing mode moves the tile with it (). VectorCraft's own rules for what the page leaves open: the tile moves only when a move takes all of the tile art, by the same offset; rotations, scales, copies and anchor moves leave it in place; the option is off for a new pattern. `pattern.options {moveTileWithArt}` sets it and `pattern.list` reports it. Tests in `tests/pattern.rs` and `panels::pattern_options::tests` cover it. - **A clip group in Properties:** with one clip group selected, the panel has Edit Clipping Path and Edit Contents at its top, under the Clip Group heading, which select the clipping path or the clipped art for the selection tools to transform alone, as Object › Clipping Mask does (: with a clip group selected, the tutorial uses Edit Clipping Path at the top of the Properties panel to select the mask for editing, and uses Edit Contents to select the masked art). Edit Contents shows only when the group clips something. Not checked against the reference app: whether Properties keeps the buttons while the clipping path or the contents is selected; the Control bar has no clip-group buttons here. Evidence: `panels::properties::tests::one_clip_group_has_edit_clipping_path_and_edit_contents_at_the_top`. - **Contextual controls in Properties:** the [Properties panel overview](https://helpx.adobe.com/illustrator/desktop/get-started/learn-the-basics/properties-panel-overview.html) lists controls that appear for particular selections and tools; Properties has them, each running the existing command. Quick Actions has Blend Options for one selected blend, with the Blend tool or any other, as the [blending page](https://helpx.adobe.com/illustrator/using/blending-objects.html) describes (#4817) (the Blend Options dialog, OK one undo step) and Merge Live Paint when a Live Paint group is selected with more paths and nothing Live Paint refuses (`livePaint.merge`). Appearance has the stroke's Variable Width Profile list, the Control bar's, with the Width tool or when the stroke has a variable width (`stroke.set {profile}`), and beside fx the Delete icon: with one effect on the one selected object (its own or a fill's or stroke's) its name and an icon that removes it (`effect.remove`), with several an icon that opens the Appearance panel, as the overview describes. With Direct Selection and corners of one path selected, Corner and Corner Radius set the kind and radius of those corners (`object.setLiveShape`); a live rectangle's and polygon's own rows already do. Each edit is one undo step. The overview's Variable Font icon was already in Character. Not checked against the reference app: whether Merge needs two Live Paint groups (the overview speaks of several selected groups; `livePaint.merge` takes a group and paths), and where exactly each control sits in its section. Evidence: `panels::properties::tests::{blend_options_is_a_quick_action_for_one_blend_with_the_blend_tool, merge_live_paint_is_a_quick_action_for_a_live_paint_group_and_more, the_width_profile_shows_for_a_variable_width_or_the_width_tool, direct_selected_corners_show_their_kind_and_radius, one_effect_has_a_delete_icon_in_appearance}`. - **Edit Artboards in Properties:** a Transform section comes first (#5219): the reference point the Transform panel and Control bar share, the active artboard's X and Y (of that point) and W and H with the Constrain Width and Height Proportions link, its preset and orientation, and Move and Scale Artwork with Artboard. The Artboards section follows, with the name field, Artboard Options and Fill. The active artboard's name is a field. Typing a new name and pressing Enter or clicking elsewhere renames that artboard in one undo step, as the Artboards panel's rename does (`artboard.setProps {index, name}`); pressing Escape or leaving the field empty keeps the old name. A click on the Artboard Options icon beside the field opens Artboard Options for the active artboard, as double-clicking the Artboard tool's button does (`tool.options {tool: "artboard"}`). Pointer and key tests in `panels/properties.rs` cover both. Public references: [Resize artboards](https://helpx.adobe.com/illustrator/desktop/create-manage-artboards/add-edit-artboards/resize-artboards.html) and [Create and add new artboards](https://helpx.adobe.com/illustrator/desktop/create-manage-artboards/add-edit-artboards/add-new-artboards.html). @@ -1145,6 +1148,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur Alt-drag with the Knife cuts in a straight line, and Shift+Alt-drag cuts at 45° or 90°, as [Cut objects using the Knife and Scissors tools](https://web.archive.org/web/20260926095638/https://helpx.adobe.com/illustrator/desktop/manage-objects/edit-objects/cut-objects.html) and [Default keyboard shortcuts](https://web.archive.org/web/20260726060015/https://helpx.adobe.com/illustrator/using/default-keyboard-shortcuts.html) describe. Alt counts only when held at the press; the dashed line runs from the press point to the pointer, stays straight after Alt is let go, and release sends `path.knife` with those two points. Alt pressed after the press leaves the cut freehand. Shift+Alt keeps the endpoint at 45° steps from the horizontal and vertical, whatever the Constrain Angle. With Smart Guides on, a straight Knife cut snaps to anchor points and paths: [Cut objects using the Knife and Scissors tools](https://helpx.adobe.com/illustrator/desktop/manage-objects/edit-objects/cut-objects.html) describes the Knife snapping to anchor points and paths while Smart Guides are on. Its ends snap as the ends of the Pencil's straight line do, to anchor points and path segments of art that is shown: the press, and the release of an Alt-drag. Shift+Alt steps 45° from the snapped press, and its release stays on that step. With Smart Guides off, or with Snap to Pixel or Snap to Grid on, the cut is not snapped. The dashed line and `path.knife` use the snapped points. Eraser Tool Options has Angle and Roundness besides Size, as [Erase paths using the Eraser tool](https://helpx.adobe.com/illustrator/desktop/draw-shapes-and-paths/modify-paths/erase-paths-using-eraser-tool.html) describes: the Eraser's tip is an ellipse Size across, its long axis at Angle and its short axis Roundness percent of the long one, and a drag with a tip that is not round shows the outline of the area it erases (`path.eraseRegion` `angle` and `roundness`). + Eraser Tool Options gives Angle, Roundness and Size each a menu of Fixed, Random, Pressure, Tilt, Bearing and Rotation with a Variation field, as the same page describes: the tip varies by up to the Variation either way, Random once per drag and the others with what the pen reads at each point of the drag, and the tip outline at the pointer follows the pen while it erases. The outline of the area a drag shows keeps the set Angle and Roundness, and a mouse drag erases at the set values (`path.eraseRegion` `angleMode`, `roundnessMode`, `sizeMode` and their variations). The outline a Blob Brush or Eraser drag shows is built a bounded amount per frame, so a long stroke does not slow the canvas: each frame takes at most 256 new points of the drag and outlines again only the pieces of up to 32 segments they change, and the parts of a piece's outline inside the rest of the area are removed a limited amount at a time. While work is left, the canvas draws again with the pointer still, the points not outlined yet show as a thin line, and the tip shows at the pointer. With Size set to Pressure the outline also shows only the edge of the whole area. The release sends the whole stroke as before (`path.blob`, `path.eraseRegion`). The settled part of that outline is kept in paths of up to 64 pieces each. A path is shared from one frame to the next until a piece in it changes, and the canvas keeps the triangles it drew the path with while the view stays the same, so a frame does not copy, flatten or tessellate a settled path again; a pan, zoom or rotation draws each one once more. This is VectorCraft's own rule. The outline of a drag with a round Eraser tip is drawn whole each frame. - **Transform:** Rotate, Reflect, Scale, Shear (click or Alt-click snaps the reference point to anchors and centres; Alt-click opens the tool's dialog, and a drag with Alt down at the release, from the press or pressed mid-drag, transforms a copy), Reshape, Free Transform (free and perspective distort and shear, switched by the keys held during a handle drag). @@ -1461,7 +1465,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur 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`). Character panel menu › Standard Vertical Roman Alignment (with the East Asian options; `text.setFormat` or `text.setRangeStyle {standardVerticalRoman}`, off by default) stands the selected Latin letters, digits and other half-width characters upright one below the other in vertical type instead of rotating them ([formatting Asian characters](https://helpx.adobe.com/illustrator/using/formatting-asian-characters.html)); spaces keep their width, tate-chu-yoko blocks stay across the column, and Character Style Options gives it its own checkbox beside Character Alignment. - The Glyphs panel menu (with the East Asian options) offers Traditional, Expert, JIS 04, JIS 90, JIS 78 and JIS 83 Forms, Monospaced Full-Width, Half-Width, Third-Width and Quarter-Width Forms, and Revert To Default Forms ([formatting Asian characters](https://helpx.adobe.com/illustrator/using/formatting-asian-characters.html)); each draws the selected characters with the font's OpenType feature for that form (`trad`, `expt`, `jp04`, `jp90`, `jp78`, `jp83`, `fwid`, `hwid`, `twid`, `qwid`), one form at a time, and a form none of the faces that draw the selected text has a feature for is disabled. `text.setFormat` and `text.setRangeStyle {glyphForm}` set it; the native file, character styles, the canvas, PDF and SVG (`font-feature-settings`) keep it (`tests::typo::glyph_forms_draw_the_fonts_east_asian_forms`, `typecmd::area_tests::glyph_form_is_one_character_attribute_saved_only_when_set`, `panels::glyphs` tests). + The Glyphs panel menu (with the East Asian options) offers Traditional, Expert, JIS 04, JIS 90, JIS 78 and JIS 83 Forms, Monospaced Full-Width, Half-Width, Third-Width and Quarter-Width Forms, and Revert To Default Forms ([formatting Asian characters](https://helpx.adobe.com/illustrator/using/formatting-asian-characters.html)); each draws the selected characters with the font's OpenType feature for that form (`trad`, `expt`, `jp04`, `jp90`, `jp78`, `jp83`, `fwid`, `hwid`, `twid`, `qwid`), one form at a time, and a form none of the faces that draw the selected text has a feature for (in `GSUB`, or for the width forms `GSUB` or `GPOS`) is disabled. A form other than the default takes away a character's glyph choice, and with a width form Proportional Metrics' `palt` is not applied and the font's pair kerning is off. `text.setFormat` and `text.setRangeStyle {glyphForm}` set it; the native file, character styles, the canvas, PDF and SVG (`font-feature-settings`) keep it (`tests::typo::glyph_forms_draw_the_fonts_east_asian_forms`, `typecmd::area_tests::glyph_form_is_one_character_attribute_saved_only_when_set`, `typecmd::area_tests::a_glyph_form_takes_away_the_characters_glyph_choice`, `features::tests::width_forms_turn_off_proportional_metrics`, `panels::glyphs` tests). Character panel menu › Tate-chu-yoko Settings… (with the East Asian options; `ui.tateChuYokoSettings`, `text.setFormat` or `text.setRangeStyle {tcyUpDown, tcyLeftRight}`, in points, 0 by default) moves a marked tate-chu-yoko block in vertical type up by a positive Up/Down and right by a positive Left/Right ([format East Asian characters in vertical text](https://helpx.adobe.com/illustrator/desktop/design-with-text/edit-format-text/format-east-asian-characters-in-vertical-text.html), [formatting Asian characters](https://helpx.adobe.com/illustrator/using/formatting-asian-characters.html)); the block moves as one piece with its first character's values and keeps its one em down the column, on a vertical path it moves toward the path's start and to the path's left, automatic digit blocks do not move, Reset Panel sets both back to 0, and character styles and the native file keep them. A file whose character styles hold a Tate-chu-yoko Up/Down or Left/Right offset past the coordinate limit (`MAX_COORD`) either way opens with the nearer end, as the type's own offsets do; an offset within it stays as saved. Character panel › Insert Aki Left and Insert Aki Right (with the East Asian options; `text.setFormat` or `text.setRangeStyle {akiLeft, akiRight}`, thousandths of an em from 0 to 1000, 0 by default) add space before and after each selected character, measured as tracking is, and kept as set through justification ([the help page](https://helpx.adobe.com/illustrator/using/formatting-asian-characters.html)); before and after follow the reading order in vertical and right-to-left type, character styles carry both values (Character Style Options › Advanced Character Formats edits them), and a file holding a value outside the range opens with the nearer end. @@ -1763,6 +1767,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur A `.ai` or EPS symbol library, whose editing data holds symbol definitions and their list but no layers, opens with those symbols in list order, each with its registration point. Other Library… in the Symbols panel's Symbol Libraries Menu imports the symbols of the file you pick ([Create or import symbol libraries](https://helpx.adobe.com/illustrator/desktop/manage-objects/traces-mockups-symbols/create-or-import-symbol-libraries.html)) and lists such a library's symbols in a library panel. A `.ai` or EPS read as its PDF part or page because its layers can't be used also gets the symbols defined before its layers (`crates/eps/src/import/tests_native.rs`, `crates/eps/src/import/ai/tests.rs`, `cmd::fileio::tests_aiimport::a_symbol_library_in_a_ai_file_without_layers_loads_its_symbols`). In a large canvas `.ai` or EPS ([Large canvas FAQ](https://helpx.adobe.com/illustrator/desktop/add-and-import-files/start-a-new-file/large-canvas-faq.html)), or a `.ai` whose page is in larger units, opened from its editing data, VectorCraft reads the symbol definitions in the same units as the layers. When the file opens at its full size, each symbol's art, strokes and effects are scaled with the layers, and each instance keeps its own scale and rotation and draws where and as large as before. A new instance comes in at the symbol's full size, the size of an instance at 100% in the file. A symbol library from such a file comes in at the same size, and opacity masks on the layers that don't move with their object are scaled with the layers too. Editing data that VectorCraft wrote keeps its symbols and those masks at their own size, as its `.ai` save writes them (`tests_native::a_large_canvas_eps_keeps_its_symbols_at_the_pages_size`, `a_large_canvas_ai_keeps_its_symbols_at_the_pages_size`, `a_large_canvas_ai_vectorcraft_wrote_keeps_its_symbols_at_their_own_size`, `a_symbol_library_in_larger_units_comes_in_at_the_pages_size`, `build::candidate_tests::scaling_the_symbols_keeps_their_instances_in_place`, `scaling_a_read_document_scales_unlinked_opacity_masks`). A `.ai` or EPS opened from its editing data whose text document makes the type of every text object that shows takes all its type from the text document: each text object stays in its own group, frame and layer, the page's type isn't matched with them, a file of more than 5,000 text objects still opens with its layers, and type the page draws differently (in another font) keeps the layers, with a warning. A line break inside a paragraph stays a line break in it (`crates/eps/src/import/tests_native.rs`, `crates/eps/src/import/ate.rs`). + `.ai` and EPS editing-data import reads a text document whose keys are written by name, as files saved for version 11 write them, and the keys that later files write by name among the numbered ones (a font record's version, a paragraph's direction). The type of such a file comes from its text document, so type outside the artboards opens and each text object keeps its own story A run that names its paragraph or character style by the style's number takes that style (`a_text_document_with_named_keys_gives_type_off_the_page_too`, `a_text_document_with_named_keys_takes_a_runs_style_by_its_number`, `crates/eps/src/import/ate/names.rs`). In SVG export without Fewer `` Elements, each character of live type that justification or Mojikumi moves (the letter gaps of a Justify All line without word spaces, the gaps between the characters of a justified Japanese line, closed-up punctuation, the space between Japanese and Latin letters) starts its own positioned ``, and type scaled horizontally on a justified line is stretched to its own width (`crates/svg/tests/it/text_layout.rs`). To find those characters, SVG export lays a type object out a second time only when the second layout can differ from the first: point type with Line-end Punctuation Half Width and no Japanese punctuation or letters, the setting new type gets, is laid out once. Exporting 5,000 such objects takes a median of 109.4 ms instead of 171.5 ms on Windows 11, with the same SVG (`crates/svg/src/export/text/tests.rs`). SVG export (live text), legacy `.ai` saves (text objects), DXF export (`TEXT` entities) and the CSS Properties panel write area type that Shrink Text to Fit scales down at the size it draws at, and scale its tracking, kerning, leading and baseline shifts the same way where the format holds them. @@ -1880,9 +1885,12 @@ 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`). - PDF export writes a stored gray or RGB TIFF in an `ICCBased` color space that holds the gray or RGB profile of its tag 34675, at 16 bits a sample from PDF 1.5 on and 8 before, with its alpha as a soft mask only when some pixel is not opaque ([ISO 32000-1, §8.6.5.5](https://opensource.adobe.com/dc-acrobat-sdk-docs/pdfstandards/PDF32000_2008.pdf#page=157)). A gray or RGB image, a PNG included, that the Compression settings resample or compress with JPEG keeps its profile too, and gray pixels in an RGB profile stay RGB. Pixels the Output settings convert, a profile whose color space differs from the samples', and a malformed profile, a PNG's or JPEG's kept as it is included, are written without it; such a JPEG keeps its JPEG data. PDF/X-1a allows no ICC-based color spaces, so there a gray image with a gray profile, kept as it is, resampled or compressed with JPEG, is written in DeviceGray without the profile, a JPEG kept as JPEG with its JPEG data unchanged, and RGB images are converted to CMYK as before. CMYK images are unchanged (`crates/pdf/src/tests/compression.rs`). + - PDF export with Output › Convert to Destination and an sRGB destination converts a gray or RGB image that carries an ICC profile from that profile to sRGB, with the color settings' rendering intent, and writes it without the profile, as the conversion applies to every color ([PDF options](https://helpx.adobe.com/illustrator/using/pdf-options.html)): 8-bit and 16-bit TIFFs (16 bits a sample from PDF 1.5 on when not resampled or recompressed), PNGs, JPEGs (kept as JPEG) and resampled images. A JPEG in an sRGB profile keeps its JPEG data, a black-and-white image whose converted levels are gray is written as grayscale, and without conversion every image keeps its profile. An image whose profile the color engine can't read, or whose pixels can't be decoded, is not converted: it keeps its profile, with a warning (`crates/pdf/src/tests/compression.rs`). + - PDF export with Output › Convert to Destination and a CMYK destination, or an RGB destination other than sRGB that images are converted to, converts an RGB image that carries an ICC profile from that profile to sRGB and then to the destination, with the color settings' rendering intent, instead of reading its numbers as sRGB ([PDF options](https://helpx.adobe.com/illustrator/using/pdf-options.html)). An untagged image converts from sRGB as before, a gray image keeps its profile, and a profile the color engine can't read leaves the numbers read as sRGB, with a warning. An RGB destination other than sRGB that is the document's own RGB profile leaves RGB colors and RGB images as they are, so a tagged RGB image keeps its profile there (`convert_to_destination_converts_tagged_images_from_their_profile` in `crates/pdf/src/tests/compression.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 decodes each image data URL (RFC 2397, ) of an `` or `` once, in the imported SVG and in the SVGs shown as its images, however many `` elements, filters or SVG images show it; every use gets the same image. The data URLs of one import decode to at most 512 MB together, VectorCraft's own limit: decoding stops where a URL would pass it, and that image is left out with a warning (`crates/svg/src/import/files.rs`, `crates/svg/tests/it/import_details.rs`). - SVG import measures what an SVG expands to before the SVG reader builds it: every copy a `` shows (), chains of ``s included, and the style values each copy gets from its `style` attribute and from the style sheet rules that match it. An SVG that expands to more than 999,999 elements (500,000 on the web) or to more than 64 MB of copied style values is refused with an error, and so is a loop of `` references that would be copied without end. The declarations the import's own style cascade keeps count toward the 64 MB too, once for each element a rule matches, including those of rules the reader drops, such as ones with `:not()`. Matching the style sheets' selectors against those elements may take at most 50,000,000 tests; past that the SVG is refused, so a selector that never matches can't keep the import busy. These limits are VectorCraft's own. An SVG shown as an image, from a data URL or a linked SVG file, counts toward the same limits as the SVG showing it, and one past any of them refuses the whole import (`crates/svg/src/import/budget.rs`, `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`). diff --git a/docs/three-d.md b/docs/three-d.md index f12ca4675c..871b28772c 100644 --- a/docs/three-d.md +++ b/docs/three-d.md @@ -66,7 +66,8 @@ pages describe it. when no selected object has a visible 3D effect. - The Lighting tab also holds the Shadows switch and its settings ([Shadows](#shadows)). The Materials tab holds the Graphics ([Graphics](#graphics)); base materials and ray-traced rendering are not built (see below). The - rotation and light rings on the artboard are part of the options dialog only. + rotation and light rings on the artboard are part of the options dialog only. The panel's Lighting tab has the + lighting widget instead ([Lighting widget](#lighting-widget)). ## Extrude & Bevel @@ -302,6 +303,33 @@ applied 3D effect. Every target must have a 3D effect at that position; validati the whole operation is one undo step. The numeric light settings are saved, not a preset name. Existing documents keep their appearance unless a preset is chosen. +### Lighting widget + +On the 3D and Materials panel's Lighting tab, above the list of lights, a sphere shows every light of the +effect where it shines from, as the public +[3D and Materials panel options](https://helpx.adobe.com/illustrator/desktop/special-effects-styles/create-3d-graphics/3d-materials-panel-options.html) +page describes for dragging light sources around the object. The sphere is seen from the front: a light's +Rotation is its angle around the center, and its Height runs from the rim (0°) to the center (90°). A light in +front of the object is a sun filled with its color, and a light behind the object is hollow. The selected light +is drawn larger, above the others. + +- Pressing a light selects it in the list of lights and records nothing. Dragging it moves it on its side of the + object, rounded to 0.1°; Shift snaps Rotation and Height to 15°. The drag is previewed while the pointer is + down and is one undo step when it is released. +- **Move Light to Back** moves the selected light behind the object, and **Move Light to Front** moves it back + in front, as the page's "Move selected light to the back/front of object" does. It sets the same + `lightBehind` (or a list light's `behind`) as the **Light behind the object** checkbox. +- Each light takes the keyboard focus. Left and Right turn the focused light around the object by 1° + (counterclockwise and clockwise), Up and Down raise and lower it by 1°, and Shift makes each step 15°. Each + press changes only the setting it moves and is one undo step. A + step past 90° or below 0° Height changes nothing. Screen readers name each light with its number, Rotation, + Height and, for a light behind the object, its side. + +The widget sends the same settings as the Rotation, Height and Light behind the object controls +(`effect.setParams`); there is no command of its own. The sphere's look, the 0.1° rounding and the arrow keys +are VectorCraft's own. The options dialogs keep the light rings on the artboard instead, and the Lighting tab +of Extrude & Bevel (Classic), Revolve (Classic) and Rotate (Classic) has no lighting widget. + ### Light settings One to ten directional lights. **Lights** selects the light whose settings and artboard gizmo are edited. **Add Light** adds and selects a white front light; **Delete Light** removes the selected light while retaining at least one. Ambient Light is shared by all lights. The public panel options' Light Settings are kept in their order: diff --git a/docs/type-parity.md b/docs/type-parity.md index fa76290e94..9a54795581 100644 --- a/docs/type-parity.md +++ b/docs/type-parity.md @@ -70,7 +70,7 @@ against Illustrator's public documentation; **estimated** unless noted. | CJK: East Asian Type units (Q, Ha) | done | Preferences › Units › East Asian Type (`unitsAsianType`, Points by default) offers every unit and Q and Ha, a quarter of a millimetre each ([Scripting Guide](https://ai-scripting.docsforadobe.dev/scripting/measurementUnits/): 0.709 pt per Q; [InDesign's unit notation](https://helpx.adobe.com/in/indesign/desktop/layout-and-grid-tools/rulers-and-measure-tools/change-ruler-measurement-units.html): `q`, `h`). The public pages do not say which fields follow it, so no field uses it yet and that waits for a source. Evidence: `q_and_ha_are_a_quarter_millimetre`, `east_asian_type_unit_*` | | CJK: manual tate-chu-yoko | done | Character panel menu › Tate-chu-yoko (`text.setFormat` / `text.setRangeStyle {tateChuYoko}`), with Show East Asian Options: marked characters stand side by side across one em of a vertical column, squeezed to the em when wider; two or three half-width digits still do so without the mark; horizontal type keeps the mark and does not move them. Tate-chu-yoko Settings… (`ui.tateChuYokoSettings`, `{tcyUpDown, tcyLeftRight}` in points) moves a marked block up or down and right or left as one piece. Evidence: `tests_vertical::manual_tate_chu_yoko_sets_marked_characters_across_one_em`, `tests_vertical::tate_chu_yoko_settings_move_a_marked_block_up_down_left_and_right`, `typecmd::tate_chu_yoko_is_a_character_attribute_saved_only_when_on`, `typecmd::tate_chu_yoko_settings_are_character_attributes_saved_only_when_set`, `dialogs::tate_chu_yoko::tests` | | CJK: Standard Vertical Roman Alignment | done | Character panel menu › Standard Vertical Roman Alignment (`text.setFormat` / `text.setRangeStyle {standardVerticalRoman}`), with Show East Asian Options, off by default: in vertical type the marked half-width characters (Latin letters, digits and other characters that would lie on their side) stand upright one below the other, each in its upright cell; spaces keep their width and tate-chu-yoko blocks stay across the column; horizontal type keeps the mark and does not change. Character Style Options has its own checkbox for it. Evidence: `tests_vertical::standard_vertical_roman_alignment_stands_half_width_characters_upright`, `typecmd::standard_vertical_roman_is_a_character_attribute_saved_only_when_on` | -| CJK: East Asian glyph forms | done | Glyphs panel menu › Traditional Forms, Expert Forms, JIS 04, JIS 90, JIS 78 and JIS 83 Forms, Monospaced Full-Width, Half-Width, Third-Width and Quarter-Width Forms, and Revert To Default Forms, with Show East Asian Options ([Format East Asian characters](https://helpx.adobe.com/illustrator/using/formatting-asian-characters.html)): the selected characters are drawn with the font's OpenType feature for that form (`trad`, `expt`, `jp04`, `jp90`, `jp78`, `jp83`, `fwid`, `hwid`, `twid`, `qwid`; [feature list](https://learn.microsoft.com/en-us/typography/opentype/spec/featurelist)), one form at a time (`text.setFormat` / `text.setRangeStyle {glyphForm}`); a form none of the faces that draw the selected text (fallback faces included) has a feature for is disabled, and Revert To Default Forms draws the default glyphs. Saved natively only when set, carried by character styles, drawn on the canvas and in PDF, and written to SVG as `font-feature-settings`. Character Style Options has no field for it. Evidence: `tests::typo::glyph_forms_draw_the_fonts_east_asian_forms`, `typecmd::area_tests::glyph_form_is_one_character_attribute_saved_only_when_set`, `panels::glyphs::tests::east_asian_glyph_forms_in_the_panel_menu_set_the_selected_characters_form` | +| CJK: East Asian glyph forms | done | Glyphs panel menu › Traditional Forms, Expert Forms, JIS 04, JIS 90, JIS 78 and JIS 83 Forms, Monospaced Full-Width, Half-Width, Third-Width and Quarter-Width Forms, and Revert To Default Forms, with Show East Asian Options ([Format East Asian characters](https://helpx.adobe.com/illustrator/using/formatting-asian-characters.html)): the selected characters are drawn with the font's OpenType feature for that form (`trad`, `expt`, `jp04`, `jp90`, `jp78`, `jp83`, `fwid`, `hwid`, `twid`, `qwid`; [feature list](https://learn.microsoft.com/en-us/typography/opentype/spec/featurelist)), one form at a time (`text.setFormat` / `text.setRangeStyle {glyphForm}`); a form none of the faces that draw the selected text (fallback faces included) has a feature for (in `GSUB`, or for the width forms `GSUB` or `GPOS`) is disabled, and Revert To Default Forms draws the default glyphs. A form other than the default takes away a character's glyph choice, and with a width form Proportional Metrics' `palt` is not applied and the font's pair kerning is off. Saved natively only when set, carried by character styles, drawn on the canvas and in PDF, and written to SVG as `font-feature-settings`. Character Style Options has no field for it. Evidence: `tests::typo::glyph_forms_draw_the_fonts_east_asian_forms`, `typecmd::area_tests::glyph_form_is_one_character_attribute_saved_only_when_set`, `typecmd::area_tests::a_glyph_form_takes_away_the_characters_glyph_choice`, `features::tests::width_forms_turn_off_proportional_metrics`, `panels::glyphs::tests::east_asian_glyph_forms_in_the_panel_menu_set_the_selected_characters_form`, `panels::glyphs::tests::glyph_form_items_at_a_caret_follow_all_the_text`, `panels::glyphs::tests::a_glyph_choice_lets_the_current_form_be_chosen_again` | | CJK: warichu | done | Character panel menu › Warichu and Warichu Settings… (`text.setFormat` / `text.setRangeStyle {warichu, warichuLines, warichuGap, warichuScale, warichuAlign}`, `ui.warichuSettings`), with Show East Asian Options ([Format East Asian characters](https://helpx.adobe.com/illustrator/using/formatting-asian-characters.html)): marked characters set at a percentage of their size and stacked on 1 to 6 lines inside their line, one above the other in horizontal type and side by side in vertical type, with a line gap and eight alignments; a block stays whole on one line. Saved natively, carried by character styles, drawn on the canvas and in every export (SVG, PDF and `.ai` write such type as outlines). Type on a path sets the characters at their own size. Evidence: `tests::warichu`, `dialogs::warichu::tests`, `typecmd::area_tests::warichu_is_a_character_attribute_set_saved_and_exported` | | CJK: composite fonts | partial | Type › Composite Fonts… (`type.compositeFonts.set`), with Show East Asian Options: a font made of a font per class of character (Kanji, Kana, Punctuation, Symbols, Roman, Numerals), each with its own style, size and baseline in percent of the type's size, Kana, Roman and Numerals with horizontal and vertical scale, and Kana with Scale at Center; New, Delete Font, Import… and Export… (`.vccomposite`, `type.compositeFonts.import` and `.export`) ([Creating composite fonts](https://help.adobe.com/archive/en_US/illustrator/cs3/illustrator_cs3_help.pdf#page=347)). Composite fonts are listed first in the font menus, saved with the document, and their type is laid out per class; SVG and legacy `.ai` write it as outlines. Not yet: custom character sets, the Units menu, Show Sample. Evidence: `text::composite::tests::characters_fall_in_their_classes`, `tests::composite::composite_type_draws_each_class_in_its_font`, `compositefonts::tests`, `dialogs::composite_fonts::tests` | | CJK: ruby | partial | ruby (ふりがな) for live text (`type.ruby`): a reading over a byte range, above (right of the column in vertical type) or below, start/centre/end alignment, size, overhang or squeeze; saved natively, laid out with its line growing, drawn on the canvas, in SVG and PDF (as outlines where ruby is present), in EPS, EMF/WMF and legacy `.ai` (legacy `.ai` writes type with ruby as outlines), and kept by Create Outlines, Expand Appearance and flattening, each glyph where the layout puts it (`typecmd::ruby_tests::create_outlines_keeps_the_ruby_where_it_is_laid_out`) | #633 |