.ai import: type keeps the manual kerning of its text document (#6899)

The text document reader never read a story's manual kerning, so type
kerned by hand in a .ai or EPS editing copy came in with automatic
kerning on every pair and drifted from the file's page from each kerned
pair on. Envelopes of such type drew apart from their drawn art and
kept it.

Key 8 of a story's text holds the kerning as runs of characters (key 1),
each with a value in thousandths of an em (key 0 of key 0) or empty
where the automatic kerning applies. Each character with a value now
takes it as its Kerning, the space after it, and the style runs split
where the value changes. A value that can't be read keeps the automatic
kerning, a run whose length can't be read ends the runs there, and past
65,536 runs the characters keep the automatic kerning.

- ate.rs: Story reads the runs (manual_kerning) and node() splits its
  text runs at them (Kerns), with a unit test over values, style-run
  boundaries, hostile values and the laid-out gap, and one that pins
  the limit of runs.
- tests_native.rs: an imported story with kerned and automatic runs.
- docs/file-format-parity.md: status line, mapping line, revision row.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
jmverges
2026-11-12 16:29:22 +00:00
co-authored by Claude Opus 5.5
parent 932830283d
commit 9d6188c47c
3 changed files with 165 additions and 9 deletions
+143 -7
View File
@@ -244,6 +244,10 @@ fn read(bytes: &[u8]) -> Option<Val> {
const MAX_SHEETS: usize = 4096;
const MAX_SHEETS_BASED_ON: usize = 16;
/// Most runs of manual kerning read of one story; the characters past them keep the automatic
/// kerning.
const MAX_KERN_RUNS: usize = 1 << 16;
/// Most frames read of one story, and most path segments of a frame.
pub(crate) const MAX_FRAMES: usize = 4096;
pub(crate) const MAX_SEGMENTS: usize = 1 << 16;
@@ -428,6 +432,10 @@ pub(super) struct Story {
paras: Vec<(usize, Para)>,
/// `(characters, style)` in order.
runs: Vec<(usize, Style)>,
/// `(characters, manual kerning)` in order: thousandths of an em after each of those characters
/// (key 8 of the story's text, a run's value its key 0), `None` where the automatic kerning
/// applies. Empty when the story has none.
kerns: Vec<(usize, Option<f64>)>,
/// Type on a path: the spacing its first frame's layout implies ([`path_spacing`]).
path_spacing: f64,
/// Vertical type (Type › Type Orientation › Vertical): its lines are columns, from right to
@@ -655,12 +663,13 @@ impl Texts {
if runs.is_empty() {
runs.push((text.chars().count(), self.style(None)));
}
let kerns = manual_kerning(s.get("0")?.get("8"));
let mut budget = MAX_STORY_TABS;
let mut tabs_left_out = false;
for (_, p) in &mut paras {
tabs_left_out |= p.tabs_truncated | spend_tabs(&mut p.tabs, &mut budget);
}
Some(Story { text, anchor, lines, frames, paras, runs, path_spacing, vertical, tabs_left_out: tabs_left_out.into() })
Some(Story { text, anchor, lines, frames, paras, runs, kerns, path_spacing, vertical, tabs_left_out: tabs_left_out.into() })
}
/// A paragraph's attributes (`run`: its attributes, key 5, and the paragraph style it is based
@@ -1111,19 +1120,16 @@ impl Story {
let scale = scale * art_scale(to_doc);
let chars: Vec<char> = text.chars().collect();
let mut runs = vec![];
let mut kerns = Kerns::new(&self.kerns);
let mut from = 0usize;
for (n, st) in &self.runs {
let to = from.saturating_add(*n).min(chars.len());
let piece: String = chars.get(from..to).unwrap_or_default().iter().collect();
kerns.split(&chars, from, to, &char_style(st, scale), &mut runs);
from = to;
if piece.is_empty() {
continue;
}
runs.push(TextRun::new(piece, char_style(st, scale)));
}
if from < chars.len() {
let last = self.runs.last().map(|r| &r.1)?;
runs.push(TextRun::new(chars.get(from..).unwrap_or_default().iter().collect::<String>(), char_style(last, scale)));
kerns.split(&chars, from, chars.len(), &char_style(last, scale), &mut runs);
}
let first = self.runs.first().map(|r| char_style(&r.1, scale))?;
let mut t = TextObject::point(Point::ZERO, "", first);
@@ -1259,6 +1265,64 @@ impl Story {
}
}
/// The manual kerning of a story (`list`: key 8 of its text): each run's characters and value
/// (thousandths of an em), `None` for a run without one (the automatic kerning) or with a value
/// that can't be read. Neighbouring runs of one value are one run. Empty without the key, and past
/// [`MAX_KERN_RUNS`] runs the characters keep the automatic kerning.
fn manual_kerning(list: Option<&Val>) -> Vec<(usize, Option<f64>)> {
let mut kerns: Vec<(usize, Option<f64>)> = vec![];
for run in list.and_then(|k| k.get("0")).map(Val::list).unwrap_or_default().iter().take(MAX_KERN_RUNS) {
let Some(n) = run.get("1").and_then(Val::num).filter(|n| *n >= 0.0 && n.fract() == 0.0) else { break };
let value = run.get("0").and_then(|v| v.get("0")).and_then(Val::num).filter(|v| v.abs() < 1e5);
match kerns.last_mut() {
Some((count, last)) if *last == value => *count = count.saturating_add(n as usize),
_ => kerns.push((n as usize, value)),
}
}
if kerns.iter().all(|(_, v)| v.is_none()) {
kerns.clear();
}
kerns
}
/// A walk along a story's manual kerning runs ([`Story::kerns`]), as its style runs are made into
/// text runs.
struct Kerns<'a> {
runs: &'a [(usize, Option<f64>)],
/// The run at the walk and the character it starts at.
at: usize,
start: usize,
}
impl<'a> Kerns<'a> {
fn new(runs: &'a [(usize, Option<f64>)]) -> Self {
Self { runs, at: 0, start: 0 }
}
/// Push the characters `from..to` of `chars` (`from` no earlier than any before) onto `out`,
/// in style `style`, as a text run for each manual kerning they have.
fn split(&mut self, chars: &[char], from: usize, to: usize, style: &CharStyle, out: &mut Vec<TextRun>) {
let mut at = from;
while at < to {
while let Some((n, _)) = self.runs.get(self.at)
&& self.start.saturating_add(*n) <= at
{
self.start = self.start.saturating_add(*n);
self.at += 1;
}
let (end, kerning) = match self.runs.get(self.at) {
Some((n, kerning)) => (self.start.saturating_add(*n).min(to), *kerning),
None => (to, None),
};
let piece: String = chars.get(at..end).unwrap_or_default().iter().collect();
if !piece.is_empty() {
out.push(TextRun::new(piece, CharStyle { kerning, ..style.clone() }));
}
at = end;
}
}
}
/// The paragraph style of paragraph attributes `p`, its lengths (indents, spacing, the
/// hyphenation zone) scaled by `scale`, as sizes are.
fn para_style(p: &Para, scale: f64) -> ParaStyle {
@@ -1638,6 +1702,77 @@ mod tests {
assert_eq!(stroke(b"/5 1 /6 << /57 false >>"), (LineCap::Butt, LineJoin::Miter, 10.0), "no stroke");
}
/// Key 8 of a story's text is its manual kerning: runs of characters (key 1), each with its
/// value (key 0 of key 0, thousandths of an em) or empty where the automatic kerning applies.
/// Each character with a value takes it as the space after it, across the story's style runs;
/// a value that can't be read keeps the automatic kerning, and a run whose length can't be read
/// ends the reading there. In users' files a heading's first pair is kerned by -140 and the
/// letters of a line by 20 to 220, and the page draws those gaps that much tighter or wider.
#[test]
fn a_story_keeps_its_manual_kerning() {
// "Wave" at 20 points and a Horizontal Scale of 50%, then " tea" at 10 points.
let styles: &[u8] = b"/6 << /0 [ << /0 << /0 << /6 << /1 20.0 /6 0.5 >> >> >> /1 4 >> << /0 << /0 << /6 << /1 10.0 >> >> >> /1 5 >> ] >>";
let texts = |kerns: &[u8]| Texts {
stories: vec![read(&[b"/0 << /0 (Wave tea\r) ", styles, b" ", kerns, b" >> /1 << /0 [ << /0 0 >> ] >>"].concat()).unwrap()],
fonts: vec![],
defaults: Val::Dict(BTreeMap::new()),
para_defaults: Val::Dict(BTreeMap::new()),
char_sheets: vec![],
para_sheets: vec![],
frames: vec![read(b"/0 << /0 [ 0 0 ] /2 << /0 0 >> >>").unwrap()],
};
let text = |kerns: &[u8]| -> TextObject {
let node = texts(kerns).story(0).unwrap().node(NodeId(1), 0, (0.0, 0.0), Affine::IDENTITY).unwrap();
let NodeKind::Text(t) = node.kind else { panic!("type") };
*t
};
let runs = |kerns: &[u8]| -> Vec<(String, f64, Option<f64>)> {
text(kerns).runs.iter().map(|r| (r.text.clone(), r.style.size, r.style.kerning)).collect()
};
let run = |text: &str, size: f64, kerning: Option<f64>| (text.to_string(), size, kerning);
// "W" -140, "av" none, "e t" 80 across the style runs, "e" a value that can't be read, "a" none.
let kerned: &[u8] = b"/8 << /0 [ << /0 << /0 -140 >> /1 1 >> << /0 << >> /1 2 >> << /0 << /0 80 >> /1 3 >> \
<< /0 << /0 (x) >> /1 1 >> << /0 << >> /1 2 >> ] >>";
assert_eq!(
runs(kerned),
[run("W", 20.0, Some(-140.0)), run("av", 20.0, None), run("e", 20.0, Some(80.0)), run(" t", 10.0, Some(80.0)), run("ea", 10.0, None)]
);
// Without the key, or with no value in it, the style runs stay as they are.
let plain = [run("Wave", 20.0, None), run(" tea", 10.0, None)];
assert_eq!(runs(b""), plain);
assert_eq!(runs(b"/8 << /0 [ << /0 << >> /1 9 >> ] >>"), plain);
// Values out of range keep the automatic kerning, and a length that can't be read ends the runs.
let out_of_range: &[u8] = b"/8 << /0 [ << /0 << /0 1e9 >> /1 1 >> << /0 << /0 NaN >> /1 1 >> << /0 << /0 60 >> /1 1 >> ] >>";
assert_eq!(runs(out_of_range)[..2], [run("Wa", 20.0, None), run("v", 20.0, Some(60.0))]);
for bad in [b"-1".as_slice(), b"1.5", b"(1)", b"NaN"] {
let k = [b"/8 << /0 [ << /0 << /0 60 >> /1 1 >> << /0 << /0 60 >> /1 ".as_slice(), bad, b" >> << /0 << /0 60 >> /1 2 >> ] >>"].concat();
assert_eq!(runs(&k), [run("W", 20.0, Some(60.0)), run("ave", 20.0, None), run(" tea", 10.0, None)], "{}", String::from_utf8_lossy(bad));
}
// Laid out, the space after "W" is 140 thousandths of the em tighter than with a value of
// 0, of the em as the run's Horizontal Scale (50%) draws it.
let db = vectorcraft_text::FontDb::global();
let gap = |t: &TextObject| {
let laid = vectorcraft_text::layout(db, t);
laid.glyphs[1].origin.x - laid.glyphs[0].origin.x
};
let (with, zero) = (gap(&text(kerned)), gap(&text(b"/8 << /0 [ << /0 << /0 0 >> /1 1 >> ] >>")));
assert!((zero - with - 0.14 * 20.0 * 0.5).abs() < 1e-6, "{with} {zero}");
}
/// Past [`MAX_KERN_RUNS`] runs of manual kerning the characters keep the automatic kerning, so a
/// story makes no more text runs of its kerning than that.
#[test]
fn manual_kerning_reads_at_most_its_limit_of_runs() {
let dict = |entries: Vec<(&str, Val)>| Val::Dict(entries.into_iter().map(|(k, v)| (k.to_string(), v)).collect());
// One character each, the values alternating, so no two neighbouring runs are one.
let run = |i: usize| dict(vec![("0", dict(vec![("0", Val::Num(if i.is_multiple_of(2) { 10.0 } else { 20.0 }))])), ("1", Val::Num(1.0))]);
let list = dict(vec![("0", Val::List((0..MAX_KERN_RUNS + 2).map(run).collect()))]);
let kerns = manual_kerning(Some(&list));
assert_eq!(kerns.len(), MAX_KERN_RUNS);
assert_eq!(kerns.iter().map(|(n, _)| n).sum::<usize>(), MAX_KERN_RUNS);
assert_eq!(kerns.last(), Some(&(1, Some(20.0))));
}
/// A right-to-left paragraph is laid out right to left even when it has no right-to-left
/// letters (a dimension's label " 120.5 mm " reads "mm 120.5"); a left-to-right one, or one
/// that doesn't say, goes by its letters.
@@ -1913,6 +2048,7 @@ mod tests {
standard_vertical_roman: false,
},
)],
kerns: vec![],
path_spacing: 0.0,
vertical: false,
tabs_left_out: false.into(),
+19
View File
@@ -3366,6 +3366,25 @@ fn stroked_type_keeps_its_cap_join_and_miter_limit() {
);
}
#[test]
fn type_keeps_its_manual_kerning() {
// GUARDS: the text document's manual kerning (key 8 of a story's text: runs of characters, each
// with its value or none) wasn't read, so type the file kerns by hand came in spaced by the
// font alone and drifted from the page from each kerned pair on. "Wave": "W" kerned by -140,
// "av" automatic, "e" by 60.
let doc = text_document(&[("Wave\r", 0, (8200.0, 8180.0), (0.0, 0.0), 0.0)]);
let run = "<< /0 << /0 << /0 () /5 0 /6 << /0 0 /1 14.0 /53 << /99 /CAITextPaint /0 << /0 2 /1 [ 1.0 0.0 1.0 0.0 0.0 ] >> >> >> >> >> /1 5 >>";
assert!(doc.contains(run));
let kerns = "/8 << /0 [ << /0 << /0 -140 >> /1 1 >> << /0 << >> /1 2 >> << /0 << /0 60 >> /1 1 >> << /0 << >> /1 1 >> ] >>";
let doc = doc.replacen(run, &format!("{run} ] >> {kerns}"), 1).replacen(&format!("{kerns} ] >> >>"), &format!("{kerns} >>"), 1);
let art = native_with_text(&(layer("Back", true, &square(10, 10)) + &layer("Spare", false, &text_object(0))), &doc);
let r = import(&eps(&page_square(10, 10), &art)).unwrap();
let texts = text_of(&r.document.layers[1]);
assert_eq!(texts.len(), 1, "{:?}", r.warnings);
let runs: Vec<(String, Option<f64>)> = texts[0].runs.iter().map(|r| (r.text.clone(), r.style.kerning)).collect();
assert_eq!(runs, [("W".to_string(), Some(-140.0)), ("av".to_string(), None), ("e".to_string(), Some(60.0))]);
}
#[test]
fn orientation_and_tate_chu_yoko_values_it_cant_use_are_ignored() {
for bad in ["(2)", "2.5", "1e300", "-2", "1", "true", "[ 2 ]"] {
+3 -2
View File
@@ -1,6 +1,6 @@
# File format parity
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-11 · **Change:** minor (newly imported SVG/SVGZ titles are filtered and bounded without changing restored titles, #3626; `.ai` and EPS import, and what every export and `.ai` save draw: type's strokes in an envelope paint at their own weight along its bent outlines, Distort Appearance on or off, #6644; `.ai` and EPS import: centred and right-aligned lines of tracked type are aligned without the tracking after their last character, as the file's page aligns them, #6610; `.ai` and EPS import: an envelope saved in Edit Contents whose mesh lies away from its drawn art by a move opens live on that art, #6570; `.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)
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-11 · **Change:** minor (`.ai` and EPS import: type keeps the manual kerning of its text document, #6899; newly imported SVG/SVGZ titles are filtered and bounded without changing restored titles, #3626; `.ai` and EPS import, and what every export and `.ai` save draw: type's strokes in an envelope paint at their own weight along its bent outlines, Distort Appearance on or off, #6644; `.ai` and EPS import: centred and right-aligned lines of tracked type are aligned without the tracking after their last character, as the file's page aligns them, #6610; `.ai` and EPS import: an envelope saved in Edit Contents whose mesh lies away from its drawn art by a move opens live on that art, #6570; `.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
@@ -56,7 +56,7 @@ private editing data. VectorCraft:
own paints with the paint before the mask's dictionary, as the writer leaves out paint that doesn't change.
Type from the text document keeps auto leading as auto, each line taking the paragraph's share
(Auto Leading) of the largest type on it ([Line and character spacing](https://helpx.adobe.com/illustrator/using/line-character-spacing.html)),
and the paragraph's hyphenation options and tab stops. Stroked type keeps its stroke's cap, join and miter limit. Area type of one
and the paragraph's hyphenation options and tab stops. Stroked type keeps its stroke's cap, join and miter limit. Type keeps its manual kerning (key 8 of a story's text, thousandths of an em after a character). Area type of one
frame whose last lines the file sets but which don't fit here (the fallback font, or a descent
past the frame's bottom) may get a frame taller from its bottom, by its lines' deepest descent plus 0.05 pt and only when that shows more of those lines, with a warning when it does.
RGB in the PDF page's `ICCBased` spaces keeps the file's numbers in a `.ai` (the `uncalibrated_rgb`
@@ -343,6 +343,7 @@ Stop opacity on translated artboards remains outside this regression coverage.
| Date | Change | Summary |
|---|---|---|
| 2026-11-12 | minor | `.ai` and EPS import: type from the text document keeps its manual kerning (#6899). Key 8 of a story's text holds it as runs of characters (key 1), each with a value (key 0 of key 0, in thousandths of an em) or empty where the automatic kerning applies; a character with a value takes it as its Kerning, the space between it and the next character ([Line and character spacing](https://helpx.adobe.com/illustrator/using/line-character-spacing.html)), of the em as its Horizontal Scale draws it, as tracking. It wasn't read, so type kerned by hand came in spaced by the font alone and drifted from the page from each kerned pair on, and envelopes of such type drew apart from their drawn art and kept it. In users' files, used locally and never committed, 48 stories in 17 of 224 files have it, and the drawn art of envelopes of such type sets each kerned gap wider or narrower by exactly that value (a heading's first pair at -140, the letters of a line at 20 to 220). Measured against each file's own page, 14 of those files draw closer (in 4 the pixels that differ go from 2.7–4.7% to under 0.6%) and 2 a little further, both where an envelope of type now opens live in place of its drawn art; envelopes of type that open live go from 154 to 159 of 203, none fewer in any file, and two notes that the type differs from the page go. A value that can't be read keeps the automatic kerning, a run whose length can't be read ends the runs there, and past 65,536 runs the characters keep the automatic kerning. `.ai` save still leaves kerning out of the editing data, so saving such a file as `.ai` again now gives the note that attributes of type are left out for type with manual kerning. Tests `ate::tests::a_story_keeps_its_manual_kerning`, `tests_native::type_keeps_its_manual_kerning`; scores unchanged |
| 2026-11-12 | minor | Save refusal follow-up (#6146): changed format opens its Save options; unchanged format retains original options. Focused UI regressions passed, including the format-change case that failed before the fix; native panel acceptance pending. Scores and estimates unchanged. |
| 2026-11-11 | minor | Newly imported SVG/SVGZ titles use the imported-text rules: generated Untitled names and control-only titles give the filename fallback, other control characters are removed, and titles are limited to 65,536 characters. Native and Preserve Editing titles remain unchanged (#3626). |
| 2026-11-11 | minor | Native and Preserve Editing restores keep an existing File Info title exactly, including repeated spaces; imported-text fallback titles alone are normalized (#3626). |