diff --git a/crates/doc/src/text.rs b/crates/doc/src/text.rs index 611edc1c81..1c89aae1fa 100644 --- a/crates/doc/src/text.rs +++ b/crates/doc/src/text.rs @@ -1150,6 +1150,44 @@ fn reverse_from_end(path: &mut PathData) { } } +/// The anti-aliasing method of a type object (Character panel › Anti-aliasing Method): how the +/// edges of its characters are drawn when the type is drawn as pixels (the canvas, raster exports, +/// rasterizing). SVG, PDF and EPS export ignore it. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum TypeAntiAlias { + /// Hard edges: a pixel takes the type's color where the type covers at least half of it, and + /// nothing elsewhere. + None, + /// Each edge pixel shows the share of it the type covers. + #[default] + Sharp, + /// An edge pixel covered by a share `c` shows `2c - 0.5`, between 0 and 1: edges about half as + /// wide as Sharp's. + Crisp, + /// An edge pixel covered by a share `c` shows `2c`, at most 1: heavier type. + Strong, +} + +impl TypeAntiAlias { + pub const ALL: [Self; 4] = [Self::None, Self::Sharp, Self::Crisp, Self::Strong]; + + /// The id used in command params and files (`none`, `sharp`, `crisp`, `strong`). + pub fn id(self) -> &'static str { + match self { + Self::None => "none", + Self::Sharp => "sharp", + Self::Crisp => "crisp", + Self::Strong => "strong", + } + } + + /// The method an id names. + pub fn from_id(id: &str) -> Option { + Self::ALL.into_iter().find(|a| a.id() == id) + } +} + /// A text object. `runs` split into paragraphs at `\n`. /// /// Paragraph attributes: `para` is paragraph 0's (and, while `paras` is empty, every @@ -1193,6 +1231,11 @@ pub struct TextObject { /// for new type and for documents from before it, and saved only when on. #[serde(default, rename = "opticalMarginAlignment", skip_serializing_if = "crate::skip::is_default")] pub optical_margin: bool, + /// Character panel › Anti-aliasing Method: how the type's edges are drawn as pixels. Every + /// frame of a thread has the same method. Sharp for new type and for documents from before it, + /// and saved only when it is another method. + #[serde(default, rename = "antiAlias", skip_serializing_if = "crate::skip::is_default")] + pub anti_alias: TypeAntiAlias, /// Cached layout bounds in text space, filled in by the layout engine (not serialized). #[serde(skip)] pub cached_bounds: Option, @@ -1259,6 +1302,7 @@ impl TextObject { path_spacing: 0.0, wrap: Vec::new(), optical_margin: false, + anti_alias: TypeAntiAlias::Sharp, cached_bounds: None, cached_baselines: Vec::new(), } diff --git a/crates/engine/src/cmd/fileio/load.rs b/crates/engine/src/cmd/fileio/load.rs index 947cd07884..aff66cb54a 100644 --- a/crates/engine/src/cmd/fileio/load.rs +++ b/crates/engine/src/cmd/fileio/load.rs @@ -276,6 +276,8 @@ pub fn load_with(name: &str, bytes: &[u8], opts: &LoadOptions) -> Result if !doc.text_threads.is_empty() && !restored { crate::cmd::threads::reflow(&Document::new(1.0, 1.0), &mut doc); } else if !doc.text_threads.is_empty() { + // Each frame also takes its thread's first frame's anti-aliasing method, as `reflow` gives it. + crate::cmd::threads::share_anti_alias(&mut doc); crate::cmd::threads::share_optical_margin(&mut doc); } if let Some(mode) = opts.color_mode.filter(|m| *m != doc.color_mode) { diff --git a/crates/engine/src/cmd/panelcmds.rs b/crates/engine/src/cmd/panelcmds.rs index 3b5dc2e770..10e79cd409 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\", 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), 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), 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), digits?: \"default\"|\"arabic\"|\"hindi\"|\"farsi\" (Character panel menu › Digits: the numeral system every digit is drawn in, whatever was typed), 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), 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\", baselineShift?: pt, hScale?: %, vScale?: %, rotation?: deg, underline?, strikethrough?, allCaps?, smallCaps?: bool, position?: \"normal\"|\"superscript\"|\"subscript\" (sizes from Document Setup), antiAlias?: \"none\"|\"sharp\"|\"crisp\"|\"strong\" (Character panel › Anti-aliasing Method, how the type's edges are drawn as pixels; it sets the whole type object and every frame of its thread, with or without a range; new type: \"sharp\"), leftIndent?, rightIndent?, firstLineIndent?, spaceBefore?, spaceAfter?: pt (±1296), 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), 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), digits?: \"default\"|\"arabic\"|\"hindi\"|\"farsi\" (Character panel menu › Digits: the numeral system every digit is drawn in, whatever was typed), 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), 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 ), @@ -257,6 +257,7 @@ fn set_format(s: &mut Session, p: &Value) -> Result { "allCaps", "smallCaps", "position", + "antiAlias", "leftIndent", "rightIndent", "firstLineIndent", @@ -284,6 +285,15 @@ fn set_format(s: &mut Session, p: &Value) -> Result { if !keys.iter().any(|k| p.get(*k).is_some()) { return Err(bad(C, "nothing to change")); } + // The anti-aliasing method belongs to the whole type object and to every frame of its thread. + let anti_alias = match p.get("antiAlias") { + None | Some(Value::Null) => None, + Some(v) => Some( + v.as_str() + .and_then(vectorcraft_doc::text::TypeAntiAlias::from_id) + .ok_or_else(|| bad(C, "`antiAlias` must be \"none\", \"sharp\", \"crisp\" or \"strong\""))?, + ), + }; let composer = match p.get("composer") { None | Some(Value::Null) => None, Some(v) => Some(match v.as_str() { @@ -460,6 +470,13 @@ fn set_format(s: &mut Session, p: &Value) -> Result { let scale = num("hScale").map(|v| v.clamp(1.0, 10000.0)); super::graph::capture_label_styles(d, &ids, &|st| chars(st, shift, scale)); } + if let Some(v) = anti_alias { + for id in super::threads::with_threads(d, &ids) { + if let Some(NodeKind::Text(t)) = d.node_mut(id).map(|n| &mut n.kind) { + t.anti_alias = v; + } + } + } Ok(()) })?; Ok(json!({ "ids": ids.iter().map(|i| i.0).collect::>() })) diff --git a/crates/engine/src/cmd/textedit.rs b/crates/engine/src/cmd/textedit.rs index 2ef5451f10..c660948eb5 100644 --- a/crates/engine/src/cmd/textedit.rs +++ b/crates/engine/src/cmd/textedit.rs @@ -839,6 +839,7 @@ fn create_in_path(s: &mut Session, p: &Value) -> Result { path_spacing: 0.0, wrap: Vec::new(), optical_margin: false, + anti_alias: Default::default(), cached_bounds: None, cached_baselines: Vec::new(), }; @@ -1059,6 +1060,7 @@ fn headline_tracking(t: &TextObject, target: f64) -> Option { path_spacing: 0.0, wrap: Vec::new(), optical_margin: false, + anti_alias: Default::default(), cached_bounds: None, cached_baselines: Vec::new(), }; diff --git a/crates/engine/src/cmd/threads.rs b/crates/engine/src/cmd/threads.rs index d599f60bb9..0c7672c021 100644 --- a/crates/engine/src/cmd/threads.rs +++ b/crates/engine/src/cmd/threads.rs @@ -157,12 +157,45 @@ pub(crate) fn reflow(before: &Document, doc: &mut Document) { } } if live.len() >= 2 { + if let Some(a) = anti_alias(before, doc, &live) { + give_anti_alias(doc, &live, a); + } kept.push(live); } } doc.text_threads = kept; } +/// The anti-aliasing method that every frame of a thread with the frames `live` takes: that of the +/// first frame whose method changed between `before` and `doc`, else the first frame's. +fn anti_alias(before: &Document, doc: &Document, live: &[NodeId]) -> Option { + let now = |id: &NodeId| text_of(doc, *id).map(|t| t.anti_alias); + let changed = live.iter().find(|id| text_of(before, **id).is_some_and(|t| Some(t.anti_alias) != now(id))); + changed.or(live.first()).and_then(now) +} + +/// Give each of the frames `live` that has another anti-aliasing method the method `a`. +fn give_anti_alias(doc: &mut Document, live: &[NodeId], a: vectorcraft_doc::text::TypeAntiAlias) { + for id in live { + if text_of(doc, *id).is_some_and(|t| t.anti_alias != a) + && let Some(NodeKind::Text(t)) = doc.node_mut(*id).map(|n| &mut n.kind) + { + t.anti_alias = a; + } + } +} + +/// Give every frame of each thread the first frame's anti-aliasing method, without re-flowing the +/// story: for a document restored from a file as it was saved. +pub(crate) fn share_anti_alias(doc: &mut Document) { + for thread in doc.text_threads.clone() { + let live: Vec = thread.into_iter().filter(|id| text_of(doc, *id).is_some()).collect(); + if let Some(a) = anti_alias(doc, doc, &live) { + give_anti_alias(doc, &live, a); + } + } +} + /// The Optical Margin Alignment setting that every frame of a thread with the frames `live` takes: /// that of the first frame whose setting changed between `before` and `doc`, else the first frame's. fn optical_margin(before: &Document, doc: &Document, live: &[NodeId]) -> Option { @@ -735,6 +768,55 @@ mod tests { assert_eq!([a, b, c].map(|id| optical(&s, id).0), [true; 3]); } + /// The anti-aliasing method (`text.setFormat {antiAlias}`) belongs to the whole thread and is + /// saved only when not Sharp: set in one frame, with or without a range, it is set in every + /// frame in one undo step, a frame threaded on takes the thread's method, and a file whose + /// frames of one thread differ opens with the first frame's method in all of them. + #[test] + fn the_anti_aliasing_method_is_set_for_every_frame_of_a_thread() { + use vectorcraft_doc::text::TypeAntiAlias::{self, Crisp, None as Hard, Sharp, Strong}; + let method = |s: &Session, id: u64| text_of(&s.doc().unwrap().doc, NodeId(id)).map(|t| t.anti_alias); + let saved = + |s: &Session, id: u64| serde_json::to_value(text_of(&s.doc().unwrap().doc, NodeId(id)).unwrap()).unwrap().get("antiAlias").cloned(); + let (mut s, a, b) = quoted_thread(); + assert_eq!((method(&s, a), method(&s, b)), (Some(Sharp), Some(Sharp))); + assert_eq!(saved(&s, a), None, "Sharp is not saved"); + s.execute("select.set", &json!({"ids": [b]})).unwrap(); + let err = s.execute("text.setFormat", &json!({"antiAlias": "smooth"})).unwrap_err().to_string(); + assert!(err.contains("`antiAlias` must be"), "{err}"); + s.execute("text.setFormat", &json!({"antiAlias": "crisp"})).unwrap(); + assert_eq!((method(&s, a), method(&s, b)), (Some(Crisp), Some(Crisp)), "the first frame takes it too"); + assert_eq!(saved(&s, a), Some(json!("crisp"))); + // A range of characters sets the whole thread as well. + s.execute("text.setFormat", &json!({"ids": [a], "start": 0, "end": 1, "antiAlias": "none"})).unwrap(); + assert_eq!((method(&s, a), method(&s, b)), (Some(Hard), Some(Hard))); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!((method(&s, a), method(&s, b)), (Some(Crisp), Some(Crisp)), "one undo step"); + // A frame threaded on from the last one takes the thread's method. + s.execute("text.setFormat", &json!({"ids": [b], "antiAlias": "strong"})).unwrap(); + let c = new_id(s.execute("text.thread.add", &json!({"from": b, "at": [400, 10]})).unwrap()); + assert_eq!([a, b, c].map(|id| method(&s, id)), [Some(Strong); 3]); + s.execute("edit.undo", &json!({})).unwrap(); + let first: [TypeAntiAlias; 2] = [Crisp, Hard]; + for (one, name) in [(first, "old.vectorcraft"), ([Hard, Crisp], "old.vectorcraft"), (first, "old.eps"), ([Hard, Crisp], "old.eps")] { + for (id, m) in [(a, one[0]), (b, one[1])] { + let doc = std::sync::Arc::make_mut(&mut s.doc_mut().unwrap().doc); + let Some(NodeKind::Text(t)) = doc.node_mut(NodeId(id)).map(|n| &mut n.kind) else { panic!("not text") }; + t.anti_alias = m; + } + let bytes = if name.ends_with(".eps") { + let r = s.execute("document.exportEps", &json!({})).unwrap(); + vectorcraft_format::base64_decode(r["dataBase64"].as_str().unwrap()).unwrap() + } else { + vectorcraft_format::save(&s.doc().unwrap().doc, false) + }; + let r = s.execute("document.open", &json!({"name": name, "dataBase64": vectorcraft_format::base64_encode(&bytes)})).unwrap(); + assert_eq!(r["restored"], name.ends_with(".eps"), "{r}"); + assert_eq!(threads(&s), [[a, b]], "{name}"); + assert_eq!((method(&s, a), method(&s, b)), (Some(one[0]), Some(one[0])), "{name}, first frame {:?}", one[0]); + } + } + /// A file saved with the frames of one thread set differently opens with the first frame's /// setting in every frame: a native file, and an EPS file restored from the document it carries. #[test] diff --git a/crates/render/src/fx.rs b/crates/render/src/fx.rs index 87ba4e1c22..62b77dd7bc 100644 --- a/crates/render/src/fx.rs +++ b/crates/render/src/fx.rs @@ -160,7 +160,8 @@ fn rasterize_bounds(symbols: &SymbolBoxes, n: &Node, rfx: &[RasterFx]) -> Option /// `f` with the content moved by (`dx`, `dy`) document units (a shadow's offset). fn shifted<'a>(f: &Frame<'a>, dx: f64, dy: f64) -> Frame<'a> { - Frame { view: f.view * Affine::translate((dx, dy)), visible: f.visible - Vec2::new(dx, dy), ..*f } + let moved = Affine::translate((dx, dy)); + Frame { view: f.view * moved, visible: f.visible - Vec2::new(dx, dy), output: f.output * moved, ..*f } } fn node_bezpath(n: &Node) -> Option { @@ -557,7 +558,7 @@ fn pcolor(ink: Ink, c: &vectorcraft_doc::color::Color) -> peniko::Color { /// Most pixels the raster of [`Renderer::pixel_content`] holds (a 4K screen's): larger content is /// cropped to the view when its effects only look nearby, else rendered at a lower resolution. -const PIXEL_BUDGET: f64 = 8.4e6; +pub(crate) const PIXEL_BUDGET: f64 = 8.4e6; /// Most Rasterize effects of one stack that [`Renderer::raster_fx`] draws (the last ones): each /// draws the content again inside the one after it. @@ -2051,7 +2052,8 @@ impl Renderer { (true, false) => crate::AntiAlias::Art, }; let opts = crate::RenderOptions { anti_alias, ..f.opts.clone() }; - let frame = Frame { mt: false, view, visible: rect, px: cell, opts: &opts, ..*f }; + // The image's pixels are the output's. + let frame = Frame { mt: false, view, visible: rect, px: cell, opts: &opts, output: view, ..*f }; let mut off = frame.offscreen_context(w, h); self.inside_layer(|s| paint(s, &mut off, &frame)); off.flush(); diff --git a/crates/render/src/lib.rs b/crates/render/src/lib.rs index 9735c6d38e..e224b80d50 100644 --- a/crates/render/src/lib.rs +++ b/crates/render/src/lib.rs @@ -24,6 +24,7 @@ pub mod proof; use std::collections::HashMap; use std::sync::Arc; +use vectorcraft_doc::text::TypeAntiAlias; use vectorcraft_doc::{AppearanceItem, Document, Node, NodeId, NodeKind, StrokeAlign, StrokeLayer, SymbolBoxes, TextObject, TraceView}; use vectorcraft_geom::{Affine, BezPath, FillRule, Rect, Shape}; use vello_cpu::kurbo; @@ -580,11 +581,16 @@ struct Frame<'a> { view: Affine, /// Visible region in document coordinates (for culling). visible: Rect, - /// Size of one output pixel in document units. + /// Size of one of this frame's pixels in document units. px: f64, opts: &'a RenderOptions, /// What colours paint as: screen colours, or one ink plane of a CMYK document (see [`ink`]). ink: Ink, + /// Document → pixels of the output being drawn: `view` itself, except while an export draws + /// its art at several times its size (the export's own pixels) and in offscreen layers (the + /// pixels they are placed back on). Type with an anti-aliasing method other than Sharp is + /// drawn on these pixels ([`Renderer::draw_type_edges`]). + output: Affine, } impl Frame<'_> { @@ -652,18 +658,21 @@ impl Renderer { /// Render `doc` into a `width`×`height` image using `view` (document → pixel transform). pub fn render(&mut self, doc: &Document, width: u32, height: u32, view: Affine, opts: &RenderOptions) -> Rendered { - self.render_as(doc, width, height, view, opts, false) + self.render_as(doc, width, height, view, opts, false, 1) } /// [`Self::render`], or with `inks` the ink amounts in the working CMYK space instead of - /// screen colours (4 bytes a pixel, see [`Self::render_region_inks`]). - fn render_as(&mut self, doc: &Document, width: u32, height: u32, view: Affine, opts: &RenderOptions, inks: bool) -> Rendered { + /// screen colours (4 bytes a pixel, see [`Self::render_region_inks`]). `cell` is the side of a + /// pixel of the output in pixels of this render: the supersampling factor while an export + /// draws its art at several times its size, else 1 ([`Frame::output`]). + #[allow(clippy::too_many_arguments)] + fn render_as(&mut self, doc: &Document, width: u32, height: u32, view: Affine, opts: &RenderOptions, inks: bool, cell: u32) -> Rendered { let start = now(); if opts.supersample && opts.anti_alias == AntiAlias::Art && opts.mask_view.is_none() { let (w, h) = (width.clamp(1, u16::MAX as u32), height.clamp(1, u16::MAX as u32)); if let Some(k) = supersample_factor(w, h) { let hard = RenderOptions { anti_alias: AntiAlias::None, supersample: false, ..opts.clone() }; - let big = self.render_as(doc, w * k, h * k, Affine::scale(f64::from(k)) * view, &hard, inks); + let big = self.render_as(doc, w * k, h * k, Affine::scale(f64::from(k)) * view, &hard, inks, k); let pixels = downsample(&big.pixels, big.width as usize, big.height as usize, k as usize); self.stats.micros = now().saturating_sub(start); return Rendered { width: w, height: h, pixels }; @@ -685,7 +694,8 @@ impl Renderer { let px = 1.0 / view.determinant().abs().sqrt().max(1e-12); let screen = if opts.rich_black { Ink::RichBlack } else { Ink::Display }; let symbols = doc.symbol_boxes().quick(); - let frame = Frame { mt: threads > 0, doc, symbols: &symbols, view, visible, px, opts, ink: screen }; + let output = Affine::scale(1.0 / f64::from(cell.max(1))) * view; + let frame = Frame { mt: threads > 0, doc, symbols: &symbols, view, visible, px, opts, ink: screen, output }; self.begin_frame(); // View Opacity Mask: the mask's coverage in greyscale instead of the artwork. if let Some(m) = opts.mask_view.and_then(|id| doc.node(id)).and_then(|n| n.extras().mask.as_deref()) { @@ -850,7 +860,7 @@ impl Renderer { pub fn render_region_inks(&mut self, doc: &Document, region: Rect, scale: f64, opts: &RenderOptions) -> Vec { let (w, h) = region_pixels(region, scale); let view = Affine::scale(scale) * Affine::translate((-region.x0, -region.y0)); - self.render_as(doc, w, h, view, opts, true).pixels + self.render_as(doc, w, h, view, opts, true, 1).pixels } /// Render a single node (thumbnails, previews) fitted into `size`×`size` pixels. @@ -868,7 +878,7 @@ impl Renderer { let mut ctx = single_threaded_context(w, h); let opts = RenderOptions::default(); let symbols = doc.symbol_boxes().quick(); - let frame = Frame { mt: false, doc, symbols: &symbols, view, visible, px, opts: &opts, ink: Ink::Display }; + let frame = Frame { mt: false, doc, symbols: &symbols, view, visible, px, opts: &opts, ink: Ink::Display, output: view }; self.begin_frame(); (self.knockout, self.nested, self.backdrop, self.picture) = (false, 0, None, None); self.clip_paths.clear(); @@ -911,7 +921,8 @@ impl Renderer { ctx.fill_rect(&kurbo::Rect::new(0.0, 0.0, w as f64, w as f64)); } let symbols = doc.symbol_boxes().quick(); - let frame = Frame { mt: false, doc, symbols: &symbols, view, visible: b.inflate(1.0, 1.0), px: 1.0 / s, opts, ink: Ink::Display }; + let frame = + Frame { mt: false, doc, symbols: &symbols, view, visible: b.inflate(1.0, 1.0), px: 1.0 / s, opts, ink: Ink::Display, output: view }; self.begin_frame(); (self.knockout, self.nested, self.backdrop, self.picture) = (false, 0, None, None); self.clip_paths.clear(); @@ -1800,6 +1811,9 @@ impl Renderer { self.draw_inlines(ctx, f, t, g); return; } + if t.anti_alias != TypeAntiAlias::Sharp && self.draw_type_edges(ctx, f, n, t, g) { + return; + } let tb = t.xf.transform_rect_bbox(g.bounds); // Overprinting characters darken per draw, like overprinting fills and strokes // ([`proof`]); the object blend mode is Normal here or applied by an enclosing layer. @@ -1861,6 +1875,119 @@ impl Renderer { } } + /// Draw type `t` of node `n` (laid out in `g`) with its anti-aliasing method, None, Crisp or + /// Strong, on the pixels of the output ([`Frame::output`]): the type is drawn offscreen on those + /// pixels and placed back pixel for pixel. With None it is drawn with hard edges, so a pixel + /// takes the type's color where the type covers at least half of it. With Crisp and Strong it + /// is drawn as Sharp draws it, and again with the characters and the object's own fills and + /// strokes in opaque black, which gives the share `c` of each pixel they cover; the pixel's + /// color and opacity are then scaled by [`edge_coverage`] of `c` divided by `c`, so the opacity + /// of the paints is kept. Where the output's pixels would number more than the frame's there + /// and more than [`fx::PIXEL_BUDGET`] (a frame drawn coarser than the output), the frame's own + /// pixels are used. + /// + /// Returns false, having drawn nothing, for type that is drawn as Sharp draws it: type with + /// inline graphics, type whose own fills or strokes include a brush stroke or a blend mode + /// other than Normal, type that overprints while Overprint Preview is on, and type that shows a + /// substitution highlight on screen. Drawn offscreen, those would blend with nothing or take + /// the method on art that is not the characters'. + fn draw_type_edges(&mut self, ctx: &mut RenderContext, f: &Frame, n: &Node, t: &TextObject, g: &TextGeom) -> bool { + if proof::overprints(f.opts) && t.runs.iter().any(|r| r.style.overprint_fill || r.style.overprint_stroke) { + return false; + } + let normal = vectorcraft_color::BlendMode::Normal; + let brush = |i: &AppearanceItem| matches!(i, AppearanceItem::Stroke(st) if st.brush.is_some()); + if t.runs.iter().any(|r| r.inline.is_some()) || n.appearance.items.iter().any(|i| i.visible() && (i.blend() != normal || brush(i))) { + return false; + } + let setup = &f.doc.setup; + let highlights = [ + (setup.highlight_substituted_fonts, &g.substituted_fonts), + (setup.highlight_substituted_glyphs, &g.substituted_glyphs), + (f.opts.highlight_alternate_glyphs, &g.alternate_glyphs), + ]; + if f.opts.highlight_substitutions && highlights.iter().any(|(on, path)| *on && !path.elements().is_empty()) { + return false; + } + // Where the type draws: its glyphs, grown by the farthest its strokes reach, besides its + // bounds (an area type frame's, or a point type estimate before it is laid out). + let glyphs = type_reach(n, t, g); + let reach = fx::cull_bounds(f.symbols, n).map_or(glyphs, |b| b.union(glyphs)); + // This frame's pixels to the output's: a scale and an offset (a supersampled export's + // factor, an offscreen layer's crop). + let grid = f.output * f.view.inverse(); + let [sx, _, _, sy, _, _] = grid.as_coeffs(); + let reach = reach.inflate(2.0 * f.px / sx.abs().min(1.0), 2.0 * f.px / sy.abs().min(1.0)); + let on = f.view.transform_rect_bbox(reach).intersect(Rect::new(0.0, 0.0, f64::from(ctx.width()), f64::from(ctx.height()))); + if !on.is_finite() { + return false; + } + if on.width() <= 0.0 || on.height() <= 0.0 { + return true; + } + let grid = edge_grid(on, grid); + let output = grid * f.view; + let [sx, _, _, sy, _, _] = grid.as_coeffs(); + let on = grid.transform_rect_bbox(on); + // Whole pixels of the output. + let (x0, y0) = (on.x0.floor(), on.y0.floor()); + let (Ok(w), Ok(h)) = (u16::try_from((on.x1 - x0).ceil() as u64), u16::try_from((on.y1 - y0).ceil() as u64)) else { return false }; + let view = Affine::translate((-x0, -y0)) * output; + let visible = view.inverse().transform_rect_bbox(Rect::new(0.0, 0.0, f64::from(w), f64::from(h))); + let px = 1.0 / view.determinant().abs().sqrt().max(1e-12); + let frame = Frame { mt: false, view, visible, px, output: view, ..*f }; + let threshold = match t.anti_alias { + TypeAntiAlias::None => AntiAlias::None.threshold(), + // The frame's pixels are smaller than the output's in a supersampled export, whose art + // is smooth on the output's pixels (Art Optimized). + _ if sx.abs() < 0.999 || sy.abs() < 0.999 => None, + _ => f.opts.anti_alias.threshold(), + }; + let draw = |r: &mut Self, frame: &Frame, n: &Node, t: &TextObject| { + let mut off = single_threaded_context(w, h); + off.set_aliasing_threshold(threshold); + r.inside_layer(|r| r.draw_text_geom(&mut off, frame, n, t, g)); + off.flush(); + let mut pm = Pixmap::new(w, h); + off.render(&mut pm, &mut r.resources); + pm + }; + let sharp = TextObject { anti_alias: TypeAntiAlias::Sharp, ..t.clone() }; + let mut pm = draw(self, &frame, n, &sharp); + if t.anti_alias != TypeAntiAlias::None { + let black = |p: &mut vectorcraft_color::Paint| { + if !p.is_none() { + *p = vectorcraft_color::Paint::solid(vectorcraft_color::Color::BLACK); + } + }; + let mut ink = sharp; + for r in &mut ink.runs { + black(&mut r.style.fill); + black(&mut r.style.stroke); + } + let mut inked = n.clone(); + for item in &mut inked.appearance.items { + black(item.paint_mut()); + match item { + AppearanceItem::Fill(fl) => fl.opacity = 1.0, + AppearanceItem::Stroke(st) => st.opacity = 1.0, + } + } + let covered = draw(self, &frame, &inked, &ink); + for (p, c) in pm.data_mut().iter_mut().zip(covered.data()) { + *p = edge_pixel(*p, c.a, t.anti_alias); + } + } + ctx.set_transform(grid.inverse() * Affine::translate((x0, y0))); + // The pixmap maps onto the rect pixel for pixel, each one on a pixel of the output. + ctx.reset_paint_transform(); + let sampler = peniko::ImageSampler { quality: peniko::ImageQuality::Low, ..Default::default() }; + ctx.set_paint(vello_cpu::Image { image: vello_cpu::ImageSource::Pixmap(Arc::new(pm)), sampler }); + ctx.fill_rect(&Rect::new(0.0, 0.0, f64::from(w), f64::from(h))); + ctx.set_transform(Affine::IDENTITY); + true + } + /// Paint some of type `n`'s own fills and strokes (`items`) on its glyph outlines `all` /// (document space; `tb` their bounds). Fills composite with their own opacity and blend mode; /// strokes take every stroke option, as on paths. @@ -2115,6 +2242,58 @@ pub(crate) fn single_threaded_context(w: u16, h: u16) -> RenderContext { RenderContext::new_with(w, h, vello_cpu::RenderSettings { num_threads: 0, ..Default::default() }) } +/// How much of a pixel (out of 255) type that covers `c` of it shows with anti-aliasing method +/// `m`: `c` with Sharp; all of it from half coverage (127) up, else none, with None; `2c - 0.5` +/// with Crisp; and `2c` with Strong; each kept between 0 and all. +pub(crate) fn edge_coverage(m: TypeAntiAlias, c: u8) -> u8 { + let c = i32::from(c); + let shown = match m { + TypeAntiAlias::None => { + if c >= 127 { + 255 + } else { + 0 + } + } + TypeAntiAlias::Sharp => c, + TypeAntiAlias::Crisp => 2 * c - 128, + TypeAntiAlias::Strong => 2 * c, + }; + shown.clamp(0, 255) as u8 +} + +/// Where (document space) type `t` of node `n`, laid out in `g`, paints: its glyphs grown by the +/// farthest the characters' strokes reach in text space, transformed as the type is, then grown by +/// the farthest the object's own strokes reach in document space. A stroke's reach takes in its +/// miter spikes and square caps ([`StrokeLayer::reach`]). +fn type_reach(n: &Node, t: &TextObject, g: &TextGeom) -> Rect { + let chars = t.runs.iter().filter(|r| r.style.has_stroke()).map(|r| r.style.stroke_layer().reach()).fold(0.0, f64::max); + let own = n.appearance.outset(); + t.xf.transform_rect_bbox(g.bounds.inflate(chars, chars)).inflate(own, own) +} + +/// The map from a frame's pixels to the pixels [`Renderer::draw_type_edges`] draws type on, for +/// the region `on` of the frame's pixels: `grid`, which maps them to the output's, unless the +/// output's pixels there would number more than the frame's and more than [`fx::PIXEL_BUDGET`], +/// and then the frame's own pixels (the identity). +fn edge_grid(on: Rect, grid: Affine) -> Affine { + let area = |r: Rect| r.width() * r.height(); + if area(grid.transform_rect_bbox(on)) > area(on).max(fx::PIXEL_BUDGET) { Affine::IDENTITY } else { grid } +} + +/// Pixel `p` of type the anti-aliasing method `m` draws, where the type covers `c` (out of 255) of +/// the pixel: its color and opacity scaled by [`edge_coverage`] of `c` divided by `c`. A pixel the +/// type covers wholly or not at all is kept. +fn edge_pixel(p: vello_cpu::color::PremulRgba8, c: u8, m: TypeAntiAlias) -> vello_cpu::color::PremulRgba8 { + if c == 0 || c == 255 { + return p; + } + let (to, c) = (u32::from(edge_coverage(m, c)), u32::from(c)); + let scale = |v: u8| ((u32::from(v) * to + c / 2) / c).min(255) as u8; + let a = scale(p.a); + vello_cpu::color::PremulRgba8 { r: scale(p.r).min(a), g: scale(p.g).min(a), b: scale(p.b).min(a), a } +} + /// Exactly one visible painted item, a fill with its own Normal blend and full opacity (one /// paint operation, so an object blend mode can be applied per draw). fn single_plain_fill(n: &Node) -> bool { @@ -2459,3 +2638,5 @@ mod tests_setup; mod tests_strokegradient; #[cfg(test)] mod tests_tileedge; +#[cfg(test)] +mod tests_typeedges; diff --git a/crates/render/src/pattern.rs b/crates/render/src/pattern.rs index d1605377a4..4eb34853c1 100644 --- a/crates/render/src/pattern.rs +++ b/crates/render/src/pattern.rs @@ -181,6 +181,7 @@ fn rasterize(r: &mut Renderer, doc: &Document, def: &PatternDef, region: Rect, w px: 1.0 / v.determinant().abs().sqrt().max(1e-12), opts: &opts, ink, + output: v, }; for a in &def.art { r.draw_arc(&mut ctx, &frame, a); @@ -311,7 +312,8 @@ impl Renderer { let comp = crate::group::Composite { opacity: dim, ..Default::default() }; self.group(ctx, f, comp, &mut |r, c, fr| { for o in def.preview_offsets() { - let frame = Frame { view: fr.view * Affine::translate(o), visible: fr.visible - o, ..*fr }; + let moved = Affine::translate(o); + let frame = Frame { view: fr.view * moved, visible: fr.visible - o, output: fr.output * moved, ..*fr }; for a in &children { r.draw_arc(c, &frame, a); } diff --git a/crates/render/src/placed_document.rs b/crates/render/src/placed_document.rs index b207e2c490..3f8641c41c 100644 --- a/crates/render/src/placed_document.rs +++ b/crates/render/src/placed_document.rs @@ -293,7 +293,7 @@ impl Renderer { // The art in its own coordinates, with its own resources. let symbols = e.doc.symbol_boxes().quick(); let visible = m.inverse().transform_rect_bbox(f.visible); - let inner = Frame { doc: &e.doc, symbols: &symbols, view: f.view * m, visible, px: f.px / k, ..*f }; + let inner = Frame { doc: &e.doc, symbols: &symbols, view: f.view * m, visible, px: f.px / k, output: f.output * m, ..*f }; let _inside = Inside::enter(); self.draw_arc(ctx, &inner, &e.art); } diff --git a/crates/render/src/tests_typeedges.rs b/crates/render/src/tests_typeedges.rs new file mode 100644 index 0000000000..bba22620c8 --- /dev/null +++ b/crates/render/src/tests_typeedges.rs @@ -0,0 +1,291 @@ +//! Character panel › Anti-aliasing Method: type drawn as pixels with None, Sharp, Crisp or Strong +//! edges. + +use vectorcraft_color::{Color, Paint}; +use vectorcraft_doc::text::{CharStyle, InlineArt, TextObject, TextRun, TypeAntiAlias}; +use vectorcraft_doc::{Appearance, AppearanceItem, FillLayer, Node, NodeKind, TextKind}; +use vectorcraft_geom::Point; +use vectorcraft_geom::shapes::rectangle; + +use super::*; + +const W: u32 = 260; +const H: u32 = 90; + +/// A page with "Hamburg" in 40 pt black type drawn with `method`, and `fill` as the type object's +/// own fill (instead of the characters' fill) when given. +fn page(method: TypeAntiAlias, fill: Option) -> Document { + let mut d = Document::new(f64::from(W), f64::from(H)); + let mut t = TextObject::point(Point::new(10.3, 60.4), "Hamburg", CharStyle { size: 40.0, ..Default::default() }); + t.anti_alias = method; + let mut n = Node::new(d.alloc_id(), NodeKind::Text(Box::new(t))); + if let Some(fl) = fill { + if let NodeKind::Text(t) = &mut n.kind { + t.runs[0].style.fill = Paint::None; + } + n.appearance.items.push(AppearanceItem::Fill(fl)); + } + let l = d.layers[0].id; + d.insert(Some(l), 0, n).unwrap(); + d +} + +/// The opacity of each pixel of `d` drawn with `opts` on a transparent page. +fn alphas(d: &Document, opts: &RenderOptions) -> Vec { + Renderer::new().render(d, W, H, Affine::IDENTITY, opts).pixels.chunks(4).map(|p| p[3]).collect() +} + +/// Pixels the type covers only in part. +fn partial(a: &[u8]) -> usize { + a.iter().filter(|v| **v > 0 && **v < 255).count() +} + +/// On the canvas each method gives every pixel the share of it that `edge_coverage` gives the +/// share Sharp draws: None paints whole pixels from half coverage up, Crisp doubles the contrast +/// around half coverage, and Strong doubles the coverage. +#[test] +fn each_method_draws_the_share_of_each_pixel_it_defines() { + let opts = RenderOptions::default(); + let sharp = alphas(&page(TypeAntiAlias::Sharp, None), &opts); + assert!(partial(&sharp) > 200, "Sharp draws smooth edges: {} partial pixels", partial(&sharp)); + for m in [TypeAntiAlias::None, TypeAntiAlias::Crisp, TypeAntiAlias::Strong] { + let drawn = alphas(&page(m, None), &opts); + assert_ne!(drawn, sharp, "{m:?} changes the edges"); + let wrong: Vec<_> = sharp + .iter() + .zip(&drawn) + .enumerate() + .filter(|(_, (c, v))| { + // One step of rounding either way in the coverage. + let near = [c.saturating_sub(1), **c, c.saturating_add(1)].map(|c| edge_coverage(m, c)); + let (lo, hi) = (near.iter().min().copied().unwrap_or(0), near.iter().max().copied().unwrap_or(255)); + **v < lo.saturating_sub(1) || **v > hi.saturating_add(1) + }) + .map(|(i, (c, v))| (i, *c, *v)) + .take(5) + .collect(); + assert!(wrong.is_empty(), "{m:?}: (pixel, Sharp, drawn) {wrong:?}"); + } + assert_eq!(partial(&alphas(&page(TypeAntiAlias::None, None), &opts)), 0, "None paints whole pixels"); +} + +/// A raster export draws its art at several times its size and averages it down; type with None +/// still has hard edges on the exported pixels, Crisp has fewer partly covered pixels than Sharp, +/// and Strong draws heavier type. +#[test] +fn an_exports_pixels_take_the_method() { + let opts = RenderOptions { supersample: true, precise: true, ..Default::default() }; + let sharp = alphas(&page(TypeAntiAlias::Sharp, None), &opts); + let none = alphas(&page(TypeAntiAlias::None, None), &opts); + let crisp = alphas(&page(TypeAntiAlias::Crisp, None), &opts); + let strong = alphas(&page(TypeAntiAlias::Strong, None), &opts); + assert!(partial(&sharp) > 200, "the export smooths Sharp type: {}", partial(&sharp)); + assert_eq!(partial(&none), 0, "None type has hard edges in the export"); + assert!(none.iter().filter(|v| **v == 255).count() > 1000); + assert!(partial(&crisp) < partial(&sharp), "Crisp: {} partial pixels, Sharp {}", partial(&crisp), partial(&sharp)); + let ink = |a: &[u8]| a.iter().map(|v| u64::from(*v)).sum::(); + assert!(ink(&strong) > ink(&sharp), "Strong draws heavier type"); +} + +/// A frame drawn with hard edges (Anti-aliased Artwork off, an export's Anti-aliasing None) draws +/// the type of every method as it draws Sharp type. +#[test] +fn hard_edged_frames_draw_every_method_alike() { + for opts in [ + RenderOptions { anti_alias: AntiAlias::None, ..Default::default() }, + RenderOptions { anti_alias: AntiAlias::None, precise: true, supersample: true, ..Default::default() }, + ] { + let sharp = alphas(&page(TypeAntiAlias::Sharp, None), &opts); + assert_eq!(partial(&sharp), 0); + for m in [TypeAntiAlias::None, TypeAntiAlias::Crisp, TypeAntiAlias::Strong] { + assert_eq!(alphas(&page(m, None), &opts), sharp, "{m:?}"); + } + } +} + +/// Crisp and Strong reshape the edges of type that is drawn with a translucent fill, and keep +/// its opacity inside the characters; None keeps it too. +#[test] +fn the_methods_keep_the_opacity_of_the_paint() { + let half = FillLayer { opacity: 0.5, ..FillLayer::new(Paint::solid(Color::BLACK)) }; + let opts = RenderOptions::default(); + let sharp = alphas(&page(TypeAntiAlias::Sharp, Some(half.clone())), &opts); + let most = |a: &[u8]| a.iter().copied().max().unwrap_or(0); + assert!((126..=129).contains(&most(&sharp)), "half opaque: {}", most(&sharp)); + for m in [TypeAntiAlias::None, TypeAntiAlias::Crisp, TypeAntiAlias::Strong] { + let drawn = alphas(&page(m, Some(half.clone())), &opts); + assert!((126..=129).contains(&most(&drawn)), "{m:?} keeps the fill's opacity: {}", most(&drawn)); + assert_ne!(drawn, sharp, "{m:?} changes the edges"); + } +} + +/// Type whose own fill multiplies with the art below it is drawn as Sharp draws it, so the fill +/// still multiplies with that art. +#[test] +fn type_with_a_blended_fill_is_drawn_as_sharp() { + let multiply = FillLayer { blend: vectorcraft_color::BlendMode::Multiply, ..FillLayer::new(Paint::solid(Color::rgb(0.0, 0.0, 1.0))) }; + let on_orange = |m: TypeAntiAlias| { + let mut d = page(m, Some(multiply.clone())); + let orange = Appearance::basic(Paint::solid(Color::rgb(1.0, 0.5, 0.0)), Paint::None, 0.0); + let back = Node::path(d.alloc_id(), rectangle(Rect::new(0.0, 0.0, f64::from(W), f64::from(H))), orange); + let l = d.layers[0].id; + d.insert(Some(l), 0, back).unwrap(); + Renderer::new().render(&d, W, H, Affine::IDENTITY, &RenderOptions::default()).pixels + }; + let sharp = on_orange(TypeAntiAlias::Sharp); + for m in [TypeAntiAlias::None, TypeAntiAlias::Crisp, TypeAntiAlias::Strong] { + assert_eq!(on_orange(m), sharp, "{m:?}"); + } +} + +/// Type with an inline graphic is drawn as Sharp draws it, so the graphic, which multiplies with +/// the art below it here, is drawn as before. +#[test] +fn type_with_an_inline_graphic_is_drawn_as_sharp() { + let on_orange = |m: TypeAntiAlias| { + let mut d = Document::new(f64::from(W), f64::from(H)); + let orange = Appearance::basic(Paint::solid(Color::rgb(1.0, 0.5, 0.0)), Paint::None, 0.0); + let back = Node::path(d.alloc_id(), rectangle(Rect::new(0.0, 0.0, f64::from(W), f64::from(H))), orange); + let blue = Appearance::basic(Paint::solid(Color::rgb(0.0, 0.0, 1.0)), Paint::None, 0.0); + let mut art = Node::path(d.alloc_id(), rectangle(Rect::new(0.0, -30.0, 30.0, 0.0)), blue); + art.blend = vectorcraft_color::BlendMode::Multiply; + d.symbols.push(vectorcraft_doc::Symbol { name: "S".into(), art: Arc::new(art) }); + let style = CharStyle { size: 40.0, ..Default::default() }; + let mut t = TextObject::point(Point::new(10.3, 60.4), "I", style.clone()); + t.runs.push(TextRun::inline(InlineArt::new("S"), style)); + t.anti_alias = m; + let n = Node::new(d.alloc_id(), NodeKind::Text(Box::new(t))); + let l = d.layers[0].id; + d.insert(Some(l), 0, back).unwrap(); + d.insert(Some(l), 1, n).unwrap(); + d.resolve_inline_art(); + Renderer::new().render(&d, W, H, Affine::IDENTITY, &RenderOptions::default()).pixels + }; + let sharp = on_orange(TypeAntiAlias::Sharp); + for m in [TypeAntiAlias::None, TypeAntiAlias::Crisp, TypeAntiAlias::Strong] { + assert_eq!(on_orange(m), sharp, "{m:?}"); + } +} + +/// In a supersampled export, None type in a group drawn offscreen (it isolates art that blends) +/// still has hard edges on the exported pixels, wherever the group's layer starts. +#[test] +fn type_in_an_offscreen_group_keeps_the_exports_pixels() { + let opts = RenderOptions { supersample: true, precise: true, ..Default::default() }; + for x in [10.3, 10.55, 10.8, 11.05] { + let mut d = Document::new(f64::from(W), f64::from(H)); + let mut t = TextObject::point(Point::new(x, 60.4), "Hamburg", CharStyle { size: 40.0, ..Default::default() }); + t.anti_alias = TypeAntiAlias::None; + let text = Node::new(d.alloc_id(), NodeKind::Text(Box::new(t))); + let black = Appearance::basic(Paint::solid(Color::BLACK), Paint::None, 0.0); + let mut square = Node::path(d.alloc_id(), rectangle(Rect::new(220.0, 10.0, 250.0, 30.0)), black); + square.blend = vectorcraft_color::BlendMode::Multiply; + let mut group = Node::group(d.alloc_id(), vec![Arc::new(square), Arc::new(text)]); + group.isolate = true; + let l = d.layers[0].id; + d.insert(Some(l), 0, group).unwrap(); + let a = alphas(&d, &opts); + assert!(a.iter().filter(|v| **v == 255).count() > 1000, "x {x}"); + assert_eq!(partial(&a), 0, "x {x}: None type has hard edges"); + } +} + +/// Type with a brush stroke of its own is drawn as Sharp draws it. +#[test] +fn type_with_a_brush_stroke_is_drawn_as_sharp() { + let draw = |m: TypeAntiAlias| { + let mut d = page(m, None); + let id = d.layers[0].children().and_then(|c| c.first()).map(|n| n.id).unwrap(); + let n = d.node_mut(id).unwrap(); + let mut st = vectorcraft_doc::StrokeLayer::new(Paint::solid(Color::rgb(0.0, 0.0, 1.0)), 2.0); + st.brush = Some("Charcoal".into()); + n.appearance.items.push(AppearanceItem::Stroke(st)); + alphas(&d, &RenderOptions::default()) + }; + let sharp = draw(TypeAntiAlias::Sharp); + for m in [TypeAntiAlias::None, TypeAntiAlias::Crisp, TypeAntiAlias::Strong] { + assert_eq!(draw(m), sharp, "{m:?}"); + } +} + +/// On screen, type in a font the system doesn't have draws as Sharp draws it while Document +/// Setup highlights substituted fonts, and with its method when nothing is highlighted. +#[test] +fn type_with_a_substitution_highlight_is_drawn_as_sharp() { + let draw = |m: TypeAntiAlias, highlight: bool| { + let mut d = Document::new(f64::from(W), f64::from(H)); + d.setup.highlight_substituted_fonts = true; + let style = CharStyle { size: 40.0, font_family: "No Such Family 7Q".into(), ..Default::default() }; + let mut t = TextObject::point(Point::new(10.3, 60.4), "Hamburg", style); + t.anti_alias = m; + let n = Node::new(d.alloc_id(), NodeKind::Text(Box::new(t))); + let l = d.layers[0].id; + d.insert(Some(l), 0, n).unwrap(); + alphas(&d, &RenderOptions { highlight_substitutions: highlight, ..Default::default() }) + }; + assert_eq!(draw(TypeAntiAlias::Strong, true), draw(TypeAntiAlias::Sharp, true)); + assert_ne!(draw(TypeAntiAlias::Strong, false), draw(TypeAntiAlias::Sharp, false)); +} + +/// The type is drawn on the output's pixels unless, in a frame coarser than the output, they +/// would number more than the frame's there and more than the effects' pixel budget. +#[test] +fn type_edges_are_drawn_on_at_most_the_budgets_pixels() { + let finer = Affine::scale(0.25); + assert_eq!(edge_grid(Rect::new(0.0, 0.0, 4000.0, 4000.0), finer), finer, "a supersampled export's pixels"); + let coarser = Affine::scale(4.0); + assert_eq!(edge_grid(Rect::new(0.0, 0.0, 100.0, 100.0), coarser), coarser, "160,000 pixels"); + assert_eq!(edge_grid(Rect::new(0.0, 0.0, 1000.0, 1000.0), coarser), Affine::IDENTITY, "16 million pixels"); +} + +/// The offscreen drawing takes in the farthest a stroke reaches: a mitered character stroke's +/// spikes up to its miter limit, transformed with the type (stretched along one axis too), and +/// the object's own strokes. +#[test] +fn the_offscreen_drawing_takes_in_the_strokes_reach() { + let style = CharStyle { + stroke: Paint::solid(Color::BLACK), + stroke_width: 4.0, + stroke_join: vectorcraft_doc::LineJoin::Miter, + stroke_miter_limit: 10.0, + ..Default::default() + }; + let mut t = TextObject::point(Point::new(0.0, 0.0), "V", style); + let n = |t: &TextObject| Node::new(NodeId(1), NodeKind::Text(Box::new(t.clone()))); + // How far the reach goes past the transformed glyph bounds on the left and on the top. + let past = |n: &Node, t: &TextObject| { + let g = text_geom(t); + let (r, b) = (type_reach(n, t, &g), t.xf.transform_rect_bbox(g.bounds)); + (b.x0 - r.x0, b.y0 - r.y0) + }; + let near = |(a, b): (f64, f64), (x, y): (f64, f64)| (a - x).abs() < 1e-6 && (b - y).abs() < 1e-6; + assert!(near(past(&n(&t), &t), (20.0, 20.0)), "half the weight times the miter limit: {:?}", past(&n(&t), &t)); + t.xf = Affine::scale_non_uniform(10.0, 0.1); + assert!(near(past(&n(&t), &t), (200.0, 2.0)), "stretched with the type: {:?}", past(&n(&t), &t)); + t.xf = Affine::scale(2.0); + let mut own = n(&t); + let mut st = vectorcraft_doc::StrokeLayer::new(Paint::solid(Color::BLACK), 10.0); + st.miter_limit = 10.0; + own.appearance.items.push(AppearanceItem::Stroke(st)); + assert!(near(past(&own, &t), (90.0, 90.0)), "the object's own stroke after the characters': {:?}", past(&own, &t)); +} + +/// Area type draws its punctuation outside its frame with Optical Margin Alignment, and None type +/// draws it there too. +#[test] +fn type_outside_its_frame_is_drawn() { + let page = |m: TypeAntiAlias| { + let mut d = Document::new(f64::from(W), f64::from(H)); + let mut t = TextObject::point(Point::new(0.0, 0.0), "“Quoted.”", CharStyle { size: 30.0, ..Default::default() }); + t.kind = TextKind::Area { frame: rectangle(Rect::new(40.0, 10.0, 240.0, 80.0)) }; + t.optical_margin = true; + t.anti_alias = m; + let n = Node::new(d.alloc_id(), NodeKind::Text(Box::new(t))); + let l = d.layers[0].id; + d.insert(Some(l), 0, n).unwrap(); + alphas(&d, &RenderOptions::default()) + }; + let outside = |a: &[u8]| a.iter().enumerate().filter(|(i, v)| (*i as u32 % W) < 39 && **v > 0).count(); + assert!(outside(&page(TypeAntiAlias::Sharp)) > 20, "the opening quotation mark hangs outside the frame"); + assert!(outside(&page(TypeAntiAlias::None)) > 20, "None type draws it there too"); +} diff --git a/crates/ui-egui/src/i18n/bg.tsv b/crates/ui-egui/src/i18n/bg.tsv index f130be20ee..d38be0cca4 100644 --- a/crates/ui-egui/src/i18n/bg.tsv +++ b/crates/ui-egui/src/i18n/bg.tsv @@ -171,6 +171,10 @@ Anti-Alias Заглаждане Anti-alias Rasters Заглаждане на растери Anti-aliased Artwork Загладено произведение + Anti-aliasing Method Метод на заглаждане + Sharp Остро + Crisp Четко + Strong Силно Anti-aliasing: Заглаждане: Any Kind Всякакъв вид Appearance Външен вид diff --git a/crates/ui-egui/src/i18n/cs.tsv b/crates/ui-egui/src/i18n/cs.tsv index 07457b69f2..437f8a4fce 100644 --- a/crates/ui-egui/src/i18n/cs.tsv +++ b/crates/ui-egui/src/i18n/cs.tsv @@ -2513,6 +2513,10 @@ lighting Light Světlo < 1 sec < 1 s [Unnamed] [Bez názvu] Amount: Míra: + Anti-aliasing Method Metoda vyhlazování + Sharp Ostré + Crisp Jasné + Strong Silné Anti-aliasing: Vyhlazování: Artboard: Pracovní plocha: Color Table Tabulka barev diff --git a/crates/ui-egui/src/i18n/da.tsv b/crates/ui-egui/src/i18n/da.tsv index 09a0e61f30..c2c0c3ff0c 100644 --- a/crates/ui-egui/src/i18n/da.tsv +++ b/crates/ui-egui/src/i18n/da.tsv @@ -2333,6 +2333,10 @@ lighting Light Lys < 1 sec < 1 sek [Unnamed] [Unavngiven] Amount: Mængde: + Anti-aliasing Method Antialiaseringsmetode + Sharp Skarp + Crisp Klar + Strong Kraftig Anti-aliasing: Antialiasering: Artboard: Tegnebræt: Color Table Farvetabel diff --git a/crates/ui-egui/src/i18n/de.tsv b/crates/ui-egui/src/i18n/de.tsv index e749b67484..c88c5c8c6f 100644 --- a/crates/ui-egui/src/i18n/de.tsv +++ b/crates/ui-egui/src/i18n/de.tsv @@ -167,6 +167,10 @@ Anti-Alias Glätten Anti-alias Rasters Pixelbilder glätten Anti-aliased Artwork Geglättete Grafik + Anti-aliasing Method Glättungsmethode + Sharp Scharf + Crisp Klar + Strong Stark Anti-aliasing: Glättung: Any Kind Beliebige Art Appearance Aussehen diff --git a/crates/ui-egui/src/i18n/el.tsv b/crates/ui-egui/src/i18n/el.tsv index 040e0be390..c4a886223b 100644 --- a/crates/ui-egui/src/i18n/el.tsv +++ b/crates/ui-egui/src/i18n/el.tsv @@ -165,6 +165,10 @@ Anti-Alias Εξομάλυνση ακμών Anti-alias Rasters Εξομάλυνση ακμών εικόνων ράστερ Anti-aliased Artwork Γραφικά με εξομαλυμένες ακμές + Anti-aliasing Method Μέθοδος εξομάλυνσης ακμών + Sharp Οξεία + Crisp Καθαρή + Strong Έντονη Anti-aliasing: Εξομάλυνση ακμών: Any Kind Οποιοσδήποτε τύπος Appearance Εμφάνιση diff --git a/crates/ui-egui/src/i18n/es.tsv b/crates/ui-egui/src/i18n/es.tsv index 52423a2837..6fce9e9a55 100644 --- a/crates/ui-egui/src/i18n/es.tsv +++ b/crates/ui-egui/src/i18n/es.tsv @@ -185,6 +185,10 @@ Anti-Alias Suavizado Anti-alias Rasters Suavizar imágenes ráster Anti-aliased Artwork Ilustración suavizada + Anti-aliasing Method Método de suavizado + Sharp Nítido + Crisp Definido + Strong Fuerte Anti-aliasing: Suavizado: Any Kind Cualquier tipo Appearance Apariencia diff --git a/crates/ui-egui/src/i18n/fi.tsv b/crates/ui-egui/src/i18n/fi.tsv index b7874cc13b..34df16f5b0 100644 --- a/crates/ui-egui/src/i18n/fi.tsv +++ b/crates/ui-egui/src/i18n/fi.tsv @@ -2468,6 +2468,10 @@ lighting Light Valo < 1 sec < 1 s [Unnamed] [Nimetön] Amount: Määrä: + Anti-aliasing Method Reunanpehmennystapa + Sharp Terävä + Crisp Selkeä + Strong Vahva Anti-aliasing: Reunanpehmennys: Artboard: Piirtoalusta: Color Table Väritaulukko diff --git a/crates/ui-egui/src/i18n/fr.tsv b/crates/ui-egui/src/i18n/fr.tsv index de7424c8b8..bef9e6d86b 100644 --- a/crates/ui-egui/src/i18n/fr.tsv +++ b/crates/ui-egui/src/i18n/fr.tsv @@ -186,6 +186,10 @@ Anti-Alias Lissage Anti-alias Rasters Lisser les images pixellisées Anti-aliased Artwork Illustration lissée + Anti-aliasing Method Méthode de lissage + Sharp Net + Crisp Précis + Strong Fort Anti-aliasing: Lissage : Any Kind Tout type Appearance Aspect diff --git a/crates/ui-egui/src/i18n/hu.tsv b/crates/ui-egui/src/i18n/hu.tsv index 8604228b48..58a4bd9140 100644 --- a/crates/ui-egui/src/i18n/hu.tsv +++ b/crates/ui-egui/src/i18n/hu.tsv @@ -273,6 +273,10 @@ Anti-Alias Élsimítás Anti-alias Rasters Raszterek élsimítása Anti-aliased Artwork Élsimított grafika + Anti-aliasing Method Élsimítási módszer + Sharp Éles + Crisp Tiszta + Strong Erős Anti-aliasing: Élsimítás: Any except extracting pages Bármi, kivéve az oldalak kinyerését Any Kind Bármilyen típus diff --git a/crates/ui-egui/src/i18n/id.tsv b/crates/ui-egui/src/i18n/id.tsv index 087cd8106d..e16fd7fb6a 100644 --- a/crates/ui-egui/src/i18n/id.tsv +++ b/crates/ui-egui/src/i18n/id.tsv @@ -2333,6 +2333,10 @@ lighting Light Cahaya < 1 sec < 1 detik [Unnamed] [Tanpa nama] Amount: Jumlah: + Anti-aliasing Method Metode anti-aliasing + Sharp Tajam + Crisp Jelas + Strong Kuat Anti-aliasing: Anti-aliasing: Artboard: Artboard: Color Table Tabel warna diff --git a/crates/ui-egui/src/i18n/it.tsv b/crates/ui-egui/src/i18n/it.tsv index a4105bd17c..eb14c25e9b 100644 --- a/crates/ui-egui/src/i18n/it.tsv +++ b/crates/ui-egui/src/i18n/it.tsv @@ -185,6 +185,10 @@ Anti-Alias Anti-aliasing Anti-alias Rasters Anti-aliasing per immagini raster Anti-aliased Artwork Grafica con anti-aliasing + Anti-aliasing Method Metodo di anti-aliasing + Sharp Nitido + Crisp Definito + Strong Forte Anti-aliasing: Anti-aliasing: Any Kind Qualsiasi tipo Appearance Aspetto diff --git a/crates/ui-egui/src/i18n/ja.tsv b/crates/ui-egui/src/i18n/ja.tsv index a1838bc822..7d0b26fda4 100644 --- a/crates/ui-egui/src/i18n/ja.tsv +++ b/crates/ui-egui/src/i18n/ja.tsv @@ -869,6 +869,10 @@ Anti-alias アンチエイリアス Anti-alias Rasters ラスターをアンチエイリアス Anti-aliased Artwork アートワークをアンチエイリアス + Anti-aliasing Method アンチエイリアスの方式 + Sharp シャープ + Crisp くっきり + Strong 太め Anti-aliasing: アンチエイリアス: Appearance Item アピアランス項目 Appearance Panel アピアランスパネル diff --git a/crates/ui-egui/src/i18n/ko.tsv b/crates/ui-egui/src/i18n/ko.tsv index 200bbf6f93..33d75e115a 100644 --- a/crates/ui-egui/src/i18n/ko.tsv +++ b/crates/ui-egui/src/i18n/ko.tsv @@ -864,6 +864,10 @@ Anti-alias 안티 앨리어싱 Anti-alias Rasters 래스터 안티 앨리어싱 Anti-aliased Artwork 안티 앨리어싱 아트워크 + Anti-aliasing Method 안티 앨리어싱 방식 + Sharp 날카롭게 + Crisp 또렷하게 + Strong 강하게 Anti-aliasing: 안티 앨리어싱: Appearance Item 모양새 항목 Appearance Panel 모양새 패널 diff --git a/crates/ui-egui/src/i18n/nl.tsv b/crates/ui-egui/src/i18n/nl.tsv index c274c26b50..66a35fc7e7 100644 --- a/crates/ui-egui/src/i18n/nl.tsv +++ b/crates/ui-egui/src/i18n/nl.tsv @@ -2347,6 +2347,10 @@ lighting Light Licht < 1 sec < 1 sec [Unnamed] [Naamloos] Amount: Hoeveelheid: + Anti-aliasing Method Anti-aliasingmethode + Sharp Scherp + Crisp Helder + Strong Krachtig Anti-aliasing: Anti-aliasing: Artboard: Tekenbord: Color Table Kleurentabel diff --git a/crates/ui-egui/src/i18n/pl.tsv b/crates/ui-egui/src/i18n/pl.tsv index 5b0fc49d7d..5e49ecde4d 100644 --- a/crates/ui-egui/src/i18n/pl.tsv +++ b/crates/ui-egui/src/i18n/pl.tsv @@ -2421,6 +2421,10 @@ lighting Light Światło < 1 sec < 1 s [Unnamed] [Bez nazwy] Amount: Wartość: + Anti-aliasing Method Metoda wygładzania + Sharp Ostre + Crisp Wyraźne + Strong Mocne Anti-aliasing: Wygładzanie: Artboard: Obszar kompozycji: Color Table Tablica kolorów diff --git a/crates/ui-egui/src/i18n/pt-br.tsv b/crates/ui-egui/src/i18n/pt-br.tsv index c40928a8be..4598bc59e9 100644 --- a/crates/ui-egui/src/i18n/pt-br.tsv +++ b/crates/ui-egui/src/i18n/pt-br.tsv @@ -190,6 +190,10 @@ Anti-alias Suavizar bordas Anti-alias Rasters Suavizar Rasters Anti-aliased Artwork Arte Suavizada + Anti-aliasing Method Método de Suavização + Sharp Nítido + Crisp Definido + Strong Forte Anti-aliasing: Suavização: Appearance Aparência Appearance Attribute Atributo de Aparência diff --git a/crates/ui-egui/src/i18n/ru.tsv b/crates/ui-egui/src/i18n/ru.tsv index 8bceed9ae4..4b91ae41af 100644 --- a/crates/ui-egui/src/i18n/ru.tsv +++ b/crates/ui-egui/src/i18n/ru.tsv @@ -183,6 +183,10 @@ Anti-Alias Сглаживание Anti-alias Rasters Сглаживать растровые изображения Anti-aliased Artwork Сглаженный рисунок + Anti-aliasing Method Метод сглаживания + Sharp Резкое + Crisp Чёткое + Strong Сильное Anti-aliasing: Сглаживание: Any Kind Любой тип Appearance Оформление diff --git a/crates/ui-egui/src/i18n/sv.tsv b/crates/ui-egui/src/i18n/sv.tsv index 4da768cf82..65122579f7 100644 --- a/crates/ui-egui/src/i18n/sv.tsv +++ b/crates/ui-egui/src/i18n/sv.tsv @@ -173,6 +173,10 @@ Anti-Alias Kantutjämning Anti-alias Rasters Kantutjämna rasterbilder Anti-aliased Artwork Kantutjämnad grafik + Anti-aliasing Method Kantutjämningsmetod + Sharp Skarp + Crisp Klar + Strong Kraftig Anti-aliasing: Kantutjämning: Any Kind Alla typer Appearance Utseende diff --git a/crates/ui-egui/src/i18n/uk.tsv b/crates/ui-egui/src/i18n/uk.tsv index 07d5afdcfc..816247930a 100644 --- a/crates/ui-egui/src/i18n/uk.tsv +++ b/crates/ui-egui/src/i18n/uk.tsv @@ -179,6 +179,10 @@ Anti-Alias Згладжування Anti-alias Rasters Згладжувати растрові зображення Anti-aliased Artwork Згладжений малюнок + Anti-aliasing Method Метод згладжування + Sharp Різке + Crisp Чітке + Strong Сильне Anti-aliasing: Згладжування: Any Kind Будь-який тип Appearance Оформлення diff --git a/crates/ui-egui/src/i18n/vi.tsv b/crates/ui-egui/src/i18n/vi.tsv index 98bee0dd7e..575ba3e8d3 100644 --- a/crates/ui-egui/src/i18n/vi.tsv +++ b/crates/ui-egui/src/i18n/vi.tsv @@ -209,6 +209,10 @@ Anti-Alias Khử răng cưa Anti-alias Rasters Khử răng cưa ảnh raster Anti-aliased Artwork Tác phẩm khử răng cưa + Anti-aliasing Method Phương pháp khử răng cưa + Sharp Sắc nét + Crisp Rõ nét + Strong Đậm Anti-aliasing: Khử răng cưa: Any Kind Bất kỳ loại Appearance Diện mạo diff --git a/crates/ui-egui/src/i18n/zh-hans.tsv b/crates/ui-egui/src/i18n/zh-hans.tsv index 9b758330d6..dbdbd2315d 100644 --- a/crates/ui-egui/src/i18n/zh-hans.tsv +++ b/crates/ui-egui/src/i18n/zh-hans.tsv @@ -141,6 +141,10 @@ Anti-Alias 消除锯齿 Anti-alias Rasters 栅格消除锯齿 Anti-aliased Artwork 图稿抗锯齿 + Anti-aliasing Method 消除锯齿方法 + Sharp 锐利 + Crisp 清晰 + Strong 强 Anti-aliasing: 消除锯齿: Appearance 外观 Appearance Attribute 外观属性 diff --git a/crates/ui-egui/src/i18n/zh-hant.tsv b/crates/ui-egui/src/i18n/zh-hant.tsv index 866f28e1ca..ebc897bbce 100644 --- a/crates/ui-egui/src/i18n/zh-hant.tsv +++ b/crates/ui-egui/src/i18n/zh-hant.tsv @@ -150,6 +150,10 @@ Anti-Alias 消除鋸齒 Anti-alias Rasters 點陣影像消除鋸齒 Anti-aliased Artwork 消除鋸齒的圖稿 + Anti-aliasing Method 消除鋸齒方法 + Sharp 銳利 + Crisp 清晰 + Strong 強 Anti-aliasing: 消除鋸齒: Any Kind 所有類型 Appearance 外觀 diff --git a/crates/ui-egui/src/panels/character.rs b/crates/ui-egui/src/panels/character.rs index fdad86744b..883c393543 100644 --- a/crates/ui-egui/src/panels/character.rs +++ b/crates/ui-egui/src/panels/character.rs @@ -1,11 +1,12 @@ //! Character panel: font family / style, size, leading, kerning, tracking, vertical and horizontal -//! scale, baseline shift, character rotation and the All Caps, Small Caps, Superscript, Subscript, -//! Underline and Strikethrough toggles. +//! scale, baseline shift, character rotation, the anti-aliasing method and the All Caps, Small Caps, +//! Superscript, Subscript, Underline and Strikethrough toggles. use std::sync::{Mutex, OnceLock}; use egui::{Key, Ui, vec2}; use serde_json::{Value, json}; +use vectorcraft_doc::text::TypeAntiAlias; use vectorcraft_doc::{CharStyle, NodeId, NodeKind}; use vectorcraft_tools::{Mods, ToolKey}; @@ -452,9 +453,10 @@ 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. +/// The Character panel's grid of fields, `w` wide: Font Size, Leading, Kerning and Tracking, +/// then with `more` the scales, Baseline Shift and Character Rotation, and a row with the +/// Anti-aliasing Method dropdown under Character Rotation (`s`: the style shown). 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(); @@ -507,9 +509,61 @@ pub(crate) fn metrics_grid(app: &mut VectorcraftApp, ui: &mut Ui, id: &str, s: & } }); ui.end_row(); + // Anti-aliasing Method, under Character Rotation. + ui.label(""); + cell(ui, "aa", tl!("Anti-aliasing Method"), |ui| anti_alias_method(app, ui, fw)); + ui.end_row(); }); } +/// The Anti-aliasing Method dropdown, `width` wide: how the edges of the type objects the panel acts +/// on are drawn as pixels ([`set_anti_alias`]), blank where their methods differ. +fn anti_alias_method(app: &mut VectorcraftApp, ui: &mut Ui, width: f32) { + // The labels of [`TypeAntiAlias::ALL`], in its order. + let labels = [tl!("None"), tl!("Sharp"), tl!("Crisp"), tl!("Strong")]; + let current = shared_anti_alias(app); + let shown = TypeAntiAlias::ALL.iter().zip(labels).find(|(m, _)| Some(**m) == current).map_or("", |(_, l)| l); + if let Some(m) = widgets::dropdown_names(ui, "ch-anti-alias", shown, &labels, width).and_then(|i| TypeAntiAlias::ALL.get(i)) { + set_anti_alias(app, *m); + } +} + +/// The anti-aliasing method of the type objects the panel acts on: the one the Type tool edits, +/// else the selected type (groups' included). `None` where they differ, or with no type. +fn shared_anti_alias(app: &VectorcraftApp) -> Option { + let st = app.session.active()?; + let mut acc = Shared::None; + match text_editing(app) { + Some((id, _, _)) => { + if let Some(NodeKind::Text(t)) = st.doc.node(id).map(|n| &n.kind) { + acc.add(t.anti_alias); + } + } + None => { + 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 { + acc.add(t.anti_alias); + } + }); + } + } + } + acc.value() +} + +/// Set the anti-aliasing method `m` of the type objects the panel acts on, and of every frame of +/// their threads (`text.setFormat {antiAlias}`): the one the Type tool edits, whatever part of its +/// text is selected, else the selected type. +fn set_anti_alias(app: &mut VectorcraftApp, m: TypeAntiAlias) { + let mut p = json!({"antiAlias": m.id()}); + if let Some((id, _, _)) = text_editing(app) { + end_typing(app); + p["ids"] = json!([id.0]); + } + app.run("text.setFormat", p).ok(); +} + /// The Font Size combo (Character and Properties panels, Control bar): typed, stepped, scrubbed or /// picked from the presets, in the type unit, blank where the selected text's sizes differ. pub(crate) fn size_field(app: &mut VectorcraftApp, ui: &mut Ui, id: &str, width: f32) { @@ -805,12 +859,16 @@ pub fn menu(app: &mut VectorcraftApp, ui: &mut Ui) { } } -/// Reset Panel: the characters the panel acts on ([`char_cmd`]) take tracking 0, Auto leading and +/// Reset Panel, in one undo step: the type objects the panel acts on take the Sharp anti-aliasing +/// method, and the characters the panel acts on ([`char_cmd`]) take tracking 0, Auto leading and /// 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 off and the Default digits, whether or not Show /// East Asian Options and Show Indic Options are on. fn reset_panel(app: &mut VectorcraftApp) { + // Typing in progress stays an undo step of its own. + end_typing(app); + app.session.begin_named_undo_group(Some("Reset Panel".into())); let d = CharStyle::default(); style(app, json!({"tracking": 0, "leading": "auto"})); format( @@ -821,6 +879,8 @@ fn reset_panel(app: &mut VectorcraftApp) { "proportionalMetrics": d.proportional_metrics, "digits": d.digits }), ); + set_anti_alias(app, TypeAntiAlias::Sharp); + app.session.end_undo_group(false); } #[cfg(test)] @@ -1126,6 +1186,56 @@ mod tests { } } + /// The Anti-aliasing Method dropdown under Character Rotation shows the method of the selected + /// type, blank where the selected objects differ, and a pick sets it for the whole object, also + /// while the Type tool has part of its text selected. Reset Panel sets Sharp. + #[test] + fn the_anti_aliasing_method_dropdown_shows_and_sets_the_types_method() { + use super::super::tests_appearance::{click, frame_events, text_rect}; + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + app.run("file.new", json!({"width": 300, "height": 200})).unwrap(); + let a = app.run("text.create", json!({"x": 20, "y": 50, "text": "Hamburg", "size": 12})).unwrap()["id"].as_u64().unwrap(); + let method = |app: &VectorcraftApp, id: u64| match &app.session.active().unwrap().doc.node(NodeId(id)).unwrap().kind { + NodeKind::Text(t) => t.anti_alias, + _ => panic!("text"), + }; + let ctx = egui::Context::default(); + let texts = |app: &mut VectorcraftApp| frame_events(&ctx, app, vec![], show); + let shown = |app: &mut VectorcraftApp| { + texts(app).into_iter().map(|(t, _)| t).filter(|t| ["None", "Sharp", "Crisp", "Strong"].contains(&t.as_str())).collect::>() + }; + assert_eq!(shown(&mut app), ["Sharp"]); + // Open the dropdown and pick Crisp. + let at = text_rect(&texts(&mut app), "Sharp").center(); + click(&ctx, &mut app, at, show); + let listed = texts(&mut app); + let crisp = text_rect(&listed, "Crisp").center(); + click(&ctx, &mut app, crisp, show); + assert_eq!(method(&app, a), TypeAntiAlias::Crisp); + assert_eq!(shown(&mut app), ["Crisp"]); + // Another object with Sharp selected as well: blank. + let b = app.run("text.create", json!({"x": 20, "y": 120, "text": "Bremen", "size": 12})).unwrap()["id"].as_u64().unwrap(); + app.run("select.set", json!({"ids": [a, b]})).unwrap(); + assert!(shown(&mut app).is_empty(), "the methods differ"); + // The Type tool with two letters selected: the whole object takes the method. + app.run("select.set", json!({"ids": [a]})).unwrap(); + 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": 1, "end": 3})); + assert!(text_editing(&app).is_some()); + set_anti_alias(&mut app, TypeAntiAlias::Strong); + assert_eq!(method(&app, a), TypeAntiAlias::Strong); + assert_eq!(shared_anti_alias(&app), Some(TypeAntiAlias::Strong)); + // Reset Panel sets Sharp, with the characters' attributes, in one undo step. + range_style(&mut app, json!({"tracking": 50})); + let tracking = |app: &VectorcraftApp| shared(app, |s| s.tracking); + assert_eq!(tracking(&app), Some(50.0)); + reset_panel(&mut app); + assert_eq!((method(&app, a), tracking(&app)), (TypeAntiAlias::Sharp, Some(0.0))); + app.run("edit.undo", json!({})).unwrap(); + assert_eq!((method(&app, a), tracking(&app)), (TypeAntiAlias::Strong, Some(50.0)), "one undo step"); + } + /// Character Alignment and Proportional Metrics are in the panel menu with the East Asian /// options only (as Mojikumi Set and Top-to-Top Leading are in the Paragraph panel). #[test] diff --git a/docs/mcp.md b/docs/mcp.md index 265c55663e..e55ad44006 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -3613,6 +3613,35 @@ Setting superscript on subscript characters replaces the subscript, and setting replaces the superscript. When `document.setup {superscript, subscript, smallCapsSize}` changes the proportions, the characters take the new ones. One undo step. {"name":"run_command","arguments":{"command":"text.setFormat","params":{"position":"superscript","smallCaps":false}}} + +## Anti-aliasing method + +`text.setFormat {antiAlias: "none"|"sharp"|"crisp"|"strong"}` (the selected type, or `ids`) sets the Character panel's +Anti-aliasing Method, which decides how the edges of the characters are drawn as pixels: on the canvas, in raster exports +(Export As PNG, JPEG and the other raster formats, Export for Screens, Save for Web) and when Object › Rasterize or +Effect › Rasterize draws the type. The method belongs to the whole type object and to every frame of its thread: a +`start`/`end` range sets the whole object, threading frames gives the thread its first frame's method, and a file +whose frames of one thread differ opens with the first frame's method in all of them. VectorCraft defines what each +method does, on the pixels of the picture being drawn (the canvas at its zoom, an export at its resolution): + +- `sharp`, the default, draws each edge pixel with the share of it the type covers, as before. +- `none` draws hard edges: a pixel takes the type's color where the type covers at least half of it, and nothing + elsewhere. +- `crisp` draws a pixel the type covers by a share c at `2c - 0.5`, kept between 0 and 1, so the edges are about half as + wide. +- `strong` draws a pixel the type covers by a share c at `2c`, at most 1, so the type looks heavier. + +The opacity of the type's paints stays as it is inside the characters. Type is drawn as Sharp draws it when it holds +inline graphics, when its own fills or strokes in the Appearance panel include a brush stroke or a blend mode other than +Normal, when it overprints while Overprint Preview is on, and on the canvas while it shows a substitution highlight. +With Preferences › General › Anti-aliased Artwork off, the canvas draws all its art with hard edges, type included, and +so does an export whose own Anti-aliasing option is None. SVG, PDF and EPS export are unchanged and carry no setting. +Reset Panel sets Sharp. Saved as `antiAlias` only when not `sharp`. One undo step. + +```json +{"name":"run_command","arguments":{"command":"text.setFormat","params":{"antiAlias":"crisp"}}} +``` + ## Character Alignment `text.setFormat {charAlign}` (the selected type, or `ids`) and `text.setRangeStyle {id, start, end, charAlign}` (a range) diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 7b822f3f1e..c7db3c6ef3 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -611,6 +611,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`. - **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, 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`). 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). 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'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 Character panel's Anti-aliasing Method dropdown, under Character Rotation, sets how the edges of the selected type objects are drawn as pixels (`text.setFormat {antiAlias}`), for the whole object and every frame of its thread, on the canvas, in raster exports and when the type is rasterized. VectorCraft defines the methods: Sharp, the default, draws edges as before; None paints a pixel only where the type covers at least half of it; Crisp shows a pixel covered by a share c at `2c - 0.5`, and Strong shows it at `2c`, each kept between 0 and 1. SVG, PDF and EPS export are unchanged. - **Paragraph alignment:** the shared Paragraph/Properties alignment row leaves every button unselected when the paragraphs it acts on differ. Auto resolves per paragraph; choosing an alignment uses the existing command. Headless paragraph_mixed_alignment_* regressions exercise mixed → Center → Undo, caret/uniform states, Unicode range boundaries, Auto/direction, split runs and grouped text. These regressions and full integration CI pass. Original captures confirm mixed → Center → Undo in the existing alignment-button layout. The [public Paragraph panel guide](https://helpx.adobe.com/illustrator/desktop/design-with-text/edit-format-text/paragraph-panel-overview.html) lists the seven controls, but does not specify mixed-state rendering. Other paragraph fields remain outside this change; scores and hours unchanged. For Auto Size area type in columns, the bisection for the frame's height runs on the lines of the one-column layout, and its result is checked with at most two layouts of the columns. When the text wraps around objects or does not fit the one-column layout, or when the check fails, the columns are laid out at every step of the bisection, as before. Typing at the start of a story of 50,000 characters in 2 columns took 1,670 ms a keystroke and now takes 292 ms (release build, `auto_height_in_columns_typing_timing`). Exports to PDF, EPS, SVG, EMF, WMF and the raster formats warn about the area type and type on a path that they draw without the text that doesn't fit, and give their ids, as Print and Print Preview do.