Merge pull request #6589 from ruchirguitar/fix/6502-ai-layer-colour-index

#6502: .ai layer colour index follows the file's numbering
This commit is contained in:
BFlat
2026-11-11 20:20:00 +07:00
committed by GitHub
9 changed files with 151 additions and 35 deletions
+45
View File
@@ -525,6 +525,51 @@ fn many_objects_of_one_name_are_numbered_in_turn() {
assert!(text.contains("(Dot_00000000000000019999_)"));
}
/// A document with one top-level layer for each of the 27 layer colour presets, named after its
/// position.
fn every_layer_preset() -> Document {
let mut doc = Document::new(100.0, 100.0);
doc.layers.clear();
for i in 0..vectorcraft_doc::LAYER_COLORS.len() {
let id = doc.alloc_id();
doc.layers.push(Arc::new(Node::layer(id, &format!("P{i}"), LayerColor::Preset(u8::try_from(i).unwrap()))));
}
doc
}
/// #6502: Yellow, Magenta and Cyan (presets 4, 5 and 6) are written as the file's indexes 6, 4
/// and 5, with their own RGB beside them; every other preset keeps its position.
#[test]
fn layer_presets_are_written_with_the_files_colour_index() {
let text = program(&every_layer_preset());
for (i, (_, [r, g, b])) in vectorcraft_doc::LAYER_COLORS.iter().enumerate() {
let index = match i {
4 => 6,
5 => 4,
6 => 5,
i => i,
};
let want = format!(" {index} {r} {g} {b} 0 50 0 Lb\n(P{i}) Ln\n");
assert!(text.contains(&want), "missing {want:?}");
}
}
/// #6502: every preset comes back as itself after a save and an open.
#[test]
fn layer_presets_survive_a_round_trip() {
let doc = read_back(&every_layer_preset());
let colors: Vec<LayerColor> = doc
.layers
.iter()
.map(|l| match l.kind {
NodeKind::Layer { color, .. } => color,
_ => panic!("{:?}", l.kind),
})
.collect();
let want: Vec<LayerColor> = (0..27u8).map(LayerColor::Preset).collect();
assert_eq!(colors, want);
}
#[test]
fn layers_have_fourteen_operands() {
let text = program(&sample());
+2 -9
View File
@@ -7,7 +7,7 @@ use std::sync::Arc;
use vectorcraft_color::cms::Cms;
use vectorcraft_color::swatch::{REGISTRATION, Swatch};
use vectorcraft_color::{BlendMode, Color, Paint};
use vectorcraft_doc::{AppearanceItem, Document, FillLayer, Knockout, LAYER_COLORS, LayerColor, Node, NodeId, NodeKind, OpacityMask, StrokeLayer};
use vectorcraft_doc::{AppearanceItem, Document, FillLayer, Knockout, LayerColor, Node, NodeId, NodeKind, OpacityMask, StrokeLayer};
use vectorcraft_effects::stroke;
use vectorcraft_geom::{Affine, FillRule, PathData, Shape, SubPath};
use vectorcraft_pathops::BoolOp;
@@ -449,14 +449,7 @@ impl<'a> Writer<'a> {
/// (`Xy`, `0 0 Xd`, `7 () XW`) when it has any, `LB`, `%AI5_EndLayer--`.
fn write_layer(&mut self, n: &Node, after: &[Arc<Node>]) {
let NodeKind::Layer { color, template, printable, children, clip, preview, dim_images } = &n.kind else { return };
let [r, g, b] = match color {
LayerColor::Preset(i) => LAYER_COLORS.get(usize::from(*i)).map_or([79, 128, 255], |c| c.1),
LayerColor::Custom(rgb) => *rgb,
};
let index = match color {
LayerColor::Preset(i) if usize::from(*i) < LAYER_COLORS.len() => i64::from(*i),
_ => -1,
};
let (index, [r, g, b]) = ai_ops::layer_color(*color);
if *template {
self.warn(TEMPLATES);
}
+38 -1
View File
@@ -6,7 +6,7 @@
use std::fmt::Write;
use vectorcraft_color::{Color, Gradient};
use vectorcraft_doc::{LineCap, LineJoin, StrokeLayer};
use vectorcraft_doc::{LAYER_COLORS, LayerColor, LineCap, LineJoin, StrokeLayer};
use vectorcraft_geom::{Affine, Anchor, AnchorKind, PathData, Point, SubPath};
use crate::ps;
@@ -62,6 +62,43 @@ pub(crate) fn guide(s: &mut String, xf: Affine, path: &PathData, editing: bool)
}
}
/// The colour index an `.ai` layer's `Lb` gives preset `preset` (its position in
/// [`LAYER_COLORS`]). The file numbers Magenta, Cyan and Yellow 4, 5 and 6, where
/// [`LAYER_COLORS`] has Yellow, Magenta and Cyan; every other preset has its own position (#6502,
/// from the index and RGB pairs of the `Lb` records in files users sent). A reader takes the index
/// and ignores the RGB beside it, so the index decides the colour.
pub(crate) fn ai_layer_index(preset: u8) -> u8 {
match preset {
4 => 6,
5 => 4,
6 => 5,
i => i,
}
}
/// The preset (position in [`LAYER_COLORS`]) of the colour index `index` in an `.ai` layer's
/// `Lb`: the inverse of [`ai_layer_index`].
pub(crate) fn preset_of_ai_layer_index(index: u8) -> u8 {
match index {
6 => 4,
4 => 5,
5 => 6,
i => i,
}
}
/// The colour index and RGB an `.ai` layer's `Lb` writes for `color`: a preset's index
/// ([`ai_layer_index`]) with its RGB, or `-1` with a custom colour's RGB.
pub(crate) fn layer_color(color: LayerColor) -> (i64, [u8; 3]) {
match color {
LayerColor::Preset(i) => match LAYER_COLORS.get(usize::from(i)) {
Some((_, rgb)) => (i64::from(ai_layer_index(i)), *rgb),
None => (-1, [79, 128, 255]),
},
LayerColor::Custom(rgb) => (-1, rgb),
}
}
pub(crate) fn unit(v: f32) -> f32 {
if v.is_finite() { v.clamp(0.0, 1.0) } else { 0.0 }
}
+2 -1
View File
@@ -471,8 +471,9 @@ impl<'a> Reader<'a> {
a.dim = flag(4, false).then_some(dim_pct.round() as u8);
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);
// The file numbers some presets differently from `LAYER_COLORS` (#6502).
a.color = Some(if (0.0..vectorcraft_doc::LAYER_COLORS.len() as f64).contains(&index) && index.fract() == 0.0 {
LayerColor::Preset(index as u8)
LayerColor::Preset(crate::ai_ops::preset_of_ai_layer_index(index as u8))
} else {
LayerColor::Custom([rgb(1), rgb(2), rgb(3)])
});
+26 -2
View File
@@ -8465,20 +8465,44 @@ fn layer_with(ops: &str) -> Node {
/// the colour index and the last one change nothing.
#[test]
fn the_template_flag_of_a_layer() {
// Index 4 is the file's Magenta (#6502).
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)));
assert_eq!((dim_images, color), (Some(50), LayerColor::Preset(5)));
// Index 5 is the file's Cyan (#6502).
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}");
assert_eq!((dim_images, color), (Some(30), LayerColor::Preset(6)), "{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, .. }));
}
/// #6502: an `.ai` file numbers Magenta, Cyan and Yellow 4, 5 and 6 (the index and RGB pairs of
/// the `Lb` records in files users sent), so they open as those presets; the index decides, not
/// the RGB beside it, and the other presets keep their position.
#[test]
fn layer_colour_indexes_follow_the_file_format() {
let color = |ops: &str| match layer_with(ops).kind {
NodeKind::Layer { color, .. } => color,
_ => panic!(),
};
let preset = |name: &str| {
let i = vectorcraft_doc::LAYER_COLORS.iter().position(|(n, _)| *n == name).unwrap();
LayerColor::Preset(u8::try_from(i).unwrap())
};
for (index, rgb, name) in
[(3, "79 79 255", "Blue"), (4, "255 79 255", "Magenta"), (5, "79 255 255", "Cyan"), (6, "255 255 79", "Yellow"), (7, "128 128 128", "Gray")]
{
assert_eq!(color(&format!("1 1 1 1 0 0 0 {index} {rgb} 0 50 0")), preset(name), "index {index}");
}
// The RGB beside an index doesn't change the preset.
assert_eq!(color("1 1 1 1 0 0 0 4 255 255 79 0 50 0"), preset("Magenta"));
}
/// 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]
+2 -2
View File
@@ -65,8 +65,8 @@ use std::sync::Arc;
use vectorcraft_color::cms::Cms;
use vectorcraft_color::{BlendMode, Color, Gradient, GradientKind, GradientPaint, Paint};
use vectorcraft_doc::{
AppearanceItem, AreaOptions, CharPosition, CharStyle, CompositeFont, Document, FillLayer, ImageBlob, Justify, LAYER_COLORS, LayerColor, Node,
NodeKind, ParaDirection, ParaStyle, PathAlign, PathEffect, StrokeLayer, TextKind, TextObject,
AppearanceItem, AreaOptions, CharPosition, CharStyle, CompositeFont, Document, FillLayer, ImageBlob, Justify, Node, NodeKind, ParaDirection,
ParaStyle, PathAlign, PathEffect, StrokeLayer, TextKind, TextObject,
};
use vectorcraft_effects::stroke;
use vectorcraft_geom::{Affine, BezPath, FillRule, PathData, Rect, Shape, SubPath, Vec2};
+29
View File
@@ -940,6 +940,35 @@ fn names_outside_windows_1252_are_replaced_and_kept_apart() {
assert_eq!(inks(&read), want);
}
/// #6502: the legacy writer gives Yellow, Magenta and Cyan (presets 4, 5 and 6) the file's
/// indexes 6, 4 and 5, and they open as the same presets.
#[test]
fn layer_presets_are_written_with_the_files_colour_index() {
let mut doc = Document::new(100.0, 100.0);
doc.layers.clear();
for i in 3..8u8 {
let id = doc.alloc_id();
doc.layers.push(Arc::new(Node::layer(id, &format!("P{i}"), vectorcraft_doc::LayerColor::Preset(i))));
}
let out = write(&doc);
let text = String::from_utf8_lossy(&out.bytes).replace("\r\n", "\n");
for (i, index) in [(3, 3), (4, 6), (5, 4), (6, 5), (7, 7)] {
let [r, g, b] = vectorcraft_doc::LAYER_COLORS[i].1;
let want = format!(" 0 {index} {r} {g} {b} Lb\n(P{i}) Ln\n");
assert!(text.contains(&want), "missing {want:?} in\n{text}");
}
let read = crate::import(&out.bytes).unwrap().document;
let colors: Vec<_> = read
.layers
.iter()
.filter_map(|l| match l.kind {
NodeKind::Layer { color, .. } => Some(color),
_ => None,
})
.collect();
assert_eq!(colors, (3..8u8).map(vectorcraft_doc::LayerColor::Preset).collect::<Vec<_>>());
}
#[test]
fn losses_are_warned() {
let mut doc = sample();
+5 -19
View File
@@ -5,9 +5,9 @@ use super::encoding::{
use super::{
Affine, AppearanceItem, Arc, BezPath, BlendMode, CharStyle, Cms, Color, Document, ELLIPTICAL, FREEFORM, FillLayer, FillRule, Gradient,
GradientKind, GradientPaint, HEX_LINE, HIDDEN, HIDDEN_LAYER, HIDDEN_SHOWN, HiddenObjects, IMAGE_ALPHA, ImageBlob, Justify, LARGE_IMAGE,
LAYER_COLORS, LAYER_NAME, LIVE, LayerColor, LegacyAiOptions, MAX_IMAGE_PIXELS, MAX_NEST, NAMES, Node, NodeKind, PATTERNS, Paint, ParaDirection,
ParaStyle, PathData, ROTATED_TYPE, Rect, STROKES, SUBLAYERS, Shape, StrokeLayer, SubPath, TITLE_AND_COLOR_NAMES, TRANSPARENCY, TYPE,
TYPE_ATTRIBUTES, TextKind, TextObject, UNDECODED, Vec2, Write, ai_ops, latin, ps, stroke,
LAYER_NAME, LIVE, LegacyAiOptions, MAX_IMAGE_PIXELS, MAX_NEST, NAMES, Node, NodeKind, PATTERNS, Paint, ParaDirection, ParaStyle, PathData,
ROTATED_TYPE, Rect, STROKES, SUBLAYERS, Shape, StrokeLayer, SubPath, TITLE_AND_COLOR_NAMES, TRANSPARENCY, TYPE, TYPE_ATTRIBUTES, TextKind,
TextObject, UNDECODED, Vec2, Write, ai_ops, latin, ps, stroke,
};
pub(super) struct Writer<'a> {
@@ -135,14 +135,7 @@ impl<'a> Writer<'a> {
/// A top-level layer. False when it isn't a layer.
pub(super) fn layer(&mut self, n: &Node) -> bool {
let NodeKind::Layer { color, template, printable, children, clip, preview, dim_images } = &n.kind else { return false };
let [r, g, b] = match color {
LayerColor::Preset(i) => LAYER_COLORS.get(usize::from(*i)).map_or([79, 128, 255], |c| c.1),
LayerColor::Custom(rgb) => *rgb,
};
let index = match color {
LayerColor::Preset(i) if usize::from(*i) < LAYER_COLORS.len() => i64::from(*i),
_ => -1,
};
let (index, [r, g, b]) = ai_ops::layer_color(*color);
let flag = |b: bool| u8::from(b);
let name = self.layer_name(n.name.as_deref().unwrap_or("Layer"));
// A template layer doesn't print and shows dimmed.
@@ -173,14 +166,7 @@ impl<'a> Writer<'a> {
}
let art = std::mem::take(&mut self.hidden_art);
let NodeKind::Layer { color, template, printable, .. } = &n.kind else { return false };
let [r, g, b] = match color {
LayerColor::Preset(i) => LAYER_COLORS.get(usize::from(*i)).map_or([79, 128, 255], |c| c.1),
LayerColor::Custom(rgb) => *rgb,
};
let index = match color {
LayerColor::Preset(i) if usize::from(*i) < LAYER_COLORS.len() => i64::from(*i),
_ => -1,
};
let (index, [r, g, b]) = ai_ops::layer_color(*color);
let name = format!("{} (hidden)", n.name.as_deref().unwrap_or("Layer"));
let name = self.layer_name(&name);
let _ = writeln!(
+2 -1
View File
@@ -1,6 +1,6 @@
# File format parity
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-11 · **Change:** minor (`.ai` save: stroked type keeps its stroke's cap, join and miter limit in the text document, #6472; `.ai` and EPS import, PDF import: tracking and manual kerning in horizontally scaled type take the Horizontal Scale, as the file's page spaces such type; `.ai` and EPS import: stroked type in an envelope maps from its glyphs grown by half its stroke, #3179; `.ai` and EPS import: warps of type open as mesh envelopes on their saved grid, #3179; `.ai` and EPS import: stroked type keeps its stroke's cap, join and miter limit from the text document, #6472; `.ai` save: type in a variable font's named instance names its design-space setting, #6388; `.ai` and EPS import: envelopes of type open live where they draw as their drawn art and the layers with them draw as the page, #3179; `.ai` and EPS import: a note for type in an envelope, #3179; `.ai` and EPS import: the live envelope and warped type check sees white art; PDF import: DeviceN images of one ink or of process plates keep their inks, #6348; `.ai` import: images in a spot ink's Separation space keep their ink, #6350; PDF import: Separation images keep their ink, #6314; PDF editable import preserves authored ActualText once, including empty/nested spans and authored ordering (#6165); cross-line/style layout remains approximate; scores and estimates unchanged; PDF marked-text export (#6309): compatible glyphs stay on one layout line with per-glyph offsets; scores and estimates unchanged; PDF import: image masks open colorized by the color they are painted in, #6313; EPS and `.ai` editing data: a ligature the page names by a private-use glyph matches its text object; EPS import: CIDFonts only named, over a Unicode CMap, set as live type; EPS import: CMap mappings to base-font codes and glyph names; EPS import: CID-keyed fonts in FontSets (CFF) draw their glyphs; EPS import: `kshow` procedures kern live type; EPS import: embedded Type 1 fonts' glyphs and widths; EPS import: CID-keyed fonts draw their glyphs; EPS import: Type 42 fonts' characters and widths from the font they carry, show operators' spacing kept as tracking; EPS import: an image decoder's own buffers stop counting against the memory budget once it is decoded; EPS import: sampled functions interpolate between their samples; `.ai` and EPS type from the text document keeps its tab stops; it keeps auto leading and hyphenation options, and may show more of the file's last lines in an area frame; `.ai` and EPS import: Art brushes in the older form of their objects come into the Brushes panel; EPS import: gradients drawn as abutting strips come in as one gradient, without seams; EPS import: composite fonts draw their Type 42 glyphs; SVG import: filter and mask regions clip, #5876, #5875; SVG import: Gaussian blurs keep `color-interpolation-filters`, #5877; PDF and `.ai` export: objects' URLs as links, #5785; SVG import: `color-interpolation="linearRGB"` gradients, #5878; `.ai` import: paths with a Calligraphic brush open brushed, #372) · **Target:** Adobe Illustrator 2026 (30.x)
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-11 · **Change:** minor (`.ai` save and open: layer colour presets Yellow, Magenta and Cyan keep their colour, as the `Lb` colour index now follows the file's numbering, #6502; `.ai` save: stroked type keeps its stroke's cap, join and miter limit in the text document, #6472; `.ai` and EPS import, PDF import: tracking and manual kerning in horizontally scaled type take the Horizontal Scale, as the file's page spaces such type; `.ai` and EPS import: stroked type in an envelope maps from its glyphs grown by half its stroke, #3179; `.ai` and EPS import: warps of type open as mesh envelopes on their saved grid, #3179; `.ai` and EPS import: stroked type keeps its stroke's cap, join and miter limit from the text document, #6472; `.ai` save: type in a variable font's named instance names its design-space setting, #6388; `.ai` and EPS import: envelopes of type open live where they draw as their drawn art and the layers with them draw as the page, #3179; `.ai` and EPS import: a note for type in an envelope, #3179; `.ai` and EPS import: the live envelope and warped type check sees white art; PDF import: DeviceN images of one ink or of process plates keep their inks, #6348; `.ai` import: images in a spot ink's Separation space keep their ink, #6350; PDF import: Separation images keep their ink, #6314; PDF editable import preserves authored ActualText once, including empty/nested spans and authored ordering (#6165); cross-line/style layout remains approximate; scores and estimates unchanged; PDF marked-text export (#6309): compatible glyphs stay on one layout line with per-glyph offsets; scores and estimates unchanged; PDF import: image masks open colorized by the color they are painted in, #6313; EPS and `.ai` editing data: a ligature the page names by a private-use glyph matches its text object; EPS import: CIDFonts only named, over a Unicode CMap, set as live type; EPS import: CMap mappings to base-font codes and glyph names; EPS import: CID-keyed fonts in FontSets (CFF) draw their glyphs; EPS import: `kshow` procedures kern live type; EPS import: embedded Type 1 fonts' glyphs and widths; EPS import: CID-keyed fonts draw their glyphs; EPS import: Type 42 fonts' characters and widths from the font they carry, show operators' spacing kept as tracking; EPS import: an image decoder's own buffers stop counting against the memory budget once it is decoded; EPS import: sampled functions interpolate between their samples; `.ai` and EPS type from the text document keeps its tab stops; it keeps auto leading and hyphenation options, and may show more of the file's last lines in an area frame; `.ai` and EPS import: Art brushes in the older form of their objects come into the Brushes panel; EPS import: gradients drawn as abutting strips come in as one gradient, without seams; EPS import: composite fonts draw their Type 42 glyphs; SVG import: filter and mask regions clip, #5876, #5875; SVG import: Gaussian blurs keep `color-interpolation-filters`, #5877; PDF and `.ai` export: objects' URLs as links, #5785; SVG import: `color-interpolation="linearRGB"` gradients, #5878; `.ai` import: paths with a Calligraphic brush open brushed, #372) · **Target:** Adobe Illustrator 2026 (30.x)
Every format Illustrator opens, places, saves or exports, against VectorCraft's format table (`FORMATS`,
`OPEN_EXTS`, `PLACE_EXTS` in `crates/engine/src/cmd/fileio/formats.rs`). Illustrator's list comes from its public
@@ -339,6 +339,7 @@ Stop opacity on translated artboards remains outside this regression coverage.
| Date | Change | Summary |
|---|---|---|
| 2026-11-11 | minor | `.ai` save (PDF-compatible editing copy and legacy) and `.ai`/EPS open: a layer's `Lb` colour index follows the file's numbering, where Magenta, Cyan and Yellow are 4, 5 and 6 and `LAYER_COLORS` has Yellow, Magenta and Cyan; Yellow, Magenta and Cyan layers no longer come out as Magenta, Cyan and Yellow, and the other 24 presets are unchanged (#6502, from the index and RGB pairs in users' files). `LAYER_COLORS` keeps its order, so `.vectorcraft` files and the Layers panel don't change. Scores and estimates unchanged. |
| 2026-11-11 | minor | `.ai` save: stroked type writes its stroke's cap, join and miter limit into the text document (keys 61, 62 and 64, as `.ai` import reads them since #6500), so they come back when the file opens again and no longer count among the attributes of type left out; a run without a stroke doesn't count them either, as they draw nothing. Tests `ai_editing::tests::type_comes_back_as_type`, `an_unstroked_run_with_a_stroke_style_loses_nothing` (follow-up to #6472) |
| 2026-11-11 | minor | `.ai` and EPS import, PDF import, SVG export and `.ai` save: type with a Horizontal Scale other than 100% and tracking or manual kerning is spaced as a `.ai`'s own page spaces it. Layout now measures tracking and manual kerning in thousandths of the em as the scale draws it (see type-parity.md, same date); they were added at the full em, so such type drifted from the page letter by letter. A `.ai`'s page writes that type's character spacing as the tracking itself in a text space as wide as the scale, which PDF scales by it (ISO 32000-1 §9.4.4). In 224 users' files (used locally, never committed), 93 render differently. Of the 25 whose render moved by more than 1% of its pixels against the page, 23 came closer. The note on the type differing from the page shows a smaller share in 5 files (11.0% to 4.4% at most) and a larger one in 2 (by 0.2 to 0.3 points). 9 more envelopes of type open live (113 to 122 of 203, none fewer in any file), and no file switched between its layers and its PDF part. PDF import: the tracking of horizontally scaled type is the file's `Tc` in thousandths of its size (it was that times the scale), the glyphs where they were (test `tests::text_placement::character_spacing_in_horizontally_scaled_type_is_the_files_tc`). SVG export writes the letter spacing in thousandths of the font size it writes (the vertical scale), so type scaled uniformly keeps its spacing in a viewer (test `text_layout::spacing_in_uniformly_scaled_type_takes_the_scale`); tracked type scaled horizontally alone still differs a little there, as before. A `.ai` saved here writes the same values as before; VectorCraft now lays them out as the file's own PDF part draws them. Scores and estimates unchanged. |
| 2026-11-11 | minor | `.ai` and EPS import: stroked type in an envelope with Distort Appearance is tried mapped from its glyphs grown by as far as its strokes paint along a side (half the weight of a centred stroke), without what miter joins add at corners, for a stroke on its appearance or on a group round it as well as its characters'; it was mapped from its bounds with strokes, which reach as far as a miter join may at a corner. Tests `ai::envelope::tests::stroked_type_is_tried_mapped_from_its_glyphs_grown_by_half_its_stroke`, `type_with_a_character_stroke_is_tried_mapped_from_its_bounds_with_strokes`, `a_stroke_on_a_group_of_type_grows_the_box_by_half_its_weight` (part of #3179) |