From 70651f585ef63da1c0352121a2df8184a44e5b4c Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Sat, 31 Oct 2026 19:28:19 -0700 Subject: [PATCH 01/12] .ai import: template layers and ruler guides open as they were set The editing data's layer options set a template layer from the flag four places after the colour index. The colour index of nine-operand options, and the colour index and dimming percentage of eleven and twelve operand options, are read from their own places (they were read one place later). A straight (N) * guide path directly on a layer that runs across the whole canvas, or from edge to edge of one artboard, opens as a ruler guide on that layer (an artboard guide of that artboard); other guide paths stay guide paths. Guides scale with the document when its editing data is in the units of a UserUnit page, and an EPS that keeps only its first artboard keeps only that artboard's guides. Fixes #779 --- crates/engine/tests/import_fuzz.rs | 55 +++++++++ crates/eps/src/import/ai/read.rs | 13 ++- crates/eps/src/import/ai/read/art.rs | 22 +++- crates/eps/src/import/ai/read/containers.rs | 2 +- crates/eps/src/import/ai/read/document.rs | 79 ++++++++++++- crates/eps/src/import/ai/read/ops.rs | 12 +- crates/eps/src/import/ai/read/types.rs | 1 + crates/eps/src/import/ai/tests.rs | 118 +++++++++++++++++++- crates/eps/src/import/native/build.rs | 14 ++- crates/eps/src/import/tests_native.rs | 40 +++++++ docs/file-format-parity.md | 2 +- docs/target-app-parity.md | 1 + 12 files changed, 343 insertions(+), 16 deletions(-) diff --git a/crates/engine/tests/import_fuzz.rs b/crates/engine/tests/import_fuzz.rs index e7a89d97c..b93b28b53 100644 --- a/crates/engine/tests/import_fuzz.rs +++ b/crates/engine/tests/import_fuzz.rs @@ -3011,6 +3011,22 @@ proptest! { } } +/// An operand of a layer's options (`Lb`): a flag, a colour index or component, a percentage, or +/// anything else. +fn arb_layer_operand() -> impl Strategy { + prop_oneof![Just("0".to_string()), Just("1".to_string()), (-2i32..300).prop_map(|v| v.to_string()), arb_num()] +} + +/// A coordinate of a guide path: the ends of the canvas around the template box's centre (100 50), +/// the edges of the artboards, near them, or anything else. +fn arb_guide_coord() -> impl Strategy { + prop_oneof![ + prop::sample::select(vec!["-8091.5", "8291.5", "-8141.5", "8241.5", "0", "50", "100", "200", "-8091.505", "8291.5001", "1e308"]) + .prop_map(str::to_string), + arb_num(), + ] +} + /// The operators, operands and section comments of hostile editing data. fn arb_ai_token() -> impl Strategy { prop_oneof![ @@ -3037,6 +3053,9 @@ fn arb_ai_token() -> impl Strategy { "b", "W", "(b) *", + "(N) *", + "(S) *", + "-8091.5 50 m 8291.5 50 L (N) *", "u", "U", "*u", @@ -3297,6 +3316,42 @@ proptest! { survive("hostile editing data in an EPS of its art's box", || vectorcraft_engine::cmd::fileio::load("x.eps", &boxed).ok().map(|l| l.doc))?; } + /// Layer options (`Lb`) of any number of operands with any values, and guide paths (`(N) *`, + /// `(S) *`) of any points, among them ones across the whole canvas or from edge to edge of + /// artboards that overlap or are the same, on layers and sublayers. + #[test] + fn ai_layer_options_and_ruler_guides_never_panic( + options in prop::collection::vec(arb_layer_operand(), 0..17), + guides in prop::collection::vec((prop::collection::vec((arb_guide_coord(), arb_guide_coord()), 0..5), any::(), any::()), 0..12), + boards in prop::collection::vec(prop::sample::select(vec![[0.0, 100.0, 200.0, 0.0], [0.0, 100.0, 100.0, 0.0], [50.0, 100.0, 200.0, 0.0]]), 0..4), + ) { + use vectorcraft_testkit::ai; + let board = |b: &[f64; 4]| { + format!( + "%_/Dictionary :\n%_{} {} /RealPointRelToROrigin\n%_ (PositionPoint1) ,\n%_{} {} /RealPointRelToROrigin\n%_ (PositionPoint2) ,\n%_; ,\n", + b[0], b[1], b[2], b[3] + ) + }; + let array: String = boards.iter().map(board).collect(); + let document = format!("%_/Document :\n%_/Dictionary :\n%_/Array :\n{array}%_; (ArtboardArray) ,\n%_; /NotRecorded ,\n%_;\n"); + let paths: Vec = guides + .iter() + .map(|(points, ruler, sub)| { + let ops: String = points.iter().enumerate().map(|(i, (x, y))| format!("{x} {y} {}\n", if i == 0 { "m" } else { "L" })).collect(); + let path = format!("{ops}({}) *\n", if *ruler { "N" } else { "S" }); + if *sub { format!("%AI5_BeginLayer\n{} Lb\n(Sub) Ln\n{path}LB\n%AI5_EndLayer--\n", options.join(" ")) } else { path } + }) + .collect(); + let body = format!("{document}%AI5_BeginLayer\n{} Lb\n(L) Ln\n{}\nLB\n%AI5_EndLayer--\n", options.join(" "), paths.concat()); + let data = ai::editing_data(200.0, 100.0, &body).replacen("%%EndComments", "%AI3_TemplateBox: 100 50 100 50\n%%EndComments", 1); + survive("layer options and ruler guides", || Some(vectorcraft_eps::layered_ai(data.as_bytes(), Document::new(200.0, 100.0), vec![], false, false, 1.0).0))?; + survive("layer options and ruler guides in units of 10 points", || { + Some(vectorcraft_eps::layered_ai(data.as_bytes(), Document::new(2000.0, 1000.0), vec![], false, false, 10.0).0) + })?; + let eps = ai::eps(data.as_bytes(), ai::page_ps()); + survive("layer options and ruler guides in an EPS", || vectorcraft_engine::cmd::fileio::load("x.eps", &eps).ok().map(|l| l.doc))?; + } + /// The testkit's sample damaged, as it is and compressed, in EPS, `.ai` and PDF files. #[test] fn ai_mutated_sample_never_panics(cut in 0usize..4_000, edits in prop::collection::vec((0usize..4_000, any::()), 0..12)) { diff --git a/crates/eps/src/import/ai/read.rs b/crates/eps/src/import/ai/read.rs index 8e7753dd7..cb381d6fd 100644 --- a/crates/eps/src/import/ai/read.rs +++ b/crates/eps/src/import/ai/read.rs @@ -1,9 +1,12 @@ //! The reader: runs the editing data's operators into a document. //! //! - Art space has y up; the first artboard's top left corner is the document's origin. -//! - Layers: `%AI5_BeginLayer`, `visible preview unlocked printing dimmed … colorIndex r g b … -//! dimPercent … Lb` (ten operands in the legacy format, without the one before the colour -//! index), `(name) Ln`, its art, `LB`. +//! - Layers: `%AI5_BeginLayer`, `visible preview unlocked printing dimmed … colorIndex r g b +//! template dimPercent … Lb` (ten operands in the legacy format, without the one before the +//! colour index; see `Reader::layer_attrs`), `(name) Ln`, its art, `LB`. +//! - Ruler guides: a straight `(N) *` guide path, directly on a layer, that runs across the whole +//! canvas (16,383 points around the template box's centre) or from edge to edge of one artboard +//! is a ruler guide on that layer ([`Document::guides`]); other guide paths stay paths. //! - Groups `u` … `U`, compound paths `*u` … `*U`, clipping groups `q` … `Q` (the path painted //! with `W` clips); a group's transparency and name follow its end. //! - Paths: `m`, `l`/`L`, `c`/`C`, `v`/`V`, `y`/`Y`, painted by `N n F f S s B b` (lower case @@ -206,6 +209,9 @@ struct Reader<'a> { clip_fills: HashSet, /// Groups closed as an object's drawn look (`9 () XW`). looks: HashSet, + /// Straight `(N) *` guide paths painted directly on a layer, by id: their two points in art + /// space. [`Reader::finish`] makes the ruler guides among them guides of their layer. + ruler_guides: HashMap, /// Legacy compound member groups, even when followed by the generic `9 () XW` marker. compound_groups: HashSet, /// Groups of a text object and its look (see [`Self::style_marker`]), which aren't a look. @@ -308,6 +314,7 @@ impl<'a> Reader<'a> { blend_budget: BLEND_BUDGET, clip_fills: HashSet::new(), looks: HashSet::new(), + ruler_guides: HashMap::new(), compound_groups: HashSet::new(), text_looks: HashSet::new(), group_ended: false, diff --git a/crates/eps/src/import/ai/read/art.rs b/crates/eps/src/import/ai/read/art.rs index 3f60ebe11..a32ea81da 100644 --- a/crates/eps/src/import/ai/read/art.rs +++ b/crates/eps/src/import/ai/read/art.rs @@ -1,11 +1,12 @@ use super::containers::close; use super::document::TEXT_SLOT; use super::types::{ - FLIP, IMAGE_TOO_LARGE, LINKED_PREVIEWS, MAX_PIXELS, MAX_PLACED_ELSEWHERE, MAX_POINTS, MAX_SIDE, NON_NATIVE_ART, NON_NATIVE_ART_UNREAD, Place, + FLIP, Frame, IMAGE_TOO_LARGE, Kind, LINKED_PREVIEWS, MAX_PIXELS, MAX_PLACED_ELSEWHERE, MAX_POINTS, MAX_SIDE, NON_NATIVE_ART, + NON_NATIVE_ART_UNREAD, Place, }; use super::{ Affine, Appearance, AppearanceItem, Arc, BezPath, CANVAS_CENTRE, FillLayer, ImageBlob, ImageObject, Inks, Instance, LinkInfo, Node, NodeId, - NodeKind, Obj, OpacityMask, Paint, PathData, Point, Reader, Rect, StrokeLayer, V, ps, + NodeKind, Obj, OpacityMask, Paint, PathData, PathEl, Point, Reader, Rect, StrokeLayer, V, ps, }; impl<'a> Reader<'a> { @@ -162,6 +163,10 @@ impl<'a> Reader<'a> { if op.chars().next().is_some_and(|c| c.is_ascii_lowercase()) { close(&mut bp); } + let line = match bp.elements() { + [PathEl::MoveTo(a), PathEl::LineTo(b)] => Some((*a, *b)), + _ => None, + }; let to_doc = self.space(); let path = PathData::from_bezpath(&(to_doc * bp)); let gs = self.gs.clone(); @@ -191,6 +196,19 @@ impl<'a> Reader<'a> { } let kind = NodeKind::Path { path, rule: gs.rule, live: None, clipping: clip, guide }; let mut node = self.new_node(kind, &gs); + // A ruler guide is written as a straight `(N) *` guide path on its layer. + if guide + && op == "N" + && !hidden + && !gs.hidden + && !clip + && self.pattern.is_none() + && matches!(self.frames.last(), Some(Frame { kind: Kind::Layer(_), .. })) + && let Some((a, b)) = line + && [a.x, a.y, b.x, b.y].iter().all(|v| v.is_finite()) + { + self.ruler_guides.insert(node.id, (a, b)); + } node.appearance = Appearance { items, ..Appearance::default() }.into(); node.opacity = gs.opacity; node.blend = gs.blend; diff --git a/crates/eps/src/import/ai/read/containers.rs b/crates/eps/src/import/ai/read/containers.rs index 39f398a2e..929f5a0cc 100644 --- a/crates/eps/src/import/ai/read/containers.rs +++ b/crates/eps/src/import/ai/read/containers.rs @@ -222,7 +222,7 @@ impl<'a> Reader<'a> { let color = a.color.unwrap_or(LayerColor::Preset(0)); let mut layer = Node::new( self.doc.alloc_id(), - NodeKind::Layer { color, template: false, printable: a.printable, children, clip, preview: a.preview, dim_images: a.dim }, + NodeKind::Layer { color, template: a.template, printable: a.printable, children, clip, preview: a.preview, dim_images: a.dim }, ); layer.name = Some(a.name.clone()); layer.visible = a.visible; diff --git a/crates/eps/src/import/ai/read/document.rs b/crates/eps/src/import/ai/read/document.rs index eeaf10854..7e63b31da 100644 --- a/crates/eps/src/import/ai/read/document.rs +++ b/crates/eps/src/import/ai/read/document.rs @@ -1,8 +1,11 @@ use super::art::{element_name, elements, join_and, sane}; use super::containers::drop_commented_copies; use super::types::{FLIP, Kind, MAX_DEPTH}; -use super::{Affine, Artboard, ColorMode, Document, NodeKind, Obj, OpacityMask, Paint, Point, Reader, Rect, Structure, Swatch, Unit, V, obj}; -use vectorcraft_doc::{ImageBlob, PatternDef}; +use super::{ + Affine, Arc, Artboard, CANVAS_CENTRE, ColorMode, Document, Node, NodeId, NodeKind, Obj, OpacityMask, Paint, Point, Reader, Rect, Structure, + Swatch, Unit, V, obj, +}; +use vectorcraft_doc::{Guide, ImageBlob, PatternDef}; /// `%AI5_RulerUnits` → [`Unit`] (Illustrator 7 spec table 3; centimeters is 4; pixels is 6 in later files). fn ruler_unit(v: u8) -> Option { @@ -395,6 +398,7 @@ impl<'a> Reader<'a> { } else { self.artboards.clone() }; + let board_rects: Vec = boards.iter().map(|(_, r)| *r).collect(); doc.artboards = boards .into_iter() .enumerate() @@ -424,6 +428,22 @@ impl<'a> Reader<'a> { color: None, }]; } + if !self.ruler_guides.is_empty() { + let ids: Vec = doc.artboards.iter().map(|a| a.id).collect(); + let (candidates, centre) = (std::mem::take(&mut self.ruler_guides), self.template_center); + let pick = |id: NodeId| -> Option { + let (a, b) = candidates.get(&id)?; + let (vertical, board) = ruler_guide(*a, *b, centre, &board_rects)?; + let p = to_doc * *a; + let artboard = match board { + Some(i) => Some(*ids.get(i)?), + None => None, + }; + let pos = if vertical { p.x } else { p.y }; + pos.is_finite().then_some(Guide { vertical, pos, artboard, layer: None }) + }; + take_ruler_guides(&mut doc.layers, &pick, &mut doc.guides, 0); + } if self.color_model.unwrap_or(self.cmyk_colors > self.rgb_colors) { doc.color_mode = ColorMode::Cmyk; let (swatches, groups) = vectorcraft_color::default_swatches(ColorMode::Cmyk.model()); @@ -474,6 +494,61 @@ impl<'a> Reader<'a> { } /// The dictionary of entry `key` in `o` or the dictionaries it holds. +/// How far (points) a ruler guide's ends may lie from the canvas's or an artboard's edges. +const GUIDE_TOLERANCE: f64 = 0.01; + +/// Whether the straight guide path from `a` to `b` (art space) is a ruler guide → whether it is +/// vertical, and the index in `boards` of the artboard it belongs to (`None`: a canvas guide). A +/// canvas guide runs across the whole canvas, 16,383 points centred on the template box's +/// centre `centre`; an artboard guide runs from edge to edge of one artboard of `boards`, inside it. +fn ruler_guide(a: Point, b: Point, centre: Option<(f64, f64)>, boards: &[Rect]) -> Option<(bool, Option)> { + let near = |u: f64, v: f64| (u - v).abs() <= GUIDE_TOLERANCE; + let vertical = match (near(a.x, b.x), near(a.y, b.y)) { + (true, false) => true, + (false, true) => false, + _ => return None, + }; + let (along, across) = if vertical { ((a.y.min(b.y), a.y.max(b.y)), a.x) } else { ((a.x.min(b.x), a.x.max(b.x)), a.y) }; + if let Some((cx, cy)) = centre { + let c = if vertical { cy } else { cx }; + if near(along.0, c - CANVAS_CENTRE) && near(along.1, c + CANVAS_CENTRE) { + return Some((vertical, None)); + } + } + let mut on = boards.iter().enumerate().filter(|(_, r)| { + let (ends, sides) = + if vertical { ((r.min_y(), r.max_y()), (r.min_x(), r.max_x())) } else { ((r.min_x(), r.max_x()), (r.min_y(), r.max_y())) }; + near(along.0, ends.0) && near(along.1, ends.1) && (sides.0..=sides.1).contains(&across) + }); + match (on.next(), on.next()) { + (Some((i, _)), None) => Some((vertical, Some(i))), + _ => None, + } +} + +/// Moves the children of the layers and sublayers of `nodes` that `pick` makes a guide of into +/// `guides`, each on its layer. +fn take_ruler_guides(nodes: &mut [Arc], pick: &dyn Fn(NodeId) -> Option, guides: &mut Vec, depth: usize) { + if depth > MAX_DEPTH { + return; + } + for n in nodes { + if !matches!(n.kind, NodeKind::Layer { .. }) { + continue; + } + let id = n.id; + let Some(kids) = Arc::make_mut(n).children_mut() else { continue }; + kids.retain(|k| match pick(k.id) { + Some(g) => { + guides.push(Guide { layer: Some(id), ..g }); + false + } + None => true, + }); + take_ruler_guides(kids, pick, guides, depth + 1); + } +} + fn find_entry<'o>(o: &'o Obj, key: &str, depth: usize) -> Option<&'o Obj> { if depth > 8 { return None; diff --git a/crates/eps/src/import/ai/read/ops.rs b/crates/eps/src/import/ai/read/ops.rs index 8eff92907..9461ecb0c 100644 --- a/crates/eps/src/import/ai/read/ops.rs +++ b/crates/eps/src/import/ai/read/ops.rs @@ -308,8 +308,11 @@ impl<'a> Reader<'a> { Ok(()) } - /// `… Lb`: a layer's options. The legacy format's has ten operands, its colour index one place - /// earlier (`visible preview unlocked printing dimmed masks colorIndex r g b`). + /// `… Lb`: a layer's options, `visible preview unlocked printing dimmed`, then the colour index + /// and its RGB. The colour index is at 5 with fewer than ten operands, 6 with ten to twelve + /// (`masks` before it) and 7 with thirteen or more (one more flag before it). Four places after + /// the colour index comes the template flag (eleven operands or more), then the dimming + /// percentage (twelve or more, else 50). A fourteenth operand changes nothing here. fn layer_attrs(&mut self, vals: &[V]) { let n = Self::nums(vals); let Some(Frame { kind: Kind::Layer(a), .. }) = self.frames.last_mut() else { return }; @@ -318,9 +321,10 @@ impl<'a> Reader<'a> { a.preview = flag(1, true); a.locked = !flag(2, true); a.printable = flag(3, true); - let dim_pct = n.get(12).copied().filter(|v| v.is_finite()).unwrap_or(50.0).clamp(0.0, 100.0); + let at = 5 + usize::from(n.len() >= 10) + usize::from(n.len() >= 13); + a.template = flag(at + 4, false); + let dim_pct = n.get(at + 5).copied().filter(|v| v.is_finite()).unwrap_or(50.0).clamp(0.0, 100.0); a.dim = flag(4, false).then_some(dim_pct.round() as u8); - let at = if n.len() <= 10 { 6 } else { 7 }; let index = n.get(at).copied().unwrap_or(0.0); let rgb = |i: usize| n.get(at + i).map_or(0, |v| v.clamp(0.0, 255.0) as u8); a.color = Some(if (0.0..vectorcraft_doc::LAYER_COLORS.len() as f64).contains(&index) && index.fract() == 0.0 { diff --git a/crates/eps/src/import/ai/read/types.rs b/crates/eps/src/import/ai/read/types.rs index a2792e84c..b73177a37 100644 --- a/crates/eps/src/import/ai/read/types.rs +++ b/crates/eps/src/import/ai/read/types.rs @@ -123,6 +123,7 @@ pub(super) struct LayerAttrs { pub(super) preview: bool, pub(super) locked: bool, pub(super) printable: bool, + pub(super) template: bool, pub(super) dim: Option, pub(super) color: Option, } diff --git a/crates/eps/src/import/ai/tests.rs b/crates/eps/src/import/ai/tests.rs index 21227eef6..f20a07d49 100644 --- a/crates/eps/src/import/ai/tests.rs +++ b/crates/eps/src/import/ai/tests.rs @@ -5,7 +5,7 @@ use std::sync::Arc; use kurbo::{Point, Rect}; use vectorcraft_color::gradient::GradientKind; use vectorcraft_color::{BlendMode, Color, Paint}; -use vectorcraft_doc::{AppearanceItem, ColorMode, Document, Knockout, LayerColor, LineCap, LineJoin, Node, NodeKind, Unit}; +use vectorcraft_doc::{AppearanceItem, ColorMode, Document, Guide, Knockout, LayerColor, LineCap, LineJoin, Node, NodeKind, Unit}; use super::*; @@ -2133,6 +2133,122 @@ fn stroked_path_guides_from_the_star_operator() { assert!(matches!(n.appearance.items[0], AppearanceItem::Stroke(_))); } +/// [`stream`] with a template box centred on (306.5, 395.5): the canvas runs 8,191.5 points each +/// way from there. +fn canvas_stream(body: &str) -> String { + stream(body).replacen("%%EndComments", "%AI3_TemplateBox: 306.5 395.5 306.5 395.5\n%%EndComments", 1) +} + +/// The straight guide path from (`x0`, `y0`) to (`x1`, `y1`), `(op) *`. +fn guide_line(x0: f64, y0: f64, x1: f64, y1: f64, op: &str) -> String { + format!("{x0} {y0} m\n{x1} {y1} L\n({op}) *\n") +} + +/// #779: a straight `(N) *` guide path across the whole canvas, directly on a layer or sublayer, is +/// a ruler guide on that layer, at its place in the document; it leaves no path behind. +#[test] +fn canvas_wide_guide_paths_are_ruler_guides_of_their_layer() { + let body = format!( + "{}{}{RED}{}f\n%AI5_BeginLayer\n1 1 1 1 0 0 1 0 79 128 255 0 50 0 Lb\n(Sub) Ln\n{}LB\n%AI5_EndLayer--\n", + guide_line(-7885.0, 40.0, 8498.0, 40.0, "N"), + guide_line(25.0, 8587.0, 25.0, -7796.0, "N"), + rect_path(0.0, 0.0, 10.0, 10.0), + guide_line(-7885.0, 90.0, 8498.0, 90.0, "N"), + ); + let doc = read(canvas_stream(&layer(&body)).as_bytes()).unwrap().doc; + let top = &doc.layers[0]; + let kids = top.children().unwrap(); + assert_eq!(kids.len(), 2, "the square and the sublayer"); + assert!(matches!(kids[0].kind, NodeKind::Path { guide: false, .. })); + let sub = &kids[1]; + assert!(sub.children().unwrap().is_empty()); + let want = [ + Guide { vertical: false, pos: 60.0, artboard: None, layer: Some(top.id) }, + Guide { vertical: true, pos: 25.0, artboard: None, layer: Some(top.id) }, + Guide { vertical: false, pos: 10.0, artboard: None, layer: Some(sub.id) }, + ]; + assert_eq!(doc.guides, want); +} + +/// #779: a straight `(N) *` guide path from edge to edge of one artboard is a ruler guide of that +/// artboard. +#[test] +fn artboard_wide_guide_paths_are_ruler_guides_of_their_artboard() { + // The crop marks' artboard, 0 0 200 100. + let doc = read(canvas_stream(&layer(&guide_line(0.0, 30.0, 200.0, 30.0, "N"))).as_bytes()).unwrap().doc; + assert_eq!(doc.guides, [Guide { vertical: false, pos: 70.0, artboard: Some(1), layer: Some(doc.layers[0].id) }]); + assert!(art(&doc).is_empty()); + // The second of two artboards: art space x 400 to 600, y -150 to 0. + let boards = "%_/Document :\n%_/Dictionary :\n%_/Array :\n\ + %_/Dictionary :\n%_0 200 /RealPointRelToROrigin\n%_ (PositionPoint1) ,\n%_300 0 /RealPointRelToROrigin\n%_ (PositionPoint2) ,\n%_; ,\n\ + %_/Dictionary :\n%_400 0 /RealPointRelToROrigin\n%_ (PositionPoint1) ,\n%_600 -150 /RealPointRelToROrigin\n%_ (PositionPoint2) ,\n%_; ,\n\ + %_; (ArtboardArray) ,\n%_; /NotRecorded ,\n%_;\n"; + let doc = read_ok(&format!("{boards}{}", layer(&guide_line(500.0, 0.0, 500.0, -150.0, "N")))).doc; + assert_eq!(doc.artboards[1].id, 2); + assert_eq!(doc.guides, [Guide { vertical: true, pos: 500.0, artboard: Some(2), layer: Some(doc.layers[0].id) }]); +} + +/// Guide paths that aren't ruler guides stay guide paths: `(S) *` ones (guides made from objects), +/// even across the whole canvas or an artboard, short ones, slanted ones, and ones in a group. +#[test] +fn other_guide_paths_stay_guide_paths() { + let cases = [ + guide_line(-7885.0, 40.0, 8498.0, 40.0, "S"), + guide_line(20.0, 40.0, 80.0, 40.0, "S"), + guide_line(20.0, 40.0, 80.0, 40.0, "N"), + guide_line(0.0, 30.0, 200.0, 30.0, "S"), + guide_line(-7885.0, -7796.0, 8498.0, 8587.0, "N"), + format!("u\n{}U\n", guide_line(-7885.0, 40.0, 8498.0, 40.0, "N")), + format!("1 Xw\n{}0 Xw\n", guide_line(-7885.0, 40.0, 8498.0, 40.0, "N")), + "-7885 40 m\n306 40 L\n8498 40 L\n(N) *\n".to_string(), + ]; + for body in cases { + let doc = read(canvas_stream(&layer(&body)).as_bytes()).unwrap().doc; + assert!(doc.guides.is_empty(), "{body}"); + assert_eq!(art(&doc).len(), 1, "{body}"); + } +} + +/// The layer whose `Lb` has `ops` as its options (the first one in the file). +fn layer_with(ops: &str) -> Node { + let doc = read_ok(&format!("%AI5_BeginLayer\n{ops} Lb\n(L) Ln\nLB\n%AI5_EndLayer--\n")).doc; + Node::clone(&doc.layers[0]) +} + +/// #779: the template flag four places after the colour index makes a template layer; locking, +/// printing and dimming keep their own flags. In thirteen and fourteen operands the flag before +/// the colour index and the last one change nothing. +#[test] +fn the_template_flag_of_a_layer() { + let l = layer_with("1 1 0 0 1 0 0 4 255 79 255 1 50 0"); + let NodeKind::Layer { template, printable, dim_images, color, .. } = l.kind else { panic!() }; + assert!(template && !printable && l.locked); + assert_eq!((dim_images, color), (Some(50), LayerColor::Preset(4))); + for ops in ["1 1 1 1 1 0 1 5 1 2 3 1 30 0", "1 1 1 1 1 0 0 5 1 2 3 1 30 1", "1 1 1 1 1 0 1 5 1 2 3 1 30"] { + let l = layer_with(ops); + let NodeKind::Layer { template, printable, dim_images, color, .. } = l.kind else { panic!() }; + assert!(template && printable && !l.locked, "{ops}"); + assert_eq!((dim_images, color), (Some(30), LayerColor::Preset(5)), "{ops}"); + } + let l = layer_with("1 1 1 1 0 0 1 0 79 128 255 0 50 0"); + assert!(matches!(l.kind, NodeKind::Layer { template: false, .. })); +} + +/// The colour index is one place earlier in eleven and twelve operands, as in ten, and the twelfth +/// is the dimming percentage; with nine it is one place earlier still. +#[test] +fn layer_options_of_nine_to_twelve_operands() { + let options = |l: Node| match l.kind { + NodeKind::Layer { template, dim_images, color, .. } => (template, dim_images, color), + _ => panic!(), + }; + assert_eq!(options(layer_with("1 1 1 1 1 0 3 1 2 3 1")), (true, Some(50), LayerColor::Preset(3))); + assert_eq!(options(layer_with("1 1 1 1 1 0 3 1 2 3 0 25")), (false, Some(25), LayerColor::Preset(3))); + assert_eq!(options(layer_with("1 1 1 1 1 0 -1 10 20 30 1 25")), (true, Some(25), LayerColor::Custom([10, 20, 30]))); + assert_eq!(options(layer_with("1 1 1 1 0 0 3 1 2 3")), (false, None, LayerColor::Preset(3))); + assert_eq!(options(layer_with("1 1 1 1 0 2 1 2 3")), (false, None, LayerColor::Preset(2))); +} + /// A blend's record as the editing data writes it after `(Adobe Path Blends) 1 0 n XP`: `DCBA`, /// version 1, then (as far as the reader reads it) the number of keys at word 10 and a record of /// six words per key starting with its id. Hand-written; the other words are zero. diff --git a/crates/eps/src/import/native/build.rs b/crates/eps/src/import/native/build.rs index 0ed2f354f..88524ef58 100644 --- a/crates/eps/src/import/native/build.rs +++ b/crates/eps/src/import/native/build.rs @@ -190,6 +190,7 @@ pub(super) fn build_from( if let Some(a) = doc.artboards.first_mut() { a.rect = rect; } + drop_guides_off_boards(&mut doc); } frame = Some(Frame { page: visible, page_rect, rect, whole, pages: page_moves }); } @@ -933,8 +934,14 @@ fn page_boards<'a>(boards: &'a [vectorcraft_doc::Artboard], first: usize, pages: ((first > 0 || pages.len() == boards.len()) && shown.iter().zip(pages).all(same)).then_some(shown) } -/// Draw `doc`'s layers and artboards `k` times as large, from the origin, with their strokes, -/// effects and pattern tiles. +/// Drops the guides of artboards `doc` no longer has: an artboard guide goes with its artboard. +fn drop_guides_off_boards(doc: &mut Document) { + let kept: Vec = doc.artboards.iter().map(|a| a.id).collect(); + doc.guides.retain(|g| g.artboard.is_none_or(|id| kept.contains(&id))); +} + +/// Draw `doc`'s layers, artboards and guides `k` times as large, from the origin, with their +/// strokes, effects and pattern tiles. fn scale_document(doc: &mut Document, k: f64) { let a = Affine::scale(k); let scaling = Scaling { strokes: true, effects: Some(vectorcraft_effects::scale_effect), patterns: true, ..Default::default() }; @@ -944,6 +951,9 @@ fn scale_document(doc: &mut Document, k: f64) { for board in &mut doc.artboards { board.rect = a.transform_rect_bbox(board.rect); } + for guide in &mut doc.guides { + guide.pos *= k; + } } /// Marks (in a node's name, until [`prune_unseen`] has looked) the text objects made from the file's diff --git a/crates/eps/src/import/tests_native.rs b/crates/eps/src/import/tests_native.rs index 9ef30473e..6ba17640d 100644 --- a/crates/eps/src/import/tests_native.rs +++ b/crates/eps/src/import/tests_native.rs @@ -1135,6 +1135,46 @@ fn editing_data_in_the_units_of_a_user_unit_page_comes_in_at_the_pages_size() { assert!(near(first(&d, 0), [100.0, 860.0, 140.0, 900.0]), "{:?}", first(&d, 0)); } +/// #779: a `.ai`'s ruler guides come in as guides of their layers, at the page's size when the +/// editing data is in the units of a `UserUnit` page. +#[test] +fn an_ai_files_ruler_guides_come_in_on_their_layers() { + // Across the canvas (16,383 points around the template box's centre, 50 50) at y 40, and + // across the artboard at x 20. + let guides = "-8141.5 40 m\n8241.5 40 L\n(N) *\n20 0 m\n20 100 L\n(N) *\n"; + let art = native(&layer("Jig", true, &format!("{}\n{guides}", square(10, 10)))); + let art = art.replacen("%AI5_NumLayers", "%AI3_TemplateBox: 50 50 50 50\n%AI5_NumLayers", 1); + for k in [1.0, 10.0] { + let page = in_points(pdf_part(10.0, 86.0), k); + let (d, w, read) = crate::layered_ai(&private(&art), page, vec![], false, false, k); + assert!(read, "{w:?}"); + let jig = d.layers[0].id; + let want = [ + vectorcraft_doc::Guide { vertical: false, pos: 60.0 * k, artboard: None, layer: Some(jig) }, + vectorcraft_doc::Guide { vertical: true, pos: 20.0 * k, artboard: Some(d.artboards[0].id), layer: Some(jig) }, + ]; + assert_eq!(d.guides, want); + assert_eq!(d.layers[0].children().map_or(0, |c| c.len()), 1, "the square alone"); + } +} + +/// An EPS whose page is its art's box keeps only its first artboard, and the guides of that one: +/// the ruler guides of the artboards left out go with them. +#[test] +fn an_eps_of_its_art_box_keeps_the_guides_of_its_first_artboard() { + let point = |x: u32, y: u32, n: u8| format!("%_{x} {y} /RealPointRelToROrigin\n%_ (PositionPoint{n}) ,\n"); + let board = |x0: u32, x1: u32| format!("%_/Dictionary :\n{}{}%_; ,\n", point(x0, 50, 1), point(x1, 0, 2)); + let array = format!("%_/Array :\n{}{}%_; (ArtboardArray) ,\n", board(0, 50), board(50, 100)); + let boards = format!("%_/Document :\n%_/Dictionary :\n{array}%_; /NotRecorded ,\n%_;\n"); + let guides = "25 0 m\n25 50 L\n(N) *\n75 0 m\n75 50 L\n(N) *\n"; + let art = native(&format!("{boards}{}", layer("Art", true, &format!("{}\n{guides}", square(10, 10))))); + let r = import(&eps(&page_square(10, 10), &art)).unwrap(); + assert_eq!(names(&r.document), ["Art"], "{:?}", r.warnings); + assert_eq!(r.document.artboards.len(), 1); + let (board, layer) = (r.document.artboards[0].id, r.document.layers[0].id); + assert_eq!(r.document.guides, [vectorcraft_doc::Guide { vertical: true, pos: 25.0, artboard: Some(board), layer: Some(layer) }]); +} + /// A PDF part read from a `UserUnit` page cropped to the art: the art's box, not the artboard, is /// the page in the editing data's units, so the layers are read at the page's size, with their /// artboard. diff --git a/docs/file-format-parity.md b/docs/file-format-parity.md index 2ca9181e3..10ad24c79 100644 --- a/docs/file-format-parity.md +++ b/docs/file-format-parity.md @@ -92,7 +92,7 @@ the app's own editing copy of a file (an EPS after its `%%EOF`, a `.ai`'s `AIPri |---|---|---|---|---| | VectorCraft `.vectorcraft` / `.vctemplate` | — | yes | yes | lossless JSON, versioned, save down to v1; property-tested round trips (`format/tests/prop_format.rs`); documents with a lot of path data are written as compact JSON, about a fifth of the size (#1492); a save falls back to a plain fsync and to writing elsewhere and copying in when the folder refuses the usual steps (#1625, #1649, part of #1620; not confirmed on a real network share); the size limits on embedded data also apply to files read from memory (#1783), and saving over a private file on Unix leaves no readable temporary copy beside it (#1778); a dynamic symbol's type (`dynamic` on the symbol) and an instance's appearance changes to its art (`symbol_overrides`, each path at most 64 deep, counted against the object budget) are written only when set, so documents without them save as before (#4938) | | Illustrator template `.ait` | open, save | yes | PDF-compatible only, from Save as Template (`.vctemplate` stays its default) | New from Template opens a saved `.ait` untitled with its layers and art (`tests_pdfedit.rs`) | -| Illustrator `.ai` | open, save | yes (PDF-compatible + editing-data structure, File Info) | PDF-compatible, or legacy version 7 (layers and groups) | see above; no real-file corpus | +| Illustrator `.ai` | open, save | yes (PDF-compatible + editing-data structure with template layers and ruler guides, File Info) | PDF-compatible, or legacy version 7 (layers and groups) | see above; no real-file corpus | | PDF | open, place, save | yes (layers, masks, editable text, security, overprint, File Info from the XMP packet and the document information dictionary; Crop To › Trim takes the bleed box as the document's bleed) | yes (presets, PDF/X-1a/3/4, layers, marks and bleed, ICC, fast web view, 1.3+) | fuzzed import; shared guarded writer and windowed reader ([pdf-backend.md](pdf-backend.md)), metadata, incomplete-content and dependency-source regressions; PDF text opens where the file draws it, glyph by glyph (#722: kerning or none, spacing per character, text rise as baseline shift, vertical writing, justified text boxes); alpha soft masks drawn as images import with the mask (#1551); a clip only counts as covering the page when its shape does (#1542); a PDF another app copied pastes as editable vector art (#1499); letter-spaced type on a path keeps its length and its glyph-by-glyph stroke comes in as one text object (#1586, #1585); a crafted name tree with shared nodes no longer takes exponential time on open (#1781); fonts named with hyphens (`Noto-Sans-Bold`) or with a composite font's CMap (`Name-Identity-H`) find the installed family; invisible text (rendering mode 3: a scanned page's recognized text) opens as type with no fill or stroke and saves back as invisible, searchable text (#2675); PDF/X-1a and X-3 flattening in a CMYK document keeps CMYK inks where it rasterizes (#2160) | | EPS | open, place, save | yes (own PostScript L3 interpreter, Illustrator EPS editing data structure) | yes (L2/L3, TIFF previews) | CID-keyed CJK type not read; #505; images encoded with `CCITTFaxDecode` (ITU-T T.4 Group 3 and T.6 Group 4, with `K`, `Columns`, `Rows`, `EndOfBlock`, `EndOfLine`, `EncodedByteAlign` and `BlackIs1`) draw, and damaged data keeps the rows decoded before its first damaged row; Type 42 (TrueType) fonts set type at its size (#1553); a clip only counts as covering the page when its shape does (#1542); an EPS another app copied pastes as editable vector art (`clipboard.importEps`, #1499); a stitched shading keeps its hard colour changes on import (#1654); CMYK images, CMYK JPEGs included, keep their ink amounts on import, as from PDF, and the pixels a `MaskColor` or `ImageType 3` mask hides become a grayscale opacity mask; ruzstd 0.9.1 for compressed editing data (#1807, RUSTSEC-2026-0344) | | PostScript `.ps` | open, place | yes (the EPS reader reads its first page; `.epsf` files open and place as EPS) | via Print to PostScript | `tests_epsimport.rs` | diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index c592788df..4712f9749 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -1069,6 +1069,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur A `.ai` or EPS opened from its editing data with one object on a shown layer that the reader can't read (an unknown operator inside a group, a pattern fill whose pattern it doesn't have, a symbol instance, a placed file without a usable preview) keeps its layers and groups: in that object's place, its layer gets a group of the page's objects inside the object's box, named after it, with a warning that lists what came in as the page's art and an export or Save As to its own path asking first (`acknowledgeLoss`). The file still opens from its page or PDF part when no box can be found, when live art or another unread object shares the page art inside it, when the object spans two artboards, or when the page art doesn't carry the object's transparency (#4806; `crates/eps/src/import/tests_native.rs`, `crates/engine/src/cmd/fileio/tests_aiimport.rs`). PDF export and `.ai` saves write a page with a side longer than 14,400 pt with a `UserUnit` (PDF 1.6), its longer side divided by 14,400 and rounded up to the next thousandth, so that no side is longer than 14,400 units (ISO 32000-1, Annex C.2); in PDF 1.3 to 1.5 files and PDF/X files such a page is written in points. Placed (linked) files in a `.ai`'s editing data come in as linked images in their place, each showing the low-resolution preview the file keeps of it until its file is found or relinked in the Links panel (a file found next to the document or at its path is read as the document opens). A file with them no longer falls back to its PDF part, so its guides, sublayers and hidden objects open (#1264; `crates/eps/src/import/ai/tests.rs`, `crates/engine/src/tests/links.rs`). In a file opened that way, a gradient with stops in the middle runs its whole ramp across its shape and a gradient object no longer passes its fill to the next path (#1390); text objects with an appearance keep their own text and frame, type of hidden layers stays hidden, and type on later artboards lands on its text objects (#1391); area type whose frame is too short for a line of the fallback font shows its first line, the frame made taller with a warning in the open result (#1392). + `.ai` and EPS editing-data import keeps template layers, and opens canvas and artboard ruler guides as ruler guides on their layers; guide objects stay editable guide paths (#779; `crates/eps/src/import/ai/tests.rs`, `crates/eps/src/import/tests_native.rs`). A `.ai` saved without PDF compatibility (its PDF part only a placeholder page) is placed and pasted from its editing data, as it opens: File › Place places the art of the layers that show on the first artboard (`file.place`'s `page` places another one), and Paste of it as PDF data (`clipboard.importPdf`) takes every object. `document.open` with `pages` naming every page of such a file opens it the same way (`crates/engine/src/cmd/fileio/tests_aiimport.rs`). - EPS and DXF in and out, EMF/WMF in and out; PNG (with PNG-8), JPEG, WebP, GIF, TIFF, BMP, Targa and layered PSD export; grayscale TIFFs with an alpha channel or other extra channels place, relink and open, the alpha as the image's transparency (#1466 in part; CCITT Group 3 and planar files with extra channels are not read); PSD and PSB files open and place, linked or embedded, as their merged image in every colour mode and depth (#918). EPS import runs a PostScript Level 3 interpreter written from the PostScript Language Reference: resource categories and `resourceforall`, local and global VM (`currentglobal`/`setglobal`/`gcheck`), `clipsave`/`cliprestore`, subarrays and substrings that share storage, the standard dictionaries in `systemdict`, `dictstack`, `internaldict`, executable filters and strings, `load` by any key, `//name` immediate names, `token`, `execform`, user paths, `cshow`, `rootfont`, `cachestatus`, colour rendering and halftone queries, file `status` and data read with `flushfile`, and `pdfmark` marks dropped (Discord feedback, #466, #469, #474). Masked images (`ImageType 3` in its three interleavings) and reusable streams are read, and an image's several data sources are read in turn, as the reference defines (procedures that read one file get their own rows: such images came in banded, #498). `restore` puts dictionaries back as they were at the `save`, so programs that count their saves in a dictionary read to their end (part of #505). Tiling patterns come in as pattern swatches, uncoloured ones too; Type 3 fonts' glyphs as their outlines, spaced by `ashow`/`widthshow`/`xshow`/`kshow`; mesh and function-based shadings (types 1, 4–7) as gradient meshes; sampled functions (type 0) and Flate/LZW predictors are read. It is tested with programs written from the specification in the ways common generators use these features; Inkscape/cairo EPS files open as vectors. When a file's PostScript still can't be read, it opens as its TIFF preview, its Windows metafile preview (as vectors) or its EPSI preview, and the warning (in the status bar, `document.open`/`app.open`/MCP `open_file` replies and `vectorcraft-cli info`) names the error, the operator that raised it and the procedures it ran in. Legacy PostScript `.ai` files (Illustrator 3–8, written with the prolog that defines their operators) keep their groups and nested groups (`u` … `U`), between and inside clipping groups; EPS and EMF/WMF import nest clips (and groups) at most 128 deep, so a hostile file can't exhaust the stack. DXF TEXT justified Fit or Aligned spans its two points (stretched, or scaled as a whole). PDF export writes type as real, searchable text in embedded subset fonts by default, as Illustrator's PDF presets do (Convert Text to Outlines is an option; printer's marks stay shapes), so text-heavy PDFs stay small (#733). DXF export writes each fill as its outlines followed by its hatch, so laser and cutter software, which skips hatches, gets filled art and type too (#546). - The Open and Place dialogs list PostScript `.ps` files and `.epsf` files. A `.ps` file opens and places through the EPS reader (its first page), also when its header is only `%!`, as the `ps` format, which exports don't write (Print to PostScript does); an `.epsf` file opens and places as EPS. Copied in a file manager, these files paste as EPS, and `clipboard.importEps` also reads PostScript whose header is only `%!` (`crates/engine/src/cmd/fileio/tests_epsimport.rs`). From c0279473623f98d021e2f524d751c4063ac009c4 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Sun, 1 Nov 2026 11:08:47 +0700 Subject: [PATCH 02/12] .ai import: Objects on Path groups open live with their path, Pivot and Rotate Squashed from #5059 onto main. --- .../engine/src/cmd/fileio/tests_aiimport.rs | 27 +++ crates/engine/tests/import_fuzz.rs | 95 ++++++++++ crates/eps/src/import/ai/read.rs | 6 + crates/eps/src/import/ai/read/document.rs | 9 +- crates/eps/src/import/ai/read/on_path.rs | 176 ++++++++++++++++++ crates/eps/src/import/ai/tests.rs | 142 ++++++++++++++ crates/testkit/src/ai.rs | 38 +++- docs/file-format-parity.md | 2 +- docs/target-app-parity.md | 1 + 9 files changed, 491 insertions(+), 5 deletions(-) create mode 100644 crates/eps/src/import/ai/read/on_path.rs diff --git a/crates/engine/src/cmd/fileio/tests_aiimport.rs b/crates/engine/src/cmd/fileio/tests_aiimport.rs index 6f603b200..29e0b0680 100644 --- a/crates/engine/src/cmd/fileio/tests_aiimport.rs +++ b/crates/engine/src/cmd/fileio/tests_aiimport.rs @@ -410,6 +410,33 @@ fn an_ai_file_with_a_variable_library_says_its_variables_werent_read() { assert!(notes(&r).is_empty(), "{r}"); } +/// An Objects on Path group in a `.ai` file opens as one: its record names the group's children by +/// their ids in the opened document. +#[test] +fn an_ai_file_opens_its_objects_on_path_group_live() { + let ids = ["9b1c0e7a-0000-4000-8000-000000000001", "9b1c0e7a-0000-4000-8000-000000000002", "9b1c0e7a-0000-4000-8000-000000000003"]; + let options = ai::on_path_options(2, 4, std::f64::consts::FRAC_PI_2, ""); + let record = ai::on_path_record("Adobe Align On Path", &ids, &options); // brand-ok: the record's name in the files + let group = format!( + "0 Ae\nu\n/ConstraintGroup :\n128 /GroupType ,\n;\n0 0 1 0 0 0 1 XA\n10 50 m\n190 50 L\nS\n{}0 1 1 0 1 0 0 Xa\n{}f\n{}{}f\n{}U\n", + ai::on_path_member(ids[0], None, 0.0), + ai::rect(5.0, 45.0, 15.0, 55.0), + ai::on_path_member(ids[1], Some(0), 0.0), + ai::rect(185.0, 45.0, 195.0, 55.0), + ai::on_path_member(ids[2], Some(1), 0.0), + ); + let data = ai::editing_data(200.0, 100.0, &format!("{record}{}", ai::layer("Art", &group))); + let mut s = Session::new(); + let r = open(&mut s, "on-path.ai", &ai::ai(&compressed(data.as_bytes()), "1 0 0 rg 5 45 10 10 re f 185 45 10 10 re f"), json!({})).unwrap(); + assert_eq!(r["format"], json!("ai"), "{r}"); + let doc = &s.doc().unwrap().doc; + let g = &doc.layers[0].children().unwrap()[0]; + let rec = g.extras().objects_on_path.as_deref().expect("an Objects on Path group"); + let kids: Vec = g.children().unwrap().iter().map(|c| c.id).collect(); + assert!(kids.contains(&rec.path) && rec.members.iter().all(|m| kids.contains(&m.id)), "{rec:?} {kids:?}"); + assert_eq!((rec.members.len(), rec.pivot, rec.rotate.round()), (2, 4, 90.0)); +} + /// A page showing only a placeholder line, as a `.ai` saved without PDF compatibility has. const PLACEHOLDER: &str = "BT /F1 12 Tf 10 50 Td (Saved without its PDF part) Tj ET"; diff --git a/crates/engine/tests/import_fuzz.rs b/crates/engine/tests/import_fuzz.rs index b93b28b53..e09c91e86 100644 --- a/crates/engine/tests/import_fuzz.rs +++ b/crates/engine/tests/import_fuzz.rs @@ -2980,6 +2980,101 @@ proptest! { let private = [b"%AI24_ZStandard_Data".as_slice(), &zstd(&text)].concat(); survive("mutated .ai variable library", || Some(vectorcraft_eps::layered_ai(&private, Document::new(100.0, 100.0), vec![], false, false, 1.0).0))?; } + + /// A `.ai` whose Objects on Path record and group are damaged or hostile. + #[test] + fn ai_objects_on_path_never_panics(cut in 0usize..4_000, edits in prop::collection::vec((0usize..4_000, prop::sample::select(vec!['0', '1', '9', '-', '.', 'e', ' ', '\n', '(', ')', '/', ':', ';', ',', '%', '_', 'u', 'U'])), 0..12)) { + let text = mutate_text(&on_path_data(&ON_PATH_IDS, &ON_PATH_IDS, 4, "4", "0.5", "", ["0", "1", "2"], true, 0), cut, &edits); + let private = [b"%AI24_ZStandard_Data".as_slice(), &zstd(&text)].concat(); + // Trusted, so that the layers read are built whatever the empty page shows. + survive("mutated .ai Objects on Path", || Some(vectorcraft_eps::layered_ai(&private, Document::new(200.0, 100.0), vec![], false, true, 1.0).0))?; + } + + /// Objects on Path records of any shape: ids in any order, repeated or missing, any number of + /// objects, pivots and angles out of range or not finite, spacings, order tags that disagree, + /// groups nested or cut short, and versioned content left open. + #[test] + fn ai_objects_on_path_records_never_panic( + listed in prop::collection::vec(prop::sample::select(ON_PATH_IDS.to_vec()), 0..7), + drawn in prop::collection::vec(prop::sample::select(ON_PATH_IDS.to_vec()), 0..7), + members in 0usize..7, + pivot in prop::sample::select(vec!["0", "4", "8", "9", "-1", "2.5", "1e400"]), + rotation in prop::sample::select(vec!["0", "0.5235987755982988", "-3.2", "1e400", "-1e400", "1e300"]), + spacing in prop::sample::select(vec!["", "%_56.5 /Real (Spacing) ,\n", "%_0 /Bool (IsEquiSpaced) ,\n", "%_1 /Bool (Flip) ,\n"]), + tags in prop::array::uniform3(prop::sample::select(vec!["0", "1", "2", "-1", "7", "(x)", "1e400"])), + versioned in any::(), + nest in 0usize..3, + cut in 0usize..2_000, + ) { + let data = on_path_data(&listed, &drawn, members, pivot, rotation, spacing, tags, versioned, nest); + let cut_at = data.len().saturating_sub(cut); + let cut_at = (0..=cut_at).rev().find(|i| data.is_char_boundary(*i)).unwrap_or(0); + for data in [data.as_str(), &data[..cut_at]] { + let private = [b"%AI24_ZStandard_Data".as_slice(), &zstd(data)].concat(); + survive("hostile Objects on Path", || Some(vectorcraft_eps::layered_ai(&private, Document::new(200.0, 100.0), vec![], false, true, 1.0).0))?; + } + } +} + +/// The data [`ai_objects_on_path_never_panics`] damages, as it is, opens with its Objects on Path group. +#[test] +fn the_objects_on_path_fuzz_data_opens_live() { + let text = on_path_data(&ON_PATH_IDS, &ON_PATH_IDS, 4, "4", "0.5", "", ["0", "1", "2"], true, 0); + let private = [b"%AI24_ZStandard_Data".as_slice(), &zstd(&text)].concat(); + // Trusted: the empty page it is read beside isn't compared with its art. + let (doc, warnings, _) = vectorcraft_eps::layered_ai(&private, Document::new(200.0, 100.0), vec![], false, true, 1.0); + let g = doc.layers.first().and_then(|l| l.children()).and_then(|c| c.first()).unwrap_or_else(|| panic!("{warnings:?}")); + assert!(g.extras().objects_on_path.is_some(), "{g:?}"); +} + +/// The ids of the objects of an Objects on Path group in the fuzz cases. +const ON_PATH_IDS: [&str; 5] = ["f-0001", "f-0002", "f-0003", "f-0004", "f-0005"]; + +/// Editing data with an Objects on Path record naming `listed` for `members` objects (Pivot +/// `pivot`, Rotation `rotation`, then the entries `more`) and a group of a path and squares whose +/// dictionaries give the ids `drawn` (the first the path's, the others with the order tags `tags` +/// in turn), inside `nest` more groups, in versioned content with a copy after it when `versioned` +/// is set. +#[allow(clippy::too_many_arguments)] +fn on_path_data( + listed: &[&str], + drawn: &[&str], + members: usize, + pivot: &str, + rotation: &str, + more: &str, + tags: [&str; 3], + versioned: bool, + nest: usize, +) -> String { + use vectorcraft_testkit::ai; + let options = ai::on_path_options(members, 0, 0.0, more) + .replace("%_0 /Int (PivotPosition)", &format!("%_{pivot} /Int (PivotPosition)")) + .replace("%_0 /Real (Rotation)", &format!("%_{rotation} /Real (Rotation)")); + let record = ai::on_path_record("Adobe Align On Path", listed, &options); // brand-ok: the record's name in the files + let mut group = String::from("0 Ae\nu\n/ConstraintGroup :\n128 /GroupType ,\n;\n0 0 1 0 0 0 1 XA\n20 20 m\n100 80 L\n180 20 L\nS\n"); + for (i, id) in drawn.iter().enumerate() { + if i == 0 { + group += &ai::on_path_member(id, None, 0.0); + } else { + let x = 10.0 + 30.0 * i as f64; + let tag = tags.get(i - 1).map_or_else(|| (i - 1).to_string(), |t| t.to_string()); + group += &format!( + "0 1 1 0 1 0 0 Xa\n{}f\n%_/ArtDictionary :\n%_{tag} /Int (ConstraintArtOrderId) ,\n%_({id}) /String (Persistent ID) ,\n%_({rotation}) /String (BBAccumRotation) ,\n%_;\n%_\n", + ai::rect(x, 40.0, x + 10.0, 46.0) + ); + } + } + group += "U\n"; + for _ in 0..nest { + group = format!("0 Ae\nu\n{group}U\n"); + } + let art = if versioned { + format!("%AI17_Begin_Content_if_version_gt:24 14\n{group}%AI17_Alternate_Content\n{group}%AI17_End_Versioned_Content\n") + } else { + group + }; + ai::editing_data(200.0, 100.0, &format!("{record}{}", ai::layer("L", &art))) } /// A `.ai` in PostScript form as other apps write it: the procsets that define its operators named, diff --git a/crates/eps/src/import/ai/read.rs b/crates/eps/src/import/ai/read.rs index cb381d6fd..4791274bd 100644 --- a/crates/eps/src/import/ai/read.rs +++ b/crates/eps/src/import/ai/read.rs @@ -18,6 +18,8 @@ //! app wrote) the bounding box. //! - Variables: the variable library, XML nodes of the `/Document` dictionary, isn't read; a note //! gives how many variables and data sets it has ([`VariableLibrary`]). +//! - Objects on Path: a group whose objects' dictionaries carry ids, and a record of the +//! `/Document` dictionary naming them, read by [`on_path`]. //! - Images: `%AI5_BeginRaster`, the colour space `XN`, `[matrix] bounds w h bits type alpha …` //! and the samples after `XI`. 8-bit CMYK samples keep their ink amounts in a CMYK TIFF, and //! an alpha channel that hides part of such an image becomes its opacity mask; other samples @@ -73,6 +75,7 @@ mod art; mod containers; mod directives; mod document; +mod on_path; mod ops; mod types; @@ -148,6 +151,8 @@ struct Reader<'a> { artboards: Vec<(String, Rect)>, /// The variables and data sets of the `/Document` dictionary's variable library. variables: VariableLibrary, + /// The Objects on Path records of the `/Document` dictionary and the ids of the objects. + on_path: on_path::OnPath, gradients: HashMap, gradient_def: Option, instance: Option, @@ -280,6 +285,7 @@ impl<'a> Reader<'a> { cropmarks: None, artboards: Vec::new(), variables: VariableLibrary::default(), + on_path: on_path::OnPath::default(), gradients: HashMap::new(), gradient_def: None, instance: None, diff --git a/crates/eps/src/import/ai/read/document.rs b/crates/eps/src/import/ai/read/document.rs index 7e63b31da..892a8893c 100644 --- a/crates/eps/src/import/ai/read/document.rs +++ b/crates/eps/src/import/ai/read/document.rs @@ -224,6 +224,7 @@ impl<'a> Reader<'a> { /// An object's dictionary: its name (`AIArtName`, or the XML id `AI10_ArtUID`), and the /// opacity mask it may carry. pub(super) fn art_dictionary(&mut self, o: &Obj) { + self.constraint_art(o); // A blend's key object or spine. if let Some(kind) = o.text("ShapeBlendPathType") && let Some(id) = self.last_object().map(|n| n.id) @@ -278,10 +279,11 @@ impl<'a> Reader<'a> { } } - /// The `/Document` dictionary: the artboards, and how many variables and data sets its variable - /// library has. + /// The `/Document` dictionary: the artboards, how many variables and data sets its variable + /// library has, and its Objects on Path records. pub(super) fn document(&mut self, o: &Obj) { variable_library(o, 0, &mut self.variables); + self.constraint_data(o); let Some(list) = find_entry(o, "ArtboardArray", 0) else { return }; for (i, ab) in list.items().enumerate().take(1000) { let (p1, p2) = (ab.nums("PositionPoint1"), ab.nums("PositionPoint2")); @@ -392,6 +394,7 @@ impl<'a> Reader<'a> { let to_doc = self.ensure_space(); let artboard = self.first_board(); drop_commented_copies(&mut self.doc.layers, &self.hidden_ids); + super::on_path::apply(&mut self.doc, &self.on_path); let mut doc = std::mem::replace(&mut self.doc, Document::new(1.0, 1.0)); let boards: Vec<(String, Rect)> = if self.artboards.is_empty() { artboard.or_else(|| self.bbox_board()).map(|r| vec![("Artboard 1".to_string(), r)]).unwrap_or_default() @@ -549,7 +552,7 @@ fn take_ruler_guides(nodes: &mut [Arc], pick: &dyn Fn(NodeId) -> Option(o: &'o Obj, key: &str, depth: usize) -> Option<&'o Obj> { +pub(super) fn find_entry<'o>(o: &'o Obj, key: &str, depth: usize) -> Option<&'o Obj> { if depth > 8 { return None; } diff --git a/crates/eps/src/import/ai/read/on_path.rs b/crates/eps/src/import/ai/read/on_path.rs new file mode 100644 index 000000000..909d61131 --- /dev/null +++ b/crates/eps/src/import/ai/read/on_path.rs @@ -0,0 +1,176 @@ +//! Objects on Path groups: a `u` … `U` group whose children's dictionaries carry a `Persistent ID` +//! (the path's with `KeyPath` 1, each object's with its `ConstraintArtOrderId` and the angle of its +//! frame, `BBAccumRotation`: radians, counterclockwise in art space), and a dictionary of the +//! `/Document` dictionary's `Constraint Data` array naming them: `Constraint Name`, `Constraint +//! Verison` 1, `Constraint IDs` (the path's id, then the objects' in their order along the path) +//! and `Constraint Dict` with the options (`PivotPosition` 0 to 8, `Rotation` in radians, +//! `IsEquiSpaced`, `StartRangeFraction`, `ObjectsOrder`, `ConstraintArtData`). The group opens +//! with its [`ObjectsOnPath`] record and its art where it is drawn. A record with options the +//! record can't hold (a `Spacing`, uneven spacing, a start other than 0, keys this reader doesn't +//! know), or one that doesn't match one group exactly, leaves the group a plain group. + +use std::collections::{HashMap, HashSet}; + +use vectorcraft_doc::objects_on_path::{Member, ObjectsOnPath, attachable_path}; + +use super::{Affine, Document, NodeId, NodeKind, Obj, Reader, V}; + +/// The constraint name of an Objects on Path record. +const NAME: &str = "Adobe Align On Path"; // brand-ok: the record's name in the files +/// Most records read from one file. +const MAX_RECORDS: usize = 1000; +/// Most objects one record names. +const MAX_IDS: usize = 10_000; +/// Most objects whose ids are kept. +const MAX_TAGGED: usize = 100_000; +/// The keys of `Constraint Dict` this reader reads; any other one leaves the group plain. +const KNOWN: [&str; 6] = ["StartRangeFraction", "ConstraintArtData", "ObjectsOrder", "PivotPosition", "IsEquiSpaced", "Rotation"]; + +/// An Objects on Path record of the `/Document` dictionary. +#[derive(Debug)] +pub(super) struct Record { + /// The path's id, then the objects' in their order along the path. + ids: Vec, + pivot: u8, + /// Degrees, counterclockwise. + rotate: f64, +} + +/// What an object's dictionary says of its part in an Objects on Path group. +#[derive(Clone, Copy, Debug, Default)] +struct Tag { + key_path: bool, + order: Option, + /// `BBAccumRotation`: radians, counterclockwise in art space. + angle: Option, +} + +/// The records and object ids read, for [`apply`]. +#[derive(Debug, Default)] +pub(super) struct OnPath { + records: Vec, + /// The objects with each `Persistent ID`. + ids: HashMap>, + tags: HashMap, +} + +/// The first number of entry `key`. +fn num(o: &Obj, key: &str) -> Option { + o.nums(key).first().copied() +} + +/// An Objects on Path record from a dictionary of the `Constraint Data` array: `None` for another +/// constraint, another version, or options the record can't hold. +fn record(o: &Obj) -> Option { + if o.text("Constraint Name").as_deref() != Some(NAME) || num(o, "Constraint Verison") != Some(1.0) { + return None; + } + let list = o.obj("Constraint IDs")?; + if list.entries.len() > MAX_IDS || !list.content.is_empty() { + return None; + } + let ids = list.entries.iter().map(|(_, v)| v.iter().find(|v| matches!(v, V::Str(_))).and_then(V::text)).collect::>>()?; + let members = ids.len().checked_sub(1).filter(|n| *n > 0)?; + let options = o.obj("Constraint Dict")?; + if options.entries.iter().any(|(k, _)| !k.as_deref().is_some_and(|k| KNOWN.contains(&k))) || !options.content.is_empty() { + return None; + } + if num(options, "IsEquiSpaced") != Some(1.0) + || !matches!(options.get("StartRangeFraction").map(|_| num(options, "StartRangeFraction")), None | Some(Some(0.0))) + { + return None; + } + let pivot = num(options, "PivotPosition").filter(|p| p.fract() == 0.0 && (0.0..=8.0).contains(p))? as u8; + let rotate = num(options, "Rotation").map(f64::to_degrees).filter(|r| r.is_finite())?; + let order = options.obj("ObjectsOrder")?; + let order: Vec = order.entries.iter().flat_map(|(_, v)| v.iter().filter_map(V::num)).collect(); + if order.len() != members || order.iter().enumerate().any(|(i, n)| *n != i as f64) { + return None; + } + let data = options.obj("ConstraintArtData")?; + if data.entries.len() != members || data.items().count() != members || data.items().any(|d| !d.entries.is_empty() || !d.content.is_empty()) { + return None; + } + Some(Record { ids, pivot, rotate }) +} + +impl Reader<'_> { + /// The Objects on Path records of a `Constraint Data` array in the `/Document` dictionary `o`. + pub(super) fn constraint_data(&mut self, o: &Obj) { + let Some(list) = super::document::find_entry(o, "Constraint Data", 0) else { return }; + for d in list.items().take(MAX_RECORDS) { + if self.on_path.records.len() >= MAX_RECORDS { + return; + } + if let Some(r) = record(d) { + self.on_path.records.push(r); + } + } + } + + /// The `Persistent ID` an object's dictionary `o` gives the last object, with its part in an + /// Objects on Path group. + pub(super) fn constraint_art(&mut self, o: &Obj) { + let Some(uuid) = o.text("Persistent ID") else { return }; + let Some(id) = self.last_object().filter(|n| !matches!(n.kind, NodeKind::Layer { .. })).map(|n| n.id) else { return }; + if self.on_path.tags.len() >= MAX_TAGGED { + return; + } + let angle = o.text("BBAccumRotation").and_then(|s| s.trim().parse::().ok()).filter(|a| a.is_finite()); + let tag = Tag { key_path: num(o, "KeyPath") == Some(1.0), order: num(o, "ConstraintArtOrderId"), angle }; + self.on_path.tags.insert(id, tag); + self.on_path.ids.entry(uuid).or_default().push(id); + } +} + +/// Give each group that a record of `found` names exactly its [`ObjectsOnPath`] record. +pub(super) fn apply(doc: &mut Document, found: &OnPath) { + if found.records.is_empty() { + return; + } + // Where every object with an id is, found in one walk of the tree (setting a record doesn't + // move anything). + let at = doc.index_paths(found.tags.keys().copied()); + for r in &found.records { + // A record that doesn't match leaves the group as it was read. + let _ = apply_one(doc, found, &at, r); + } +} + +fn apply_one(doc: &mut Document, found: &OnPath, at: &HashMap>, r: &Record) -> Option<()> { + // The one object still in the document with the id `uuid` (a commented copy may have gone). + let resolve = |uuid: &str| -> Option<(NodeId, &Vec)> { + let mut live = found.ids.get(uuid)?.iter().filter_map(|id| at.get(id).map(|p| (*id, p))); + let one = live.next()?; + live.next().is_none().then_some(one) + }; + let (first, rest) = r.ids.split_first()?; + let (path, path_at) = resolve(first)?; + if !found.tags.get(&path)?.key_path { + return None; + } + let (_, group_at) = path_at.split_last()?; + let mut seen = HashSet::from([path]); + let mut members = Vec::with_capacity(rest.len()); + for (i, uuid) in rest.iter().enumerate() { + let (id, place) = resolve(uuid)?; + let tag = found.tags.get(&id)?; + if !seen.insert(id) || place.split_last().map(|(_, up)| up) != Some(group_at) || tag.key_path || tag.order != Some(i as f64) { + return None; + } + // The frame the object is drawn in: turned by its angle (the document's y points down) + // about the center of its bounds. + let angle = -tag.angle?; + let center = doc.node_at(place)?.geometric_bounds()?.center(); + members.push(Member { id, frame: Affine::rotate_about(angle, center), angle }); + } + let g = doc.node_at(group_at)?; + let NodeKind::Group { children, clip: false } = &g.kind else { return None }; + if g.extras().objects_on_path.is_some() || children.len() != seen.len() || children.iter().any(|c| !seen.contains(&c.id)) { + return None; + } + attachable_path(children.iter().find(|c| c.id == path)?)?; + let on_path = ObjectsOnPath { path, members, pivot: r.pivot, rotate: r.rotate }; + doc.node_mut_at(group_at)?.extras_mut().objects_on_path = Some(Box::new(on_path)); + Some(()) +} diff --git a/crates/eps/src/import/ai/tests.rs b/crates/eps/src/import/ai/tests.rs index f20a07d49..be27b9f5c 100644 --- a/crates/eps/src/import/ai/tests.rs +++ b/crates/eps/src/import/ai/tests.rs @@ -2478,3 +2478,145 @@ fn crop_marks_after_the_layers_are_not_art() { assert_eq!(art(&r.doc).len(), 1); assert_eq!(r.doc.layers.len(), 1); } + +/// The name of an Objects on Path record. +const ALIGN_ON_PATH: &str = "Adobe Align On Path"; // brand-ok: the record's name in the files +/// The ids of an Objects on Path group's path and its four objects. +const ON_PATH_IDS: [&str; 5] = [ + "0a6d1e52-0000-4000-8000-000000000001", + "0a6d1e52-0000-4000-8000-000000000002", + "0a6d1e52-0000-4000-8000-000000000003", + "0a6d1e52-0000-4000-8000-000000000004", + "0a6d1e52-0000-4000-8000-000000000005", +]; +const OPEN_PATH: &str = "20 20 m\n100 80 L\n180 20 L\nS\n"; +const CLOSED_PATH: &str = + "40 50 m\n40 66.57 66.86 80 100 80 c\n133.14 80 160 66.57 160 50 c\n160 33.43 133.14 20 100 20 c\n66.86 20 40 33.43 40 50 c\ns\n"; + +/// An Objects on Path group: the stroked `path` with the id `ids[0]`, then a 10 × 6 red square for +/// each other id, 50 pt apart from x 20, whose frame is turned `angle` radians. +fn on_path_group(path: &str, ids: &[&str], angle: f64) -> String { + use vectorcraft_testkit::ai::on_path_member; + let (first, rest) = ids.split_first().unwrap(); + let mut group = format!("0 Ae\nu\n/ConstraintGroup :\n128 /GroupType ,\n;\n0 0 1 0 0 0 1 XA\n2 w\n{path}{}", on_path_member(first, None, 0.0)); + for (i, id) in rest.iter().enumerate() { + let x = 20.0 + 50.0 * i as f64; + group += &format!("{RED}{}f\n{}", rect_path(x, 40.0, x + 10.0, 46.0), on_path_member(id, Some(i), angle)); + } + group + "U\n" +} + +/// The group the layer of `body` holds first, and its Objects on Path record. +fn on_path(body: &str) -> (Arc, Option) { + let doc = read_ok(body).doc; + let g = art(&doc).first().cloned().unwrap(); + let rec = g.extras().objects_on_path.as_deref().cloned(); + (g, rec) +} + +/// An Objects on Path group opens as one, with its path, its objects in their order along the path, +/// Pivot and Rotate, and its art where the file draws it; the same art without the record opens as a +/// plain group. +#[test] +fn an_objects_on_path_group_opens_with_its_path_objects_and_options() { + use vectorcraft_testkit::ai::{on_path_options, on_path_record}; + let group = on_path_group(OPEN_PATH, &ON_PATH_IDS, 0.5); + let record = on_path_record(ALIGN_ON_PATH, &ON_PATH_IDS, &on_path_options(4, 0, std::f64::consts::FRAC_PI_6, "")); + let (g, rec) = on_path(&format!("{record}{}", layer(&group))); + let rec = rec.expect("an Objects on Path group"); + let kids = g.children().unwrap(); + assert_eq!(kids.len(), 5); + let NodeKind::Path { path, .. } = &kids.iter().find(|c| c.id == rec.path).unwrap().kind else { panic!("the path") }; + assert!(path.subpaths.first().is_some_and(|s| !s.closed), "the open path"); + let child = |id| kids.iter().find(|c| c.id == id).unwrap(); + let xs: Vec = rec.members.iter().map(|m| child(m.id).geometric_bounds().unwrap().x0).collect(); + assert_eq!(xs, [20.0, 70.0, 120.0, 170.0], "the objects in the file's order"); + for m in &rec.members { + let center = child(m.id).geometric_bounds().unwrap().center(); + assert_eq!((m.angle, m.frame), (-0.5, kurbo::Affine::rotate_about(-0.5, center)), "the frame turned counterclockwise"); + } + assert!((rec.rotate - 30.0).abs() < 1e-9, "{}", rec.rotate); + assert_eq!(rec.pivot, 0); + let (plain, none) = on_path(&layer(&group)); + assert!(none.is_none()); + let kinds = |g: &Node| g.children().unwrap().iter().map(|c| c.kind.clone()).collect::>(); + assert_eq!(kinds(&g), kinds(&plain), "the art stays where it is drawn"); +} + +/// A group on a closed path opens as an Objects on Path group too. +#[test] +fn an_objects_on_path_group_on_a_closed_path_opens_live() { + use vectorcraft_testkit::ai::{on_path_options, on_path_record}; + let record = on_path_record(ALIGN_ON_PATH, &ON_PATH_IDS, &on_path_options(4, 4, 0.0, "")); + let (g, rec) = on_path(&format!("{record}{}", layer(&on_path_group(CLOSED_PATH, &ON_PATH_IDS, 0.0)))); + let rec = rec.expect("an Objects on Path group"); + let NodeKind::Path { path, .. } = &g.children().unwrap().iter().find(|c| c.id == rec.path).unwrap().kind else { panic!("the path") }; + assert!(path.subpaths.first().is_some_and(|s| s.closed), "the closed path"); + assert_eq!((rec.members.len(), rec.pivot, rec.rotate), (4, 4, 0.0)); +} + +/// A record the group can't be read from, or with options an Objects on Path group doesn't hold, +/// leaves the group a plain group with its art. +#[test] +fn an_objects_on_path_record_that_doesnt_fit_leaves_a_plain_group() { + use vectorcraft_testkit::ai::{on_path_options, on_path_record}; + let [p, a, b, c, d] = ON_PATH_IDS; + let options = |more: &str| on_path_options(4, 4, 0.0, more); + let good = options(""); + let group = on_path_group(OPEN_PATH, &ON_PATH_IDS, 0.0); + assert!(on_path(&format!("{}{}", on_path_record(ALIGN_ON_PATH, &ON_PATH_IDS, &good), layer(&group))).1.is_some()); + let cases: Vec<(&str, String, String)> = vec![ + ("the path's id is missing", on_path_record(ALIGN_ON_PATH, &["0a6d1e52-ffff", a, b, c, d], &good), group.clone()), + ("an object's id is missing", on_path_record(ALIGN_ON_PATH, &[p, a, b, c], &good), group.clone()), + ("an id twice", on_path_record(ALIGN_ON_PATH, &[p, a, b, b, d], &good), group.clone()), + ("objects out of order", on_path_record(ALIGN_ON_PATH, &[p, b, a, c, d], &good), group.clone()), + ("a spacing", on_path_record(ALIGN_ON_PATH, &ON_PATH_IDS, &options("%_56.5 /Real (Spacing) ,\n")), group.clone()), + ("an option this reader doesn't know", on_path_record(ALIGN_ON_PATH, &ON_PATH_IDS, &options("%_1 /Bool (Flip) ,\n")), group.clone()), + ( + "uneven spacing", + on_path_record(ALIGN_ON_PATH, &ON_PATH_IDS, &good.replace("1 /Bool (IsEquiSpaced)", "0 /Bool (IsEquiSpaced)")), + group.clone(), + ), + ( + "a later start", + on_path_record(ALIGN_ON_PATH, &ON_PATH_IDS, &good.replace("0 /Real (StartRangeFraction)", "0.25 /Real (StartRangeFraction)")), + group.clone(), + ), + ("a pivot past 8", on_path_record(ALIGN_ON_PATH, &ON_PATH_IDS, &on_path_options(4, 9, 0.0, "")), group.clone()), + ("another constraint", on_path_record("Some Other Constraint", &ON_PATH_IDS, &good), group.clone()), + ( + "another version", + on_path_record(ALIGN_ON_PATH, &ON_PATH_IDS, &good).replace("%_1 /Int (Constraint Verison)", "%_2 /Int (Constraint Verison)"), + group.clone(), + ), + ("an object without its angle", on_path_record(ALIGN_ON_PATH, &ON_PATH_IDS, &good), group.replace("%_(0) /String (BBAccumRotation) ,\n", "")), + ( + "another child in the group", + on_path_record(ALIGN_ON_PATH, &ON_PATH_IDS, &good), + group.replace("U\n", &format!("{}f\nU\n", rect_path(0.0, 0.0, 5.0, 5.0))), + ), + ("a path without its key", on_path_record(ALIGN_ON_PATH, &ON_PATH_IDS, &good), group.replace("%_1 /Bool (KeyPath) ,\n", "")), + ]; + for (why, record, group) in cases { + let (g, rec) = on_path(&format!("{record}{}", layer(&group))); + assert!(rec.is_none(), "{why}"); + assert!(matches!(g.kind, NodeKind::Group { clip: false, .. }), "{why}"); + assert!(g.children().unwrap().len() >= 5, "{why}: the art is kept"); + } +} + +/// The copy of the drawn group that follows it as versioned content is skipped: the group opens once, +/// live. +#[test] +fn an_objects_on_path_group_in_versioned_content_opens_once() { + use vectorcraft_testkit::ai::{on_path_options, on_path_record}; + let record = on_path_record(ALIGN_ON_PATH, &ON_PATH_IDS, &on_path_options(4, 4, 0.0, "")); + let group = on_path_group(OPEN_PATH, &ON_PATH_IDS, 0.0); + let copy = group.replace("/ConstraintGroup :\n128 /GroupType ,\n;\n", ""); + let versioned = format!("%AI17_Begin_Content_if_version_gt:24 14\n{group}%AI17_Alternate_Content\n{copy}%AI17_End_Versioned_Content\n"); + let doc = read_ok(&format!("{record}{}", layer(&versioned))).doc; + let kids = art(&doc); + assert_eq!(kids.len(), 1); + assert_eq!(kids[0].children().unwrap().len(), 5); + assert!(kids[0].extras().objects_on_path.is_some()); +} diff --git a/crates/testkit/src/ai.rs b/crates/testkit/src/ai.rs index c7bbcbff1..d94423ef6 100644 --- a/crates/testkit/src/ai.rs +++ b/crates/testkit/src/ai.rs @@ -1,6 +1,7 @@ //! Hand-written Illustrator editing data (the format `vectorcraft_eps`'s reader documents) and the //! EPS, `.ai` and PDF files that carry it, for import tests. Nothing here comes from files another -//! app wrote, except the key names that [`pdf`] uses and the XML node keys of [`variable_library`]. +//! app wrote, except the key names that [`pdf`] uses, the XML node keys of [`variable_library`] and +//! the keys of [`on_path_record`] and [`on_path_member`]. /// Editing data of a `width` × `height` page: header comments (the art's bounding box and the crop /// marks are the page), `body` (setup and layers), the trailer. @@ -77,6 +78,41 @@ pub fn variable_library(variables: &[&str], data_sets: &[&str]) -> String { ) } +/// The document data with an Objects on Path record: a `/Document` dictionary whose `Constraint +/// Data` array holds a record named `name` (version 1) for the objects with the ids `ids` (the +/// path's first), with the options `options` ([`on_path_options`]). +pub fn on_path_record(name: &str, ids: &[&str], options: &str) -> String { + let ids: String = ids.iter().map(|id| format!("%_({id}) /UnicodeString ,\n")).collect(); + format!( + "%AI9_BeginDocumentData\n%_/Document :\n%_/Dictionary :\n%_/Array :\n%_/Dictionary :\n%_1 /Int (Constraint Verison) ,\n\ + %_({name}) /UnicodeString (Constraint Name) ,\n%_/Array :\n{ids}%_; (Constraint IDs) ,\n%_/Dictionary :\n{options}%_; (Constraint Dict) ,\n\ + %_; ,\n%_; (Constraint Data) ,\n%_; /Recorded ,\n%_;\n%AI9_EndDocumentData\n" + ) +} + +/// The options of an Objects on Path record for `members` objects: Pivot `pivot` (0 to 8), +/// Rotate `rotation` (radians), spaced evenly from the start of the path, then `more` entries. +pub fn on_path_options(members: usize, pivot: u8, rotation: f64, more: &str) -> String { + let data = "%_/Dictionary :\n%_; ,\n".repeat(members); + let order: String = (0..members).map(|i| format!("%_{i} /Int ,\n")).collect(); + format!( + "%_0 /Real (StartRangeFraction) ,\n%_/Array :\n{data}%_; (ConstraintArtData) ,\n%_/Array :\n{order}%_; (ObjectsOrder) ,\n\ + %_{pivot} /Int (PivotPosition) ,\n%_1 /Bool (IsEquiSpaced) ,\n%_{rotation} /Real (Rotation) ,\n{more}" + ) +} + +/// The dictionary of an object of an Objects on Path group, with the id `id`: the path's +/// (`order` `None`), or the object's at `order` along the path whose frame is turned `angle` +/// radians counterclockwise. +pub fn on_path_member(id: &str, order: Option, angle: f64) -> String { + match order { + None => format!("%_/ArtDictionary :\n%_({id}) /String (Persistent ID) ,\n%_1 /Bool (KeyPath) ,\n%_;\n%_\n"), + Some(i) => format!( + "%_/ArtDictionary :\n%_{i} /Int (ConstraintArtOrderId) ,\n%_({id}) /String (Persistent ID) ,\n%_({angle}) /String (BBAccumRotation) ,\n%_;\n%_\n" + ), + } +} + /// A document of 200 × 100 pt with a bit of everything the reader reads. A layer `Art` that shows: /// a group `Pair` (a red square `Red` and a hidden one), a compound path `Ring`, a clipping group /// `Window` (a grey square seen through a smaller one), what [`page_ps`] and [`page_pdf`] draw. A diff --git a/docs/file-format-parity.md b/docs/file-format-parity.md index 10ad24c79..de7e1cfce 100644 --- a/docs/file-format-parity.md +++ b/docs/file-format-parity.md @@ -84,7 +84,7 @@ private editing data. VectorCraft: ### Illustrator's private editing data -the app's own editing copy of a file (an EPS after its `%%EOF`, a `.ai`'s `AIPrivateData` streams, a PDF's `AIPDFPrivateData` streams when it was saved with the editing data (#868), Zstandard or zlib, the EPS's in ASCII85) is not documented as a whole. VectorCraft reads its structure: layers and sublayers (names, order, visibility, lock, printing, preview, dimming, colour), groups as they were nested, compound paths (painted by their last path, or the last painted one, with the paths of groups inside them kept as their paths: #1410), clipping groups and layer clipping, object names, hidden and locked objects, fills and strokes (grey, CMYK, RGB, named and spot colours, spot colours defined in Lab with their Lab values (#1032), global process colours as global swatches at their tint), linear and radial gradients, pattern fills with their patterns as pattern swatches (#1025), opacity, blend modes, isolation and knockout, embedded images where their matrix puts them, rotated ones too (#2269), with their alpha channel (CMYK images keep their ink amounts, as they do when a `.ai` opens from its PDF part, and an alpha channel that hides part of a CMYK image becomes a grayscale opacity mask), placed (linked) files as linked images in their place, each showing the low-resolution preview the file keeps of it until its file is found or relinked in the Links panel (#1264), guides, and every artboard where it is. A gradient with stops in the middle runs its whole ramp across its shape, and a gradient object does not pass its fill to the next path (#1390). An object whose art style is one fill and one stroke at most (solid colours, its own gradient, a variable-width profile) with an Outer Glow or Drop Shadow comes in as itself with them; one with more, other effects, a brush or dashes aligned to corners comes in as its drawn look (a group named after it). Type keeps its art style as a live appearance whatever its stack, so it can be retyped: each fill and stroke of the style's stack (solid colours, gradients and pattern fills, placed as the drawn look places them) with its own opacity, blend mode and effects, the effects on the whole object, and where the characters paint among them; the effects read are Warp, Offset Path, Transform (with copies), Drop Shadow, Outer Glow, Feather, Gaussian Blur and Bas Relief (its settings at their defaults), and a style with others keeps the type's drawn look. The page's drawing of such a style isn't held to look like VectorCraft's where its drawn look lay. Gradients take their matrix (`Xm`, radials and skewed ones too), focal point and the order of stops sharing a ramp point; gradient meshes, a clipping path's own fill and the artboards' names are read too. `editingData: "always"` (`convert --editing-data`) opens a `.ai` or an Illustrator EPS from its editing data even where it draws differently from its page, which can be older, with a note. Type is made from the file's text document, whole and editable, shown or not: point type (its frame's matrix, which newer files leave out when it is neither turned nor scaled, read as none), area type (a story in several frames as threaded type) and type on a path, with its characters, fonts, size, leading, tracking, scaling, baseline shift, fill and stroke (grey, RGB or CMYK), alignment, indents and paragraph spacing (a character attribute a run doesn't set comes from its character style, then the style that one is based on, then the document's defaults); the page's drawing of it (often in pieces) is left out. When it makes the type of every text object that shows, the page's type is only compared with it, each text object keeps its own group, frame and layer, and a line break inside a paragraph stays one. Where it can't make a text object's type (a frame of another kind), that text object takes the page's type where the text document puts its lines, or else what is left of the page's type in painting order (no group around it); the page's type where made type draws stays that type's, and hidden type is left out with a note. Kerning, underline and the other character and paragraph options aren't read yet. A `.ai` saved without PDF compatibility (a placeholder page) opens from its editing data alone, its point type, area type and type on a path made from the text document. A `.ai` whose PDF part shows only text isn't taken for that placeholder when its page states an art, trim or bleed box: it opens from its editing data alone where that reads, else from its PDF part (#1387). A `.ai` whose PDF part draws nothing at all (an empty page, with or without those boxes) opens from its editing data alone where that reads too: there is no page art to check its layers against, and the empty page would lose all of it. Text objects with an appearance keep their own text and frame, type of hidden layers stays hidden, and type on later artboards lands on its text objects (#1391); area type whose frame is too short for a line of the fallback font shows its first line (the frame is made taller, with a warning in the open result) (#1392). Symbols, pattern fills whose pattern it can't read, placed files the data keeps no preview of and anything else it doesn't read make the file come in as its page (on a layer that doesn't show, they are left out of that layer), with a warning saying what. An unread object on a shown layer whose box can be found instead comes in as a group of the page's objects inside that box, named after it, with the rest of the layers and groups kept and a warning listing what came in as the page's art; the file still comes in as its page when live art or another unread object shares that page art, when the object spans two artboards, or when the page art doesn't carry its transparency (#4806). The layers' art is also compared with the page (by lightness, so colours written as their CMYK equivalents compare alike, and by whether a pixel is inked at all); the page is used when they differ by more than 5% (away from type, when all the type is made from the text document), where one draws an object the other doesn't (away from type, whose lines may be laid out afresh), and when an EPS whose page is its art's box has art printed outside it (#735). An EPS whose page is its art's box keeps that page when it comes in through its layers, not the editing data's artboard, and a text object counts as drawn by the page by its colours, not only its lightness, so a red stroke stacked over a dark red one stays (#1102). `textAs: "outlines"` opens a `.ai` whose type shows from its PDF part, which has the outlines; `editingData: false` opens any of them as its page or PDF part only, for print pipelines (#735). A file whose import left something out (hidden text, art or layers) is never written over by an export or Save As to its own path unless `acknowledgeLoss: true` (`convert --replace-lossy`), so the original keeps what the document lacks. Where a `.ai` can't be read that way (or its layers are turned off in the import options), we read its PDF-compatible part, so Illustrator-only live objects arrive as appearance, and plain groups arrive ungrouped: that part draws each object on its own and marks only layers, clipping groups and groups with opacity, blending or a mask (those come back). Art outside the artboards isn't in that part at all (Illustrator's PDF part leaves it out), so it doesn't open; the import says so in a note (#472). Its pages still go where the editing data puts the artboards (a grid stays a grid), with the art on each, when it has as many artboards as pages, of the same sizes (#1068). A `.ai` whose PDF part's pages are cropped to its art (each page the box of all the art, which is the editing data's bounding box) instead of its artboards opens from its layers, with its own artboards, where they draw as that page does. Its pages are laid out so that art one of them draws past its artboard (notes beside a design, which the part doesn't clip) doesn't lie over the page before it in the row, where it was taken for that page's art: the check against the editing data then found art its layers don't have there and opened the file as its PDF part, without the art outside the artboards. That part also has no paragraphs: area type is written line by line, and comes back as area type only where its font is installed and lays the lines out with the same breaks, else as a point type object per line (#508). Art off the page that a PDF does draw opens on the pasteboard. +the app's own editing copy of a file (an EPS after its `%%EOF`, a `.ai`'s `AIPrivateData` streams, a PDF's `AIPDFPrivateData` streams when it was saved with the editing data (#868), Zstandard or zlib, the EPS's in ASCII85) is not documented as a whole. VectorCraft reads its structure: layers and sublayers (names, order, visibility, lock, printing, preview, dimming, colour), groups as they were nested, compound paths (painted by their last path, or the last painted one, with the paths of groups inside them kept as their paths: #1410), clipping groups and layer clipping, object names, hidden and locked objects, fills and strokes (grey, CMYK, RGB, named and spot colours, spot colours defined in Lab with their Lab values (#1032), global process colours as global swatches at their tint), linear and radial gradients, pattern fills with their patterns as pattern swatches (#1025), opacity, blend modes, isolation and knockout, embedded images where their matrix puts them, rotated ones too (#2269), with their alpha channel (CMYK images keep their ink amounts, as they do when a `.ai` opens from its PDF part, and an alpha channel that hides part of a CMYK image becomes a grayscale opacity mask), placed (linked) files as linked images in their place, each showing the low-resolution preview the file keeps of it until its file is found or relinked in the Links panel (#1264), guides, and every artboard where it is. Objects on Path groups open live, with their path, their objects in order along it, Pivot and Rotate; one with a dragged spacing or other options opens as a plain group. A gradient with stops in the middle runs its whole ramp across its shape, and a gradient object does not pass its fill to the next path (#1390). An object whose art style is one fill and one stroke at most (solid colours, its own gradient, a variable-width profile) with an Outer Glow or Drop Shadow comes in as itself with them; one with more, other effects, a brush or dashes aligned to corners comes in as its drawn look (a group named after it). Type keeps its art style as a live appearance whatever its stack, so it can be retyped: each fill and stroke of the style's stack (solid colours, gradients and pattern fills, placed as the drawn look places them) with its own opacity, blend mode and effects, the effects on the whole object, and where the characters paint among them; the effects read are Warp, Offset Path, Transform (with copies), Drop Shadow, Outer Glow, Feather, Gaussian Blur and Bas Relief (its settings at their defaults), and a style with others keeps the type's drawn look. The page's drawing of such a style isn't held to look like VectorCraft's where its drawn look lay. Gradients take their matrix (`Xm`, radials and skewed ones too), focal point and the order of stops sharing a ramp point; gradient meshes, a clipping path's own fill and the artboards' names are read too. `editingData: "always"` (`convert --editing-data`) opens a `.ai` or an Illustrator EPS from its editing data even where it draws differently from its page, which can be older, with a note. Type is made from the file's text document, whole and editable, shown or not: point type (its frame's matrix, which newer files leave out when it is neither turned nor scaled, read as none), area type (a story in several frames as threaded type) and type on a path, with its characters, fonts, size, leading, tracking, scaling, baseline shift, fill and stroke (grey, RGB or CMYK), alignment, indents and paragraph spacing (a character attribute a run doesn't set comes from its character style, then the style that one is based on, then the document's defaults); the page's drawing of it (often in pieces) is left out. When it makes the type of every text object that shows, the page's type is only compared with it, each text object keeps its own group, frame and layer, and a line break inside a paragraph stays one. Where it can't make a text object's type (a frame of another kind), that text object takes the page's type where the text document puts its lines, or else what is left of the page's type in painting order (no group around it); the page's type where made type draws stays that type's, and hidden type is left out with a note. Kerning, underline and the other character and paragraph options aren't read yet. A `.ai` saved without PDF compatibility (a placeholder page) opens from its editing data alone, its point type, area type and type on a path made from the text document. A `.ai` whose PDF part shows only text isn't taken for that placeholder when its page states an art, trim or bleed box: it opens from its editing data alone where that reads, else from its PDF part (#1387). A `.ai` whose PDF part draws nothing at all (an empty page, with or without those boxes) opens from its editing data alone where that reads too: there is no page art to check its layers against, and the empty page would lose all of it. Text objects with an appearance keep their own text and frame, type of hidden layers stays hidden, and type on later artboards lands on its text objects (#1391); area type whose frame is too short for a line of the fallback font shows its first line (the frame is made taller, with a warning in the open result) (#1392). Symbols, pattern fills whose pattern it can't read, placed files the data keeps no preview of and anything else it doesn't read make the file come in as its page (on a layer that doesn't show, they are left out of that layer), with a warning saying what. An unread object on a shown layer whose box can be found instead comes in as a group of the page's objects inside that box, named after it, with the rest of the layers and groups kept and a warning listing what came in as the page's art; the file still comes in as its page when live art or another unread object shares that page art, when the object spans two artboards, or when the page art doesn't carry its transparency (#4806). The layers' art is also compared with the page (by lightness, so colours written as their CMYK equivalents compare alike, and by whether a pixel is inked at all); the page is used when they differ by more than 5% (away from type, when all the type is made from the text document), where one draws an object the other doesn't (away from type, whose lines may be laid out afresh), and when an EPS whose page is its art's box has art printed outside it (#735). An EPS whose page is its art's box keeps that page when it comes in through its layers, not the editing data's artboard, and a text object counts as drawn by the page by its colours, not only its lightness, so a red stroke stacked over a dark red one stays (#1102). `textAs: "outlines"` opens a `.ai` whose type shows from its PDF part, which has the outlines; `editingData: false` opens any of them as its page or PDF part only, for print pipelines (#735). A file whose import left something out (hidden text, art or layers) is never written over by an export or Save As to its own path unless `acknowledgeLoss: true` (`convert --replace-lossy`), so the original keeps what the document lacks. Where a `.ai` can't be read that way (or its layers are turned off in the import options), we read its PDF-compatible part, so Illustrator-only live objects arrive as appearance, and plain groups arrive ungrouped: that part draws each object on its own and marks only layers, clipping groups and groups with opacity, blending or a mask (those come back). Art outside the artboards isn't in that part at all (Illustrator's PDF part leaves it out), so it doesn't open; the import says so in a note (#472). Its pages still go where the editing data puts the artboards (a grid stays a grid), with the art on each, when it has as many artboards as pages, of the same sizes (#1068). A `.ai` whose PDF part's pages are cropped to its art (each page the box of all the art, which is the editing data's bounding box) instead of its artboards opens from its layers, with its own artboards, where they draw as that page does. Its pages are laid out so that art one of them draws past its artboard (notes beside a design, which the part doesn't clip) doesn't lie over the page before it in the row, where it was taken for that page's art: the check against the editing data then found art its layers don't have there and opened the file as its PDF part, without the art outside the artboards. That part also has no paragraphs: area type is written line by line, and comes back as area type only where its font is installed and lays the lines out with the same breaks, else as a point type object per line (#508). Art off the page that a PDF does draw opens on the pasteboard. ## Format by format diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 4712f9749..7fd540e93 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -1063,6 +1063,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur In a `.ai` opened from its editing data, a text object on a layer that shows and doesn't print stays on that layer, made from the file's text document, even where the page doesn't draw it (`crates/eps/src/import/tests_native.rs`). Such a `.ai` opens without the PDF part's warnings that text was kept as outlines when no outlined glyphs come in, and a warning counts the text objects that hold the page's type next to glyphs the page has only as outlines, which don't come in (`crates/engine/src/cmd/fileio/tests_aiimport.rs`). Where the art of a `.ai` or EPS opened from its editing data differs a little from the file's page, the warning gives the share of the page that differs away from type. When that share is 0.2% or less, the warning names the type and gives the share of the page that differs where the type is; when all the type is made from the text document, the type's share has a warning of its own beside the art's (`crates/eps/src/import/tests_native.rs`). PDF text opens with every glyph where the file draws it (#722): the font's kerning or none, whichever the file followed, word and character spacing, `TJ` adjustments and horizontal scaling kept per character as tracking, text rise as baseline shift within its line, vertical glyphs narrower than an em centred on their column (`/W2`, `/W`, `/DW`), justified text boxes with word spacing back as justified area type (`tests_text_placement`, fixtures written from ISO 32000-1 §9). A `.ai` or PDF whose editing data holds a variable library opens with a warning that its variables and data sets weren't read, with how many of each it has, whether its layers are read or it comes in as its PDF part (`crates/engine/src/cmd/fileio/tests_aiimport.rs`). + A `.ai` opened from its editing data opens its Objects on Path groups live, with their path, their objects in order along it, Pivot and Rotate, and their art where the file draws it; a group whose spacing has been dragged, or with options an Objects on Path group doesn't hold, opens as a plain group (`crates/eps/src/import/ai/tests.rs`). A PDF page whose `UserUnit` (PDF 1.6) is above 1 opens at its size in points, its numbers times the `UserUnit` (one above 75,000 counts as 75,000), and `document.pdfInfo` gives its size and boxes in points too. A `.ai` whose editing data has its artboards in the units of such a page opens from that data at the page's size, with its layers, and when the layers can't be read its pages still take the artboard layout of the data, scaled the same way (part of #1482). When the editing data of a `.ai` or EPS is for a page of another size than the file's, the warning that its layers weren't read gives both sizes in points, and the scale of the editing data's page to the file's page (1:10 for a tenth of its size) when their sides are in proportion (#2034; `crates/eps/src/import/tests_native.rs`). A `.ai`, or a PDF saved with Illustrator's editing data, whose editing data can't be decompressed opens from its PDF part with a warning that its layers weren't read and why, and an export or Save As to its own path asks first (`acknowledgeLoss`), as for a file whose layers the reader can't read (`crates/eps/src/import/tests_native.rs`, `crates/engine/src/cmd/fileio/tests_aiimport.rs`). From af133b1ee4eedd181ffe9c02bfc65fbc428928a7 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Sat, 31 Oct 2026 20:15:31 -0700 Subject: [PATCH 03/12] .ai import: envelopes open as live envelopes An envelope in a .ai's editing data opened as its distorted art, with its mesh and contents dropped. It now opens as a live envelope of its contents: a warp with its style, bend, distortions and orientation, a mesh with its points and handles, or a top object with its outline, with Envelope Options, Fidelity and Edit Contents. The envelope replaces its drawn art only when VectorCraft draws it like that art, with a budget so that live envelopes don't make the file open from its PDF part. When the layers still differ too much from the page, the drawn art is put back. An envelope that can't be read, or that draws differently, keeps its drawn art with a note. Envelopes of type keep their drawn art and hidden type as before. --- crates/engine/tests/import_fuzz.rs | 40 ++ crates/eps/src/import/ai.rs | 2 + crates/eps/src/import/ai/envelope.rs | 361 +++++++++++++++ crates/eps/src/import/ai/read.rs | 21 +- crates/eps/src/import/ai/read/containers.rs | 100 +++- crates/eps/src/import/ai/read/document.rs | 14 +- crates/eps/src/import/ai/read/ops.rs | 2 + crates/eps/src/import/ai/read/types.rs | 15 + crates/eps/src/import/ai/style.rs | 6 + crates/eps/src/import/ai/tests.rs | 482 ++++++++++++++++++++ crates/eps/src/import/native.rs | 2 +- crates/eps/src/import/native/build.rs | 47 +- crates/eps/src/import/native/compare.rs | 10 +- crates/eps/src/import/tests_native.rs | 34 ++ docs/target-app-parity.md | 1 + 15 files changed, 1124 insertions(+), 13 deletions(-) create mode 100644 crates/eps/src/import/ai/envelope.rs diff --git a/crates/engine/tests/import_fuzz.rs b/crates/engine/tests/import_fuzz.rs index e09c91e86..19c6ef93a 100644 --- a/crates/engine/tests/import_fuzz.rs +++ b/crates/engine/tests/import_fuzz.rs @@ -2954,6 +2954,40 @@ proptest! { survive("mutated EPS editing data", || vectorcraft_eps::import(&bytes).ok().map(|r| r.document))?; } + /// A `.ai` whose envelopes are damaged or hostile: any kind, warp style, options word, + /// Fidelity and grid size, corners out of range or repeated, the contents block cut short. + #[test] + fn ai_envelope_never_panics( + kind in prop::sample::select(vec![0i64, 1, 2, 3, 4, -1, 1 << 40]), + style in prop::sample::select(vec![0i64, 1, 8, 15, 16, -3, i64::MAX]), + word in prop::sample::select(vec![0i64, 1, 131073, 65536, 0x2_0007, 1 << 18, -1, i64::MAX]), + fidelity in prop::sample::select(vec!["50", "0", "100", "101", "-1", "1e308", "nan"]), + size in prop::sample::select(vec!["[1 1]", "[3 2]", "[0 0]", "[51 1]", "[100000 100000]", "[-1 2]", "[1e308 1]"]), + corner in prop::sample::select(vec!["10 10", "1e308 -1e308", "nan 0", "10 10 10 10", "0"]), + edit in 0i64..2, + cut in 0usize..2_000, + edits in prop::collection::vec((0usize..2_000, prop::sample::select(vec!['0', '9', '-', ' ', '\n', '(', ')', '/', ':', ';', ',', '[', ']', '%', '_', 'X', 'u', 'U'])), 0..8), + ) { + use vectorcraft_testkit::ai; + let n = |x: &str, y: &str| format!("%_[0.5 1 {x} {y} {x} {y} 1 {x} {y} 1] /N X#\n"); + let mesh = format!( + "%_/Mesh X!\n%_2 /Version X#\n%_/Cartesian /Type X#\n%_{size} /Size X#\n%_/Data X#\n%_[0 0] /P X#\n{}{}{}%_[{corner}] /N X#\n%_/E X#\n%_[0 0] /P X#\n%_/E X#\n%_/End X!\n", + n("10", "10"), + n("10", "90"), + n("90", "90") + ); + let content = "%_u\n%_1 0 0 1 0 0 Xa\n%_10 10 m\n%_10 90 L\n%_90 90 L\n%_90 10 L\n%_f\n%_U\n"; + let keys = format!( + "%_{word} /Int (AIDeformOptionsKey) ,\n%_{fidelity} /Int (AIDeformFidelityKey) ,\n%_{edit} /Bool (AIDeformEditModeKey) ,\n%_{style} /Int (DeformStyle) ,\n%_0.5 /Real (DeformValue) ,\n%_1 /Bool (Rotate) ,\n" + ); + let art = format!( + "%_u\n{mesh}%_U\n%_/ArtDictionary :\n{keys}%_X=\n{content}%_X+\n%_ (AIDeformArtKey) ,\n%_{kind} /Int (AIDeformStyle) ,\n%_;\n%_\nu\n1 0 0 1 0 0 Xa\n12 12 m\n12 88 L\n88 88 L\nf\nU\n(Adobe Deform Plugin) 1 0 0 XP\n0 0 1 1 0 0 Xa\n0 0 m\n5 5 L\nf\n" + ); + let data = mutate_text(&ai::editing_data(100.0, 100.0, &ai::layer("L", &art)), cut, &edits); + let private = [b"%AI24_ZStandard_Data".as_slice(), &zstd(&data)].concat(); + survive("hostile .ai envelope", || Some(vectorcraft_eps::layered_ai(&private, Document::new(100.0, 100.0), vec![], false, false, 1.0).0))?; + } + /// A `.ai` whose editing data is damaged or hostile. #[test] fn ai_editing_data_never_panics(cut in 0usize..3_000, edits in prop::collection::vec((0usize..3_000, prop::sample::select(vec!['0', '9', '-', '.', ' ', '\n', '(', ')', '/', ':', ';', '[', ']', '%', 'q', 'Q', 'W', 'u', 'U', 'L', 'k', 'x'])), 0..12), damage in prop::collection::vec((0usize..4_000, any::()), 0..4)) { @@ -3328,6 +3362,12 @@ fn arb_ai_token() -> impl Strategy { "[1 1] /P X#", "(Adobe Path Blends) 1 0 92 XP", "(Adobe Path Blends) 1 0 999999 XP", + "(Adobe Deform Plugin) 1 0 0 XP", + "%_/ArtDictionary :\n%_131073 /Int (AIDeformOptionsKey) ,\n%_X=\n", + "%_X+\n%_ (AIDeformArtKey) ,\n%_2 /Int (AIDeformStyle) ,\n%_;\n", + "%_1 /Int (AIDeformStyle) ,\n%_9 /Int (DeformStyle) ,\n%_1e308 /Real (DeformValue) ,\n%_;\n", + "%_1 /Bool (AIDeformEditModeKey) ,", + "[1e308 1e308] /Size X#", "\n%44434241010000000000000000000000\n", "\n%0200000041000000000000000000000000000000\n", "\n%4G\n", diff --git a/crates/eps/src/import/ai.rs b/crates/eps/src/import/ai.rs index 50fcec241..c32bf2217 100644 --- a/crates/eps/src/import/ai.rs +++ b/crates/eps/src/import/ai.rs @@ -18,6 +18,7 @@ //! did before (the printed page, or the PDF), with a warning saying why. mod blend; +mod envelope; mod frames; mod lex; mod mesh; @@ -32,6 +33,7 @@ use std::io::Read as _; use super::lex::find; use crate::ps; +pub(crate) use envelope::DRAWN as ENVELOPE_DRAWN; pub(crate) use frames::text_frames; pub use read::{IMAGE_TOO_LARGE, PaletteSwatches, Structure, artboards, palette, read, variables}; pub(crate) use read::{join_and, slot_frame, slot_of}; diff --git a/crates/eps/src/import/ai/envelope.rs b/crates/eps/src/import/ai/envelope.rs new file mode 100644 index 000000000..33fae9eab --- /dev/null +++ b/crates/eps/src/import/ai/envelope.rs @@ -0,0 +1,361 @@ +//! Envelopes from the editing data (`(Adobe Deform Plugin) 1 0 0 XP`), read as live envelopes. +//! +//! What an envelope is and does is Illustrator's public documentation: Make with Warp, Make with +//! Mesh (rows and columns) and Make with Top Object +//! (), +//! Edit Contents +//! (), +//! Envelope Options +//! () +//! and the warp styles with their bend and distortions +//! (). +//! +//! An envelope is written in three parts. On `%_` lines, a group holding the mesh that shapes it, +//! then its `/ArtDictionary`: Envelope Options as a bit word (`AIDeformOptionsKey`), Fidelity +//! (`AIDeformFidelityKey`), Edit Contents (`AIDeformEditModeKey`), a warp's style (`DeformStyle`, +//! numbered as Effect › Warp lists them), bend and distortions (`DeformValue`, `DeformHoriz`, +//! `DeformVert`, fractions) and orientation (`Rotate`), the contents (`X=` … `X+`, then +//! `(AIDeformArtKey)`) and the kind (`AIDeformStyle`: 1 a warp, 2 a mesh, 3 a top object). Then the +//! distorted art on plain lines, and the plugin's `XP`. While the contents are edited, the group +//! holds the contents and the dictionary the mesh. A top object's outline is the edge of its +//! one-patch mesh. An envelope whose parts don't read, or that doesn't come out as drawn, keeps its +//! drawn art. + +use std::collections::BTreeMap; +use std::sync::Arc; + +use kurbo::{BezPath, Point, Rect, Vec2}; +use vectorcraft_doc::live::{EnvelopeKind, EnvelopeOptions, GradientMesh, H_DOWN, H_LEFT, H_RIGHT, H_UP, PreserveShape, default_fidelity}; +use vectorcraft_doc::{Document, ImageBlob, LayerColor, Node, NodeId, NodeKind, PatternDef}; +use vectorcraft_geom::PathData; + +use super::obj::Obj; +use crate::import::native::compare::{MAX_DIFFERENCE, MAX_SOLID_DIFFERENCE, compare_scale, differ, differ_in_colour}; + +/// The plugin that writes envelopes. +pub(super) const PLUGIN: &str = "Adobe Deform Plugin"; // brand-ok: the plugin name the files use +/// The note for an envelope that keeps its drawn art. +pub(crate) const DRAWN: &str = "an envelope VectorCraft couldn't read as a live envelope comes in as its drawn art"; +/// The most envelopes waiting for their drawn art at once. +pub(super) const MAX_PENDING: usize = 64; +/// The most envelopes one import draws to check; more keep their drawn art. +pub(super) const MAX_CHECKED: usize = 256; +/// The most rows or columns of a mesh read (Make with Mesh's own limit). +const MAX_GRID: u32 = 50; +/// The bits of the options word that are read: Distort Appearance, Distort Pattern Fills, Distort +/// Linear Gradients, Anti-Alias (set: off) and Preserve Shape Using (set: Clipping Mask). +const KNOWN_BITS: i64 = 0b111 | 1 << 16 | 1 << 17; +/// More of the inked pixels than this differing, each with all its neighbours, and the envelope +/// doesn't come out as drawn. +const MAX_SOLID: f64 = 0.03; +/// More of the inked pixels than this differing at all, and the envelope doesn't come out as drawn. +const MAX_ANY: f64 = 0.1; + +/// The whole number of entry `key`: `None` when it is missing, `Some(None)` when it isn't one. +fn whole(o: &Obj, key: &str) -> Option> { + o.get(key)?; + let n = o.nums(key); + Some(match n.as_slice() { + [v] if v.is_finite() && v.fract() == 0.0 && v.abs() < 1e12 => Some(*v as i64), + _ => None, + }) +} + +/// A switch (`0` or `1`), off when missing. +fn switch(o: &Obj, key: &str) -> Option { + match whole(o, key) { + None | Some(Some(0)) => Some(false), + Some(Some(1)) => Some(true), + _ => None, + } +} + +/// Envelope Options and Fidelity. +pub(super) fn options(o: &Obj) -> Option<(EnvelopeOptions, f64)> { + let word = whole(o, "AIDeformOptionsKey")??; + if word < 0 || word & !KNOWN_BITS != 0 { + return None; + } + let fidelity = match whole(o, "AIDeformFidelityKey") { + None => default_fidelity(), + Some(Some(f)) if (0..=100).contains(&f) => f as f64, + Some(_) => return None, + }; + let bit = |n: u32| word & (1 << n) != 0; + let options = EnvelopeOptions { + anti_alias: !bit(16), + preserve_shape: if bit(17) { PreserveShape::ClippingMask } else { PreserveShape::Transparency }, + distort_appearance: bit(0), + distort_pattern_fills: bit(1), + distort_linear_gradients: bit(2), + }; + Some((options, fidelity)) +} + +/// A warp: its style, bend, distortions and orientation. +fn warp(o: &Obj) -> Option { + let style = whole(o, "DeformStyle")??; + let id = super::style::warp_style(style)?; + let percent = |key: &str| -> Option { + if o.get(key).is_none() { + return Some(0.0); + } + match o.nums(key).as_slice() { + [v] if v.is_finite() && (-1.0..=1.0).contains(v) => Some(v * 100.0), + _ => None, + } + }; + o.get("DeformValue")?; + let vertical = switch(o, "Rotate")?; + Some(EnvelopeKind::Warp { + style: id.into(), + bend: percent("DeformValue")?, + h: percent("DeformHoriz")?, + v: percent("DeformVert")?, + horizontal: !vertical, + }) +} + +/// The gradient mesh `n` is, or the one its plain groups hold alone. +fn only_mesh(n: &Node) -> Option<&GradientMesh> { + match &n.kind { + NodeKind::Mesh(m) => Some(m), + NodeKind::Group { children, clip: false } => match children.as_slice() { + [c] => only_mesh(c), + _ => None, + }, + _ => None, + } +} + +/// A grid point and its handles. +type GridPoint = (Point, [Vec2; 4]); + +/// The points of mesh `m` as a `rows`×`cols` grid with its first row at the top and its first +/// column at the left (y down), as an envelope's mesh lays them out; `None` for a grid out of range +/// or with points that aren't finite. +fn upright(m: &GradientMesh) -> Option<(u32, u32, Vec)> { + let (mut rows, mut cols) = (m.rows, m.cols); + if rows == 0 || cols == 0 || rows > MAX_GRID || cols > MAX_GRID { + return None; + } + let len = (rows as usize + 1) * (cols as usize + 1); + if m.points.len() != len { + return None; + } + let finite = |v: Vec2| v.x.is_finite() && v.y.is_finite(); + if !m.points.iter().all(|q| finite(q.p.to_vec2()) && q.handles.iter().all(|h| finite(*h))) { + return None; + } + let mut pts: Vec = m.points.iter().map(|q| (q.p, q.handles)).collect(); + let at = |pts: &[GridPoint], cols: u32, r: u32, c: u32| pts.get((r * (cols + 1) + c) as usize).map_or(Point::ZERO, |q| q.0); + let remap = |h: [Vec2; 4], slots: [usize; 4]| slots.map(|s| h.get(s).copied().unwrap_or(Vec2::ZERO)); + // Columns that run down the page: the grid goes the other way round. + let along = at(&pts, cols, 0, cols) - at(&pts, cols, 0, 0); + if along.y.abs() > along.x.abs() { + let mut t = Vec::with_capacity(len); + for r in 0..=cols { + for c in 0..=rows { + let (p, h) = pts.get((c * (cols + 1) + r) as usize).copied()?; + // The new next column is the old next row. + let mut slots = [0; 4]; + (slots[H_RIGHT], slots[H_LEFT], slots[H_DOWN], slots[H_UP]) = (H_DOWN, H_UP, H_RIGHT, H_LEFT); + t.push((p, remap(h, slots))); + } + } + (rows, cols) = (cols, rows); + pts = t; + } + if (at(&pts, cols, 0, cols) - at(&pts, cols, 0, 0)).x < 0.0 { + for row in pts.chunks_mut(cols as usize + 1) { + row.reverse(); + for (_, h) in row.iter_mut() { + h.swap(H_RIGHT, H_LEFT); + } + } + } + if (at(&pts, cols, rows, 0) - at(&pts, cols, 0, 0)).y < 0.0 { + let mut flipped: Vec = pts.chunks(cols as usize + 1).rev().flatten().copied().collect(); + for (_, h) in &mut flipped { + h.swap(H_DOWN, H_UP); + } + pts = flipped; + } + Some((rows, cols, pts)) +} + +/// The outline of an upright grid: its edge, clockwise from the top left corner. +fn outline(rows: u32, cols: u32, pts: &[GridPoint]) -> Option { + let at = |r: u32, c: u32| pts.get((r * (cols + 1) + c) as usize).copied(); + // Each edge: from a point by one of its handles to the next point by one of its handles. + type Edge = ((u32, u32), usize, (u32, u32), usize); + let mut ring: Vec = vec![]; + ring.extend((0..cols).map(|c| ((0, c), H_RIGHT, (0, c + 1), H_LEFT))); + ring.extend((0..rows).map(|r| ((r, cols), H_DOWN, (r + 1, cols), H_UP))); + ring.extend((1..=cols).rev().map(|c| ((rows, c), H_LEFT, (rows, c - 1), H_RIGHT))); + ring.extend((1..=rows).rev().map(|r| ((r, 0), H_UP, (r - 1, 0), H_DOWN))); + let mut bp = BezPath::new(); + bp.move_to(at(0, 0)?.0); + for ((ar, ac), out, (br, bc), into) in ring { + let ((a, ha), (b, hb)) = (at(ar, ac)?, at(br, bc)?); + bp.curve_to(a + *ha.get(out)?, b + *hb.get(into)?, b); + } + bp.close_path(); + Some(PathData::from_bezpath(&bp)) +} + +/// Has group `n` nothing of its own: no name, lock, transparency, appearance or other properties, +/// shown? +fn plain_group(n: &Node) -> bool { + let plain = Node::new(n.id, NodeKind::Group { children: vec![], clip: false }); + matches!(n.kind, NodeKind::Group { clip: false, .. }) + && (&n.name, n.visible, n.locked, n.opacity, n.blend, n.isolate, n.knockout, n.knockout_shape) + == (&plain.name, plain.visible, plain.locked, plain.opacity, plain.blend, plain.isolate, plain.knockout, plain.knockout_shape) + && n.appearance == plain.appearance + && n.extras() == plain.extras() +} + +/// `nodes` without the plain group the editing data puts round an envelope's contents. +fn unwrapped(mut nodes: Vec) -> Vec> { + if nodes.len() == 1 + && let Some(n) = nodes.first() + && plain_group(n) + && let Some(n) = nodes.pop() + && let NodeKind::Group { children, .. } = n.kind + { + return children; + } + nodes.into_iter().map(Arc::new).collect() +} + +/// The live envelope of dictionary `o`, the group on `%_` lines before it (`before`) and the art +/// in its `AIDeformArtKey` block (`inner`); `None` where they don't make one. +pub(super) fn live(o: &Obj, before: Option, inner: Vec) -> Option { + let editing = switch(o, "AIDeformEditModeKey")?; + let (options, fidelity) = options(o)?; + let before = before?; + // While the contents are edited, they are the group before the dictionary and the mesh is in it. + let (content, shape): (Vec>, Option<&GradientMesh>) = if editing { + let NodeKind::Group { children, clip: false } = &before.kind else { return None }; + let shape = match inner.as_slice() { + [n] => only_mesh(n), + _ => None, + }; + (children.clone(), shape) + } else { + let shape = only_mesh(&before); + (unwrapped(inner), shape) + }; + if content.is_empty() { + return None; + } + let kind = match whole(o, "AIDeformStyle")?? { + 1 => warp(o)?, + 2 => { + let (rows, cols, pts) = upright(shape?)?; + let (points, handles) = pts.into_iter().unzip(); + EnvelopeKind::Mesh { rows, cols, points, handles } + } + 3 => { + let (rows, cols, pts) = upright(shape?)?; + EnvelopeKind::TopObject { path: outline(rows, cols, &pts)? } + } + _ => return None, + }; + Some(NodeKind::Envelope { content, kind: Box::new(kind), fidelity, editing, options, frame: Box::default() }) +} + +/// What the import's documents hold that art refers to: the patterns and image blobs. +pub(super) struct Shared<'a> { + pub(super) patterns: &'a [PatternDef], + pub(super) images: &'a BTreeMap, +} + +/// A document of `node` alone, on a layer with id `layer`, with the patterns and images it shows. +fn alone(node: &Node, layer: NodeId, shared: &Shared<'_>) -> Document { + let mut doc = Document::new(1.0, 1.0); + let mut l = Node::layer(layer, "", LayerColor::Preset(0)); + if let Some(c) = l.children_mut() { + c.push(Arc::new(node.clone())); + } + doc.layers = vec![Arc::new(l)]; + doc.patterns = shared.patterns.to_vec(); + let mut keys = vec![]; + doc.visit_images(|_, im| keys.push(im.key.clone())); + for k in keys { + if let Some(blob) = shared.images.get(&k) { + doc.images.insert(k, blob.clone()); + } + } + doc +} + +/// How `live` and `drawn`, drawn over `region` at `scale`, differ: the pixels that differ in +/// lightness or colour, those whose neighbours all differ too (where one draws something the other +/// doesn't, not an edge drawn a little apart), and the pixels either inks. `None` when they can't +/// be drawn. +fn differing(live: &Document, drawn: &Document, region: Rect, scale: f64) -> Option<(usize, usize, usize)> { + let x = vectorcraft_render::Renderer::new().render_region(live, region, scale, false); + let y = vectorcraft_render::Renderer::new().render_region(drawn, region, scale, false); + if x.width == 0 || x.height == 0 || x.width != y.width || x.height != y.height { + return None; + } + let (w, h) = (x.width as usize, x.height as usize); + let (xs, ys) = (x.pixels.as_chunks::<4>().0, y.pixels.as_chunks::<4>().0); + let differs: Vec = xs.iter().zip(ys).map(|(p, q)| differ(p, q) || differ_in_colour(p, q)).collect(); + let inked = xs.iter().zip(ys).filter(|(p, q)| p[3] > 8 || q[3] > 8).count(); + let at = |i: usize, j: usize| differs.get(j * w + i).copied().unwrap_or(false); + let any = differs.iter().filter(|d| **d).count(); + let solid = (1..h.saturating_sub(1)) + .flat_map(|j| (1..w.saturating_sub(1)).map(move |i| (i, j))) + .filter(|&(i, j)| (j - 1..=j + 1).all(|n| (i - 1..=i + 1).all(|m| at(m, n)))) + .count(); + Some((any, solid, inked)) +} + +/// The differences from their drawn art that the live envelopes of one import may add up to, as +/// shares of the page drawn as the page check draws it: half of what that check allows +/// ([`MAX_DIFFERENCE`], [`MAX_SOLID_DIFFERENCE`]), so live envelopes never make the file open from +/// its PDF part instead. +#[derive(Debug, Default)] +pub(super) struct Spent { + any: f64, + solid: f64, +} + +/// Do `live` and `drawn` draw alike: few of the pixels either inks differ, and fewer still where a +/// whole patch differs; and, on `page` (when the file has one), do they differ little enough that +/// with the differences in `spent` the page check still takes the layers' art (it is added to +/// `spent` when they do)? Both are drawn on `layer`, a layer id no other node has. +pub(super) fn drawn_alike(live: &Node, drawn: &Node, layer: NodeId, shared: &Shared<'_>, page: Option, spent: &mut Spent) -> bool { + let (Some(a), Some(b)) = (live.visual_bounds(), drawn.visual_bounds()) else { return false }; + let region = a.union(b).inflate(2.0, 2.0); + if !(region.is_finite() && region.width() > 0.0 && region.height() > 0.0) || region.width().max(region.height()) > 1e6 { + return false; + } + let (x, y) = (alone(live, layer, shared), alone(drawn, layer, shared)); + let Some((any, solid, inked)) = differing(&x, &y, region, compare_scale(region)) else { return false }; + if inked == 0 { + return false; + } + let share = |n: usize| n as f64 / inked as f64; + if share(solid) > MAX_SOLID || share(any) > MAX_ANY { + return false; + } + let Some(page) = page.filter(|p| p.is_finite() && p.width() > 0.0 && p.height() > 0.0) else { return true }; + // Only what is on the page counts, drawn as the page check draws it (about 400 pixels along the + // page's longer side). + let on_page = region.intersect(page); + let scale = compare_scale(page); + if on_page.width() * scale < 1.0 || on_page.height() * scale < 1.0 { + return true; + } + let Some((any, solid, _)) = differing(&x, &y, on_page, scale) else { return false }; + let pixels = (page.width() * scale * page.height() * scale).max(1.0); + let (any, solid) = (spent.any + any as f64 / pixels, spent.solid + solid as f64 / pixels); + if any > MAX_DIFFERENCE / 2.0 || solid > MAX_SOLID_DIFFERENCE / 2.0 { + return false; + } + *spent = Spent { any, solid }; + true +} diff --git a/crates/eps/src/import/ai/read.rs b/crates/eps/src/import/ai/read.rs index 4791274bd..cad61b744 100644 --- a/crates/eps/src/import/ai/read.rs +++ b/crates/eps/src/import/ai/read.rs @@ -84,7 +84,7 @@ use art::preview_png; pub use document::{PaletteSwatches, VariableLibrary, artboards, palette, variables}; pub(crate) use document::{slot_frame, slot_of}; pub use types::IMAGE_TOO_LARGE; -use types::{BLEND_BUDGET, FLIP, Frame, GState, Kind, PatternHead, Place, Raster}; +use types::{BLEND_BUDGET, FLIP, Frame, GState, Kind, PatternHead, PendingEnvelope, Place, Raster}; #[cfg(test)] pub use types::{NON_NATIVE_ART, NON_NATIVE_ART_UNREAD, VERSIONED_UNENDED}; pub(super) use types::{blend_mode, blend_name}; @@ -121,6 +121,9 @@ pub struct Structure { /// move, in its pixels, that places them there (the corner of the box they are written with, /// in place of the canvas point of art space's origin). pub placed_elsewhere: Vec<(NodeId, Affine)>, + /// The live envelopes read from the editing data, by id, with the drawn art each took the place + /// of: the importer puts that art back when the layers with the envelopes don't draw as the page. + pub envelopes_drawn: Vec<(NodeId, Node)>, pub warnings: Vec, } @@ -200,6 +203,17 @@ struct Reader<'a> { blend_record: Option<(usize, Vec)>, /// How many more objects blends may lay out in this import ([`super::blend::live_blend`]). blend_budget: usize, + /// The group on `%_` lines an envelope's dictionary just took (see [`Reader::drop_envelope_art`]). + envelope_art: Option, + /// Envelopes waiting for their drawn art, innermost last. + envelopes: Vec, + /// How many more envelopes this import may draw to check ([`super::envelope::drawn_alike`]). + envelope_checks: usize, + /// How far the live envelopes read so far draw from their drawn art ([`super::envelope::Spent`]). + envelope_spent: super::envelope::Spent, + /// The live envelopes made so far and the drawn art each took the place of + /// ([`Structure::envelopes_drawn`]). + envelopes_drawn: Vec<(NodeId, Node)>, /// A group just ended, its state (`Xy`, `Xd`, `XW`) not yet read. group_ended: bool, /// A gradient mesh just ended (`/End X!`), its own state (`Xy`) not yet read: also after its @@ -318,6 +332,11 @@ impl<'a> Reader<'a> { blend_roles: HashMap::new(), blend_record: None, blend_budget: BLEND_BUDGET, + envelope_art: None, + envelopes: Vec::new(), + envelope_checks: super::envelope::MAX_CHECKED, + envelope_spent: super::envelope::Spent::default(), + envelopes_drawn: Vec::new(), clip_fills: HashSet::new(), looks: HashSet::new(), ruler_guides: HashMap::new(), diff --git a/crates/eps/src/import/ai/read/containers.rs b/crates/eps/src/import/ai/read/containers.rs index 929f5a0cc..8d69d8114 100644 --- a/crates/eps/src/import/ai/read/containers.rs +++ b/crates/eps/src/import/ai/read/containers.rs @@ -1,6 +1,6 @@ use super::document::slot_of; use super::ops::{paints_apart, path_rule}; -use super::types::{Frame, GState, Kind, MAX_DEPTH, MAX_NODES}; +use super::types::{Frame, GState, Kind, MAX_DEPTH, MAX_NODES, PendingEnvelope}; use super::{AppearanceItem, Arc, BezPath, BlendMode, FillRule, HashSet, LayerColor, Node, NodeId, NodeKind, Obj, Paint, PathEl, Rc, Reader, V, obj}; impl<'a> Reader<'a> { @@ -324,17 +324,30 @@ impl<'a> Reader<'a> { "ArtDictionary" => { let envelope = o.get("AIDeformOptionsKey").is_some(); self.art_dictionary(&o); + let before = self.envelope_art.take(); if envelope { let mut slots = vec![]; for child in &children { envelope_text_slots(child, &mut slots); } + let typed = !slots.is_empty() || before.as_ref().is_some_and(has_text_slot); for mut slot in slots { // The editing data keeps the source text inside the envelope's options; // its visible result is the outlined art the PDF page carries. slot.visible = false; self.add(slot, false)?; } + // An envelope of type keeps its drawn art and its type, hidden, as above: the + // type is filled in after reading, which the envelope can't wait for. + if typed { + return Ok(()); + } + if self.envelopes.len() >= crate::import::ai::envelope::MAX_PENDING { + self.warn(crate::import::ai::envelope::DRAWN); + } else { + let siblings = self.frames.last().map_or(0, |f| f.children.len()); + self.envelopes.push(PendingEnvelope { obj: *o, before, inner: children, depth: self.frames.len(), siblings }); + } } } "Document" => self.document(&o), @@ -467,7 +480,8 @@ impl<'a> Reader<'a> { /// envelope's dictionary. It holds the mesh that shapes the envelope, or, while the envelope's /// contents are being edited, those contents (the mesh then is in the dictionary); either way the /// distorted art follows on plain lines. The source text in the contents stays, hidden, as the - /// dictionary's does. Whether it was dropped. + /// dictionary's does. The group is kept in [`Reader::envelope_art`] for the envelope to be made + /// of. Whether it was dropped. pub(super) fn drop_envelope_art(&mut self) -> bool { let Some(n) = self.last_object() else { return false }; let id = n.id; @@ -483,6 +497,7 @@ impl<'a> Reader<'a> { slot.visible = false; f.children.push(slot); } + self.envelope_art = Some(group); } self.hidden_ids.remove(&id); self.after_object = false; @@ -543,6 +558,87 @@ impl<'a> Reader<'a> { // The blend's own state block follows its record, as a group's follows its end. self.after_object = true; } + + /// An envelope's plugin `XP`: the envelope whose dictionary was read last in this container + /// and its drawn art, the one object read since, become one live envelope, or, where they + /// can't, the drawn art stays (see [`crate::import::ai::envelope`]). Envelopes left waiting + /// in containers that closed keep their drawn art. + pub(super) fn end_envelope(&mut self) { + let depth = self.frames.len(); + while self.envelopes.last().is_some_and(|p| p.depth > depth) { + self.envelopes.pop(); + self.warn(crate::import::ai::envelope::DRAWN); + } + if self.envelopes.last().is_none_or(|p| p.depth != depth) { + return; + } + let Some(p) = self.envelopes.pop() else { return }; + let drawn = self.frames.last().filter(|f| f.children.len() == p.siblings + 1).and_then(|f| f.children.last()); + let Some(drawn) = drawn else { + self.warn(crate::import::ai::envelope::DRAWN); + return; + }; + let live = (self.envelope_checks > 0).then(|| crate::import::ai::envelope::live(&p.obj, p.before, p.inner)).flatten(); + let Some(kind) = live else { + self.warn(crate::import::ai::envelope::DRAWN); + return; + }; + // Objects the reader couldn't read come in as the page's art in their place: the envelope's + // drawn art stays round them. + let mut ids = HashSet::new(); + fn collect(n: &Node, ids: &mut HashSet) { + ids.insert(n.id); + for c in n.children().into_iter().flatten() { + collect(c, ids); + } + } + collect(drawn, &mut ids); + if let NodeKind::Envelope { content, .. } = &kind { + content.iter().for_each(|c| collect(c, &mut ids)); + } + if self.unread.iter().any(|(id, _)| ids.contains(id)) { + self.warn(crate::import::ai::envelope::DRAWN); + return; + } + self.envelope_checks -= 1; + // The drawn art's own properties (name, visibility, lock, transparency, appearance, mask, + // attributes) become the envelope's. + let mut envelope = drawn.clone(); + (envelope.id, envelope.kind) = (self.doc.alloc_id(), kind); + let layer = self.doc.alloc_id(); + let shared = crate::import::ai::envelope::Shared { patterns: &self.doc.patterns, images: &self.doc.images }; + let mut shown = envelope.clone(); + (shown.visible, shown.opacity) = (true, 1.0); + let mut plain = drawn.clone(); + (plain.visible, plain.opacity) = (true, 1.0); + let page = self.page_in_doc(); + if !crate::import::ai::envelope::drawn_alike(&shown, &plain, layer, &shared, page, &mut self.envelope_spent) { + self.warn(crate::import::ai::envelope::DRAWN); + return; + } + // The contents are the envelope's own now, not a copy on `%_` lines. + fn forget(n: &Node, hidden: &mut HashSet) { + hidden.remove(&n.id); + for c in n.children().into_iter().flatten() { + forget(c, hidden); + } + } + forget(&envelope, &mut self.hidden_ids); + if let Some(f) = self.frames.last_mut() + && let Some(slot) = f.children.last_mut() + { + let id = envelope.id; + let drawn = std::mem::replace(slot, envelope); + self.envelopes_drawn.push((id, drawn)); + } + } +} + +/// Does `node` hold a text slot? +fn has_text_slot(node: &Node) -> bool { + let mut slots = vec![]; + envelope_text_slots(node, &mut slots); + !slots.is_empty() } /// The source text inside an envelope options dictionary, carried through its containing metadata. diff --git a/crates/eps/src/import/ai/read/document.rs b/crates/eps/src/import/ai/read/document.rs index 892a8893c..0874ec095 100644 --- a/crates/eps/src/import/ai/read/document.rs +++ b/crates/eps/src/import/ai/read/document.rs @@ -235,7 +235,7 @@ impl<'a> Reader<'a> { // An envelope (a distortion, `AIDeformStyle` / `DeformValue`) is written on `%_` lines as // a group holding the mesh that shapes it (or its contents, while they are being edited); // the distorted art the file's PDF part draws follows on plain lines. That group isn't - // art, so it goes (#2405). + // art, so it goes (#2405), kept aside for the live envelope ([`Reader::end_envelope`]). if (o.get("AIDeformStyle").is_some() || o.get("DeformValue").is_some()) && self.drop_envelope_art() { return; } @@ -317,6 +317,12 @@ impl<'a> Reader<'a> { self.artboards.first().map(|(_, r)| *r).or(self.cropmarks).or_else(|| self.sized_board()) } + /// The page the art is compared with in the document's space, once the art has started. + pub(super) fn page_in_doc(&self) -> Option { + let board = self.first_board().or_else(|| self.bbox_board())?; + Some(self.to_doc?.transform_rect_bbox(board)) + } + /// The page of a file that says nothing of its artboard (the legacy format as other apps write /// it): its art's bounding box. fn bbox_board(&self) -> Option { @@ -380,6 +386,11 @@ impl<'a> Reader<'a> { } pub(super) fn finish(mut self) -> Result { + // Envelopes whose plugin never closed them keep their drawn art. + if !self.envelopes.is_empty() { + self.envelopes.clear(); + self.warn(crate::import::ai::envelope::DRAWN); + } if !self.unknown.is_empty() { let ops: Vec = self.unknown.iter().map(|o| format!("`{o}`")).collect(); self.unsupported.insert(format!("operators VectorCraft doesn't know ({})", ops.join(", "))); @@ -491,6 +502,7 @@ impl<'a> Reader<'a> { slot_names: self.slot_names, restyled: self.restyled, placed_elsewhere: self.placed_elsewhere, + envelopes_drawn: self.envelopes_drawn, warnings, }) } diff --git a/crates/eps/src/import/ai/read/ops.rs b/crates/eps/src/import/ai/read/ops.rs index 9461ecb0c..7e7b79746 100644 --- a/crates/eps/src/import/ai/read/ops.rs +++ b/crates/eps/src/import/ai/read/ops.rs @@ -298,6 +298,8 @@ impl<'a> Reader<'a> { let len = Self::nums(&vals).last().copied().unwrap_or(0.0); if name == crate::import::ai::blend::PLUGIN && len.is_finite() && len > 0.0 && len <= crate::import::ai::blend::MAX_RECORD as f64 { self.blend_record = Some((len as usize, vec![])); + } else if name == crate::import::ai::envelope::PLUGIN { + self.end_envelope(); } } _ if IGNORED.contains(&w) => {} diff --git a/crates/eps/src/import/ai/read/types.rs b/crates/eps/src/import/ai/read/types.rs index b73177a37..3857cbe62 100644 --- a/crates/eps/src/import/ai/read/types.rs +++ b/crates/eps/src/import/ai/read/types.rs @@ -166,6 +166,21 @@ pub(super) struct Frame { pub(super) state: Option<(f32, BlendMode, bool, Knockout, bool)>, } +/// An envelope whose dictionary was read, waiting for its drawn art and its plugin's `XP` (see +/// [`super::super::envelope`]). +#[derive(Debug)] +pub(super) struct PendingEnvelope { + pub(super) obj: Obj, + /// The group on `%_` lines before the dictionary. + pub(super) before: Option, + /// The art in the dictionary's `AIDeformArtKey` block. + pub(super) inner: Vec, + /// How many frames were open when the dictionary closed, and how many children the innermost + /// had: its drawn art is the one child after them. + pub(super) depth: usize, + pub(super) siblings: usize, +} + /// What a reader of a pattern's definition has of its head, `(name) llx lly urx ury`: the operands /// before its first operator. #[derive(Debug, Default)] diff --git a/crates/eps/src/import/ai/style.rs b/crates/eps/src/import/ai/style.rs index 57d7842f2..ede1ae8e9 100644 --- a/crates/eps/src/import/ai/style.rs +++ b/crates/eps/src/import/ai/style.rs @@ -82,6 +82,12 @@ const WARPS: [&str; 15] = [ "warp.twist", ]; +/// The warp style (`arc`, `arcLower`, …) numbered `n` in `DeformStyle`. +pub(super) fn warp_style(n: i64) -> Option<&'static str> { + let id = WARPS.get(usize::try_from(n.checked_sub(1)?).ok()?)?; + id.strip_prefix("warp.") +} + /// A plain style's paint, as the object comes in. #[derive(Clone, Debug, Default, PartialEq)] pub(super) struct ArtStyle { diff --git a/crates/eps/src/import/ai/tests.rs b/crates/eps/src/import/ai/tests.rs index be27b9f5c..a0a24d865 100644 --- a/crates/eps/src/import/ai/tests.rs +++ b/crates/eps/src/import/ai/tests.rs @@ -1997,6 +1997,488 @@ fn an_envelope_s_contents_being_edited_are_not_read_as_art() { assert_eq!(fill(&a[0]).color(), Some(Color::rgb(1.0, 0.0, 0.0))); } +/// `art` on `%_` lines. +fn commented(art: &str) -> String { + art.lines().map(|l| format!("%_{l}\n")).collect() +} + +/// Envelope Options as new envelopes have them (Distort Appearance, Anti-Alias, Clipping Mask), +/// Fidelity 50, the contents not being edited. +const ENVELOPE_KEYS: &str = "131073 /Int (AIDeformOptionsKey) ,\n50 /Int (AIDeformFidelityKey) ,\n0 /Bool (AIDeformEditModeKey) ,\n"; + +/// An envelope as the editing data writes it: on `%_` lines a group holding `before`, then the +/// dictionary with `keys`, the art `block` in its `AIDeformArtKey` block and the kind `kind`; then +/// `drawn` in a group on plain lines, and the plugin's `XP`. +fn envelope_parts(before: &str, keys: &str, block: &str, kind: u32, drawn: &str) -> String { + format!( + "{}%_/ArtDictionary :\n{}%_X=\n{}%_X+\n%_ (AIDeformArtKey) ,\n%_{kind} /Int (AIDeformStyle) ,\n%_;\n%_\n%_9 () XW\nu\n{drawn}U\n9 () XW\n(Adobe Deform Plugin) 1 0 0 XP\n", + commented(&format!("u\n{before}U\n")), + commented(keys), + commented(block), + ) +} + +/// An envelope of `content` (in the plain group the editing data puts round it) shaped by the mesh +/// `shape`. +fn envelope_of(shape: &str, keys: &str, content: &str, kind: u32, drawn: &str) -> String { + envelope_parts(shape, keys, &format!("u\n{content}U\n9 () XW\n"), kind, drawn) +} + +/// A grid point's place in art space and its handle towards a neighbour. +type MeshAt<'a> = &'a dyn Fn((usize, usize)) -> (f64, f64); +type MeshHandle<'a> = &'a dyn Fn((usize, usize), (usize, usize)) -> (f64, f64); + +/// A grey `/Mesh` of `cols`×`rows` patches (`/P [i j]`, `i` along x, column by column), each +/// corner of patch `(i, j)` at `at`, from the one nearest the origin up, across and down, with its +/// handles towards the next corner and the previous one at `handle`; `anchor`, written after the +/// second corner of the first patch, puts an anchor on its second edge. +fn mesh_text(cols: usize, rows: usize, at: MeshAt<'_>, handle: MeshHandle<'_>, anchor: Option<[f64; 6]>) -> String { + let mut s = format!("/Mesh X!\n2 /Version X#\n/Cartesian /Type X#\n[{cols} {rows}] /Size X#\n/Data X#\n/DeviceGray /CS X#\n"); + for i in 0..cols { + for j in 0..rows { + s += &format!("[{i} {j}] /P X#\n"); + let (a, b, c, d) = ((i, j), (i, j + 1), (i + 1, j + 1), (i + 1, j)); + for (k, (p, next, prev)) in [(a, b, d), (b, c, a), (c, d, b), (d, a, c)].into_iter().enumerate() { + let ((x, y), (nx, ny), (px, py)) = (at(p), handle(p, next), handle(p, prev)); + s += &format!("[0.8 1 {x} {y} {nx} {ny} 1 {px} {py} 1] /N X#\n"); + if let (1, Some([x, y, ax, ay, bx, by])) = (k, anchor.filter(|_| (i, j) == (0, 0))) { + s += &format!("[{x} {y} {ax} {ay} {bx} {by} 1] /A X#\n"); + } + } + s += "/E X#\n"; + } + } + s + "/End X!\n" +} + +/// A grid in art space, its points `step` apart from (40, 20), handles a third of the way. +fn straight_mesh(cols: usize, rows: usize, step: (f64, f64)) -> String { + let at = move |(i, j): (usize, usize)| (40.0 + i as f64 * step.0, 20.0 + j as f64 * step.1); + let third = move |p: (usize, usize), q: (usize, usize)| { + let ((x, y), (u, v)) = (at(p), at(q)); + (x + (u - x) / 3.0, y + (v - y) / 3.0) + }; + mesh_text(cols, rows, &at, &third, None) +} + +/// The red rectangle 40 20 160 80 in art space, which is the same box in the document's. +fn envelope_content() -> String { + format!("{RED}{}f\n", rect_path(40.0, 20.0, 160.0, 80.0)) +} + +/// What envelope `kind` draws of [`envelope_content`]: the rectangle's edges, many points to a +/// side, mapped as VectorCraft maps them, as a red path in art space. +fn drawn_content(kind: &vectorcraft_doc::live::EnvelopeKind) -> String { + let rect = Rect::new(40.0, 20.0, 160.0, 80.0); + let map = vectorcraft_doc::live::envelope_mapper(kind, rect); + let corners = [Point::new(40.0, 20.0), Point::new(160.0, 20.0), Point::new(160.0, 80.0), Point::new(40.0, 80.0)]; + let mut s = RED.to_string(); + for (k, a) in corners.iter().enumerate() { + let b = corners[(k + 1) % 4]; + for t in 0..64 { + let q = map(a.lerp(b, f64::from(t) / 64.0)); + s += &format!("{} {} {}\n", q.x, 100.0 - q.y, if k == 0 && t == 0 { "m" } else { "L" }); + } + } + s + "f\n" +} + +/// The live envelope `n` is: its content, kind, fidelity, editing and options. +fn live_envelope(n: &Node) -> (&[Arc], &vectorcraft_doc::live::EnvelopeKind, f64, bool, vectorcraft_doc::live::EnvelopeOptions) { + match &n.kind { + NodeKind::Envelope { content, kind, fidelity, editing, options, frame } => { + assert_eq!(**frame, kurbo::Affine::IDENTITY); + (content, kind, *fidelity, *editing, *options) + } + other => panic!("not a live envelope: {other:#?}"), + } +} + +/// A warp's dictionary keys: style number `style`, bend, horizontal and vertical distortion as +/// fractions, and the orientation (vertical with `rotate` 1). +fn warp_keys(style: u32, bend: f64, h: f64, v: f64, rotate: u32) -> String { + format!( + "{ENVELOPE_KEYS}{style} /Int (DeformStyle) ,\n{h} /Real (DeformHoriz) ,\n{bend} /Real (DeformValue) ,\n{v} /Real (DeformVert) ,\n{rotate} /Bool (Rotate) ,\n(Warp) /UnicodeString (DisplayString) ,\n" + ) +} + +/// An envelope made with a warp opens as that live envelope of its contents, its style, bend, +/// distortions (fractions in the file) and orientation read; the warp's drawn art goes. +#[test] +fn a_warp_envelope_opens_live() { + use vectorcraft_doc::live::EnvelopeKind; + for (style, bend, h, v, rotate, id) in + [(1, 0.5, 0.0, 0.0, 0, "arc"), (1, 0.5, -0.2, 0.3, 1, "arc"), (8, 0.5, 0.0, 0.0, 0, "flag"), (15, 0.5, 0.0, 0.0, 0, "twist")] + { + let want = EnvelopeKind::Warp { style: id.into(), bend: bend * 100.0, h: h * 100.0, v: v * 100.0, horizontal: rotate == 0 }; + let body = + envelope_of(&straight_mesh(1, 1, (120.0, 60.0)), &warp_keys(style, bend, h, v, rotate), &envelope_content(), 1, &drawn_content(&want)); + let r = read_ok(&layer(&body)); + let a = art(&r.doc); + assert_eq!(a.len(), 1, "{a:#?}"); + let (content, kind, fidelity, editing, options) = live_envelope(&a[0]); + let EnvelopeKind::Warp { style: s, bend: b, h: dh, v: dv, horizontal } = kind else { panic!("{kind:?}") }; + assert_eq!((s.as_str(), *horizontal), (id, rotate == 0)); + assert!([(*b, bend), (*dh, h), (*dv, v)].iter().all(|(got, f)| (got - f * 100.0).abs() < 1e-9), "{kind:?}"); + assert_eq!((fidelity, editing, options), (50.0, false, vectorcraft_doc::live::EnvelopeOptions::NEW)); + assert_eq!(content.len(), 1, "{content:#?}"); + assert!(matches!(content[0].kind, NodeKind::Path { .. }), "{content:#?}"); + assert_eq!(fill(&content[0]).color(), Some(Color::rgb(1.0, 0.0, 0.0))); + assert!(r.warnings.is_empty(), "{:?}", r.warnings); + } +} + +/// Every warp style number reads as its style, in Effect › Warp's order. +#[test] +fn warp_envelopes_read_every_style() { + use vectorcraft_doc::live::EnvelopeKind; + let ids = [ + "arc", + "arcLower", + "arcUpper", + "arch", + "bulge", + "shellLower", + "shellUpper", + "flag", + "wave", + "fish", + "rise", + "fisheye", + "inflate", + "squeeze", + "twist", + ]; + for (n, id) in ids.iter().enumerate() { + let want = EnvelopeKind::Warp { style: (*id).into(), bend: 30.0, h: 0.0, v: 0.0, horizontal: true }; + let keys = warp_keys(n as u32 + 1, 0.3, 0.0, 0.0, 0); + let r = read_ok(&layer(&envelope_of(&straight_mesh(1, 1, (120.0, 60.0)), &keys, &envelope_content(), 1, &drawn_content(&want)))); + let a = art(&r.doc); + assert_eq!(a.len(), 1, "{id}: {a:#?}"); + assert_eq!(live_envelope(&a[0]).1, &want, "{id}"); + } +} + +/// An envelope made with a mesh opens as a live mesh envelope: its rows and columns, its points +/// row by row from the top left, and each point's four handles, a moved point and an uneven handle +/// included. +#[test] +fn a_mesh_envelope_opens_live_with_its_points_and_handles() { + use vectorcraft_doc::live::{EnvelopeKind, H_DOWN, H_LEFT, H_RIGHT, H_UP}; + // 3 columns 40 apart and 2 rows 30 apart from (40, 20); the inner point (1, 1) moved, and its + // handle towards (2, 1) longer and off the line. + let at = |(i, j): (usize, usize)| if (i, j) == (1, 1) { (90.0, 55.0) } else { (40.0 + i as f64 * 40.0, 20.0 + j as f64 * 30.0) }; + let handle = |p: (usize, usize), q: (usize, usize)| { + if (p, q) == ((1, 1), (2, 1)) { + return (108.0, 62.0); + } + let ((x, y), (u, v)) = (at(p), at(q)); + (x + (u - x) / 3.0, y + (v - y) / 3.0) + }; + let shape = mesh_text(3, 2, &at, &handle, None); + // Row r of the envelope is row 2 - r of the file (art space has y up). + let doc_pt = |(x, y): (f64, f64)| Point::new(x, 100.0 - y); + let mut points = vec![]; + let mut handles = vec![]; + for r in 0..=2usize { + for c in 0..=3usize { + let g = (c, 2 - r); + let p = doc_pt(at(g)); + points.push(p); + let mut h = [kurbo::Vec2::ZERO; 4]; + let towards = |q: Option<(usize, usize)>| q.filter(|q| q.0 <= 3 && q.1 <= 2).map_or(kurbo::Vec2::ZERO, |q| doc_pt(handle(g, q)) - p); + h[H_RIGHT] = towards(Some((g.0 + 1, g.1))); + h[H_LEFT] = towards(g.0.checked_sub(1).map(|i| (i, g.1))); + h[H_UP] = towards(Some((g.0, g.1 + 1))); + h[H_DOWN] = towards(g.1.checked_sub(1).map(|j| (g.0, j))); + handles.push(h); + } + } + let want = EnvelopeKind::Mesh { rows: 2, cols: 3, points: points.clone(), handles: handles.clone() }; + let r = read_ok(&layer(&envelope_of(&shape, ENVELOPE_KEYS, &envelope_content(), 2, &drawn_content(&want)))); + let a = art(&r.doc); + assert_eq!(a.len(), 1, "{a:#?}"); + let EnvelopeKind::Mesh { rows, cols, points: got, handles: got_h } = live_envelope(&a[0]).1 else { panic!("{a:#?}") }; + assert_eq!((*rows, *cols, got.len(), got_h.len()), (2, 3, 12, 12)); + for (k, (p, q)) in got.iter().zip(&points).enumerate() { + assert!((*p - *q).hypot() < 1e-6, "point {k}: {p:?}, not {q:?}"); + } + // The moved inner point (row 1, column 1): each of its handles, the uneven one towards the right. + let inner = 4 + 1; + assert!((got[inner] - Point::new(90.0, 45.0)).hypot() < 1e-6, "{:?}", got[inner]); + for slot in [H_RIGHT, H_LEFT, H_DOWN, H_UP] { + assert!((got_h[inner][slot] - handles[inner][slot]).hypot() < 1e-6, "slot {slot}: {:?} vs {:?}", got_h[inner], handles[inner]); + } + assert!((got_h[inner][H_RIGHT] - kurbo::Vec2::new(18.0, -7.0)).hypot() < 1e-6, "{:?}", got_h[inner]); + assert!(r.warnings.is_empty(), "{:?}", r.warnings); +} + +/// An envelope made with a top object opens as a live envelope whose top path is the edge of its +/// one-patch mesh, an anchor on the edge included; its contents stay the rectangle. +#[test] +fn a_top_object_envelope_opens_live_with_its_outline() { + use vectorcraft_doc::live::EnvelopeKind; + // Corners (40, 20) (40, 80) (160, 80) (160, 20); the top edge bows up through an anchor at (100, 95). + let at = |(i, j): (usize, usize)| (40.0 + i as f64 * 120.0, 20.0 + j as f64 * 60.0); + let handle = |p: (usize, usize), q: (usize, usize)| match (p, q) { + ((0, 1), (1, 1)) => (60.0, 88.0), + ((1, 1), (0, 1)) => (140.0, 88.0), + _ => { + let ((x, y), (u, v)) = (at(p), at(q)); + (x + (u - x) / 3.0, y + (v - y) / 3.0) + } + }; + let shape = mesh_text(1, 1, &at, &handle, Some([100.0, 95.0, 70.0, 95.0, 130.0, 95.0])); + let mut bp = kurbo::BezPath::new(); + bp.move_to((40.0, 20.0)); + bp.curve_to((60.0, 12.0), (70.0, 5.0), (100.0, 5.0)); + bp.curve_to((130.0, 5.0), (140.0, 12.0), (160.0, 20.0)); + bp.curve_to((160.0, 40.0), (160.0, 60.0), (160.0, 80.0)); + bp.curve_to((120.0, 80.0), (80.0, 80.0), (40.0, 80.0)); + bp.curve_to((40.0, 60.0), (40.0, 40.0), (40.0, 20.0)); + bp.close_path(); + let want = EnvelopeKind::TopObject { path: vectorcraft_geom::PathData::from_bezpath(&bp) }; + let r = read_ok(&layer(&envelope_of(&shape, ENVELOPE_KEYS, &envelope_content(), 3, &drawn_content(&want)))); + let a = art(&r.doc); + assert_eq!(a.len(), 1, "{a:#?}"); + let (content, kind, ..) = live_envelope(&a[0]); + let EnvelopeKind::TopObject { path } = kind else { panic!("{kind:?}") }; + let anchors: Vec = path.subpaths.iter().flat_map(|s| s.anchors.iter().map(|a| a.p)).collect(); + for p in [(40.0, 20.0), (100.0, 5.0), (160.0, 20.0), (160.0, 80.0), (40.0, 80.0)] { + assert!(anchors.iter().any(|q| (*q - Point::from(p)).hypot() < 1e-6), "{p:?} not in {anchors:?}"); + } + // The curve through the anchor: its handles there run level. + let mid = path.subpaths.iter().flat_map(|s| s.anchors.iter()).find(|a| (a.p - Point::new(100.0, 5.0)).hypot() < 1e-6).unwrap(); + assert!((mid.h_in.y - 5.0).abs() < 1e-6 && (mid.h_out.y - 5.0).abs() < 1e-6, "{mid:?}"); + assert_eq!(content.len(), 1, "{content:#?}"); + assert_eq!(fill(&content[0]).color(), Some(Color::rgb(1.0, 0.0, 0.0))); + assert!(r.warnings.is_empty(), "{:?}", r.warnings); +} + +/// Envelope Options come from the options word, Fidelity from its own key. +#[test] +fn an_envelope_reads_its_options_and_fidelity() { + use vectorcraft_doc::live::{EnvelopeKind, EnvelopeOptions, PreserveShape}; + let shape = straight_mesh(1, 1, (120.0, 60.0)); + // A flat 1 × 1 mesh over the box draws the box as it is. + let drawn = drawn_content(&EnvelopeKind::Mesh { + rows: 1, + cols: 1, + points: vec![Point::new(40.0, 20.0), Point::new(160.0, 20.0), Point::new(40.0, 80.0), Point::new(160.0, 80.0)], + handles: vec![], + }); + for (word, fidelity, options) in [ + ( + 65536, + 80, + EnvelopeOptions { + anti_alias: false, + preserve_shape: PreserveShape::Transparency, + distort_appearance: false, + distort_linear_gradients: false, + distort_pattern_fills: false, + }, + ), + ( + 0x20007, + 100, + EnvelopeOptions { + anti_alias: true, + preserve_shape: PreserveShape::ClippingMask, + distort_appearance: true, + distort_linear_gradients: true, + distort_pattern_fills: true, + }, + ), + ( + 0x20002, + 0, + EnvelopeOptions { + anti_alias: true, + preserve_shape: PreserveShape::ClippingMask, + distort_appearance: false, + distort_linear_gradients: false, + distort_pattern_fills: true, + }, + ), + ] { + let keys = format!("{word} /Int (AIDeformOptionsKey) ,\n{fidelity} /Int (AIDeformFidelityKey) ,\n0 /Bool (AIDeformEditModeKey) ,\n"); + let r = read_ok(&layer(&envelope_of(&shape, &keys, &envelope_content(), 2, &drawn))); + let a = art(&r.doc); + assert_eq!(a.len(), 1, "{word}: {a:#?}"); + let (_, _, got_fidelity, _, got) = live_envelope(&a[0]); + assert_eq!((got_fidelity, got), (f64::from(fidelity), options), "{word}"); + } +} + +/// An envelope's contents keep their groups: a group of two rectangles stays that group. +#[test] +fn an_envelope_s_contents_keep_their_groups() { + use vectorcraft_doc::live::EnvelopeKind; + let group = format!( + "u\n{RED}{}f\n{}f\nU\n/ArtDictionary :\n(Pair) /String (AIArtName) ,\n;\n", + rect_path(40.0, 20.0, 90.0, 80.0), + rect_path(110.0, 20.0, 160.0, 80.0) + ); + let mut drawn = RED.to_string(); + drawn += &format!("{}f\n{}f\n", rect_path(40.0, 20.0, 90.0, 80.0), rect_path(110.0, 20.0, 160.0, 80.0)); + let r = read_ok(&layer(&envelope_of(&straight_mesh(1, 1, (120.0, 60.0)), ENVELOPE_KEYS, &group, 2, &drawn))); + let a = art(&r.doc); + assert_eq!(a.len(), 1, "{a:#?}"); + let (content, kind, ..) = live_envelope(&a[0]); + assert!(matches!(kind, EnvelopeKind::Mesh { rows: 1, cols: 1, .. }), "{kind:?}"); + assert_eq!(content.len(), 1, "{content:#?}"); + assert_eq!(content[0].name.as_deref(), Some("Pair")); + let kids = content[0].children().unwrap(); + assert_eq!(kids.len(), 2, "{kids:#?}"); + assert!(kids.iter().all(|k| matches!(k.kind, NodeKind::Path { .. })), "{kids:#?}"); +} + +/// While an envelope's contents are edited, the contents come first and the mesh is in the +/// dictionary: the envelope opens live all the same, in Edit Contents. +#[test] +fn an_envelope_whose_contents_are_edited_opens_live_in_edit_contents() { + use vectorcraft_doc::live::EnvelopeKind; + let at = |(i, j): (usize, usize)| (40.0 + i as f64 * 120.0, 20.0 + j as f64 * 60.0); + let handle = |p: (usize, usize), q: (usize, usize)| match (p, q) { + ((0, 1), (1, 1)) => (70.0, 95.0), + ((1, 1), (0, 1)) => (130.0, 95.0), + _ => { + let ((x, y), (u, v)) = (at(p), at(q)); + (x + (u - x) / 3.0, y + (v - y) / 3.0) + } + }; + let mesh = mesh_text(1, 1, &at, &handle, None); + let mut bp = kurbo::BezPath::new(); + bp.move_to((40.0, 20.0)); + bp.curve_to((70.0, 5.0), (130.0, 5.0), (160.0, 20.0)); + bp.curve_to((160.0, 40.0), (160.0, 60.0), (160.0, 80.0)); + bp.curve_to((120.0, 80.0), (80.0, 80.0), (40.0, 80.0)); + bp.curve_to((40.0, 60.0), (40.0, 40.0), (40.0, 20.0)); + bp.close_path(); + let want = EnvelopeKind::TopObject { path: vectorcraft_geom::PathData::from_bezpath(&bp) }; + let keys = ENVELOPE_KEYS.replace("0 /Bool (AIDeformEditModeKey)", "1 /Bool (AIDeformEditModeKey)"); + let body = envelope_parts(&envelope_content(), &keys, &format!("0 g\n{mesh}"), 3, &drawn_content(&want)); + let r = read_ok(&layer(&body)); + let a = art(&r.doc); + assert_eq!(a.len(), 1, "{a:#?}"); + let (content, kind, _, editing, _) = live_envelope(&a[0]); + assert!(editing); + assert!(matches!(kind, EnvelopeKind::TopObject { .. }), "{kind:?}"); + assert_eq!(content.len(), 1, "{content:#?}"); + assert!(matches!(content[0].kind, NodeKind::Path { .. }), "{content:#?}"); + assert_eq!(fill(&content[0]).color(), Some(Color::rgb(1.0, 0.0, 0.0))); +} + +/// An envelope that doesn't read (a warp style number past the list, a mesh envelope whose shape +/// isn't a mesh, one whose plugin never closes it) keeps its drawn art, with one note; the art +/// round it is read as ever. +#[test] +fn an_envelope_that_doesnt_read_keeps_its_drawn_art() { + let blue = "0 0 1 1 0 0 1 Xa\n"; + let drawn = format!("{blue}{}f\n", rect_path(40.0, 20.0, 160.0, 80.0)); + let mesh = straight_mesh(1, 1, (120.0, 60.0)); + let cases = [ + envelope_of(&mesh, &warp_keys(99, 0.5, 0.0, 0.0, 0), &envelope_content(), 1, &drawn), + envelope_of(&format!("{}f\n", rect_path(40.0, 20.0, 160.0, 80.0)), ENVELOPE_KEYS, &envelope_content(), 2, &drawn), + envelope_of(&mesh, ENVELOPE_KEYS, &envelope_content(), 7, &drawn), + envelope_of(&mesh, &ENVELOPE_KEYS.replace("131073", "131081"), &envelope_content(), 2, &drawn), + envelope_of(&mesh, ENVELOPE_KEYS, &envelope_content(), 2, &drawn).replace("(Adobe Deform Plugin) 1 0 0 XP\n", ""), + ]; + for case in cases { + let body = format!("{RED}{}f\n{case}{RED}{}f\n", rect_path(0.0, 0.0, 10.0, 10.0), rect_path(190.0, 0.0, 200.0, 10.0)); + let r = read_ok(&layer(&body)); + let a = art(&r.doc); + assert_eq!(a.len(), 3, "{case}\n{a:#?}"); + assert!(matches!(a[0].kind, NodeKind::Path { .. }) && matches!(a[2].kind, NodeKind::Path { .. }), "{a:#?}"); + let NodeKind::Group { children, .. } = &a[1].kind else { panic!("{a:#?}") }; + assert_eq!(fill(&children[0]).color(), Some(Color::rgb(0.0, 0.0, 1.0)), "{case}"); + assert_eq!(r.warnings, [super::envelope::DRAWN], "{case}"); + } +} + +/// An envelope whose contents don't come out as its drawn art (its warp bends them another way) +/// keeps the drawn art. +#[test] +fn an_envelope_that_doesnt_come_out_as_drawn_keeps_its_drawn_art() { + use vectorcraft_doc::live::EnvelopeKind; + let other = EnvelopeKind::Warp { style: "arc".into(), bend: -50.0, h: 0.0, v: 0.0, horizontal: true }; + let body = envelope_of(&straight_mesh(1, 1, (120.0, 60.0)), &warp_keys(1, 0.5, 0.0, 0.0, 0), &envelope_content(), 1, &drawn_content(&other)); + let r = read_ok(&layer(&body)); + let a = art(&r.doc); + assert_eq!(a.len(), 1, "{a:#?}"); + assert!(matches!(a[0].kind, NodeKind::Group { .. }), "{a:#?}"); + assert_eq!(r.warnings, [super::envelope::DRAWN]); +} + +/// An envelope that comes out close to its drawn art, but with more of the page differing than the +/// page check can take from the live envelopes (its drawn art 2 pt to the side on a 200 × 100 +/// page), keeps the drawn art, so the file still opens from its layers. +#[test] +fn an_envelope_that_differs_from_its_drawn_art_over_the_page_keeps_it() { + let shifted = format!("{RED}{}f\n", rect_path(42.0, 20.0, 162.0, 80.0)); + let body = envelope_of(&straight_mesh(1, 1, (120.0, 60.0)), ENVELOPE_KEYS, &envelope_content(), 2, &shifted); + let r = read_ok(&layer(&body)); + let a = art(&r.doc); + assert_eq!(a.len(), 1, "{a:#?}"); + assert!(matches!(a[0].kind, NodeKind::Group { .. }), "{a:#?}"); + assert_eq!(r.warnings, [super::envelope::DRAWN]); +} + +/// An envelope whose drawn art holds an object the reader can't read keeps that drawn art, which +/// the page's art replaces. +#[test] +fn an_envelope_whose_drawn_art_holds_an_unread_object_keeps_it() { + let flat = vectorcraft_doc::live::EnvelopeKind::Mesh { + rows: 1, + cols: 1, + points: vec![Point::new(40.0, 20.0), Point::new(160.0, 20.0), Point::new(40.0, 80.0), Point::new(160.0, 80.0)], + handles: vec![], + }; + let drawn = format!("{}frobnicate\n", drawn_content(&flat)); + let r = read_ok(&layer(&envelope_of(&straight_mesh(1, 1, (120.0, 60.0)), ENVELOPE_KEYS, &envelope_content(), 2, &drawn))); + let a = art(&r.doc); + assert_eq!(a.len(), 1, "{a:#?}"); + assert!(matches!(a[0].kind, NodeKind::Group { .. }), "{a:#?}"); + assert_eq!(r.unread.iter().map(|(id, _)| *id).collect::>(), [a[0].id]); + assert_eq!(r.warnings, [super::envelope::DRAWN]); +} + +/// A live envelope takes its drawn art's own properties: its name here. +#[test] +fn a_live_envelope_keeps_its_drawn_art_s_name() { + let flat = vectorcraft_doc::live::EnvelopeKind::Mesh { + rows: 1, + cols: 1, + points: vec![Point::new(40.0, 20.0), Point::new(160.0, 20.0), Point::new(40.0, 80.0), Point::new(160.0, 80.0)], + handles: vec![], + }; + let body = envelope_of(&straight_mesh(1, 1, (120.0, 60.0)), ENVELOPE_KEYS, &envelope_content(), 2, &drawn_content(&flat)) + .replace("U\n9 () XW\n(Adobe Deform Plugin)", "U\n/ArtDictionary :\n(Bent) /String (AIArtName) ,\n;\n9 () XW\n(Adobe Deform Plugin)"); + let r = read_ok(&layer(&body)); + let a = art(&r.doc); + assert_eq!(a.len(), 1, "{a:#?}"); + live_envelope(&a[0]); + assert_eq!(a[0].name.as_deref(), Some("Bent")); +} + +/// The same envelope beside the page opens live: what differs off the page isn't counted. +#[test] +fn an_envelope_off_the_page_opens_live_when_it_draws_close_to_its_drawn_art() { + let at = |(i, j): (usize, usize)| (300.0 + i as f64 * 120.0, 20.0 + j as f64 * 60.0); + let third = |p: (usize, usize), q: (usize, usize)| { + let ((x, y), (u, v)) = (at(p), at(q)); + (x + (u - x) / 3.0, y + (v - y) / 3.0) + }; + let content = format!("{RED}{}f\n", rect_path(300.0, 20.0, 420.0, 80.0)); + let shifted = format!("{RED}{}f\n", rect_path(302.0, 20.0, 422.0, 80.0)); + let r = read_ok(&layer(&envelope_of(&mesh_text(1, 1, &at, &third, None), ENVELOPE_KEYS, &content, 2, &shifted))); + let a = art(&r.doc); + assert_eq!(a.len(), 1, "{a:#?}"); + live_envelope(&a[0]); + assert!(r.warnings.is_empty(), "{:?}", r.warnings); +} + #[test] fn a_gradient_mesh_reads_as_a_patch() { let corner = |rgb: &str, x: i32, y: i32, next: (i32, i32), prev: (i32, i32)| { diff --git a/crates/eps/src/import/native.rs b/crates/eps/src/import/native.rs index 8744db0ea..13a746b78 100644 --- a/crates/eps/src/import/native.rs +++ b/crates/eps/src/import/native.rs @@ -40,7 +40,7 @@ use super::ai::{self, Structure, slot_of}; use super::ate::{Story, Texts}; mod build; -mod compare; +pub(super) mod compare; mod layers; mod plan; mod slots; diff --git a/crates/eps/src/import/native/build.rs b/crates/eps/src/import/native/build.rs index 88524ef58..bf0a8d4a5 100644 --- a/crates/eps/src/import/native/build.rs +++ b/crates/eps/src/import/native/build.rs @@ -71,6 +71,7 @@ pub(super) fn build_from( slot_names, restyled, placed_elsewhere, + envelopes_drawn, warnings, } = structure; // Editing data in the units of the page's user space ([`in_units`]) is compared with the page @@ -311,10 +312,10 @@ pub(super) fn build_from( doc = other; notes.extend(unseen_note(unseen).into_iter().chain(note)); } - _ if trust => { - notes.extend(unseen_note(unseen).into_iter().chain([format!("{why}: they come from its editing data all the same")])) + _ => { + let more = drawn_or_trusted(&frame, &mut view, &mut doc, &envelopes_drawn, ¬es, unseen, trust, why, reflow)?; + notes.extend(more); } - _ => return Err(why), }, } } @@ -755,6 +756,46 @@ fn slot_content( Ok((plan(infos, stories, made, template, to_doc, pages, notes), false)) } +/// The layers `doc` don't draw as the page (`why`), `unseen` text objects of them taken out: the +/// notes to add when `doc` with each live envelope `drawn` lists replaced by the drawn art it took +/// the place of (see [`ai::Structure::envelopes_drawn`]) draws as the page, `doc` then being that; +/// else, with `trust`, the notes for `doc` as it is. An error, `why`, otherwise. The envelope note +/// is added unless `notes` has it. +#[allow(clippy::too_many_arguments)] +fn drawn_or_trusted( + frame: &Frame<'_>, + view: &mut PageView, + doc: &mut Document, + drawn: &[(NodeId, Node)], + notes: &[String], + unseen: usize, + trust: bool, + why: String, + reflow: bool, +) -> Result, String> { + let unseen_note = |n: usize| (n > 0).then(|| format!("{n} {UNSEEN_TEXT}")); + let mut put_back = doc.clone(); + let mut any = false; + for (id, art) in drawn { + if let Some(n) = put_back.node_mut(*id) { + *n = art.clone(); + any = true; + } + } + if any { + let more = prune_unseen(frame, view, &mut put_back, reflow); + if let Ok(note) = compare(frame, view, &put_back, reflow) { + *doc = put_back; + let envelope = (!notes.iter().any(|n| n == ai::ENVELOPE_DRAWN)).then(|| ai::ENVELOPE_DRAWN.to_string()); + return Ok(envelope.into_iter().chain(unseen_note(unseen + more)).chain(note).collect()); + } + } + if trust { + return Ok(unseen_note(unseen).into_iter().chain([format!("{why}: they come from its editing data all the same")]).collect()); + } + Err(why) +} + /// `doc` with the placed files `placed` lists at their other placement (see /// [`ai::Structure::placed_elsewhere`]): each image's transform followed by its move. `None` when /// no listed image survives replacement. diff --git a/crates/eps/src/import/native/compare.rs b/crates/eps/src/import/native/compare.rs index e8ebed707..f382059b6 100644 --- a/crates/eps/src/import/native/compare.rs +++ b/crates/eps/src/import/native/compare.rs @@ -47,7 +47,7 @@ const WHITE: u32 = 8; /// Do two pixels look different: in lightness, or one inked (as a light yellow is) where the other /// is white? -pub(super) fn differ(p: &[u8; 4], q: &[u8; 4]) -> bool { +pub(in crate::import) fn differ(p: &[u8; 4], q: &[u8; 4]) -> bool { let (p, q) = (on_white(p), on_white(q)); let (a, b) = (ink(p), ink(q)); lightness(p).abs_diff(lightness(q)) > SAME_PIXEL || (a.min(b) <= WHITE && a.abs_diff(b) > SAME_PIXEL) @@ -59,7 +59,7 @@ const SAME_COLOUR: u32 = 64; /// Are two pixels different colours ([`differ`] also takes a red and a dark red of about the same /// lightness to be alike)? -pub(super) fn differ_in_colour(p: &[u8; 4], q: &[u8; 4]) -> bool { +pub(in crate::import) fn differ_in_colour(p: &[u8; 4], q: &[u8; 4]) -> bool { let (p, q) = (on_white(p), on_white(q)); p.iter().zip(q).any(|(a, b)| a.abs_diff(b) > SAME_COLOUR) } @@ -84,7 +84,7 @@ impl Difference { } /// The scale the comparisons draw a region at: about 400 pixels along its longer side. -fn compare_scale(r: Rect) -> f64 { +pub(in crate::import) fn compare_scale(r: Rect) -> f64 { (400.0 / r.width().max(r.height()).max(1.0)).min(4.0) } @@ -362,14 +362,14 @@ const MARKED: f64 = 0.05; /// More of the page than this differing between the layers' art and the page's own, and the /// layers aren't used. -pub(super) const MAX_DIFFERENCE: f64 = 0.05; +pub(in crate::import) const MAX_DIFFERENCE: f64 = 0.05; /// More than this and the import says so. const NOTABLE_DIFFERENCE: f64 = 0.002; /// More of the page than this where one draws an object that the other doesn't (see /// [`Difference`]), and the layers aren't used: about a centimetre square on a letter page. -pub(super) const MAX_SOLID_DIFFERENCE: f64 = 0.001; +pub(in crate::import) const MAX_SOLID_DIFFERENCE: f64 = 0.001; /// How far (points) the art the layers print may reach past a page that holds it all. const PAST_THE_PAGE: f64 = 1.0; diff --git a/crates/eps/src/import/tests_native.rs b/crates/eps/src/import/tests_native.rs index 6ba17640d..94127e8cf 100644 --- a/crates/eps/src/import/tests_native.rs +++ b/crates/eps/src/import/tests_native.rs @@ -2861,6 +2861,40 @@ fn envelope_options_keep_the_source_text_hidden_under_its_outlined_appearance() assert!(r.warnings.is_empty(), "{:?}", r.warnings); } +/// A live envelope that draws a little apart from its drawn art, in layers that already differ +/// from the page almost as much as the page check takes, would make them differ too much: its +/// drawn art is put back, so the file still opens from its layers. +#[test] +fn a_live_envelope_that_would_tip_the_page_check_keeps_its_drawn_art() { + let rect = |x0: f64, y0: f64, x1: f64, y1: f64| format!("{x0} {y0} m\n{x0} {y1} L\n{x1} {y1} L\n{x1} {y0} L\n{x0} {y0} L\n"); + let hidden = |s: &str| -> String { s.lines().map(|l| format!("%_{l}\n")).collect() }; + let corner = |x: f64, y: f64, nx: f64, ny: f64, px: f64, py: f64| format!("[0.8 1 {x} {y} {nx} {ny} 1 {px} {py} 1] /N X#\n"); + let mesh = format!( + "/Mesh X!\n2 /Version X#\n/Cartesian /Type X#\n[1 1] /Size X#\n/Data X#\n/DeviceGray /CS X#\n[0 0] /P X#\n{}{}{}{}/E X#\n/End X!\n", + corner(20.0, 5.0, 20.0, 35.0, 40.0, 5.0), + corner(20.0, 95.0, 40.0, 95.0, 20.0, 65.0), + corner(80.0, 95.0, 80.0, 65.0, 60.0, 95.0), + corner(80.0, 5.0, 60.0, 5.0, 80.0, 35.0), + ); + let red = "0 1 1 0 1 0 0 Xa\n"; + let envelope = format!( + "{}%_/ArtDictionary :\n%_131073 /Int (AIDeformOptionsKey) ,\n%_50 /Int (AIDeformFidelityKey) ,\n%_0 /Bool (AIDeformEditModeKey) ,\n%_X=\n{}%_X+\n%_ (AIDeformArtKey) ,\n%_2 /Int (AIDeformStyle) ,\n%_;\n%_\nu\n{red}{}f\nU\n(Adobe Deform Plugin) 1 0 0 XP\n", + hidden(&format!("u\n{mesh}U\n")), + hidden(&format!("u\n{red}{}f\nU\n", rect(20.0, 5.0, 80.0, 95.0))), + rect(20.5, 5.0, 80.5, 95.0), + ); + // Hairlines the page doesn't draw: about 4.5% of it differs before the envelope. + let lines: String = [3, 6, 9, 12, 15, 85, 88, 91, 94].iter().map(|x| format!("0 0 0 1 K\n0.5 w\n{x} 0 m\n{x} 100 L\nS\n")).collect(); + let art = native(&layer("Art", true, &format!("{lines}{envelope}"))); + let page = "1 0 0 setrgbcolor 20.5 5 moveto 80.5 5 lineto 80.5 95 lineto 20.5 95 lineto closepath fill"; + let r = import(&eps(page, &art)).unwrap(); + assert_eq!(names(&r.document), ["Art"], "{:?}", r.warnings); + let mut envelopes = 0; + r.document.walk(|n| envelopes += usize::from(matches!(n.kind, NodeKind::Envelope { .. }))); + assert_eq!(envelopes, 0, "{:?}", r.warnings); + assert!(r.warnings.iter().any(|w| w == crate::import::ai::ENVELOPE_DRAWN), "{:?}", r.warnings); +} + /// An art style section with style `Comic`: a white fill offset and repeated by Transform under a /// red fill with its own blend mode, the characters at the bottom. fn comic_style() -> String { diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 7fd540e93..ddcee05b2 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -1068,6 +1068,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur When the editing data of a `.ai` or EPS is for a page of another size than the file's, the warning that its layers weren't read gives both sizes in points, and the scale of the editing data's page to the file's page (1:10 for a tenth of its size) when their sides are in proportion (#2034; `crates/eps/src/import/tests_native.rs`). A `.ai`, or a PDF saved with Illustrator's editing data, whose editing data can't be decompressed opens from its PDF part with a warning that its layers weren't read and why, and an export or Save As to its own path asks first (`acknowledgeLoss`), as for a file whose layers the reader can't read (`crates/eps/src/import/tests_native.rs`, `crates/engine/src/cmd/fileio/tests_aiimport.rs`). A `.ai` or EPS opened from its editing data with one object on a shown layer that the reader can't read (an unknown operator inside a group, a pattern fill whose pattern it doesn't have, a symbol instance, a placed file without a usable preview) keeps its layers and groups: in that object's place, its layer gets a group of the page's objects inside the object's box, named after it, with a warning that lists what came in as the page's art and an export or Save As to its own path asking first (`acknowledgeLoss`). The file still opens from its page or PDF part when no box can be found, when live art or another unread object shares the page art inside it, when the object spans two artboards, or when the page art doesn't carry the object's transparency (#4806; `crates/eps/src/import/tests_native.rs`, `crates/engine/src/cmd/fileio/tests_aiimport.rs`). + In a `.ai` opened from its editing data, an envelope made with a warp, a mesh or a top object opens as a live envelope of its contents, with its warp style, bend and distortions, its mesh points and handles or its top object's outline, its Envelope Options, its Fidelity and whether its contents are being edited. An envelope that can't be read, or that VectorCraft draws differently from the file's drawn art, keeps that drawn art, with a note. An envelope of type keeps its drawn art, with its type hidden (`crates/eps/src/import/ai/tests.rs`, `crates/eps/src/import/tests_native.rs`). PDF export and `.ai` saves write a page with a side longer than 14,400 pt with a `UserUnit` (PDF 1.6), its longer side divided by 14,400 and rounded up to the next thousandth, so that no side is longer than 14,400 units (ISO 32000-1, Annex C.2); in PDF 1.3 to 1.5 files and PDF/X files such a page is written in points. Placed (linked) files in a `.ai`'s editing data come in as linked images in their place, each showing the low-resolution preview the file keeps of it until its file is found or relinked in the Links panel (a file found next to the document or at its path is read as the document opens). A file with them no longer falls back to its PDF part, so its guides, sublayers and hidden objects open (#1264; `crates/eps/src/import/ai/tests.rs`, `crates/engine/src/tests/links.rs`). In a file opened that way, a gradient with stops in the middle runs its whole ramp across its shape and a gradient object no longer passes its fill to the next path (#1390); text objects with an appearance keep their own text and frame, type of hidden layers stays hidden, and type on later artboards lands on its text objects (#1391); area type whose frame is too short for a line of the fallback font shows its first line, the frame made taller with a warning in the open result (#1392). `.ai` and EPS editing-data import keeps template layers, and opens canvas and artboard ruler guides as ruler guides on their layers; guide objects stay editable guide paths (#779; `crates/eps/src/import/ai/tests.rs`, `crates/eps/src/import/tests_native.rs`). From 427bce12b0b8a90aae750a72ac0ac1349d4a6a8c Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Sun, 1 Nov 2026 11:08:55 +0700 Subject: [PATCH 04/12] Character: Font Size can measure the cap height, x-height or ICF box Squashed from #5026 onto main. --- crates/cad/Cargo.toml | 5 +- crates/cad/src/scene.rs | 5 +- crates/cad/src/tests.rs | 17 +++ crates/doc/src/lib.rs | 1 + crates/doc/src/text.rs | 2 +- crates/doc/src/text/char_style.rs | 20 +++ crates/doc/src/text/tests.rs | 18 +++ crates/engine/src/cmd/panelcmds.rs | 7 +- crates/engine/src/cmd/textedit.rs | 28 +++- crates/engine/src/cmd/textstyles.rs | 1 + crates/engine/src/cmd/typecmd/area_tests.rs | 91 +++++++++++ crates/eps/src/legacy_ai/tests.rs | 33 ++++ crates/eps/src/legacy_ai/writer.rs | 9 +- crates/svg/src/css.rs | 6 +- crates/svg/src/export/text.rs | 7 +- crates/svg/tests/it/css_rules.rs | 20 +++ crates/svg/tests/it/text_layout.rs | 17 +++ crates/text/src/fontdb/face.rs | 20 +++ crates/text/src/fontdb/lookup.rs | 31 ++++ crates/text/src/layout/driver.rs | 5 +- crates/text/src/shape.rs | 2 + crates/text/src/test_fonts.rs | 80 ++++++++++ crates/text/src/tests.rs | 56 +++++++ crates/ui-egui/src/i18n/bg.tsv | 6 + crates/ui-egui/src/i18n/bs.tsv | 6 + crates/ui-egui/src/i18n/ca.tsv | 6 + crates/ui-egui/src/i18n/cs.tsv | 7 + crates/ui-egui/src/i18n/da.tsv | 6 + crates/ui-egui/src/i18n/de.tsv | 8 + crates/ui-egui/src/i18n/el.tsv | 7 + crates/ui-egui/src/i18n/es.tsv | 7 + crates/ui-egui/src/i18n/fi.tsv | 7 + crates/ui-egui/src/i18n/fr.tsv | 7 + crates/ui-egui/src/i18n/hu.tsv | 7 + crates/ui-egui/src/i18n/id.tsv | 6 + crates/ui-egui/src/i18n/it.tsv | 8 + crates/ui-egui/src/i18n/ja.tsv | 8 + crates/ui-egui/src/i18n/ka.tsv | 7 + crates/ui-egui/src/i18n/ko.tsv | 8 + crates/ui-egui/src/i18n/nl.tsv | 6 + crates/ui-egui/src/i18n/pl.tsv | 7 + crates/ui-egui/src/i18n/pt-br.tsv | 7 + crates/ui-egui/src/i18n/ru.tsv | 7 + crates/ui-egui/src/i18n/sv.tsv | 7 + crates/ui-egui/src/i18n/tests.rs | 11 ++ crates/ui-egui/src/i18n/tr.tsv | 7 + crates/ui-egui/src/i18n/uk.tsv | 8 + crates/ui-egui/src/i18n/vi.tsv | 8 + crates/ui-egui/src/i18n/zh-hans.tsv | 8 + crates/ui-egui/src/i18n/zh-hant.tsv | 8 + crates/ui-egui/src/panels/character.rs | 88 ++++++++--- crates/ui-egui/src/panels/character/tests.rs | 149 ++++++++++++++++++- docs/mcp.md | 21 +++ docs/target-app-parity.md | 1 + 54 files changed, 895 insertions(+), 40 deletions(-) diff --git a/crates/cad/Cargo.toml b/crates/cad/Cargo.toml index 4886d9ea5..2ea6dea4f 100644 --- a/crates/cad/Cargo.toml +++ b/crates/cad/Cargo.toml @@ -21,5 +21,6 @@ image = { workspace = true } [dev-dependencies] serde_json = { workspace = true } -# The tests lay type out in the bundled fonts (the workspace leaves turning them on to the engine). -vectorcraft-text = { workspace = true, features = ["bundled-fonts"] } +# The tests lay type out in the bundled fonts (the workspace leaves turning them on to the engine) +# and in fonts built in code (`vectorcraft_text::test_fonts`). +vectorcraft-text = { workspace = true, features = ["bundled-fonts", "test-fonts"] } diff --git a/crates/cad/src/scene.rs b/crates/cad/src/scene.rs index 6ae29487a..ea3b8558d 100644 --- a/crates/cad/src/scene.rs +++ b/crates/cad/src/scene.rs @@ -1062,7 +1062,8 @@ impl<'a> Scene<'a> { } /// Type: glyph outlines (Preserve Appearance, Outline Text, type on a path), else one text - /// entity per run of each line. + /// entity per run of each line, whose height (group 40) is [`CAP_HEIGHT`] times the em size its + /// run is drawn at ([`vectorcraft_text::FontDb::font_heights_resolved`]). fn text(&mut self, n: &Node, t: &TextObject) { let outline = self.o.preserve == Preserve::Appearance || self.o.outline_text || matches!(t.kind, TextKind::OnPath { .. }); if outline { @@ -1074,6 +1075,8 @@ impl<'a> Scene<'a> { if !n.appearance.items.is_empty() { self.warn(TYPE_ITEMS); } + let resolved = vectorcraft_text::FontDb::global().font_heights_resolved(t); + let t = &*resolved; let layout = vectorcraft_text::layout(vectorcraft_text::FontDb::global(), t); let plain = t.plain_text(); let xf = self.to_dxf * t.xf; diff --git a/crates/cad/src/tests.rs b/crates/cad/src/tests.rs index ba0a13b76..f1b537b70 100644 --- a/crates/cad/src/tests.rs +++ b/crates/cad/src/tests.rs @@ -906,6 +906,23 @@ fn text_outside_ascii_is_escaped_before_2007() { assert_eq!(text(DxfVersion::R2018), "Größe"); } +/// Type whose font size measures the cap height is written at the em size it is drawn at: 14 pt +/// measuring a 0.7 em cap height is 20 pt type, whose TEXT height is 14. +#[test] +fn a_font_height_reference_is_written_as_the_size_drawn() { + use vectorcraft_text::test_fonts::{HEIGHT_FAMILY, height_font}; + vectorcraft_text::FontDb::global().add_font(height_font(true).unwrap()); + let mut d = text_doc("Plan A", 14.0); + let id = d.layers[0].children().and_then(|c| c.first()).map(|n| n.id).unwrap(); + if let Some(NodeKind::Text(t)) = d.node_mut(id).map(|n| &mut n.kind) { + t.runs[0].style.font_family = HEIGHT_FAMILY.into(); + t.runs[0].style.font_height_reference = vectorcraft_doc::FontHeightReference::CapHeight; + } + let editable = export(&d, &DxfOptions { preserve: Preserve::Editability, ..opts(&d) }).unwrap(); + let e = entities(&editable); + assert!((all(&e[0], 40)[0] - 14.0).abs() < 1e-6, "{:?}", all(&e[0], 40)); +} + #[test] fn images_link_png_or_jpeg_files() { let mut png = vec![]; diff --git a/crates/doc/src/lib.rs b/crates/doc/src/lib.rs index 26e2bfebf..8d3b2a883 100644 --- a/crates/doc/src/lib.rs +++ b/crates/doc/src/lib.rs @@ -106,6 +106,7 @@ pub use selection::{AnchorRef, SegmentRef, Selection}; pub use setup::{Background, DocSetup, ExportText, GridSize, Quotes}; pub use slices::{CellAlign, CellVAlign, Slice, SliceArea, SliceKind, SliceOptions, SliceSource}; pub use style_libs::StyleLibrary; +pub use text::FontHeightReference; pub use text::{ AreaFit, AreaOptions, Burasagari, CharAlign, CharPosition, CharStyle, Composer, Digits, FirstBaseline, HyphenationOptions, InlineArt, JustificationOptions, Justify, Kinsoku, LeadingModel, Mojikumi, ParaDirection, ParaStyle, PathAlign, PathEffect, RubyAlign, RubyPosition, diff --git a/crates/doc/src/text.rs b/crates/doc/src/text.rs index c1a36e058..675245b56 100644 --- a/crates/doc/src/text.rs +++ b/crates/doc/src/text.rs @@ -15,7 +15,7 @@ mod tabs_lists; pub use area::{AreaFit, AreaOptions, PathAlign, PathEffect, TextFlow, TextWrap, VerticalAlign, WrapShape}; use char_style::yes; -pub use char_style::{CharAlign, CharPosition, CharStyle, Digits, ScriptMetrics}; +pub use char_style::{CharAlign, CharPosition, CharStyle, Digits, FontHeightReference, ScriptMetrics}; pub use para_style::{ Burasagari, Composer, FirstBaseline, HyphenationOptions, JustificationOptions, Justify, Kashida, Kinsoku, LeadingModel, Mojikumi, ParaDirection, ParaStyle, SingleWordJustify, diff --git a/crates/doc/src/text/char_style.rs b/crates/doc/src/text/char_style.rs index dd84912f0..b5bc7e065 100644 --- a/crates/doc/src/text/char_style.rs +++ b/crates/doc/src/text/char_style.rs @@ -116,6 +116,10 @@ pub struct CharStyle { /// these characters are drawn in, whatever digits were typed. #[serde(default, skip_serializing_if = "crate::skip::is_default")] pub digits: Digits, + /// Font height reference (Character panel menu › Show Font Height Options): the height of the + /// font that [`Self::size`] measures, the em box by default. + #[serde(default, rename = "fontHeightReference", skip_serializing_if = "crate::skip::is_default")] + pub font_height_reference: FontHeightReference, /// No Break (Character panel menu): these characters are not broken across lines, including /// at a space or a hyphenation point inside the run. A run longer than the line stays together /// and can overflow it. @@ -130,6 +134,21 @@ pub struct CharStyle { pub language: Option, } +/// What a character's font size measures ([`CharStyle::font_height_reference`]): the em square +/// of the font, the height of its capitals or of its lowercase letters (the cap height and x +/// height of its OS/2 table), or its ideographic character face (ICF, OpenType's `icfb` and +/// `icft` baselines). Layout sets the characters at the em size at which the chosen height equals +/// the font size; with a font that has no such value, the size measures the em box. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum FontHeightReference { + #[default] + EmBox, + CapHeight, + XHeight, + IcfBox, +} + /// The numeral system digits are drawn in ([`CharStyle::digits`]): as typed, or every digit of /// the Western, Arabic-Indic and Eastern Arabic-Indic sets drawn in one of them or in the digits /// of an Indic script. The text keeps the digits that were typed; only the glyphs change (like @@ -380,6 +399,7 @@ impl Default for CharStyle { standard_vertical_roman: false, small_caps: None, digits: Digits::Default, + font_height_reference: FontHeightReference::EmBox, no_break: false, language: None, } diff --git a/crates/doc/src/text/tests.rs b/crates/doc/src/text/tests.rs index bd53024b1..04deae8ee 100644 --- a/crates/doc/src/text/tests.rs +++ b/crates/doc/src/text/tests.rs @@ -60,6 +60,24 @@ fn digits_map_between_the_numeral_systems() { assert_eq!(serde_json::from_value::(serde_json::to_value(&farsi).unwrap()).unwrap(), farsi); } +/// The font height reference is the em box by default and is saved only when it is another. +#[test] +fn font_height_reference_is_saved_only_when_not_the_em_box() { + let st = CharStyle::default(); + assert_eq!(st.font_height_reference, FontHeightReference::EmBox); + assert!(serde_json::to_value(&st).unwrap().get("fontHeightReference").is_none()); + for (r, key) in + [(FontHeightReference::CapHeight, "capHeight"), (FontHeightReference::XHeight, "xHeight"), (FontHeightReference::IcfBox, "icfBox")] + { + let set = CharStyle { font_height_reference: r, ..CharStyle::default() }; + assert_eq!(serde_json::to_value(&set).unwrap()["fontHeightReference"], key); + assert_eq!(serde_json::from_value::(serde_json::to_value(&set).unwrap()).unwrap(), set); + } + // Styles written before the attribute existed read as the em box. + let old = serde_json::to_value(&st).unwrap(); + assert_eq!(serde_json::from_value::(old).unwrap().font_height_reference, FontHeightReference::EmBox); +} + /// Each Indic numeral system draws 0–9 (and the Arabic-Indic sets) as the ten digits of its /// script's block; every system saves under its id, which reads back, and other names are refused. #[test] diff --git a/crates/engine/src/cmd/panelcmds.rs b/crates/engine/src/cmd/panelcmds.rs index 7f504dcc4..7934dbe69 100644 --- a/crates/engine/src/cmd/panelcmds.rs +++ b/crates/engine/src/cmd/panelcmds.rs @@ -43,7 +43,7 @@ pub fn specs() -> Vec { "Character / Paragraph", [], None, - "{ids?|id?, kerning?: 1/1000 em|\"auto\"|\"optical\", baselineShift?: pt, hScale?: %, vScale?: %, rotation?: deg, underline?, strikethrough?, allCaps?, smallCaps?: bool, position?: \"normal\"|\"superscript\"|\"subscript\" (sizes from Document Setup), 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\" (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), 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\"|\"lineEndHalf\" (Japanese punctuation spacing), kinsoku?: \"none\"|\"hard\"|\"soft\" (Kinsoku Set: Soft lets 々, ー and small kana start a line), 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), 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), 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), 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), 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)", + "{ids?|id?, kerning?: 1/1000 em|\"auto\"|\"optical\", baselineShift?: pt, hScale?: %, vScale?: %, rotation?: deg, underline?, strikethrough?, allCaps?, smallCaps?: bool, position?: \"normal\"|\"superscript\"|\"subscript\" (sizes from Document Setup), 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\" (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), 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\"|\"lineEndHalf\" (Japanese punctuation spacing), kinsoku?: \"none\"|\"hard\"|\"soft\" (Kinsoku Set: Soft lets 々, ー and small kana start a line), 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), 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), 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), 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), 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)", has_doc, set_format ), @@ -373,6 +373,7 @@ fn set_format(s: &mut Session, p: &Value) -> Result { "standardVerticalRoman", "noBreak", "digits", + "fontHeightReference", "language", "fontAxes", "burasagari", @@ -397,6 +398,7 @@ fn set_format(s: &mut Session, p: &Value) -> Result { let digits = super::textedit::digits_param(p, C)?; let language = super::textedit::language_param(p, C)?; let font_axes = super::textedit::font_axes_param(p, C)?; + let font_height_reference = super::textedit::font_height_reference_param(p, C)?; let mojikumi = match p.get("mojikumi") { None => None, Some(v) => Some(match v.as_str() { @@ -545,6 +547,9 @@ fn set_format(s: &mut Session, p: &Value) -> Result { if let Some(v) = &font_axes { super::textedit::change_font_axes(st, v); } + if let Some(v) = font_height_reference { + st.font_height_reference = v; + } }; s.edit("Character", |d, _| { for id in &ids { diff --git a/crates/engine/src/cmd/textedit.rs b/crates/engine/src/cmd/textedit.rs index 8cfdaf47f..5f5f653a9 100644 --- a/crates/engine/src/cmd/textedit.rs +++ b/crates/engine/src/cmd/textedit.rs @@ -39,7 +39,7 @@ pub fn specs() -> Vec { "Character", [], None, - "{id? (default: the one selected type object), start?: byte, end?: byte (default: all text), font?, style?, size?: pt, leading?: pt|\"auto\", tracking?, kerning?: 1/1000 em|\"auto\"|\"optical\", baselineShift?: pt, hScale?: %, vScale?: %, rotation?: deg, fill?: colour|\"none\", stroke?: colour|\"none\", 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\", …], 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), 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), 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), 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)} 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), font?, style?, size?: pt, leading?: pt|\"auto\", tracking?, kerning?: 1/1000 em|\"auto\"|\"optical\", baselineShift?: pt, hScale?: %, vScale?: %, rotation?: deg, fill?: colour|\"none\", stroke?: colour|\"none\", 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\", …], 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), 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), 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), 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)} 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 ), @@ -325,6 +325,7 @@ pub(crate) struct CharChange { /// The language to set: a tag, or `None` for the default. language: Option>, font_axes: Option, + font_height_reference: Option, } /// `charDirection: "default"|"leftToRight"|"rightToLeft"` → the Character Direction to set (`None` @@ -452,6 +453,20 @@ pub(crate) fn char_align_param(p: &Value, cmd: &str) -> Result Result> { + use vectorcraft_doc::FontHeightReference; + let Some(v) = p.get("fontHeightReference").filter(|v| !v.is_null()) else { return Ok(None) }; + Ok(Some(match v.as_str() { + Some("emBox") => FontHeightReference::EmBox, + Some("capHeight") => FontHeightReference::CapHeight, + Some("xHeight") => FontHeightReference::XHeight, + Some("icfBox") => FontHeightReference::IcfBox, + _ => return Err(bad(cmd, "`fontHeightReference` must be \"emBox\", \"capHeight\", \"xHeight\" or \"icfBox\"")), + })) +} + fn paint_param(p: &Value, k: &str, cmd: &str) -> Result> { match p.get(k) { None | Some(Value::Null) => Ok(None), @@ -523,6 +538,7 @@ impl CharChange { digits: digits_param(p, cmd)?, language: language_param(p, cmd)?, font_axes: font_axes_param(p, cmd)?, + font_height_reference: font_height_reference_param(p, cmd)?, }; if c.size.is_some_and(|v| v <= 0.0) { return Err(bad(cmd, "size must be positive")); @@ -570,6 +586,7 @@ impl CharChange { && self.digits.is_none() && self.language.is_none() && self.font_axes.is_none() + && self.font_height_reference.is_none() } fn localize(&mut self, t: &TextObject, cmd: &str) -> Result<()> { @@ -680,6 +697,9 @@ impl CharChange { if let Some(v) = self.digits { st.digits = v; } + if let Some(v) = self.font_height_reference { + st.font_height_reference = v; + } if let Some(v) = &self.language { st.language.clone_from(v); } @@ -1216,7 +1236,11 @@ fn type_step(s: &mut Session, p: &Value) -> Result { match step { Step::Size => st.size = super::typecmd::local_type_value(t, st.size * points + inc, (0.1, 1296.0), false, C)?, Step::Leading => { - let leading = t.para_at(paras.get(i).copied().unwrap_or(0)).effective_leading(st); + // With Auto leading the step starts from the leading the lines are set with: + // the paragraph's Auto Leading share of the em size the font height reference + // gives the characters. An inline graphic keeps its size. + let em = if run.inline.is_some() { st.size } else { vectorcraft_text::FontDb::global().em_size(st, t.vertical) }; + let leading = t.para_at(paras.get(i).copied().unwrap_or(0)).effective_leading(&CharStyle { size: em, ..st.clone() }); st.leading = Some(super::typecmd::local_type_value(t, leading * points + inc, (0.1, 5000.0), false, C)?); } Step::Tracking => st.tracking = (st.tracking + inc).clamp(-1000.0, 10000.0), diff --git a/crates/engine/src/cmd/textstyles.rs b/crates/engine/src/cmd/textstyles.rs index 5995c490d..ee8ab1d3c 100644 --- a/crates/engine/src/cmd/textstyles.rs +++ b/crates/engine/src/cmd/textstyles.rs @@ -178,6 +178,7 @@ pub fn char_attrs(v: &CharStyle) -> Map { ("standardVerticalRoman", json!(v.standard_vertical_roman)), ("noBreak", json!(v.no_break)), ("digits", json!(v.digits)), + ("fontHeightReference", json!(v.font_height_reference)), ("language", json!(v.language)), ] { m.insert(k.into(), x); diff --git a/crates/engine/src/cmd/typecmd/area_tests.rs b/crates/engine/src/cmd/typecmd/area_tests.rs index 65febdf0b..4a1200c25 100644 --- a/crates/engine/src/cmd/typecmd/area_tests.rs +++ b/crates/engine/src/cmd/typecmd/area_tests.rs @@ -1306,6 +1306,97 @@ fn digits_are_a_character_attribute_saved_only_when_set() { assert_eq!(set, [("صفحة ".to_string(), Digits::Default), ("12".to_string(), Digits::Farsi)]); } +/// Font height reference (Character panel › Set Font Height Reference): a character attribute, +/// saved only when not the em box; the type is laid out again at the em size it gives, so 20 pt +/// type measuring a 0.7 em cap height grows by 1 / 0.7, and a leading step from Auto starts at +/// 120% of that em size. +#[test] +fn font_height_reference_is_a_character_attribute_saved_only_when_not_the_em_box() { + use vectorcraft_doc::FontHeightReference as R; + use vectorcraft_text::test_fonts::{HEIGHT_CAP, HEIGHT_FAMILY, height_font}; + vectorcraft_text::FontDb::global().add_font(height_font(true).unwrap()); + 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": "Hx Hx", "font": HEIGHT_FAMILY, "size": 20})).unwrap()["id"].as_u64().unwrap(); + let text = |s: &Session| match &s.doc().unwrap().doc.node(NodeId(id)).unwrap().kind { + NodeKind::Text(t) => t.clone(), + _ => panic!("text"), + }; + let height = |s: &Session| text(s).cached_bounds.unwrap().height(); + let em_box = height(&s); + assert_eq!(text(&s).runs[0].style.font_height_reference, R::EmBox); + s.execute("select.set", &json!({"ids": [id]})).unwrap(); + for (key, want) in [("capHeight", R::CapHeight), ("xHeight", R::XHeight), ("icfBox", R::IcfBox), ("emBox", R::EmBox)] { + s.execute("text.setFormat", &json!({"fontHeightReference": key})).unwrap(); + assert!(text(&s).runs.iter().all(|r| r.style.font_height_reference == want && r.style.size == 20.0)); + let saved = serde_json::to_value(&text(&s).runs[0].style).unwrap(); + assert_eq!(saved.get("fontHeightReference").and_then(|v| v.as_str()), (want != R::EmBox).then_some(key)); + } + s.execute("text.setFormat", &json!({"fontHeightReference": "capHeight"})).unwrap(); + let grown = height(&s) / em_box; + assert!((grown - 1000.0 / f64::from(HEIGHT_CAP)).abs() < 1e-6, "{grown}"); + // A leading step from Auto starts at the leading the lines are set with: 120% of the em size + // (the paragraph's Auto Leading). + s.execute("type.step", &json!({"attribute": "leading"})).unwrap(); + let stepped = text(&s).runs[0].style.leading.unwrap(); + assert!((stepped - (1.2 * 20.0 * 1000.0 / f64::from(HEIGHT_CAP) + 2.0)).abs() < 1e-9, "{stepped}"); + s.execute("edit.undo", &json!({})).unwrap(); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(text(&s).runs[0].style.font_height_reference, R::EmBox); + assert!((height(&s) - em_box).abs() < 1e-9); + let err = s.execute("text.setFormat", &json!({"fontHeightReference": "baseline"})).unwrap_err().to_string(); + assert!(err.contains("`fontHeightReference` must be"), "{err}"); + // Characters selected with the Type tool: only the range takes it. + s.execute("text.setRangeStyle", &json!({"id": id, "start": 3, "end": 5, "fontHeightReference": "xHeight"})).unwrap(); + let set: Vec<_> = text(&s).runs.iter().map(|r| (r.text.clone(), r.style.font_height_reference)).collect(); + assert_eq!(set, [("Hx ".to_string(), R::EmBox), ("Hx".to_string(), R::XHeight)]); +} + +/// A leading step from Auto starts at the leading each paragraph's lines are set with, its +/// Auto Leading share of the em size: 20 pt type in paragraphs at 120% and 150% steps from 24 pt +/// and from 30 pt. +#[test] +fn a_leading_step_from_auto_starts_at_each_paragraphs_auto_leading() { + 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": "Ab\nCd", "size": 20})).unwrap()["id"].as_u64().unwrap(); + s.execute("text.setFormat", &json!({"id": id, "start": 3, "end": 5, "autoLeading": 150})).unwrap(); + s.execute("select.set", &json!({"ids": [id]})).unwrap(); + s.execute("type.step", &json!({"attribute": "leading"})).unwrap(); + let NodeKind::Text(t) = &s.doc().unwrap().doc.node(NodeId(id)).unwrap().kind else { + panic!("text"); + }; + let leading: Vec<_> = t.runs.iter().map(|r| (r.text.as_str(), r.style.leading)).collect(); + assert_eq!(leading, [("Ab\n", Some(26.0)), ("Cd", Some(32.0))]); +} + +/// An inline graphic keeps its size whatever the font height reference, so a leading step from +/// Auto starts at 120% of that size: 20 pt Cap Height type steps from 120% of 20 / 0.7 pt and +/// the graphic in it from 24 pt. +#[test] +fn a_leading_step_from_auto_starts_at_an_inline_graphics_own_size() { + use vectorcraft_text::test_fonts::{HEIGHT_CAP, HEIGHT_FAMILY, height_font}; + vectorcraft_text::FontDb::global().add_font(height_font(true).unwrap()); + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 400, "height": 400})).unwrap(); + s.execute("shape.ellipse", &json!({"x": 300, "y": 300, "width": 10, "height": 10})).unwrap(); + s.execute("symbol.new", &json!({"name": "Dot"})).unwrap(); + s.execute("edit.clear", &json!({})).unwrap(); + let id = s.execute("text.create", &json!({"x": 10, "y": 50, "text": "Hx", "font": HEIGHT_FAMILY, "size": 20})).unwrap()["id"].as_u64().unwrap(); + s.execute("text.insertInline", &json!({"id": id, "at": 2, "symbol": "Dot"})).unwrap(); + s.execute("select.set", &json!({"ids": [id]})).unwrap(); + s.execute("text.setFormat", &json!({"fontHeightReference": "capHeight"})).unwrap(); + s.execute("type.step", &json!({"attribute": "leading"})).unwrap(); + let NodeKind::Text(t) = &s.doc().unwrap().doc.node(NodeId(id)).unwrap().kind else { + panic!("text"); + }; + let leading: Vec<_> = t.runs.iter().map(|r| (r.inline.is_some(), r.style.leading.unwrap())).collect(); + let text = 1.2 * 20.0 * 1000.0 / f64::from(HEIGHT_CAP) + 2.0; + assert_eq!(leading.len(), 2, "{leading:?}"); + assert!(leading.iter().all(|&(inline, l)| (l - if inline { 26.0 } else { text }).abs() < 1e-9), "{leading:?}"); +} + /// The Indic digits (#4942): each set by its id through text.setFormat and text.setRangeStyle, /// saved under it, kept by save and reopen and by character styles; the text stays as typed. #[test] diff --git a/crates/eps/src/legacy_ai/tests.rs b/crates/eps/src/legacy_ai/tests.rs index ee55b0a5d..1c750d983 100644 --- a/crates/eps/src/legacy_ai/tests.rs +++ b/crates/eps/src/legacy_ai/tests.rs @@ -1017,6 +1017,39 @@ fn variable_font_settings_are_warned_as_left_out() { assert!(String::from_utf8(out.bytes).unwrap().contains("(H\\351llo \\2005\\r) Tx\n"), "still written as text"); } +/// Type whose font size measures the cap height is written at the em size it is drawn at, with +/// no warning: 14 pt measuring a 0.7 em cap height reads back as 20 pt type. +#[test] +fn a_font_height_reference_is_written_as_the_size_drawn() { + use vectorcraft_text::test_fonts::{HEIGHT_FAMILY, height_font}; + vectorcraft_text::FontDb::global().add_font(height_font(true).unwrap()); + let mut doc = typed(); + let id = texts(&doc)[0].0.id; + if let Some(NodeKind::Text(t)) = doc.node_mut(id).map(|n| &mut n.kind) { + let st = &mut t.runs[0].style; + (st.font_family, st.size) = (HEIGHT_FAMILY.into(), 14.0); + st.font_height_reference = vectorcraft_doc::FontHeightReference::CapHeight; + } + let out = write(&doc); + assert!(!out.warnings.iter().any(|w| w == TYPE_ATTRIBUTES || w == TYPE), "{:?}", out.warnings); + let imported = crate::import(&out.bytes).unwrap(); + let size = texts(&imported.document)[0].1.runs[0].style.size; + assert!((size - 20.0).abs() < 1e-6, "{size}"); +} + +/// Characters with Auto leading are written with their paragraph's Auto Leading share of the +/// size: 18 pt type in paragraphs at 150% is written with 27 pt leading. +#[test] +fn auto_leading_is_written_at_the_paragraphs_share() { + let mut doc = typed(); + let id = texts(&doc)[0].0.id; + if let Some(NodeKind::Text(t)) = doc.node_mut(id).map(|n| &mut n.kind) { + t.edit_paras(None, |p| p.justification.auto_leading = 150.0); + } + let text = String::from_utf8(write(&doc).bytes).unwrap(); + assert!(text.contains("\n27 0 Tl\n"), "{text}"); +} + /// A legacy `.ai` paragraph has no list: type with a bulleted or numbered list is written as its /// text, with the warning that the bullets and numbers are left out. #[test] diff --git a/crates/eps/src/legacy_ai/writer.rs b/crates/eps/src/legacy_ai/writer.rs index b0ba5508f..9879a51ab 100644 --- a/crates/eps/src/legacy_ai/writer.rs +++ b/crates/eps/src/legacy_ai/writer.rs @@ -719,11 +719,15 @@ impl<'a> Writer<'a> { // ---------- type ---------- - /// Type: a text object when the format can hold it as text, else its outlines. + /// Type: a text object when the format can hold it as text, else its outlines. A run whose + /// font size measures a height other than the em box is written at the em size it is drawn + /// at ([`vectorcraft_text::FontDb::font_heights_resolved`]). fn text(&mut self, t: &TextObject) { if !invertible(t.xf) { return; } + let resolved = vectorcraft_text::FontDb::global().font_heights_resolved(t); + let t = &*resolved; if !self.outline_text { if writable(t) { return self.text_object(t); @@ -849,7 +853,8 @@ impl<'a> Writer<'a> { ps::push_num(&mut self.body, finite(st.tracking, 0.0)); self.line(" Tt"); self.line(if st.kerning.is_none() { "1 TA" } else { "0 TA" }); - ps::push_nums(&mut self.body, &[finite(st.effective_leading(), size * 1.2), finite(p.space_before, 0.0)]); + let leading = st.leading.unwrap_or_else(|| p.justification.auto_leading_for(st.size)); + ps::push_nums(&mut self.body, &[finite(leading, size * 1.2), finite(p.space_before, 0.0)]); self.line("Tl"); } diff --git a/crates/svg/src/css.rs b/crates/svg/src/css.rs index 178528887..747dae25b 100644 --- a/crates/svg/src/css.rs +++ b/crates/svg/src/css.rs @@ -567,8 +567,12 @@ impl Rules<'_> { } } - /// Type: its first character style's font, colour and spacing, its paragraph's alignment. + /// Type: its first character style's font, colour and spacing, its paragraph's alignment. The + /// font size is the em size the characters are drawn at + /// ([`vectorcraft_text::FontDb::font_heights_resolved`]). fn type_art(&mut self, n: &Node, t: &vectorcraft_doc::TextObject, art: &mut Art) { + let resolved = vectorcraft_text::FontDb::global().font_heights_resolved(t); + let t = &*resolved; if matches!(t.kind, TextKind::OnPath { .. }) { art.unsupported("type on a path"); } diff --git a/crates/svg/src/export/text.rs b/crates/svg/src/export/text.rs index b94a7449c..c8b6ced72 100644 --- a/crates/svg/src/export/text.rs +++ b/crates/svg/src/export/text.rs @@ -73,11 +73,14 @@ impl Writer<'_> { /// instances of their symbols (a `` of the symbol's def where it can be shared), all in /// one group carrying the object's transparency. The live text skips their characters and /// places the text after each one; type on a path with inline graphics is written as outlines - /// (a `` couldn't leave room for the art). + /// (a `` couldn't leave room for the art). A run whose font size measures a height + /// other than the em box is written at the em size it is drawn at + /// ([`vectorcraft_text::FontDb::font_heights_resolved`]). pub(super) fn text_node(&mut self, n: &Node, t: &TextObject) { let doc = self.doc; let resolved = doc.inline_resolved(t); - let t = &*resolved; + let heights = vectorcraft_text::FontDb::global().font_heights_resolved(&resolved); + let t = &*heights; let inline = t.runs.iter().any(|r| r.inline.is_some()); // Ruby is written as outlines with its base text: a reader's can't place it. So are // small capitals, superscript and subscript and tab leaders, which SVG text doesn't draw (#3869). diff --git a/crates/svg/tests/it/css_rules.rs b/crates/svg/tests/it/css_rules.rs index 1c385a095..88e96cddf 100644 --- a/crates/svg/tests/it/css_rules.rs +++ b/crates/svg/tests/it/css_rules.rs @@ -148,6 +148,26 @@ fn ellipses_round_fully_and_options_change_units_and_names() { assert!(prop(&r, "width").is_none() && prop(&r, "height").is_none()); } +/// Type whose font size measures the cap height gives the em size it is drawn at: 14 pt measuring +/// a 0.7 em cap height is `font-size: 20px`, and its Auto leading is not written. +#[test] +fn type_gives_the_font_size_it_is_drawn_at() { + use vectorcraft_text::test_fonts::{HEIGHT_FAMILY, height_font}; + vectorcraft_text::FontDb::global().add_font(height_font(true).unwrap()); + let mut d = doc(); + let st = CharStyle { + font_family: HEIGHT_FAMILY.into(), + size: 14.0, + font_height_reference: vectorcraft_doc::FontHeightReference::CapHeight, + ..CharStyle::default() + }; + let n = text(&mut d, "Hello", st); + let id = add(&mut d, n); + let r = one(&d, id, &CssOptions::default()); + assert_eq!(prop(&r, "font-size"), Some("20px")); + assert!(prop(&r, "line-height").is_none()); +} + #[test] fn type_gives_font_properties() { let mut d = doc(); diff --git a/crates/svg/tests/it/text_layout.rs b/crates/svg/tests/it/text_layout.rs index 76c30a107..3107a1f44 100644 --- a/crates/svg/tests/it/text_layout.rs +++ b/crates/svg/tests/it/text_layout.rs @@ -176,6 +176,23 @@ fn digits_are_written_as_drawn() { assert!(!text.contains("page 4") && !text.contains("page ٥"), "{svg}"); } +/// A run whose font size measures the cap height is written at the em size it is drawn at: 14 pt +/// measuring a 0.7 em cap height is `font-size="20"`, after a 12 pt run measuring the em box, +/// which takes the `` element's 12. +#[test] +fn font_height_reference_writes_the_size_drawn() { + use vectorcraft_doc::FontHeightReference; + use vectorcraft_text::test_fonts::{HEIGHT_FAMILY, height_font}; + vectorcraft_text::FontDb::global().add_font(height_font(true).unwrap()); + let em_box = CharStyle { font_family: HEIGHT_FAMILY.into(), ..style(12.0) }; + let cap = CharStyle { size: 14.0, font_height_reference: FontHeightReference::CapHeight, ..em_box.clone() }; + let d = area_doc(vec![run("Hx ", em_box), run("Hx", cap)], 280.0, Justify::Left); + let svg = export(&d, &ExportOptions::default()); + assert!(svg.contains("font-size=\"12\" fill"), "{svg}"); + let sizes: Vec<_> = tspans(&svg).iter().map(|t| attr(t, "font-size")).collect(); + assert_eq!(sizes, [None, Some(20.0)], "{svg}"); +} + #[test] fn fewer_tspans_writes_one_per_line() { let bold = CharStyle { font_style: "Bold".into(), ..style(12.0) }; diff --git a/crates/text/src/fontdb/face.rs b/crates/text/src/fontdb/face.rs index 758acc2c2..0df8c2e33 100644 --- a/crates/text/src/fontdb/face.rs +++ b/crates/text/src/fontdb/face.rs @@ -279,6 +279,26 @@ impl FontFace { *self.icf_margins.get_or_init(|| self.icf_from_base().or_else(|| self.icf_from_glyphs()).unwrap_or_default()) } + /// The height of the face that a font size measures with `reference`, in ems: 1 for the em + /// box; the cap height or x height of its OS/2 table; the height of its ideographic character + /// face ([`Self::icf_margins`]), or across a `vertical` line its width. None for a face whose + /// font has no such value (an OS/2 table without the heights, no ICF), or one outside 0.1 to + /// 2 ems. + pub fn font_height(&self, reference: vectorcraft_doc::FontHeightReference, vertical: bool) -> Option { + use vectorcraft_doc::FontHeightReference as R; + let units = |v: Option| v.map(f64::from).map(|v| v / self.upem); + let h = match reference { + R::EmBox => Some(1.0), + R::CapHeight => units(self.skrifa()?.metrics(Size::unscaled(), self.location()).cap_height), + R::XHeight => units(self.skrifa()?.metrics(Size::unscaled(), self.location()).x_height), + R::IcfBox => { + let m = self.icf_margins(); + (m != IcfMargins::default()).then_some(if vertical { 1.0 - m.left - m.right } else { 1.0 - m.top - m.bottom }) + } + }; + h.filter(|h| (0.1..=2.0).contains(h)) + } + /// [`Self::icf_margins`] from the BASE table: the ICF's bottom edge `icfb` (top edge `icft`, else /// as far below the em box top) against the em box `ideo` .. `idtp` (else the OS/2 typographic /// descender, and one em above it); across vertical lines, `icfb` and `icft` of the vertical diff --git a/crates/text/src/fontdb/lookup.rs b/crates/text/src/fontdb/lookup.rs index e01a47493..010f96ffa 100644 --- a/crates/text/src/fontdb/lookup.rs +++ b/crates/text/src/fontdb/lookup.rs @@ -247,6 +247,37 @@ impl FontDb { Some(self.instance(&face, &st.font_axes)) } + /// The em size characters of style `st` are set at, in `vertical` type or not: its size, or + /// with a font height reference other than the em box ([`vectorcraft_doc::FontHeightReference`]) + /// the size at which its face ([`Self::style_face`]) has that height + /// ([`FontFace::font_height`]). The size itself when the face has no such height. + pub fn em_size(&self, st: &vectorcraft_doc::CharStyle, vertical: bool) -> f64 { + let reference = st.font_height_reference; + if reference == vectorcraft_doc::FontHeightReference::EmBox { + return st.size; + } + self.style_face(st).and_then(|f| f.font_height(reference, vertical)).map_or(st.size, |h| st.size / h) + } + + /// `t` as layout sets it: each run takes its [`Self::em_size`] and the em box as its font + /// height reference; an inline graphic keeps its size. Explicit leading and baseline shift + /// stay in points; Auto leading, tracking and kerning follow the em size. `t` itself when every + /// run measures the em box. + pub fn font_heights_resolved<'a>(&self, t: &'a vectorcraft_doc::TextObject) -> std::borrow::Cow<'a, vectorcraft_doc::TextObject> { + use vectorcraft_doc::FontHeightReference; + if t.runs.iter().all(|r| r.style.font_height_reference == FontHeightReference::EmBox) { + return std::borrow::Cow::Borrowed(t); + } + let mut out = t.clone(); + for r in &mut out.runs { + if r.inline.is_none() { + r.style.size = self.em_size(&r.style, t.vertical); + } + r.style.font_height_reference = FontHeightReference::EmBox; + } + std::borrow::Cow::Owned(out) + } + /// `face` with the axis settings `axes` (by tag, in the axes' own units) in place of its own, /// each clamped to its axis's range: its outlines, metrics and advances taken there, and its /// file embedded instanced there. Tags the font has no axis for are left out. `face` itself diff --git a/crates/text/src/layout/driver.rs b/crates/text/src/layout/driver.rs index 4cb518d0d..58ca0a525 100644 --- a/crates/text/src/layout/driver.rs +++ b/crates/text/src/layout/driver.rs @@ -106,8 +106,11 @@ pub fn decorations(layout: &TextLayout, db: &FontDb, t: &TextObject) -> Vec<(usi const SHRINK_PASSES: usize = 11; /// Lay out a text object with explicit options (area type rows/columns, inset, first baseline, -/// fit, composer, OpenType features). +/// fit, composer, OpenType features). Each run is set at the em size its font height reference +/// gives it ([`FontDb::font_heights_resolved`]). pub fn layout_with(db: &FontDb, t: &TextObject, opts: &LayoutOptions) -> TextLayout { + let resolved = db.font_heights_resolved(t); + let t = &*resolved; let first = layout_once(db, t, opts); match (opts.fit.min_scale(), &t.kind) { (Some(min), TextKind::Area { .. }) if first.overflow => shrink_to_fit(db, t, opts, min).unwrap_or(first), diff --git a/crates/text/src/shape.rs b/crates/text/src/shape.rs index e04c6c6dc..6f22baa7f 100644 --- a/crates/text/src/shape.rs +++ b/crates/text/src/shape.rs @@ -718,6 +718,7 @@ fn shapes_alike(a: &CharStyle, b: &CharStyle) -> bool { tate_chu_yoko, standard_vertical_roman: _, digits, + font_height_reference, no_break, language, } = a; @@ -735,6 +736,7 @@ fn shapes_alike(a: &CharStyle, b: &CharStyle) -> bool { && *proportional_metrics == b.proportional_metrics && *tate_chu_yoko == b.tate_chu_yoko && *digits == b.digits + && *font_height_reference == b.font_height_reference && *no_break == b.no_break && *language == b.language && ligatures_suppressed_by(*tracking) == ligatures_suppressed_by(b.tracking) diff --git a/crates/text/src/test_fonts.rs b/crates/text/src/test_fonts.rs index 817f1689f..a4e0fcde6 100644 --- a/crates/text/src/test_fonts.rs +++ b/crates/text/src/test_fonts.rs @@ -254,6 +254,86 @@ pub fn vertical_font(vorg: bool) -> Option> { Some(builder.build()) } +/// The family of [`height_font`] with its heights. +pub const HEIGHT_FAMILY: &str = "Heighttest Sans"; + +/// The family of [`height_font`] without them. +pub const HEIGHTLESS_FAMILY: &str = "Heightlesstest Sans"; + +/// The cap height and x height [`height_font`] gives, in font units (1,000 to the em). +pub const HEIGHT_CAP: i16 = 700; +pub const HEIGHT_X: i16 = 500; + +/// The height (and the width across a vertical line) of [`height_font`]'s ideographic character +/// face, in ems. +pub const HEIGHT_ICF: f64 = 0.9; + +/// The family of [`height_outside_font`]. +pub const HEIGHT_OUTSIDE_FAMILY: &str = "Heightoutsidetest Sans"; + +/// The cap height and x height [`height_outside_font`] gives, in font units (1,000 to the em): +/// 2.5 em and 0.05 em, outside the 0.1 to 2 ems a font size measures. +pub const HEIGHT_OUTSIDE_CAP: i16 = 2500; +pub const HEIGHT_OUTSIDE_X: i16 = 50; + +/// The bundled Source Sans 3 Regular as family [`HEIGHT_FAMILY`], with the cap height +/// [`HEIGHT_CAP`] and x height [`HEIGHT_X`] in its OS/2 table and a `BASE` table whose Han +/// baselines put the ideographic character face [`HEIGHT_ICF`] em high inside the em box (`ideo` +/// -120, `idtp` 880, `icfb` -70, `icft` 830). Without `heights`: family [`HEIGHTLESS_FAMILY`], +/// with a version 1 OS/2 table, which holds neither height, and no `BASE` table. `None` when it +/// can't be built. +pub fn height_font(heights: bool) -> Option> { + if heights { heights_font(HEIGHT_FAMILY, Some((HEIGHT_X, HEIGHT_CAP)), true) } else { heights_font(HEIGHTLESS_FAMILY, None, false) } +} + +/// The bundled Source Sans 3 Regular as family [`HEIGHT_OUTSIDE_FAMILY`], with the cap height +/// [`HEIGHT_OUTSIDE_CAP`] and x height [`HEIGHT_OUTSIDE_X`] in its OS/2 table and no `BASE` +/// table. `None` when it can't be built. +pub fn height_outside_font() -> Option> { + heights_font(HEIGHT_OUTSIDE_FAMILY, Some((HEIGHT_OUTSIDE_X, HEIGHT_OUTSIDE_CAP)), false) +} + +/// The bundled Source Sans 3 Regular as `family`: with the x height and cap height `heights` in +/// its OS/2 table, else a version 1 OS/2 table, which holds neither; with `base`, the `BASE` +/// table of [`height_font`]. +fn heights_font(family: &str, heights: Option<(i16, i16)>, base: bool) -> Option> { + use write_fonts::tables::base::{Axis, Base, BaseCoord, BaseScript, BaseScriptList, BaseScriptRecord, BaseTagList, BaseValues}; + + const BASE: &[u8] = include_bytes!("../../../assets/fonts/SourceSans3-Regular.ttf"); + let font = skrifa::FontRef::new(BASE).ok()?; + let full = format!("{family} Regular"); + let postscript = format!("{}-Regular", family.replace(' ', "")); + let name = name_table(&[(1, family), (2, "Regular"), (4, &full), (6, &postscript)])?; + let mut builder = FontBuilder::new(); + builder.add_raw(Tag::new(b"name"), name); + if base { + let tags = BaseTagList::new([b"icfb", b"icft", b"ideo", b"idtp"].map(Tag::new).to_vec()); + let values = BaseValues::new(2, [-70, 830, -120, 880].map(BaseCoord::format_1).to_vec()); + let hani = BaseScriptRecord::new(Tag::new(b"hani"), BaseScript::new(Some(values), None, vec![])); + let axis = Axis::new(Some(tags), BaseScriptList::new(vec![hani])); + builder.add_table(&Base::new(Some(axis), None)).ok()?; + } + for r in font.table_directory.table_records() { + let tag = Tag::new(&r.tag().to_be_bytes()); + if builder.contains(tag) { + continue; + } + let mut data = font.table_data(r.tag())?.as_bytes().to_vec(); + if tag == Tag::new(b"OS/2") { + if let Some((x, cap)) = heights { + // sxHeight and sCapHeight, at bytes 86 and 88 of a version 2 or later table. + data.get_mut(86..90)?.copy_from_slice(&[x.to_be_bytes(), cap.to_be_bytes()].concat()); + } else { + // Version 1 ends before sxHeight. + data.truncate(86); + data.get_mut(0..2)?.copy_from_slice(&1_u16.to_be_bytes()); + } + } + builder.add_raw(tag, data); + } + Some(builder.build()) +} + /// The family of [`vertical_punctuation_font`]. pub const VERTICAL_PUNCTUATION_FAMILY: &str = "Punctuationtest Sans"; diff --git a/crates/text/src/tests.rs b/crates/text/src/tests.rs index 7ed2eb48c..db28b2013 100644 --- a/crates/text/src/tests.rs +++ b/crates/text/src/tests.rs @@ -1347,6 +1347,62 @@ fn character_alignment_on_the_icf_lines_small_characters_up_with_the_largest_fac } } +/// The font height reference: 20 pt type that measures the cap height is set at the em size at +/// which the font's cap height (OS/2) is 20 pt, and the same for the x height and for the ideographic +/// character face's height (its width in vertical type); the em box keeps 20 pt to the em, and +/// so does every reference in a font without these values. Auto leading follows the em size, +/// and two runs that measure different heights each take their own em size. +#[test] +fn font_size_measures_the_font_height_reference() { + use crate::test_fonts::{HEIGHT_CAP, HEIGHT_FAMILY, HEIGHT_ICF, HEIGHT_X, HEIGHTLESS_FAMILY, height_font}; + use vectorcraft_doc::FontHeightReference as R; + let db = FontDb::with_font_dirs(vec![]); + db.add_font(height_font(true).unwrap()); + db.add_font(height_font(false).unwrap()); + let st = |family: &str, r: R| CharStyle { font_family: family.into(), font_height_reference: r, ..style(20.0) }; + // The em size each glyph of `t` is drawn at (its transform's scale times 1,000 units). + let ems = |t: &TextObject| layout(&db, t).glyphs.iter().map(|g| g.xf.determinant().abs().sqrt() * 1000.0).collect::>(); + let near = |a: f64, b: f64| (a - b).abs() < 1e-6; + let (cap, x) = (f64::from(HEIGHT_CAP) / 1000.0, f64::from(HEIGHT_X) / 1000.0); + for vertical_type in [false, true] { + for (r, h) in [(R::EmBox, 1.0), (R::CapHeight, cap), (R::XHeight, x), (R::IcfBox, HEIGHT_ICF)] { + let mut t = point("Hx", st(HEIGHT_FAMILY, r)); + t.vertical = vertical_type; + assert!(ems(&t).iter().all(|e| near(*e, 20.0 / h)), "{r:?} vertical {vertical_type}: {:?}", ems(&t)); + t.runs[0].style.font_family = HEIGHTLESS_FAMILY.into(); + assert!(ems(&t).iter().all(|e| near(*e, 20.0)), "{r:?} without the value, vertical {vertical_type}: {:?}", ems(&t)); + } + } + let face = db.face(HEIGHT_FAMILY, "Regular").unwrap(); + assert_eq!(face.font_height(R::CapHeight, false), Some(cap)); + assert_eq!(db.face(HEIGHTLESS_FAMILY, "Regular").unwrap().font_height(R::XHeight, false), None); + // Auto leading: 120% of the em size the line is set at. + let lines = layout(&db, &point("H\nH", st(HEIGHT_FAMILY, R::CapHeight))).lines; + assert!(near(lines[1].baseline - lines[0].baseline, 1.2 * 20.0 / cap), "{lines:?}"); + // One run at the em box, the next at the cap height. + let mut t = point("H", st(HEIGHT_FAMILY, R::EmBox)); + t.runs.push(TextRun { text: "H".into(), style: st(HEIGHT_FAMILY, R::CapHeight), inline: None }); + let set = ems(&t); + assert!(set.len() == 2 && near(set[0], 20.0) && near(set[1], 20.0 / cap), "{set:?}"); +} + +/// A font height outside 0.1 to 2 ems is not used: 20 pt type that measures a font's 2.5 em cap +/// height or its 0.05 em x height is set at 20 pt to the em. +#[test] +fn a_font_height_outside_a_tenth_to_two_ems_measures_the_em_box() { + use crate::test_fonts::{HEIGHT_OUTSIDE_FAMILY, height_outside_font}; + use vectorcraft_doc::FontHeightReference as R; + let db = FontDb::with_font_dirs(vec![]); + db.add_font(height_outside_font().unwrap()); + let face = db.face(HEIGHT_OUTSIDE_FAMILY, "Regular").unwrap(); + for r in [R::CapHeight, R::XHeight] { + assert_eq!(face.font_height(r, false), None, "{r:?}"); + let st = CharStyle { font_family: HEIGHT_OUTSIDE_FAMILY.into(), font_height_reference: r, ..style(20.0) }; + let ems = layout(&db, &point("Hx", st)).glyphs.iter().map(|g| g.xf.determinant().abs().sqrt() * 1000.0).collect::>(); + assert!(!ems.is_empty() && ems.iter().all(|e| (e - 20.0).abs() < 1e-6), "{r:?}: {ems:?}"); + } +} + /// Burasagari leaves Latin punctuation alone: in a measure of exactly "abcd", the full stop of /// "abcd. ef" doesn't hang with Standard or Forced (the line breaks as it does with None), and a /// Latin line ending in a full stop isn't shortened by Forced. diff --git a/crates/ui-egui/src/i18n/bg.tsv b/crates/ui-egui/src/i18n/bg.tsv index 801ed44b5..904c220ba 100644 --- a/crates/ui-egui/src/i18n/bg.tsv +++ b/crates/ui-egui/src/i18n/bg.tsv @@ -4500,6 +4500,12 @@ axis Both И двете Insert Inline Symbol Вграждане на вграден символ ICF Top/Right ICF горе/дясно ICF Bottom/Left ICF долу/ляво + Show Font Height Options Показване на опциите за височина на шрифта + Hide Font Height Options Скриване на опциите за височина на шрифта + Set Font Height Reference Задаване на референтна височина на шрифта + Em Box Квадратна касетка (Em Box) + x-Height Височина на x + ICF Box Касетка ICF @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} @msg `corners` must be an array of anchor indices, not {v} `corners` трябва да бъде масив от индекси на опорни точки, а не {v} diff --git a/crates/ui-egui/src/i18n/bs.tsv b/crates/ui-egui/src/i18n/bs.tsv index d2e67c11d..d9e96915f 100644 --- a/crates/ui-egui/src/i18n/bs.tsv +++ b/crates/ui-egui/src/i18n/bs.tsv @@ -1454,6 +1454,12 @@ digits Malayalam Malajalam Axis Osa ICF Top/Right ICF vrh/desno ICF Bottom/Left ICF dno/lijevo + Show Font Height Options Prikaži opcije visine fonta + Hide Font Height Options Sakrij opcije visine fonta + Set Font Height Reference Postavi referentnu visinu fonta + Em Box Em okvir + x-Height Visina malih slova + ICF Box ICF okvir Copy Color Value (Hex) Kopiraj vrijednost boje (hex) Create New Swatch… Kreiraj novi uzorak boje… Name 40% Naziv 40% diff --git a/crates/ui-egui/src/i18n/ca.tsv b/crates/ui-egui/src/i18n/ca.tsv index ffb752f8a..5cbf470f6 100644 --- a/crates/ui-egui/src/i18n/ca.tsv +++ b/crates/ui-egui/src/i18n/ca.tsv @@ -5735,6 +5735,12 @@ theme Appearance Aparença Glyph Bounds Límits del glif Proximity Guides Guies de proximitat Angular Guides Guies angulars + Show Font Height Options Mostra les opcions d’alçada de la font + Hide Font Height Options Amaga les opcions d’alçada de la font + Set Font Height Reference Estableix la referència d’alçada de la font + Em Box Caixa em + x-Height Alçada de la x + ICF Box Caixa ICF # Average & Join (Object › Path, Cmd+Alt+Shift+J). Average & Join… Mitjana i unió… Average & Join Mitjana i unió diff --git a/crates/ui-egui/src/i18n/cs.tsv b/crates/ui-egui/src/i18n/cs.tsv index 97ab1c407..86c2c2ef4 100644 --- a/crates/ui-egui/src/i18n/cs.tsv +++ b/crates/ui-egui/src/i18n/cs.tsv @@ -1478,6 +1478,12 @@ digits Malayalam Malajálamské Axis Osa ICF Top/Right ICF horní/pravý ICF Bottom/Left ICF dolní/levý + Show Font Height Options Zobrazit možnosti výšky písma + Hide Font Height Options Skrýt možnosti výšky písma + Set Font Height Reference Nastavit referenční výšku písma + Em Box Čtverčík + x-Height Výška x + ICF Box Rámeček ICF Copy Color Value (Hex) Kopírovat hodnotu barvy (hex) Create New Swatch… Vytvořit novou položku vzorníku… Name 40% Název 40 % @@ -4209,6 +4215,7 @@ imageTrace Outline Obrys @msg the file's layers weren't read ({_1}): it comes in as its page, in one layer vrstvy souboru nebyly načteny ({_1}): soubor se otevře jako jeho stránka v jedné vrstvě @msg the file's layers weren't read from its editing data ({_1}): they come from its PDF part vrstvy souboru nebyly načteny z jeho dat pro úpravy ({_1}): pocházejí z jeho části PDF @msg `to` must be "corner" or "smooth" `to` musí být "corner" nebo "smooth" +@msg `fontHeightReference` must be "emBox", "capHeight", "xHeight" or "icfBox" `fontHeightReference` musí být "emBox", "capHeight", "xHeight" nebo "icfBox" @msg BMP files are RGB or grayscale: colorModel rgb or gray soubory BMP jsou RGB nebo ve stupních šedi: colorModel rgb nebo gray @msg Blend colors: select at least three objects with filled colours Prolnout barvy: vyberte alespoň tři objekty s barevnou výplní @msg Combine Slices: the slices have no bounds Spojit řezy: řezy nemají hranice diff --git a/crates/ui-egui/src/i18n/da.tsv b/crates/ui-egui/src/i18n/da.tsv index 1d1741662..bee5e7270 100644 --- a/crates/ui-egui/src/i18n/da.tsv +++ b/crates/ui-egui/src/i18n/da.tsv @@ -1388,6 +1388,12 @@ digits Malayalam Malayalam Axis Akse ICF Top/Right ICF øverst/højre ICF Bottom/Left ICF nederst/venstre + Show Font Height Options Vis indstillinger for skrifthøjde + Hide Font Height Options Skjul indstillinger for skrifthøjde + Set Font Height Reference Angiv reference for skrifthøjde + Em Box Em-felt + x-Height x-højde + ICF Box ICF-felt Copy Color Value (Hex) Kopiér farveværdi (hex) Create New Swatch… Opret ny farveprøve… Name 40% Navn 40% diff --git a/crates/ui-egui/src/i18n/de.tsv b/crates/ui-egui/src/i18n/de.tsv index 804542013..92ca0c239 100644 --- a/crates/ui-egui/src/i18n/de.tsv +++ b/crates/ui-egui/src/i18n/de.tsv @@ -3821,6 +3821,7 @@ theme Appearance Erscheinungsbild @msg the file's layers weren't read ({_1}): it comes in as its page, in one layer die Ebenen der Datei wurden nicht gelesen ({_1}): sie wird als ihre Seite in einer Ebene geöffnet @msg the file's layers weren't read from its editing data ({_1}): they come from its PDF part die Ebenen der Datei wurden nicht aus ihren Bearbeitungsdaten gelesen ({_1}): sie stammen aus ihrem PDF-Teil @msg `to` must be "corner" or "smooth" `to` muss "corner" oder "smooth" sein +@msg `fontHeightReference` must be "emBox", "capHeight", "xHeight" or "icfBox" `fontHeightReference` muss "emBox", "capHeight", "xHeight" oder "icfBox" sein @msg BMP files are RGB or grayscale: colorModel rgb or gray BMP-Dateien sind RGB oder Graustufen: colorModel rgb oder gray @msg Blend colors: select at least three objects with filled colours Farben angleichen: Wählen Sie mindestens drei Objekte mit Flächenfarbe aus @msg Combine Slices: the slices have no bounds Slices kombinieren: Die Slices haben keine Begrenzung @@ -4555,6 +4556,13 @@ theme Appearance Erscheinungsbild Insert Inline Symbol Inline-Symbol einfügen ICF Top/Right ICF oben/rechts ICF Bottom/Left ICF unten/links + Show Font Height Options Optionen für Schrifthöhe einblenden + Hide Font Height Options Optionen für Schrifthöhe ausblenden + Set Font Height Reference Bezugsgröße der Schrifthöhe festlegen + Em Box Geviert + Cap Height Versalhöhe + x-Height x-Höhe + ICF Box ICF-Rahmen @msg `id` is the step reference {r}, which only `run` and `command_batch` resolve `id` ist der Schrittverweis {r}, den nur `run` und `command_batch` auflösen @msg `ids` must be an array of object ids, not {v} `ids` muss ein Array von Objekt-IDs sein, nicht {v} @msg `corners` must be an array of anchor indices, not {v} `corners` muss ein Array von Ankerpunkt-Indizes sein, nicht {v} diff --git a/crates/ui-egui/src/i18n/el.tsv b/crates/ui-egui/src/i18n/el.tsv index ce061787a..30ab097ec 100644 --- a/crates/ui-egui/src/i18n/el.tsv +++ b/crates/ui-egui/src/i18n/el.tsv @@ -2123,6 +2123,7 @@ axis Both Και τα δύο @msg the art on them differs from the page's in {_1}% of it: they come from its editing data all the same Τα γραφικά τους διαφέρουν από εκείνα της σελίδας κατά {_1}%, αλλά θα χρησιμοποιηθούν τα δεδομένα επεξεργασίας @msg `Custom` stands for changed settings: save the preset under another name Το `Custom` αντιστοιχεί σε αλλαγμένες ρυθμίσεις: αποθηκεύστε την προεπιλογή με άλλο όνομα @msg `to` must be "corner" or "smooth" Το `to` πρέπει να είναι "corner" ή "smooth" +@msg `fontHeightReference` must be "emBox", "capHeight", "xHeight" or "icfBox" Το `fontHeightReference` πρέπει να είναι "emBox", "capHeight", "xHeight" ή "icfBox" @msg BMP files are RGB or grayscale: colorModel rgb or gray Τα αρχεία BMP πρέπει να είναι RGB ή σε κλίμακα του γκρι: colorModel rgb ή gray @msg Blend colors: select at least three objects with filled colours Ανάμειξη χρωμάτων: επιλέξτε τουλάχιστον τρία αντικείμενα με χρώμα γεμίσματος @msg Combine Slices: the slices have no bounds Συνδυασμός τομών: οι τομές δεν έχουν όρια @@ -4580,6 +4581,12 @@ digits Malayalam Μαλαγιάλαμ Insert Inline Symbol Εισαγωγή ενσωματωμένου συμβόλου ICF Top/Right ICF επάνω/δεξιά ICF Bottom/Left ICF κάτω/αριστερά + Show Font Height Options Εμφάνιση επιλογών ύψους γραμματοσειράς + Hide Font Height Options Απόκρυψη επιλογών ύψους γραμματοσειράς + Set Font Height Reference Ορισμός αναφοράς ύψους γραμματοσειράς + Em Box Τετράγωνο em + x-Height Ύψος x + ICF Box Πλαίσιο ICF @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} @msg `corners` must be an array of anchor indices, not {v} το `corners` πρέπει να είναι πίνακας δεικτών αγκύρωσης, όχι {v} diff --git a/crates/ui-egui/src/i18n/es.tsv b/crates/ui-egui/src/i18n/es.tsv index 3f389ff27..48797306c 100644 --- a/crates/ui-egui/src/i18n/es.tsv +++ b/crates/ui-egui/src/i18n/es.tsv @@ -949,6 +949,12 @@ font-technology Variable Variables Em Box Top/Right Arriba/derecha del cuadro eme ICF Top/Right Arriba/derecha del ICF ICF Bottom/Left Abajo/izquierda del ICF + Show Font Height Options Mostrar opciones de altura de fuente + Hide Font Height Options Ocultar opciones de altura de fuente + Set Font Height Reference Definir la referencia de altura de fuente + Em Box Cuadro eme + x-Height Altura de la x + ICF Box Cuadro ICF Em Dash Raya Em Space Espacio eme Embed Incrustar @@ -3901,6 +3907,7 @@ axis Both Ambos @msg the file's layers weren't read ({_1}): it comes in as its page, in one layer no se leyeron las capas del archivo ({_1}): se abre como su página, en una sola capa @msg the file's layers weren't read from its editing data ({_1}): they come from its PDF part no se leyeron las capas del archivo desde sus datos de edición ({_1}): proceden de su parte PDF @msg `to` must be "corner" or "smooth" `to` debe ser "corner" o "smooth" +@msg `fontHeightReference` must be "emBox", "capHeight", "xHeight" or "icfBox" `fontHeightReference` debe ser "emBox", "capHeight", "xHeight" o "icfBox" @msg BMP files are RGB or grayscale: colorModel rgb or gray los archivos BMP son RGB o en escala de grises: colorModel rgb o gray @msg Blend colors: select at least three objects with filled colours Fusionar colores: selecciona al menos tres objetos con relleno de color @msg Combine Slices: the slices have no bounds Combinar sectores: los sectores no tienen límites diff --git a/crates/ui-egui/src/i18n/fi.tsv b/crates/ui-egui/src/i18n/fi.tsv index d3cf99cd8..1f7de4be1 100644 --- a/crates/ui-egui/src/i18n/fi.tsv +++ b/crates/ui-egui/src/i18n/fi.tsv @@ -1451,6 +1451,12 @@ digits Malayalam Malajalam Axis Akseli ICF Top/Right ICF ylä/oikea ICF Bottom/Left ICF ala/vasen + Show Font Height Options Näytä fontin korkeuden asetukset + Hide Font Height Options Piilota fontin korkeuden asetukset + Set Font Height Reference Aseta fontin korkeuden viite + Em Box Em-laatikko + x-Height x-korkeus + ICF Box ICF-laatikko Copy Color Value (Hex) Kopioi väriarvo (heksa) Create New Swatch… Luo uusi värimalli… Name 40% Nimi 40% @@ -4123,6 +4129,7 @@ imageTrace Outline Ääriviiva @msg the art on them differs from the page's in {_1}% of it: they come from its editing data all the same niiden kuvitus eroaa sivun kuvituksesta {_1} %:n osalta: ne tuodaan silti sen muokkaustiedoista @msg `Custom` stands for changed settings: save the preset under another name `Custom` tarkoittaa muutettuja asetuksia: tallenna esiasetus toisella nimellä @msg `to` must be "corner" or "smooth" `to` on oltava "corner" tai "smooth" +@msg `fontHeightReference` must be "emBox", "capHeight", "xHeight" or "icfBox" `fontHeightReference` on oltava "emBox", "capHeight", "xHeight" tai "icfBox" @msg BMP files are RGB or grayscale: colorModel rgb or gray BMP-tiedostot ovat RGB- tai harmaasävykuvia: colorModel rgb tai gray @msg Blend colors: select at least three objects with filled colours Sulauta värit: valitse vähintään kolme objektia, joilla on väritäyttö @msg Combine Slices: the slices have no bounds Yhdistä viipaleet: viipaleilla ei ole rajoja diff --git a/crates/ui-egui/src/i18n/fr.tsv b/crates/ui-egui/src/i18n/fr.tsv index e82972fbc..523c06bb5 100644 --- a/crates/ui-egui/src/i18n/fr.tsv +++ b/crates/ui-egui/src/i18n/fr.tsv @@ -3897,6 +3897,7 @@ axis Both Les deux @msg the file's layers weren't read ({_1}): it comes in as its page, in one layer les calques du fichier n’ont pas été lus ({_1}) : il est ouvert tel que sa page, sur un seul calque @msg the file's layers weren't read from its editing data ({_1}): they come from its PDF part les calques du fichier n’ont pas été lus depuis ses données de modification ({_1}) : ils proviennent de sa partie PDF @msg `to` must be "corner" or "smooth" `to` doit être "corner" ou "smooth" +@msg `fontHeightReference` must be "emBox", "capHeight", "xHeight" or "icfBox" `fontHeightReference` doit être "emBox", "capHeight", "xHeight" ou "icfBox" @msg BMP files are RGB or grayscale: colorModel rgb or gray Les fichiers BMP sont en RVB ou en niveaux de gris : colorModel rgb ou gray @msg Blend colors: select at least three objects with filled colours Fusion des couleurs : sélectionnez au moins trois objets dotés d’un fond de couleur @msg Combine Slices: the slices have no bounds Combiner les tranches : les tranches n’ont pas de limites @@ -4628,6 +4629,12 @@ axis Both Les deux Free Transform Transformation manuelle Perspective Distort Distorsion de perspective Insert Inline Symbol Insérer un symbole dans le texte + Show Font Height Options Afficher les options de hauteur de police + Hide Font Height Options Masquer les options de hauteur de police + Set Font Height Reference Définir la référence de hauteur de police + Em Box Cadratin + x-Height Hauteur d’x + ICF Box Cadre ICF ICF Top/Right ICF haut/droite ICF Bottom/Left ICF bas/gauche @msg `id` is the step reference {r}, which only `run` and `command_batch` resolve `id` est la référence d’étape {r}, que seuls `run` et `command_batch` résolvent diff --git a/crates/ui-egui/src/i18n/hu.tsv b/crates/ui-egui/src/i18n/hu.tsv index 1f7317fad..af31ed39c 100644 --- a/crates/ui-egui/src/i18n/hu.tsv +++ b/crates/ui-egui/src/i18n/hu.tsv @@ -1937,6 +1937,12 @@ file-filter Templates Sablonok Hyphenate Capitalized Words Nagybetűs szavak elválasztása ICC Profile ICC-profil ICF Bottom/Left ICF alja/bal oldala + Show Font Height Options Betűmagasság-beállítások megjelenítése + Hide Font Height Options Betűmagasság-beállítások elrejtése + Set Font Height Reference Betűmagasság viszonyítási alapjának beállítása + Em Box Kvirtmező + x-Height x-magasság + ICF Box ICF-mező ICF Top/Right ICF teteje/jobb oldala Identify Objects By Objektumok azonosítása Ignore Kihagy @@ -4518,6 +4524,7 @@ imageTrace Outline Körvonal @msg `stops` must be an array `stops`: tömb kell @msg `svg` must be an object of SVG options (see document.formats) `svg`: SVG-beállításokat tartalmazó objektum kell (lásd: document.formats) @msg `to` must be "corner" or "smooth" `to`: "corner" vagy "smooth" kell +@msg `fontHeightReference` must be "emBox", "capHeight", "xHeight" or "icfBox" `fontHeightReference`: "emBox", "capHeight", "xHeight" vagy "icfBox" kell @msg `{_1}` is no save command nem mentési parancs: `{_1}` @msg `{area}` is no recovery area name nem helyreállítási terület neve: `{area}` @msg `{color}` is not a colour (#rrggbb or a preset name) nem szín: `{color}` (#rrggbb vagy előbeállítás neve) diff --git a/crates/ui-egui/src/i18n/id.tsv b/crates/ui-egui/src/i18n/id.tsv index b90aadc98..a15105ef9 100644 --- a/crates/ui-egui/src/i18n/id.tsv +++ b/crates/ui-egui/src/i18n/id.tsv @@ -1388,6 +1388,12 @@ digits Malayalam Malayalam Axis Sumbu ICF Top/Right ICF atas/kanan ICF Bottom/Left ICF bawah/kiri + Show Font Height Options Tampilkan opsi tinggi font + Hide Font Height Options Sembunyikan opsi tinggi font + Set Font Height Reference Atur acuan tinggi font + Em Box Em Box + x-Height Tinggi x + ICF Box Kotak ICF Copy Color Value (Hex) Salin nilai warna (Hex) Create New Swatch… Buat swatch baru… Name 40% Nama 40% diff --git a/crates/ui-egui/src/i18n/it.tsv b/crates/ui-egui/src/i18n/it.tsv index 9a9709b8d..e94ffaf0b 100644 --- a/crates/ui-egui/src/i18n/it.tsv +++ b/crates/ui-egui/src/i18n/it.tsv @@ -949,6 +949,13 @@ font-technology Variable Variabili Em Box Top/Right Riquadro em in alto/a destra ICF Top/Right ICF in alto/a destra ICF Bottom/Left ICF in basso/a sinistra + Show Font Height Options Mostra opzioni altezza font + Hide Font Height Options Nascondi opzioni altezza font + Set Font Height Reference Imposta riferimento altezza font + Em Box Riquadro em + Cap Height Altezza maiuscole + x-Height Altezza x + ICF Box Riquadro ICF Em Dash Lineetta Em Space Spazio em Embed Incorpora @@ -3941,6 +3948,7 @@ axis Both Entrambi @msg the file's layers weren't read ({_1}): it comes in as its page, in one layer i livelli del file non sono stati letti ({_1}): si apre come la sua pagina, in un solo livello @msg the file's layers weren't read from its editing data ({_1}): they come from its PDF part i livelli del file non sono stati letti dai suoi dati di modifica ({_1}): provengono dalla sua parte PDF @msg `to` must be "corner" or "smooth" `to` deve essere "corner" o "smooth" +@msg `fontHeightReference` must be "emBox", "capHeight", "xHeight" or "icfBox" `fontHeightReference` deve essere "emBox", "capHeight", "xHeight" o "icfBox" @msg BMP files are RGB or grayscale: colorModel rgb or gray I file BMP sono RGB o in scala di grigi: colorModel rgb o gray @msg Blend colors: select at least three objects with filled colours Fondi colori: seleziona almeno tre oggetti con riempimento colore @msg Combine Slices: the slices have no bounds Combina sezioni: le sezioni non hanno limiti diff --git a/crates/ui-egui/src/i18n/ja.tsv b/crates/ui-egui/src/i18n/ja.tsv index b901b9336..902df6a9d 100644 --- a/crates/ui-egui/src/i18n/ja.tsv +++ b/crates/ui-egui/src/i18n/ja.tsv @@ -42,6 +42,13 @@ font-technology Variable バリアブル Em Box Top/Right 仮想ボディの上 / 右 ICF Top/Right 平均字面の上 / 右 ICF Bottom/Left 平均字面の下 / 左 + Show Font Height Options フォントの高さオプションを表示 + Hide Font Height Options フォントの高さオプションを隠す + Set Font Height Reference フォントの高さの基準を設定 + Em Box 仮想ボディ + Cap Height キャップハイト + x-Height x ハイト + ICF Box 平均字面 Proportional Metrics プロポーショナルメトリクス Character Direction 文字の方向 Digits 数字 @@ -3983,6 +3990,7 @@ swatch Bright Violet ビビッドバイオレット @msg Saving {_1} {_1} を保存中 @msg Sent to {_1} {_1} に送信しました @msg `to` must be "corner" or "smooth" `to` には "corner" または "smooth" を指定してください +@msg `fontHeightReference` must be "emBox", "capHeight", "xHeight" or "icfBox" `fontHeightReference` には "emBox"、"capHeight"、"xHeight"、"icfBox" のいずれかを指定してください @msg a layer couldn't be read 読み込めなかったレイヤーがあります @msg it has more than one artboard アートボードが複数あります @msg its editing data has no layers 編集データにレイヤーがありません diff --git a/crates/ui-egui/src/i18n/ka.tsv b/crates/ui-egui/src/i18n/ka.tsv index be58e0b36..d6dcb9e03 100644 --- a/crates/ui-egui/src/i18n/ka.tsv +++ b/crates/ui-egui/src/i18n/ka.tsv @@ -1305,6 +1305,12 @@ digits Malayalam მალაიალამური Axis ღერძი ICF Top/Right ICF ზედა/მარჯვენა ICF Bottom/Left ICF ქვედა/მარცხენა + Show Font Height Options შრიფტის სიმაღლის პარამეტრების ჩვენება + Hide Font Height Options შრიფტის სიმაღლის პარამეტრების დამალვა + Set Font Height Reference შრიფტის სიმაღლის საზომის დაყენება + Em Box Em-ბლოკი + x-Height x-სიმაღლე + ICF Box ICF-ბლოკი Copy Color Value (Hex) ფერის მნიშვნელობის კოპირება (Hex) Create New Swatch… ახალი ფერის ნიმუშის შექმნა… Name 40% სახელი 40% @@ -3692,6 +3698,7 @@ font-technology Variable ცვლადი @msg the art on them differs from the page's in {_1}% of it მათზე არსებული ნახატი გვერდისას {_1}%-ით განსხვავდება @msg `Custom` stands for changed settings: save the preset under another name `Custom` შეცვლილ პარამეტრებს ნიშნავს: შეინახეთ პრესეტი სხვა სახელით @msg `to` must be "corner" or "smooth" `to` უნდა იყოს "corner" ან "smooth" +@msg `fontHeightReference` must be "emBox", "capHeight", "xHeight" or "icfBox" `fontHeightReference` უნდა იყოს "emBox", "capHeight", "xHeight" ან "icfBox" @msg BMP files are RGB or grayscale: colorModel rgb or gray BMP ფაილები RGB-ა ან ნაცრისფერი: colorModel rgb ან gray @msg Blend colors: select at least three objects with filled colours ფერების შერევა: მონიშნეთ მინიმუმ სამი ობიექტი ფერადი შევსებით @msg Combine Slices: the slices have no bounds ნაჭრების გაერთიანება: ნაჭრებს საზღვრები არ აქვს diff --git a/crates/ui-egui/src/i18n/ko.tsv b/crates/ui-egui/src/i18n/ko.tsv index b979e2613..e2da7c92a 100644 --- a/crates/ui-egui/src/i18n/ko.tsv +++ b/crates/ui-egui/src/i18n/ko.tsv @@ -36,6 +36,13 @@ font-technology Variable 가변 Em Box Top/Right 엠 박스 위/오른쪽 ICF Top/Right ICF 위/오른쪽 ICF Bottom/Left ICF 아래/왼쪽 + Show Font Height Options 글꼴 높이 옵션 표시 + Hide Font Height Options 글꼴 높이 옵션 숨기기 + Set Font Height Reference 글꼴 높이 기준 설정 + Em Box 엠 박스 + Cap Height 대문자 높이 + x-Height x 높이 + ICF Box ICF 박스 Proportional Metrics 비례 메트릭 Character Direction 문자 방향 Digits 숫자 @@ -3910,6 +3917,7 @@ theme Appearance 외관 @msg Saving {_1} {_1} 저장 중 @msg Sent to {_1} {_1}(으)로 보냈습니다 @msg `to` must be "corner" or "smooth" `to`는 "corner" 또는 "smooth"여야 합니다 +@msg `fontHeightReference` must be "emBox", "capHeight", "xHeight" or "icfBox" `fontHeightReference`는 "emBox", "capHeight", "xHeight" 또는 "icfBox"여야 합니다 @msg a layer couldn't be read 레이어를 읽을 수 없습니다 @msg it has more than one artboard 아트보드가 둘 이상입니다 @msg its editing data has no layers 편집 데이터에 레이어가 없습니다 diff --git a/crates/ui-egui/src/i18n/nl.tsv b/crates/ui-egui/src/i18n/nl.tsv index 60b73cb0f..9d3d2c7c8 100644 --- a/crates/ui-egui/src/i18n/nl.tsv +++ b/crates/ui-egui/src/i18n/nl.tsv @@ -1402,6 +1402,12 @@ digits Malayalam Malayalam Axis As ICF Top/Right ICF boven/rechts ICF Bottom/Left ICF onder/links + Show Font Height Options Opties voor letterhoogte tonen + Hide Font Height Options Opties voor letterhoogte verbergen + Set Font Height Reference Referentie voor letterhoogte instellen + Em Box Em-vak + x-Height x-hoogte + ICF Box ICF-vak Copy Color Value (Hex) Kleurwaarde kopiëren (Hex) Create New Swatch… Nieuw staal maken… Name 40% Naam 40% diff --git a/crates/ui-egui/src/i18n/pl.tsv b/crates/ui-egui/src/i18n/pl.tsv index e32cb1fa1..24c3685b3 100644 --- a/crates/ui-egui/src/i18n/pl.tsv +++ b/crates/ui-egui/src/i18n/pl.tsv @@ -1391,6 +1391,12 @@ digits Malayalam Malajalam Axis Oś ICF Top/Right ICF góra/prawo ICF Bottom/Left ICF dół/lewo + Show Font Height Options Pokaż opcje wysokości czcionki + Hide Font Height Options Ukryj opcje wysokości czcionki + Set Font Height Reference Ustaw odniesienie wysokości czcionki + Em Box Ramka firetowa + x-Height Wysokość x + ICF Box Ramka ICF Copy Color Value (Hex) Kopiuj wartość koloru (hex) Create New Swatch… Utwórz nową próbkę… Name 40% Nazwa 40% @@ -4032,6 +4038,7 @@ imageTrace Outline Kontur @msg the file's layers weren't read ({_1}): it comes in as its page, in one layer nie odczytano warstw pliku ({_1}): plik jest wczytywany jako jego strona, na jednej warstwie @msg the file's layers weren't read from its editing data ({_1}): they come from its PDF part nie odczytano warstw pliku z jego danych edycyjnych ({_1}): pochodzą z jego części PDF @msg `to` must be "corner" or "smooth" `to` musi mieć wartość "corner" lub "smooth" +@msg `fontHeightReference` must be "emBox", "capHeight", "xHeight" or "icfBox" `fontHeightReference` musi mieć wartość "emBox", "capHeight", "xHeight" lub "icfBox" @msg BMP files are RGB or grayscale: colorModel rgb or gray Pliki BMP są w RGB lub skali szarości: colorModel rgb lub gray @msg Blend colors: select at least three objects with filled colours Przejście kolorów: zaznacz co najmniej trzy obiekty z wypełnieniem kolorem @msg Combine Slices: the slices have no bounds Połącz plasterki: plasterki nie mają granic diff --git a/crates/ui-egui/src/i18n/pt-br.tsv b/crates/ui-egui/src/i18n/pt-br.tsv index af74d69dd..152cb4b41 100644 --- a/crates/ui-egui/src/i18n/pt-br.tsv +++ b/crates/ui-egui/src/i18n/pt-br.tsv @@ -840,6 +840,12 @@ Em Box Top/Right Topo/Direita da Caixa Eme ICF Top/Right Topo/Direita do ICF ICF Bottom/Left Base/Esquerda do ICF + Show Font Height Options Mostrar Opções de Altura da Fonte + Hide Font Height Options Ocultar Opções de Altura da Fonte + Set Font Height Reference Definir Referência de Altura da Fonte + Em Box Caixa Eme + x-Height Altura do x + ICF Box Caixa ICF Em Dash Travessão Em Space Espaço Em Embed Incorporar @@ -3485,6 +3491,7 @@ axis Both Ambos @msg the file's layers weren't read ({_1}): it comes in as its page, in one layer as camadas do arquivo não foram lidas ({_1}): ele entra como sua página, em uma só camada @msg the file's layers weren't read from its editing data ({_1}): they come from its PDF part as camadas do arquivo não foram lidas dos seus dados de edição ({_1}): elas vêm da sua parte PDF @msg `to` must be "corner" or "smooth" `to` deve ser "corner" ou "smooth" +@msg `fontHeightReference` must be "emBox", "capHeight", "xHeight" or "icfBox" `fontHeightReference` deve ser "emBox", "capHeight", "xHeight" ou "icfBox" @msg BMP files are RGB or grayscale: colorModel rgb or gray arquivos BMP são RGB ou em tons de cinza: colorModel rgb ou gray @msg Blend colors: select at least three objects with filled colours Mesclar cores: selecione pelo menos três objetos com preenchimento de cor @msg Combine Slices: the slices have no bounds Combinar fatias: as fatias não têm limites diff --git a/crates/ui-egui/src/i18n/ru.tsv b/crates/ui-egui/src/i18n/ru.tsv index c497b2ad2..f60e17a75 100644 --- a/crates/ui-egui/src/i18n/ru.tsv +++ b/crates/ui-egui/src/i18n/ru.tsv @@ -947,6 +947,12 @@ font-technology Variable Вариативные Em Box Top/Right Верх/право кегельной площадки ICF Top/Right Верх/право ICF ICF Bottom/Left Низ/лево ICF + Show Font Height Options Показать параметры высоты шрифта + Hide Font Height Options Скрыть параметры высоты шрифта + Set Font Height Reference Задать опорную высоту шрифта + Em Box Кегельная площадка + x-Height Высота строчных + ICF Box Рамка ICF Em Dash Длинное тире Em Space Круглая шпация Embed Внедрить @@ -3937,6 +3943,7 @@ axis Both Оба @msg the file's layers weren't read ({_1}): it comes in as its page, in one layer слои файла не прочитаны ({_1}): он открывается как его страница, на одном слое @msg the file's layers weren't read from its editing data ({_1}): they come from its PDF part слои файла не прочитаны из его данных для редактирования ({_1}): они взяты из его PDF-части @msg `to` must be "corner" or "smooth" `to` должно быть "corner" или "smooth" +@msg `fontHeightReference` must be "emBox", "capHeight", "xHeight" or "icfBox" `fontHeightReference` должно быть "emBox", "capHeight", "xHeight" или "icfBox" @msg BMP files are RGB or grayscale: colorModel rgb or gray файлы BMP бывают RGB или в оттенках серого: colorModel rgb или gray @msg Blend colors: select at least three objects with filled colours Смешивание цветов: выберите не менее трёх объектов с цветными заливками @msg Combine Slices: the slices have no bounds Объединение фрагментов: у фрагментов нет границ diff --git a/crates/ui-egui/src/i18n/sv.tsv b/crates/ui-egui/src/i18n/sv.tsv index 4b8742d00..91595dfa2 100644 --- a/crates/ui-egui/src/i18n/sv.tsv +++ b/crates/ui-egui/src/i18n/sv.tsv @@ -3818,6 +3818,7 @@ axis Both Båda @msg the art on them differs from the page's in {_1}% of it: they come from its editing data all the same grafiken på dem skiljer sig från sidans i {_1} % av ytan: de hämtas ändå från dess redigeringsdata @msg `Custom` stands for changed settings: save the preset under another name `Custom` står för ändrade inställningar: spara förinställningen med ett annat namn @msg `to` must be "corner" or "smooth" `to` måste vara "corner" eller "smooth" +@msg `fontHeightReference` must be "emBox", "capHeight", "xHeight" or "icfBox" `fontHeightReference` måste vara "emBox", "capHeight", "xHeight" eller "icfBox" @msg BMP files are RGB or grayscale: colorModel rgb or gray BMP-filer är RGB eller gråskala: colorModel rgb eller gray @msg Blend colors: select at least three objects with filled colours Blanda färger: markera minst tre objekt med färgfyllning @msg Combine Slices: the slices have no bounds Kombinera skivor: skivorna har inga gränser @@ -4537,6 +4538,12 @@ axis Both Båda Insert Inline Symbol Infoga symbol i texten ICF Top/Right ICF överkant/höger ICF Bottom/Left ICF nederkant/vänster + Show Font Height Options Visa alternativ för teckenhöjd + Hide Font Height Options Dölj alternativ för teckenhöjd + Set Font Height Reference Ange referens för teckenhöjd + Em Box Em-ruta + x-Height x-höjd + ICF Box ICF-ruta @msg `id` is the step reference {r}, which only `run` and `command_batch` resolve `id` är stegreferensen {r}, som bara `run` och `command_batch` löser upp @msg `ids` must be an array of object ids, not {v} `ids` måste vara en array med objekt-id:n, inte {v} @msg `corners` must be an array of anchor indices, not {v} `corners` måste vara en array med index för ankarpunkter, inte {v} diff --git a/crates/ui-egui/src/i18n/tests.rs b/crates/ui-egui/src/i18n/tests.rs index e6517df49..7e5ca938c 100644 --- a/crates/ui-egui/src/i18n/tests.rs +++ b/crates/ui-egui/src/i18n/tests.rs @@ -144,6 +144,17 @@ fn catalan_reads_as_catalan() { assert_eq!(message(ca, nested), "l’ordre `object.group` no està disponible ara mateix: no hi ha res seleccionat"); } +#[test] +fn font_height_reference_labels_are_in_complete_catalogs() { + for lang in LANGUAGES.iter().filter(|lang| lang.complete_menus) { + for label in + ["Show Font Height Options", "Hide Font Height Options", "Set Font Height Reference", "Em Box", "Cap Height", "x-Height", "ICF Box"] + { + assert!(lang.catalog().plain(label).is_some(), "{} is missing {label}", lang.code); + } + } +} + /// Each language counts with its own plural rule: the form a count picks (`one`, then `few`, then /// `other`/`many`). #[test] diff --git a/crates/ui-egui/src/i18n/tr.tsv b/crates/ui-egui/src/i18n/tr.tsv index 5aeae9118..18449679b 100644 --- a/crates/ui-egui/src/i18n/tr.tsv +++ b/crates/ui-egui/src/i18n/tr.tsv @@ -3703,6 +3703,7 @@ axis Both Her İkisi @msg the file's layers weren't read ({_1}): it comes in as its page, in one layer dosyanın katmanları okunmadı ({_1}): tek katmanda kendi sayfası olarak geliyor @msg the file's layers weren't read from its editing data ({_1}): they come from its PDF part dosyanın katmanları düzenleme verilerinden okunmadı ({_1}): PDF kısmından geliyorlar @msg `to` must be "corner" or "smooth" "to" "köşe" veya "pürüzsüz" olmalıdır +@msg `fontHeightReference` must be "emBox", "capHeight", "xHeight" or "icfBox" `fontHeightReference` "emBox", "capHeight", "xHeight" veya "icfBox" olmalıdır @msg BMP files are RGB or grayscale: colorModel rgb or gray BMP dosyaları RGB veya gri tonlamalıdır: colorModel rgb veya gri @msg Blend colors: select at least three objects with filled colours Renkleri karıştır: dolgulu renklere sahip en az üç nesne seçin @msg Combine Slices: the slices have no bounds Dilimleri Birleştir: dilimlerin sınırları yoktur @@ -4380,6 +4381,12 @@ axis Both Her İkisi Insert Inline Symbol Satır İçi Sembol Ekle ICF Top/Right ICF Üst/Sağ ICF Bottom/Left ICF Alt/Sol + Show Font Height Options Yazı Tipi Yüksekliği Seçeneklerini Göster + Hide Font Height Options Yazı Tipi Yüksekliği Seçeneklerini Gizle + Set Font Height Reference Yazı Tipi Yüksekliği Referansını Ayarla + Em Box Em Kutusu + x-Height x-Yüksekliği + ICF Box ICF Kutusu @msg `id` is the step reference {r}, which only `run` and `command_batch` resolve "id", yalnızca "run" ve "command_batch" çözümleyen {r} adım referansıdır @msg `ids` must be an array of object ids, not {v} "kimlikler" {v} değil, bir nesne kimlikleri dizisi olmalıdır @msg `corners` must be an array of anchor indices, not {v} "köşeler" {v} değil, bir bağlantı indeksleri dizisi olmalıdır diff --git a/crates/ui-egui/src/i18n/uk.tsv b/crates/ui-egui/src/i18n/uk.tsv index 53fe1f462..ba2ae98bc 100644 --- a/crates/ui-egui/src/i18n/uk.tsv +++ b/crates/ui-egui/src/i18n/uk.tsv @@ -943,6 +943,13 @@ font-technology Variable Варіативні Em Box Top/Right Верх/право кегельного майданчика ICF Top/Right Верх/право ICF ICF Bottom/Left Нижній/лівий край ICF + Show Font Height Options Показати параметри висоти шрифту + Hide Font Height Options Приховати параметри висоти шрифту + Set Font Height Reference Задати опорну висоту шрифту + Em Box Кегельний майданчик + Cap Height Висота великих літер + x-Height Висота малих літер + ICF Box Рамка ICF Em Dash Довге тире Em Space Кругла шпація Embed Вбудувати @@ -3925,6 +3932,7 @@ axis Both Обидві @msg the file's layers weren't read ({_1}): it comes in as its page, in one layer шари файлу не прочитано ({_1}): він відкривається як його сторінка, на одному шарі @msg the file's layers weren't read from its editing data ({_1}): they come from its PDF part шари файлу не прочитано з його даних для редагування ({_1}): їх взято з його PDF-частини @msg `to` must be "corner" or "smooth" `to` має бути "corner" або "smooth" +@msg `fontHeightReference` must be "emBox", "capHeight", "xHeight" or "icfBox" `fontHeightReference` має бути "emBox", "capHeight", "xHeight" або "icfBox" @msg BMP files are RGB or grayscale: colorModel rgb or gray Файли BMP бувають RGB або у відтінках сірого: colorModel rgb або gray @msg Blend colors: select at least three objects with filled colours Змішування кольорів: виберіть не менше трьох об’єктів із кольоровими заливками @msg Combine Slices: the slices have no bounds Об’єднання фрагментів: фрагменти не мають кордонів diff --git a/crates/ui-egui/src/i18n/vi.tsv b/crates/ui-egui/src/i18n/vi.tsv index ea692ca64..e215bb2eb 100644 --- a/crates/ui-egui/src/i18n/vi.tsv +++ b/crates/ui-egui/src/i18n/vi.tsv @@ -1781,6 +1781,13 @@ digits Hindi Hindi Hyphenate Capitalized Words Gạch nối từ viết hoa chữ đầu ICC Profile Hồ sơ ICC ICF Bottom/Left ICF Dưới/Trái + Show Font Height Options Hiện tùy chọn chiều cao phông chữ + Hide Font Height Options Ẩn tùy chọn chiều cao phông chữ + Set Font Height Reference Đặt tham chiếu chiều cao phông chữ + Em Box Ô em + Cap Height Chiều cao chữ hoa + x-Height Chiều cao chữ x + ICF Box Ô ICF ICF Top/Right ICF Trên/Phải Identify Objects By Nhận diện đối tượng theo Ignore Bỏ qua @@ -4169,6 +4176,7 @@ theme Appearance Giao diện @msg `stops` must be an array `stops` phải là một mảng @msg `svg` must be an object of SVG options (see document.formats) `svg` phải là đối tượng tùy chọn SVG (xem document.formats) @msg `to` must be "corner" or "smooth" `to` phải là "corner" hoặc "smooth" +@msg `fontHeightReference` must be "emBox", "capHeight", "xHeight" or "icfBox" `fontHeightReference` phải là "emBox", "capHeight", "xHeight" hoặc "icfBox" @msg `{_1}` is no save command `{_1}` không phải là lệnh lưu @msg `{area}` is no recovery area name `{area}` không phải là tên vùng phục hồi @msg `{color}` is not a colour (#rrggbb or a preset name) `{color}` không phải là màu (#rrggbb hoặc tên thiết lập sẵn) diff --git a/crates/ui-egui/src/i18n/zh-hans.tsv b/crates/ui-egui/src/i18n/zh-hans.tsv index 431eb3355..acdfaf1b1 100644 --- a/crates/ui-egui/src/i18n/zh-hans.tsv +++ b/crates/ui-egui/src/i18n/zh-hans.tsv @@ -889,6 +889,13 @@ Em Box Top/Right 全角字框上 / 右 ICF Top/Right ICF 上 / 右 ICF Bottom/Left ICF 下 / 左 + Show Font Height Options 显示字体高度选项 + Hide Font Height Options 隐藏字体高度选项 + Set Font Height Reference 设置字体高度参考 + Em Box 全角字框 + Cap Height 大写字母高度 + x-Height x 字高 + ICF Box ICF 框 Em Dash 长破折号 Em Space 全角空格 Embed 嵌入 @@ -3720,6 +3727,7 @@ imageTrace Outline 轮廓 @msg Saving {_1} 正在保存 {_1} @msg Sent to {_1} 已发送到 {_1} @msg `to` must be "corner" or "smooth" `to` 必须是 "corner" 或 "smooth" +@msg `fontHeightReference` must be "emBox", "capHeight", "xHeight" or "icfBox" `fontHeightReference` 必须是 "emBox"、"capHeight"、"xHeight" 或 "icfBox" @msg a layer couldn't be read 无法读取某个图层 @msg it has more than one artboard 它有多个画板 @msg its editing data has no layers 其编辑数据中没有图层 diff --git a/crates/ui-egui/src/i18n/zh-hant.tsv b/crates/ui-egui/src/i18n/zh-hant.tsv index c55958717..460926d92 100644 --- a/crates/ui-egui/src/i18n/zh-hant.tsv +++ b/crates/ui-egui/src/i18n/zh-hant.tsv @@ -911,6 +911,13 @@ font-technology Variable 可變 Em Box Top/Right 全形字框上 / 右 ICF Top/Right ICF 上 / 右 ICF Bottom/Left ICF 下 / 左 + Show Font Height Options 顯示字體高度選項 + Hide Font Height Options 隱藏字體高度選項 + Set Font Height Reference 設定字體高度參考 + Em Box 全形字框 + Cap Height 大寫字母高度 + x-Height x 字高 + ICF Box ICF 框 Em Dash 長破折號 Em Space 全形空格 Embed 嵌入 @@ -1645,6 +1652,7 @@ file-filter Templates 範本 @msg Saving {_1} 正在儲存 {_1} @msg Sent to {_1} 已傳送至 {_1} @msg `to` must be "corner" or "smooth" `to` 必須是 "corner" 或 "smooth" +@msg `fontHeightReference` must be "emBox", "capHeight", "xHeight" or "icfBox" `fontHeightReference` 必須是 "emBox"、"capHeight"、"xHeight" 或 "icfBox" @msg a layer couldn't be read 有一個圖層無法讀取 @msg it has more than one artboard 它有多個工作區域 @msg its editing data has no layers 其編輯資料中沒有圖層 diff --git a/crates/ui-egui/src/panels/character.rs b/crates/ui-egui/src/panels/character.rs index b3a72b9d3..f00c33d70 100644 --- a/crates/ui-egui/src/panels/character.rs +++ b/crates/ui-egui/src/panels/character.rs @@ -91,20 +91,27 @@ pub(crate) fn shared_text_flag(app: &VectorcraftApp, f: impl Fn(&vectorcraft_doc /// caret's style at a caret), else every run of the selected type, groups' included. `None` (a /// blank field) where they differ, or with no type. pub(crate) fn shared(app: &VectorcraftApp, f: impl Fn(&CharStyle) -> T) -> Option { - shared_styles(app, |_, _, style| Some(f(style))) + shared_styles(app, |_, _, style, _| Some(f(style))) } /// Dimensional controls use document units and tolerate arithmetic roundoff when comparing /// compensated runs. Keep raw styles available to font/paragraph controls and style definitions, /// even if a collapsed affine prevents editing dimensions. fn shared_effective(app: &VectorcraftApp, f: impl Fn(&CharStyle) -> f64) -> Option { - shared_styles(app, |t, _, style| t.effective_char_style(style).map(|s| Metric(f(&s)))).map(|v| v.0) + shared_styles(app, |t, _, style, _| t.effective_char_style(style).map(|s| Metric(f(&s)))).map(|v| v.0) } -/// The Leading shown: each character's own, or for Auto leading the share of its size its -/// paragraph's Justification › Auto Leading sets, as the layout uses. +/// The Leading shown: each character's own, or for Auto leading the share of the em size their +/// font height reference gives them ([`vectorcraft_text::FontDb::em_size`]) that their paragraph's +/// Justification › Auto Leading sets, as the layout uses. An inline graphic keeps its size. fn shared_leading(app: &VectorcraftApp) -> Option { - shared_styles(app, |t, para, style| t.effective_char_style(style).map(|s| Metric(para.effective_leading(&s)))).map(|v| v.0) + let db = vectorcraft_text::FontDb::global(); + shared_styles(app, |t, para, style, inline| { + let s = t.effective_char_style(style)?; + let em = if inline { s.size } else { db.em_size(&s, t.vertical) }; + Some(Metric(para.effective_leading(&CharStyle { size: em, ..s }))) + }) + .map(|v| v.0) } pub(crate) struct Metric(pub(crate) f64); @@ -115,11 +122,12 @@ impl PartialEq for Metric { } } -/// `f` of each character style in the selection, with its text object and the paragraph the -/// characters are in (a run that spans paragraphs counts once for each). +/// `f` of each character style in the selection, with its text object, the paragraph the +/// characters are in (a run that spans paragraphs counts once for each) and whether the run is an +/// inline graphic. fn shared_styles( app: &VectorcraftApp, - f: impl Fn(&vectorcraft_doc::TextObject, &vectorcraft_doc::ParaStyle, &CharStyle) -> Option, + f: impl Fn(&vectorcraft_doc::TextObject, &vectorcraft_doc::ParaStyle, &CharStyle, bool) -> Option, ) -> Option { let st = app.session.active()?; let text = |id: NodeId| match st.doc.node(id).map(|n| &n.kind) { @@ -131,14 +139,14 @@ fn shared_styles( let t = text(id)?; if b <= a { let para = t.plain_text().get(..a).map_or(0, |s| s.bytes().filter(|&c| c == b'\n').count()); - return f(t, t.para_at(para), &vectorcraft_text::edit::insertion_style(&t.runs, a, b)); + return f(t, t.para_at(para), &vectorcraft_text::edit::insertion_style(&t.runs, a, b), false); } - each_paragraph_run(t, Some((a, b)), |para, style| acc.add(f(t, para, style))); + each_paragraph_run(t, Some((a, b)), |para, style, inline| acc.add(f(t, para, style, inline))); } else { for n in st.selection.objects.iter().filter_map(|id| st.doc.node(*id)) { n.walk(&mut |c| { if let NodeKind::Text(t) = &c.kind { - each_paragraph_run(t, None, |para, style| acc.add(f(t, para, style))); + each_paragraph_run(t, None, |para, style, inline| acc.add(f(t, para, style, inline))); } }); } @@ -147,13 +155,14 @@ fn shared_styles( } /// `f` of the paragraph and style of each piece of `t`'s runs that lies in one paragraph (a -/// paragraph's closing line break belongs to it): the pieces within bytes `a..b`, or with no -/// range every run's, empty ones included. With no range, a final empty paragraph (the text ends -/// in a line break) counts too, with the style of the run before it, as the caret there types. +/// paragraph's closing line break belongs to it), and whether the run is an inline graphic: the +/// pieces within bytes `a..b`, or with no range every run's, empty ones included. With no range, a +/// final empty paragraph (the text ends in a line break) counts too, with the style of the run +/// before it, as the caret there types. fn each_paragraph_run<'a>( t: &'a vectorcraft_doc::TextObject, range: Option<(usize, usize)>, - mut f: impl FnMut(&'a vectorcraft_doc::ParaStyle, &'a CharStyle), + mut f: impl FnMut(&'a vectorcraft_doc::ParaStyle, &'a CharStyle, bool), ) { let (mut end, mut para) = (0, 0); // The style of the last character and whether it is a line break: the paragraph after it has @@ -162,7 +171,7 @@ fn each_paragraph_run<'a>( for r in &t.runs { if r.text.is_empty() { if range.is_none_or(|(a, b)| a < end && end < b) { - f(t.para_at(para), &r.style); + f(t.para_at(para), &r.style, r.inline.is_some()); } continue; } @@ -170,7 +179,7 @@ fn each_paragraph_run<'a>( let start = end; end += piece.len(); if range.is_none_or(|(a, b)| start < b && end > a) { - f(t.para_at(para), &r.style); + f(t.para_at(para), &r.style, r.inline.is_some()); } para += usize::from(piece.ends_with('\n')); last = Some((&r.style, piece.ends_with('\n'))); @@ -179,7 +188,7 @@ fn each_paragraph_run<'a>( if range.is_none() && let Some((style, true)) = last { - f(t.para_at(para), style); + f(t.para_at(para), style, false); } } @@ -657,7 +666,8 @@ fn cell(ui: &mut Ui, label: &str, tip: &str, add: impl FnOnce(&mut Ui)) { /// The Character panel's grid of numeric fields, `w` wide: Font Size, Leading, Kerning and /// Tracking, then with `more` the scales, Baseline Shift and Character Rotation (`s`: the style -/// shown). The Properties panel shows its first rows. +/// shown). With Show Font Height Options on, a row under Font Size holds the Set Font Height +/// Reference dropdown. The Properties panel shows its first rows. pub(crate) fn metrics_grid(app: &mut VectorcraftApp, ui: &mut Ui, id: &str, s: &CharStyle, w: f32, more: bool) { let fw = ((w - 66.0) / 2.0).clamp(60.0, 100.0); let unit = app.session.type_unit(); @@ -674,6 +684,10 @@ pub(crate) fn metrics_grid(app: &mut VectorcraftApp, ui: &mut Ui, id: &str, s: & } }); ui.end_row(); + if pstate::(ui.ctx(), FONT_HEIGHT_OPTIONS) { + cell(ui, "T↕", tl!("Set Font Height Reference"), |ui| font_height_reference(app, ui, fw)); + ui.end_row(); + } cell(ui, "VA", tl!("Kerning"), |ui| { let mode = shared(app, |s| (s.optical_kerning, s.kerning)); let caret = text_editing(app).is_some_and(|(_, a, b)| a == b); @@ -741,6 +755,30 @@ pub(crate) fn size_field(app: &mut VectorcraftApp, ui: &mut Ui, id: &str, width: } } +/// The panel state of the Character panel menu's Show Font Height Options. +const FONT_HEIGHT_OPTIONS: &str = "ch-font-height-options"; + +/// The font height references the Set Font Height Reference dropdown lists, with their +/// `text.setFormat` values. +const FONT_HEIGHT_REFERENCES: [(vectorcraft_doc::FontHeightReference, &str); 4] = [ + (vectorcraft_doc::FontHeightReference::EmBox, "emBox"), + (vectorcraft_doc::FontHeightReference::CapHeight, "capHeight"), + (vectorcraft_doc::FontHeightReference::XHeight, "xHeight"), + (vectorcraft_doc::FontHeightReference::IcfBox, "icfBox"), +]; + +/// The Set Font Height Reference dropdown, `width` wide: the height of the font that the Font Size +/// measures, blank where the characters the panel acts on differ. +fn font_height_reference(app: &mut VectorcraftApp, ui: &mut Ui, width: f32) { + // The labels of [`FONT_HEIGHT_REFERENCES`], in its order. + let labels = [tl!("Em Box"), tl!("Cap Height"), tl!("x-Height"), tl!("ICF Box")]; + let current = shared(app, |s| s.font_height_reference); + let shown = FONT_HEIGHT_REFERENCES.iter().zip(labels).find(|(r, _)| Some(r.0) == current).map_or("", |(_, l)| l); + if let Some((_, key)) = widgets::dropdown_names(ui, "ch-font-height", shown, &labels, width).and_then(|i| FONT_HEIGHT_REFERENCES.get(i)) { + format(app, json!({"fontHeightReference": key})); + } +} + /// The font family menu and the font style dropdown of style `s` (Character and Properties panels, /// Control bar), with their `ids` and `widths`. pub(crate) fn font_pickers(app: &mut VectorcraftApp, ui: &mut Ui, s: &CharStyle, ids: (&str, &str), widths: (f32, f32)) { @@ -1065,6 +1103,11 @@ pub fn menu(app: &mut VectorcraftApp, ui: &mut Ui) { if menu_item(ui, if glyph { tl!("Hide Snap to Glyph Options") } else { tl!("Show Snap to Glyph Options") }, true, false) { set_pstate(ui.ctx(), SNAP_TO_GLYPH_OPTIONS, !glyph); } + // Show Font Height Options shows the Set Font Height Reference dropdown under Font Size. + let heights: bool = pstate(ui.ctx(), FONT_HEIGHT_OPTIONS); + if menu_item(ui, if heights { tl!("Hide Font Height Options") } else { tl!("Show Font Height Options") }, true, false) { + set_pstate(ui.ctx(), FONT_HEIGHT_OPTIONS, !heights); + } ui.separator(); let s = st.map(|x| x.0); for (label, key, on) in [ @@ -1191,8 +1234,9 @@ fn fractional_widths_on(app: &VectorcraftApp) -> bool { /// kerning, no baseline shift, 100% scales, no rotation, no underline or strikethrough, All Caps /// and Small Caps off, the normal position (neither superscript nor subscript), Roman Baseline /// character alignment, Proportional Metrics, Tate-chu-yoko, Standard Vertical Roman Alignment and -/// No Break off, the Default character direction and the Default digits, whether or not Show East -/// Asian Options and Show Indic Options are on. +/// No Break off, the Default character direction, the Default digits and the Em Box font height +/// reference, whether or not Show East Asian Options, Show Indic Options and Show Font Height +/// Options are on. fn reset_panel(app: &mut VectorcraftApp) { let d = CharStyle::default(); style(app, json!({"tracking": 0, "leading": "auto"})); @@ -1203,7 +1247,7 @@ fn reset_panel(app: &mut VectorcraftApp) { "allCaps": false, "smallCaps": d.small_caps.is_some(), "position": d.position.id(), "charAlign": d.char_align, "proportionalMetrics": d.proportional_metrics, "tateChuYoko": d.tate_chu_yoko, "standardVerticalRoman": d.standard_vertical_roman, "noBreak": d.no_break, "charDirection": "default", - "digits": d.digits + "digits": d.digits, "fontHeightReference": d.font_height_reference }), ); } diff --git a/crates/ui-egui/src/panels/character/tests.rs b/crates/ui-egui/src/panels/character/tests.rs index 92809a2a4..da7a3dc09 100644 --- a/crates/ui-egui/src/panels/character/tests.rs +++ b/crates/ui-egui/src/panels/character/tests.rs @@ -89,6 +89,102 @@ fn scaled_character_fields_show_document_sizes_and_mixed_group_values() { assert_eq!(shared_effective(&app, |s| s.h_scale), Some(100.0)); } +/// The Leading field shows the Auto leading the characters are set with, their paragraph's Auto +/// Leading share (120% by default) of their em size: 20 pt type measuring a 0.7 em cap height +/// is set at 20 / 0.7 pt. Paragraphs whose Auto leading differs show a blank field, and an +/// explicit leading shows as it is. +#[test] +fn leading_shows_the_auto_leading_of_the_em_size() { + use vectorcraft_text::test_fonts::{HEIGHT_CAP, HEIGHT_FAMILY, height_font}; + vectorcraft_text::FontDb::global().add_font(height_font(true).unwrap()); + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + app.run("file.new", json!({"width": 300, "height": 200})).unwrap(); + let id = app.run("text.create", json!({"x": 20, "y": 50, "text": "Hx", "size": 20, "font": HEIGHT_FAMILY})).unwrap()["id"].clone(); + app.run("select.set", json!({"ids": [id]})).unwrap(); + let near = |l: Option, want: f64| l.is_some_and(|l| (l - want).abs() < 1e-9); + assert!(near(shared_leading(&app), 24.0), "{:?}", shared_leading(&app)); + app.run("text.setFormat", json!({"fontHeightReference": "capHeight"})).unwrap(); + let cap = f64::from(HEIGHT_CAP) / 1000.0; + assert!(near(shared_leading(&app), 1.2 * 20.0 / cap), "{:?}", shared_leading(&app)); + app.run("text.setFormat", json!({"autoLeading": 150})).unwrap(); + assert!(near(shared_leading(&app), 1.5 * 20.0 / cap), "{:?}", shared_leading(&app)); + // The second paragraph ends in a two-byte character. + let two = app.run("text.create", json!({"x": 20, "y": 120, "text": "Hx\nHé", "size": 20, "font": HEIGHT_FAMILY})).unwrap()["id"].clone(); + app.run("select.set", json!({"ids": [two]})).unwrap(); + assert!(near(shared_leading(&app), 24.0), "{:?}", shared_leading(&app)); + app.run("text.setFormat", json!({"id": two, "start": 3, "end": 6, "autoLeading": 150})).unwrap(); + assert_eq!(shared_leading(&app), None, "the second paragraph's Auto leading is 30 pt"); + app.run("select.set", json!({"ids": [id]})).unwrap(); + app.run("text.setStyle", json!({"leading": 30})).unwrap(); + assert!(near(shared_leading(&app), 30.0), "{:?}", shared_leading(&app)); +} + +/// An inline graphic keeps its size whatever the font height reference, so the Leading field +/// shows 120% of that size for it: blank for 20 pt Cap Height type with a graphic in it, and +/// 24 pt for the graphic alone. +#[test] +fn leading_of_an_inline_graphic_follows_its_own_size() { + use vectorcraft_text::test_fonts::{HEIGHT_FAMILY, height_font}; + vectorcraft_text::FontDb::global().add_font(height_font(true).unwrap()); + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + app.run("file.new", json!({"width": 300, "height": 200})).unwrap(); + app.run("shape.ellipse", json!({"x": 200, "y": 150, "width": 10, "height": 10})).unwrap(); + app.run("symbol.new", json!({"name": "Dot"})).unwrap(); + app.run("edit.clear", json!({})).unwrap(); + app.session.select_tool("type", Default::default()).unwrap(); + let id = app.run("text.create", json!({"x": 20, "y": 50, "text": "Hx", "size": 20, "font": HEIGHT_FAMILY})).unwrap()["id"].clone(); + app.session.apply_actions(vec![vectorcraft_tools::Action::Notify("text.editNew".into())]).unwrap(); + app.run("text.insertInline", json!({"id": id, "at": 2, "symbol": "Dot"})).unwrap(); + app.run("text.setRangeStyle", json!({"id": id, "start": 0, "end": 5, "fontHeightReference": "capHeight"})).unwrap(); + app.session.set_tool_option("select", &json!({"start": 0, "end": 5})); + assert_eq!(shared_leading(&app), None, "the type is set with 34.3 pt Auto leading and the graphic with 24 pt"); + app.session.set_tool_option("select", &json!({"start": 2, "end": 5})); + assert_eq!(text_editing(&app).map(|(_, a, b)| (a, b)), Some((2, 5))); + let shown = shared_leading(&app); + assert!(shown.is_some_and(|l| (l - 24.0).abs() < 1e-9), "{shown:?}"); +} + +/// A selected text object that ends in a line break has an empty last paragraph, which the +/// Leading field takes in; a Type tool selection of all the text does not. +#[test] +fn leading_of_a_whole_object_takes_in_the_empty_last_paragraph() { + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + app.run("file.new", json!({"width": 300, "height": 200})).unwrap(); + let id = app.run("text.create", json!({"x": 20, "y": 50, "text": "Ab\n", "size": 20})).unwrap()["id"].clone(); + app.run("select.set", json!({"ids": [id]})).unwrap(); + let near = |l: Option, want: f64| l.is_some_and(|l| (l - want).abs() < 1e-9); + assert!(near(shared_leading(&app), 24.0), "{:?}", shared_leading(&app)); + app.run("text.setFormat", json!({"id": id, "start": 3, "end": 3, "autoLeading": 150})).unwrap(); + assert_eq!(shared_leading(&app), None, "the empty last paragraph's Auto leading is 30 pt"); + app.session.select_tool("type", Default::default()).unwrap(); + app.session.apply_actions(vec![vectorcraft_tools::Action::Notify("text.editNew".into())]).unwrap(); + app.session.set_tool_option("select", &json!({"start": 0, "end": 3})); + assert_eq!(text_editing(&app).map(|(_, a, b)| (a, b)), Some((0, 3))); + assert!(near(shared_leading(&app), 24.0), "a line break belongs to the paragraph it ends: {:?}", shared_leading(&app)); +} + +/// With the Type tool, the Leading field shows the Auto leading of the paragraphs the selection +/// covers (a line break belongs to the paragraph it ends), and at a caret that of the caret's +/// paragraph. +#[test] +fn leading_follows_the_paragraphs_the_type_tool_selection_covers() { + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + app.run("file.new", json!({"width": 300, "height": 200})).unwrap(); + app.session.select_tool("type", Default::default()).unwrap(); + // é is two bytes, 4..6. + let id = app.run("text.create", json!({"x": 20, "y": 50, "text": "Ab\nCé", "size": 20})).unwrap()["id"].clone(); + app.session.apply_actions(vec![vectorcraft_tools::Action::Notify("text.editNew".into())]).unwrap(); + app.run("text.setFormat", json!({"id": id, "start": 3, "end": 6, "autoLeading": 150})).unwrap(); + let ranges = [((0, 2), Some(24.0)), ((0, 3), Some(24.0)), ((3, 6), Some(30.0)), ((1, 4), None), ((1, 6), None), ((4, 4), Some(30.0))]; + for ((start, end), want) in ranges { + app.session.set_tool_option("select", &json!({"start": start, "end": end})); + assert_eq!(text_editing(&app).map(|(_, a, b)| (a, b)), Some((start, end))); + let shown = shared_leading(&app); + assert_eq!(shown.is_some(), want.is_some(), "{start}..{end}: {shown:?}"); + assert!(shown.zip(want).is_none_or(|(l, w)| (l - w).abs() < 1e-9), "{start}..{end}: {shown:?}"); + } +} + #[test] fn scaled_range_fields_follow_the_type_tool_selection() { let mut app = VectorcraftApp::new(Session::new(), Default::default()); @@ -498,6 +594,48 @@ fn character_alignment_shows_with_the_east_asian_options() { assert_eq!(shown(&mut app), (true, true)); } +/// The panel menu's Show Font Height Options shows the Set Font Height Reference dropdown, +/// which shows the selected type's reference and sets the one picked (one undo step); Hide +/// Font Height Options hides it again. +#[test] +fn show_font_height_options_shows_the_font_height_reference() { + use super::super::tests_appearance::{click, frame_events, text_rect}; + use vectorcraft_doc::FontHeightReference as R; + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + app.run("file.new", json!({"width": 300, "height": 200})).unwrap(); + let id = app.run("text.create", json!({"x": 20, "y": 50, "text": "Hx", "size": 12})).unwrap()["id"].as_u64().unwrap(); + app.run("select.set", json!({"ids": [id]})).unwrap(); + let reference = |app: &VectorcraftApp| match &app.session.active().unwrap().doc.node(NodeId(id)).unwrap().kind { + NodeKind::Text(t) => t.runs.iter().map(|r| r.style.font_height_reference).collect::>(), + _ => panic!("text"), + }; + let ctx = egui::Context::default(); + crate::theme::install_fonts(&ctx); + let texts = |ctx: &egui::Context, app: &mut VectorcraftApp, f: fn(&mut VectorcraftApp, &mut Ui)| { + frame_events(ctx, app, vec![], f); + frame_events(ctx, app, vec![], f) + }; + // A menu item's label is drawn after its check column (a check mark or spaces). + let item = |texts: &[(String, egui::Rect)], s: &str| texts.iter().find(|(t, _)| t.trim_start_matches(['✓', ' ']) == s).map(|(_, r)| r.center()); + assert!(item(&texts(&ctx, &mut app, show), "Em Box").is_none()); + let at = item(&texts(&ctx, &mut app, menu), "Show Font Height Options").unwrap(); + click(&ctx, &mut app, at, menu); + assert!(item(&texts(&ctx, &mut app, menu), "Hide Font Height Options").is_some()); + let panel = texts(&ctx, &mut app, show); + assert!(item(&panel, "Em Box").is_some(), "{panel:?}"); + // Pick Cap Height from the dropdown. + click(&ctx, &mut app, text_rect(&panel, "Em Box").center(), show); + let undo = app.session.active().unwrap().history.undo.len(); + let open = texts(&ctx, &mut app, show); + click(&ctx, &mut app, text_rect(&open, "Cap Height").center(), show); + assert_eq!(reference(&app), [R::CapHeight]); + assert_eq!(app.session.active().unwrap().history.undo.len(), undo + 1); + assert!(item(&texts(&ctx, &mut app, show), "Cap Height").is_some()); + let at = item(&texts(&ctx, &mut app, menu), "Hide Font Height Options").unwrap(); + click(&ctx, &mut app, at, menu); + assert!(item(&texts(&ctx, &mut app, show), "Cap Height").is_none()); +} + /// Tate-chu-yoko is in the panel menu with the East Asian options, and choosing it marks the /// selected characters. #[test] @@ -723,8 +861,8 @@ fn no_break_menu_item_toggles_the_selected_characters() { /// Reset Panel sets every character attribute the panel shows, except the font family, style /// and size, back to its default (Small Caps, the position, Character Alignment, Proportional -/// Metrics, Tate-chu-yoko, No Break, Character Direction and Digits included), in the selected -/// type and in the text the Type tool has selected while it edits. +/// Metrics, Tate-chu-yoko, No Break, Character Direction, Digits and the font height reference +/// included), in the selected type and in the text the Type tool has selected while it edits. #[test] fn reset_panel_resets_every_attribute_but_the_font() { let mut app = VectorcraftApp::new(Session::new(), Default::default()); @@ -755,6 +893,7 @@ fn reset_panel_resets_every_attribute_but_the_font() { s.no_break != d.no_break, s.char_direction != d.char_direction, s.digits != d.digits, + s.font_height_reference != d.font_height_reference, ] }; let custom = |app: &mut VectorcraftApp| { @@ -764,16 +903,16 @@ fn reset_panel_resets_every_attribute_but_the_font() { "id": id, "tracking": 50, "leading": 30, "kerning": 20, "baselineShift": 3, "hScale": 120, "vScale": 90, "rotation": 15, "underline": true, "strikethrough": true, "allCaps": true, "smallCaps": true, "position": "superscript", "charAlign": "emBoxCenter", "proportionalMetrics": true, "tateChuYoko": true, "standardVerticalRoman": true, - "noBreak": true, "charDirection": "rightToLeft", "digits": "hindi" + "noBreak": true, "charDirection": "rightToLeft", "digits": "hindi", "fontHeightReference": "capHeight" }), ) .unwrap(); }; let plain = runs(&app)[0].1.clone(); - assert_eq!(differs(&plain, &CharStyle::default()), [false; 19]); + assert_eq!(differs(&plain, &CharStyle::default()), [false; 20]); custom(&mut app); let styled = runs(&app)[0].1.clone(); - assert_eq!(differs(&styled, &CharStyle::default()), [true; 19]); + assert_eq!(differs(&styled, &CharStyle::default()), [true; 20]); app.run("select.set", json!({"ids": [id]})).unwrap(); reset_panel(&mut app); assert_eq!(runs(&app), [("One 12".to_string(), plain.clone())]); diff --git a/docs/mcp.md b/docs/mcp.md index ddd23ecfa..0f586deb3 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -5119,6 +5119,27 @@ A digit the font lacks comes from a fallback font. Default and saved only when s {"name":"run_command","arguments":{"command":"text.setFormat","params":{"digits":"hindi"}}} ``` +## Font height reference + +`text.setFormat {fontHeightReference: "emBox"|"capHeight"|"xHeight"|"icfBox"}` (the selected type, or `ids`) and +`text.setRangeStyle {id, start, end, fontHeightReference}` (a range) set the height of the font that the characters' font +size measures, as the Character panel's Set Font Height Reference dropdown does (panel menu › Show Font Height Options). +With `emBox`, the default, the font size is the size of the em square. With `capHeight`, `xHeight` or `icfBox`, the +characters are set at the em size at which the font's cap height or x height (OS/2 `sCapHeight`, `sxHeight`), or the +height of its ideographic character face (the BASE table's `icfb` and `icft`, else the ink of some ideographs and kana; +across a vertical line, its width), equals the font size: 20 pt Cap Height type in a font whose capitals are 0.7 em +tall is set at about 28.6 pt, and its font size stays 20 pt. With a font that has no such value, or one outside 0.1 to 2 +em, the size measures the em box. Auto leading, tracking and kerning follow the em size the characters are set at (the +Character panel's Leading field shows that Auto leading, and `type.step {attribute: "leading"}` starts from it); +explicit leading and baseline shift stay in points, and an inline graphic keeps its size. +SVG, PDF and legacy `.ai` export and the CSS Properties rules write the em size. DXF export writes 0.7 times the em +size as each TEXT entity's height (group 40, the height of its capitals). +Saved only when not `emBox`. One undo step. + +```json +{"name":"run_command","arguments":{"command":"text.setFormat","params":{"fontHeightReference":"capHeight"}}} +``` + ## Language `text.setFormat {language}` (the selected type, or `ids`) and `text.setRangeStyle {id, start, end, language}` (a range) diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index ddcee05b2..581f4bd9f 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -855,6 +855,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur - **Kerning across style runs:** characters that differ only in fill, stroke, underline, strikethrough, overprint, character style name, leading, baseline shift, rotation, Character Alignment, kerning, or tracking that keeps or drops ligatures alike are shaped together, so restyling one character keeps the font's kerning pairs with its neighbors. A character with manual kerning that follows one with automatic kerning keeps the font's kerning with it (unless All Caps or Small Caps sets it as two letters, as ß becomes SS), and its value replaces the font's kerning after it. A ligature does not form across a change of style, and a change of font, size or OpenType features still shapes the characters apart. Behavior reference: [Line and character spacing](https://web.archive.org/web/20260916050333/https://helpx.adobe.com/illustrator/using/line-character-spacing.html) (public documentation). Tests: `crates/text/src/tests/typo.rs`, `crates/text/src/tests/bidi.rs`. - **Recent font pickers (#3515):** The shared Character, Properties, Glyphs and Find Font family picker lists recently applied families first, once each, in most-recent order and limited by Preferences › Type › Number of Recent Fonts. Search and filters apply to both sections; unavailable families are omitted. Section headings cannot be selected or previewed. Confirmed character-range font edits update the same saved history; previews and failed edits do not. Requirement: [issue #3515](https://github.com/storytold/vectorcraft/issues/3515). - **Type:** Character size, leading, baseline shift and horizontal-scale controls account for object/group transforms (#1034): exact document-point sizes, mixed-value display and keyboard increments, range edits, resettable horizontal stretch, undo/redo and native save/reopen (`tests_typescale`, Character panel tests). Point size is the transformed em-height axis, independent of glyph ink bounds; affine rotation, reflection and shear are retained. Group scaling still scales text; preserving font sizes is a separate enhancement. Behaviour reference: [Transform text](https://helpx.adobe.com/illustrator/desktop/design-with-text/edit-format-text/transform-text.html) (public documentation). Text Wrap, Type on a Path effects (Rainbow/Skew/3D Ribbon/Stair Step/Gravity) and Options (Flip, Align to Path, Spacing; OK keeps the type where it is), type on a path's start, centre and end brackets (Selection and Direct Selection drag them to set where the type runs, slide it along its path and, across the path, flip it: #429), Character and Paragraph Styles (override-preserving redefine), paragraph attributes per paragraph (alignment, indents, spacing, hyphenation, hanging punctuation, tab stops, paragraph style, direction, mojikumi and leading model; the Paragraph, Tabs and Paragraph Styles panels act on the paragraphs the Type tool's selection or caret touches, Return continues the paragraph's style, threads and SVG keep them), Area Type Options (rows/columns/inset/first baseline, Text Flow by rows or by columns (#4978), vertical Align Top/Center/Bottom/Justify per row/column, reflowing non-rectangular frames and text wrap), threaded text across any closed shapes, Fit Headline, the Every-line Composer for justified and ragged text (paragraph-wide breaks for an even rag, Single-line per object from the Paragraph panel menu or `text.setFormat {composer}`). Area type resizes like Illustrator's type areas: a bounding-box handle drag (Selection tool) or Area Type Options Width/Height size the frame and the text reflows at its size (threads too); Direct Selection drags a frame corner or edge (`text.reshapeArea`), and an anchor of the path of type on a path outside perspective, the type reflowing along it (`text.reshapePath`, #3980). Point type still scales. Auto Size: the frame height follows the text after every edit, in the same undo step (rectangular frames of horizontal type in one row; columns get the least height that holds them; a height set by hand or threading turns it off), and the "Auto Size New Area Type" preference gives it to new area type. Shrink Text to Fit (beyond Illustrator): overflowing area type scales its sizes, leading and baseline shifts down by the largest factor (in 0.1 pt steps of the first run, down to a minimum percentage) that fits, at layout time so every export sees it. `text.areaOptions` and `document.inspect` report overflow and the shrink factor. The Selection tool shows the type widget beside selected type's bounding box (hollow on point type, filled on area type); double-clicking it converts between them (Type › Convert To Area Type / Convert To Point Type), keeping the text and its styles in place. Variable fonts list every named instance as a style of their family (Figtree Light to Black and their italics, Bahnschrift, Segoe UI Variable), and the instance chosen is shaped, drawn, outlined and measured at its axis settings; PDF and SVG embed it as that instance. The Character panel's Variable Font sliders set each axis (see Fonts). Beyond Illustrator: inline graphics (`text.insertInline`, Type ▸ Insert Inline Symbol) set a document symbol in a run of type as one character that flows, wraps and justifies like a glyph (InDesign-style inline objects, e.g. mana symbols in card rules text): sized to a multiple of the font size, centred on the cap height, opening up tall lines, following Redefine Symbol; a character of its run's size for Character Alignment and Top-to-Top leading, placed by the bidi reordering in right-to-left text (a neutral U+FFFC); drawn on the canvas, kept as live text plus a `` in SVG and as vector art in PDF; Backspace removes it whole. Hebrew and Arabic are laid out right to left with the Unicode bidirectional algorithm (embedded Latin and numbers keep their direction, lines put in visual order after wrapping) in point, area and path type, with caret, selection and arrow keys in visual order; each paragraph's direction comes from its first strong character unless Paragraph Direction sets it (Paragraph panel with Show Indic Options, `text.setFormat {direction}`), and new type aligns to the start of its direction. SVG export outlines right-to-left text. Arabic typefaces: builds made with a craft-fonts checkout that holds its Arabic families (Noto Sans Arabic, the Arabic fallback, plus Cairo, Tajawal, Almarai, IBM Plex Sans Arabic, Noto Kufi Arabic, Amiri, Alexandria, Fustat, Reem Kufi, Aref Ruqaa and Gulzar once craft-fonts adds them, storytold/craft-fonts#11; releases get them when their craft-fonts pin moves) list them in the font menus on desktop; the web build fetches them from beside the wasm (Noto Sans Arabic before start, so Arabic never shows as boxes, the rest in the background). Font Size and Leading are combo boxes in the Character and Properties panels (Font Size in the Control bar too, beside the Character link, font family and style): type, step with ↑/↓ or the spinner, scrub the label, or pick a preset (6–72 pt, shown in the type unit; Leading also Auto), each pick one undo step; Kerning and Tracking list -100…200; a size, leading, kerning or tracking the selected text doesn't share shows blank (#458). Type › Bold and Italic (beyond Illustrator, as page layout apps have them; Cmd+Shift+B / Cmd+Shift+I while the Type tool edits text) switch the selected type to its family's own Bold or Italic face and back, keeping its width (a Narrow face stays Narrow); a family without that face says so (#724). The commands that make or restyle type (and the MCP `add_text` tool) say in their result when area type does not fit its frame, and `add_text` takes Auto Size and Shrink Text to Fit (#1500, fixes #1471). Cmd+Shift+C (Align center) works on Windows and Linux too (#1940). A double-click on area type's bottom middle handle turns on Auto Size (#1940). Print and Print Preview warn about the area type and type on a path that prints without the text that doesn't fit, and give their ids. A tab stop in the Tabs panel drags from where the press landed, a stop just added included (#1487, fixes #1458). Area type in a non-rectangular shape moves a word too wide for the top lines down to the first line that holds it instead of breaking it (#1617, fixes #1615). Stylistic sets (ss01–ss20), stylistic and titling alternates, proportional and lining figures, and superscript and subscript glyphs can be turned on as OpenType features of a character style (`text.setStyle {features}`), and the OpenType panel keeps them (#1611, fixes #1607). Escape while typing ends the edit, keeps the type selected and switches to the Selection tool (#1802, #1686, #1656). Select › Same matches type on its characters' fill and stroke (#1746, #1737). `text.create`, `text.createInPath` and the MCP `add_text` tool take `justify`, so point type can be centred on or end at its point (#1752, #1748). + The Character panel menu's Show Font Height Options shows a Set Font Height Reference dropdown under Font Size (`text.setFormat` or `text.setRangeStyle` `{fontHeightReference}`). With Em Box, the default, the font size is the size of the em square; with Cap Height, x-Height or ICF Box, the characters are set at the em size at which the font's OS/2 cap height, its x height or the height of its ideographic character face equals the font size. With a font that has no such value, or one outside 0.1 to 2 em, the size measures the em box. SVG, PDF and legacy `.ai` export and the CSS Properties rules write the em size the characters are drawn at, and DXF export writes 0.7 times that em size as each TEXT entity's height (group 40, the height of its capitals). Behavior reference: [Vary font height](https://web.archive.org/web/20251118174722/https://helpx.adobe.com/illustrator/desktop/design-with-text/edit-format-text/vary-font-height.html). The Every-line Composer composes a paragraph whose characters differ in line metrics (type size, font, leading or vertical scale, or an inline graphic taller than the text) in rectangular area type, rows and columns included. In area type of another shape, or with text wrap, such a paragraph is broken with the Single-line Composer. The Character panel's Small Caps, Superscript and Subscript buttons and the panel menu's items of the same names turn small caps and the superscript or subscript position on and off for the selected type, or for the text the Type tool has selected (`text.setFormat` or `text.setRangeStyle` `{smallCaps, position}`), in Document Setup's proportions. Turning Superscript on turns Subscript off, and turning Subscript on turns Superscript off. A button or item shows as on when the first selected character (at a caret, the character before it) has its attribute. The OpenType panel's Stylistic Alternates and Titling Alternates checkboxes turn `salt` and `titl` on and off for the selected type, and its Figure menu lists Default Figure, Tabular Lining, Proportional Lining, Proportional Oldstyle and Tabular Oldstyle; Proportional Lining sets `pnum` and `lnum` and turns `tnum` and `onum` off, Tabular Lining turns `pnum` off and keeps `lnum`, Proportional Oldstyle turns `lnum` off and keeps `pnum`, and Default Figure and Tabular Oldstyle turn `pnum` and `lnum` off. Behavior reference: [Format text with OpenType features](https://helpx.adobe.com/illustrator/desktop/design-with-text/special-characters-glyphs/format-text-with-opentype-features.html) and [Use swashes, titling alternates, or stylistic alternates](https://helpx.adobe.com/illustrator/desktop/design-with-text/special-characters-glyphs/use-swashes-titling-alternates-or-stylistic-alternates.html) (public documentation). Tests: `panels::opentype::tests`. From 94ef9b87c28bd289f270d696454dd34a08f3200b Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Sat, 31 Oct 2026 19:37:34 -0700 Subject: [PATCH 05/12] Type: Character panel toggles show off for a mixed selection, and text.setFormat checks on/off values The Character panel's All Caps, Underline and Strikethrough buttons, and its menu's All Caps, Underline, Strikethrough, Proportional Metrics, Tate-chu-yoko and No Break, read the first selected character's style. With only some characters marked they could show on, and choosing one turned the attribute off for every character. They now show on only when every selected character has the attribute, and a click on a mixed selection turns it on for all, as Small Caps and Standard Vertical Roman Alignment do. type.underline (Cmd+Shift+U) underlines a mixed selection the same way. The Character Alignment and Digits submenus check no item while the selected characters differ. text.setFormat ignored a value that was not true or false for underline, strikethrough, allCaps, proportionalMetrics, tateChuYoko, standardVerticalRoman, noBreak, hyphenate, hyphenateCapitalized and romanHangingPunctuation. It now refuses it with "`` must be true or false" and changes nothing, as text.setRangeStyle does. --- crates/engine/src/cmd/panelcmds.rs | 16 +++ crates/engine/src/tests/textedit.rs | 13 +- crates/ui-egui/src/panels/character.rs | 41 +++--- crates/ui-egui/src/panels/character/tests.rs | 140 ++++++++++++++++++- docs/mcp.md | 3 +- docs/target-app-parity.md | 1 + 6 files changed, 191 insertions(+), 23 deletions(-) diff --git a/crates/engine/src/cmd/panelcmds.rs b/crates/engine/src/cmd/panelcmds.rs index 7934dbe69..68449f5eb 100644 --- a/crates/engine/src/cmd/panelcmds.rs +++ b/crates/engine/src/cmd/panelcmds.rs @@ -318,6 +318,22 @@ fn spacing_param(p: &Value, key: &str, c: &str) -> Result; 3 fn set_format(s: &mut Session, p: &Value) -> Result { const C: &str = "text.setFormat"; let ids = super::typecmd::text_targets(s, p, C)?; + check_switches( + p, + &[ + "underline", + "strikethrough", + "allCaps", + "proportionalMetrics", + "tateChuYoko", + "standardVerticalRoman", + "noBreak", + "hyphenate", + "hyphenateCapitalized", + "romanHangingPunctuation", + ], + C, + )?; let num = |k: &str| p.get(k).and_then(Value::as_f64); let flag = |k: &str| p.get(k).and_then(Value::as_bool); let kerning = match p.get("kerning") { diff --git a/crates/engine/src/tests/textedit.rs b/crates/engine/src/tests/textedit.rs index 025cf6d30..178f8f528 100644 --- a/crates/engine/src/tests/textedit.rs +++ b/crates/engine/src/tests/textedit.rs @@ -325,6 +325,16 @@ fn type_commands_refuse_a_number_or_boolean_given_as_text() { ("text.setRangeStyle", json!({"id": id.0, "allCaps": 1}), "allCaps"), ("text.setRangeStyle", json!({"id": id.0, "noBreak": "true"}), "noBreak"), ("text.setRangeStyle", json!({"id": id.0, "size": 30, "tateChuYoko": 1}), "tateChuYoko"), + ("text.setFormat", json!({"ids": [id.0], "underline": "true"}), "underline"), + ("text.setFormat", json!({"ids": [id.0], "strikethrough": 1}), "strikethrough"), + ("text.setFormat", json!({"ids": [id.0], "allCaps": "false"}), "allCaps"), + ("text.setFormat", json!({"ids": [id.0], "proportionalMetrics": 1}), "proportionalMetrics"), + ("text.setFormat", json!({"ids": [id.0], "tateChuYoko": "true"}), "tateChuYoko"), + ("text.setFormat", json!({"ids": [id.0], "standardVerticalRoman": "true"}), "standardVerticalRoman"), + ("text.setFormat", json!({"ids": [id.0], "rotation": 10, "noBreak": 1}), "noBreak"), + ("text.setFormat", json!({"ids": [id.0], "hyphenate": "false"}), "hyphenate"), + ("text.setFormat", json!({"ids": [id.0], "hyphenateCapitalized": 0}), "hyphenateCapitalized"), + ("text.setFormat", json!({"ids": [id.0], "romanHangingPunctuation": "true"}), "romanHangingPunctuation"), ("text.getRange", json!({"id": id.0, "effective": "true"}), "effective"), ("type.opticalMarginAlignment", json!({"on": "true"}), "on"), ("type.systemLayout", json!({"on": "true"}), "on"), @@ -338,12 +348,13 @@ fn type_commands_refuse_a_number_or_boolean_given_as_text() { assert!(wrong.is_empty(), "not refused: {wrong:#?}"); let style = json!({"id": id.0, "size": 30, "underline": true, "tracking": null, "start": null, "noBreak": true}); s.execute("text.setRangeStyle", &style).unwrap(); + s.execute("text.setFormat", &json!({"ids": [id.0], "strikethrough": true, "hyphenate": null})).unwrap(); s.execute("type.size.increase", &json!({"by": null})).unwrap(); s.execute("type.opticalMarginAlignment", &json!({"on": true})).unwrap(); s.execute("type.ruby", &json!({"text": "a", "size": null, "overhang": false})).unwrap(); let t = obj(&s, id); let st = &t.runs[0].style; - assert!(st.underline && st.no_break && t.optical_margin && st.size > 30.0, "{st:?}"); + assert!(st.underline && st.strikethrough && st.no_break && t.optical_margin && st.size > 30.0, "{st:?}"); assert!(!t.ruby.is_empty() && t.ruby.iter().all(|r| !r.overhang && r.size_percent == 50.0), "{:?}", t.ruby); } diff --git a/crates/ui-egui/src/panels/character.rs b/crates/ui-egui/src/panels/character.rs index f00c33d70..db01f2e38 100644 --- a/crates/ui-egui/src/panels/character.rs +++ b/crates/ui-egui/src/panels/character.rs @@ -419,11 +419,12 @@ pub(crate) fn highlight(app: &mut VectorcraftApp, field: Field) -> Result Result { - let (s, _) = text_style(app).ok_or("select some type first")?; - format(app, json!({"underline": !s.underline})); - Ok(json!(!s.underline)) + text_style(app).ok_or("select some type first")?; + let on = shared(app, |s| s.underline) != Some(true); + format(app, json!({"underline": on})); + Ok(json!(on)) } /// `text.setRangeStyle` with `p` on the range the Type tool has selected (the whole text when @@ -946,15 +947,19 @@ fn attributes(app: &mut VectorcraftApp, ui: &mut Ui) { } ui.add_space(4.0); ui.horizontal(|ui| { + // On only when every character the panel acts on has it; a mixed selection turns it on. + let all_caps = shared(app, |s| s.all_caps) == Some(true); let small = shared(app, |s| s.small_caps.is_some()) == Some(true); let position = shared(app, |s| s.position).map(|p| p.id()).unwrap_or(""); + let underline = shared(app, |s| s.underline) == Some(true); + let strike = shared(app, |s| s.strikethrough) == Some(true); for (kind, tip, on, change) in [ - (Glyph::AllCaps, tl!("All Caps"), s.all_caps, json!({"allCaps": !s.all_caps})), + (Glyph::AllCaps, tl!("All Caps"), all_caps, json!({"allCaps": !all_caps})), (Glyph::SmallCaps, tl!("Small Caps"), small, json!({"smallCaps": !small})), (Glyph::Super, tl!("Superscript"), position == "superscript", position_toggle(position, "superscript")), (Glyph::Sub, tl!("Subscript"), position == "subscript", position_toggle(position, "subscript")), - (Glyph::Underline, tl!("Underline"), s.underline, json!({"underline": !s.underline})), - (Glyph::Strike, tl!("Strikethrough"), s.strikethrough, json!({"strikethrough": !s.strikethrough})), + (Glyph::Underline, tl!("Underline"), underline, json!({"underline": !underline})), + (Glyph::Strike, tl!("Strikethrough"), strike, json!({"strikethrough": !strike})), ] { if style_toggle(ui, kind, tip, on) { format(app, change); @@ -1093,8 +1098,7 @@ fn digits_items(app: &mut VectorcraftApp, ui: &mut Ui, digits: Option