mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-11-11 17:44:02 +00:00
.ai save: stroked type keeps its cap, join and miter limit
The text document writer wrote a type stroke's paint and weight but not its cap, join or miter limit, which .ai import reads since #6500 (keys 61, 62 and 64). A file saved here and opened again came back with butt caps, miter joins and a miter limit of 10, and the save reported those attributes as left out. A stroked run now writes the three keys, numbered as PostScript numbers caps and joins, with the miter limit as import takes it back (1 to 500, else 10), and they no longer count as lost. A run without a stroke no longer counts them as lost either: they draw nothing. Follow-up to #6472. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
212bc9f837
commit
8b39cbc324
@@ -3449,6 +3449,9 @@ fn typed() -> Document {
|
||||
v_scale: 90.0,
|
||||
stroke: solid(Color::rgb(0.0, 0.5, 0.0)),
|
||||
stroke_width: 0.5,
|
||||
stroke_cap: vectorcraft_doc::LineCap::Square,
|
||||
stroke_join: vectorcraft_doc::LineJoin::Bevel,
|
||||
stroke_miter_limit: 2.5,
|
||||
all_caps: true,
|
||||
..regular.clone()
|
||||
};
|
||||
@@ -3565,7 +3568,7 @@ fn type_lines(doc: &Document) -> Vec<String> {
|
||||
for r in &t.runs {
|
||||
let s = &r.style;
|
||||
out.push(format!(
|
||||
" run {:?} {} {} {} {:?} {} {} {} {} fill {} stroke {} {} caps={} small={:?} tcy={} roman={}",
|
||||
" run {:?} {} {} {} {:?} {} {} {} {} fill {} stroke {} {} {:?} {:?} {} caps={} small={:?} tcy={} roman={}",
|
||||
r.text,
|
||||
s.font_family,
|
||||
s.font_style,
|
||||
@@ -3578,6 +3581,9 @@ fn type_lines(doc: &Document) -> Vec<String> {
|
||||
paint(&s.fill),
|
||||
paint(&s.stroke),
|
||||
r3(s.stroke_width),
|
||||
s.stroke_cap,
|
||||
s.stroke_join,
|
||||
r3(s.stroke_miter_limit),
|
||||
s.all_caps,
|
||||
s.small_caps.map(r3),
|
||||
s.tate_chu_yoko,
|
||||
@@ -3718,6 +3724,22 @@ fn type_comes_back_as_type() {
|
||||
assert_eq!(back.text_threads.len(), 1, "the thread comes back");
|
||||
}
|
||||
|
||||
/// A run without a stroke whose stroke's cap, join and miter limit aren't the defaults loses
|
||||
/// nothing that draws: the save reports no attributes left out.
|
||||
#[test]
|
||||
fn an_unstroked_run_with_a_stroke_style_loses_nothing() {
|
||||
use vectorcraft_doc::{CharStyle, LineCap, LineJoin, TextObject};
|
||||
for miter in [3.0, f64::NAN] {
|
||||
let st = CharStyle { stroke_cap: LineCap::Round, stroke_join: LineJoin::Bevel, stroke_miter_limit: miter, ..style(14.0, Color::BLACK) };
|
||||
let mut doc = Document::new(200.0, 200.0);
|
||||
let layer = doc.layers[0].id;
|
||||
let n = text_node(&mut doc, TextObject::point(vectorcraft_geom::Point::new(20.0, 40.0), "Plain", st));
|
||||
doc.insert(Some(layer), usize::MAX, n).unwrap();
|
||||
let out = export_ai_editing(&doc, &opts()).unwrap();
|
||||
assert!(out.warnings.is_empty(), "{miter}: {:?}", out.warnings);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn type_is_written_again_as_the_same_bytes() {
|
||||
let doc = typed();
|
||||
|
||||
@@ -21,15 +21,15 @@ use std::fmt::Write;
|
||||
use kurbo::{ParamCurveArclen, PathSeg};
|
||||
use vectorcraft_color::{Color, Paint};
|
||||
use vectorcraft_doc::{
|
||||
AreaOptions, Burasagari, CharStyle, Document, FirstBaseline, Justify, Mojikumi, Node, NodeId, NodeKind, ParaDirection, ParaStyle, PathAlign,
|
||||
PathEffect, TextKind, TextObject, VerticalAlign,
|
||||
AreaOptions, Burasagari, CharStyle, Document, FirstBaseline, Justify, LineCap, LineJoin, Mojikumi, Node, NodeId, NodeKind, ParaDirection,
|
||||
ParaStyle, PathAlign, PathEffect, TextKind, TextObject, VerticalAlign,
|
||||
};
|
||||
use vectorcraft_geom::{Affine, PathData, Point};
|
||||
|
||||
use crate::ps;
|
||||
|
||||
/// What the text document holds of type, and what it leaves out.
|
||||
pub(in crate::ai_editing) const TYPE_ATTRIBUTES: &str = "attributes of type other than its font, size, leading, tracking, baseline shift, horizontal and vertical scale, solid fill, solid stroke and stroke weight, capitals, small capitals at the document's size, Tate-chu-yoko, Standard Vertical Roman Alignment, paragraph alignment, indents, space before and after, hyphenation on or off and right-to-left direction (for example underline, kerning, OpenType features, swatch links of its colors, character and paragraph styles, tabs, lists, optical margin alignment, area type's inset spacing, vertical alignment and flow, a minimum first baseline with the Ascent first baseline, and type on a path's effect, alignment and spacing and an end bracket at the very end of an open path) are left out of the editing data for other apps";
|
||||
pub(in crate::ai_editing) const TYPE_ATTRIBUTES: &str = "attributes of type other than its font, size, leading, tracking, baseline shift, horizontal and vertical scale, solid fill, solid stroke and its weight, cap, join and miter limit, capitals, small capitals at the document's size, Tate-chu-yoko, Standard Vertical Roman Alignment, paragraph alignment, indents, space before and after, hyphenation on or off and right-to-left direction (for example underline, kerning, OpenType features, swatch links of its colors, character and paragraph styles, tabs, lists, optical margin alignment, area type's inset spacing, vertical alignment and flow, a minimum first baseline with the Ascent first baseline, and type on a path's effect, alignment and spacing and an end bracket at the very end of an open path) are left out of the editing data for other apps";
|
||||
|
||||
/// The version of the text document's form (`/98 << /0 n >>`), as the newest files users sent have it.
|
||||
const STREAM_VERSION: u32 = 14;
|
||||
@@ -347,6 +347,9 @@ pub(in crate::ai_editing) fn loses(t: &TextObject) -> bool {
|
||||
fill: unlinked(&s.fill),
|
||||
stroke: unlinked(&s.stroke),
|
||||
stroke_width: if stroked { s.stroke_width } else { CharStyle::default().stroke_width },
|
||||
stroke_cap: if stroked { s.stroke_cap } else { CharStyle::default().stroke_cap },
|
||||
stroke_join: if stroked { s.stroke_join } else { CharStyle::default().stroke_join },
|
||||
stroke_miter_limit: if stroked { miter_limit(s.stroke_miter_limit) } else { CharStyle::default().stroke_miter_limit },
|
||||
all_caps: s.all_caps,
|
||||
small_caps: s.small_caps.filter(|v| *v == default_small_caps),
|
||||
tate_chu_yoko: s.tate_chu_yoko,
|
||||
@@ -357,7 +360,19 @@ pub(in crate::ai_editing) fn loses(t: &TextObject) -> bool {
|
||||
};
|
||||
let run_lost = |s: &CharStyle| {
|
||||
let stroked = !s.stroke.is_none();
|
||||
let s = CharStyle { stroke_width: if stroked { s.stroke_width } else { CharStyle::default().stroke_width }, ..s.clone() };
|
||||
// Without a stroke, its weight, cap, join and miter limit draw nothing: nothing is lost.
|
||||
let unstroked = CharStyle::default();
|
||||
let s = if stroked {
|
||||
s.clone()
|
||||
} else {
|
||||
CharStyle {
|
||||
stroke_width: unstroked.stroke_width,
|
||||
stroke_cap: unstroked.stroke_cap,
|
||||
stroke_join: unstroked.stroke_join,
|
||||
stroke_miter_limit: unstroked.stroke_miter_limit,
|
||||
..s.clone()
|
||||
}
|
||||
};
|
||||
// A Lab color is written as RGB.
|
||||
let lab = |p: &Paint| matches!(p, Paint::Solid { color: Color::Lab { .. }, .. });
|
||||
s != held_char(&s) || lab(&s.fill) || lab(&s.stroke) || !font_kept(&s)
|
||||
@@ -463,6 +478,32 @@ fn font_name(st: &CharStyle) -> FontRecord {
|
||||
}
|
||||
}
|
||||
|
||||
/// The text document's number for a stroke's cap (key 61), as PostScript numbers them: 0 butt, 1
|
||||
/// round, 2 projecting.
|
||||
fn cap_code(cap: LineCap) -> i64 {
|
||||
match cap {
|
||||
LineCap::Butt => 0,
|
||||
LineCap::Round => 1,
|
||||
LineCap::Square => 2,
|
||||
}
|
||||
}
|
||||
|
||||
/// The text document's number for a stroke's join (key 62), as PostScript numbers them: 0 miter, 1
|
||||
/// round, 2 bevel.
|
||||
fn join_code(join: LineJoin) -> i64 {
|
||||
match join {
|
||||
LineJoin::Miter => 0,
|
||||
LineJoin::Round => 1,
|
||||
LineJoin::Bevel => 2,
|
||||
}
|
||||
}
|
||||
|
||||
/// A stroke's miter limit (key 64) as the reader takes it back: at least 1 and at most 500, 10 when
|
||||
/// it isn't a number at least 1.
|
||||
fn miter_limit(v: f64) -> f64 {
|
||||
if v.is_finite() && v >= 1.0 { v.min(500.0) } else { 10.0 }
|
||||
}
|
||||
|
||||
/// `v` as the 16.16 fixed-point number a design vector holds (`wght` 700 is 45875200).
|
||||
fn fixed(v: f32) -> i64 {
|
||||
if v.is_finite() { (f64::from(v) * 65536.0).round() as i64 } else { 0 }
|
||||
@@ -621,7 +662,12 @@ impl TextDoc {
|
||||
}
|
||||
d.extend([(56, V::Bool(fill.is_some())), (57, V::Bool(stroke.is_some()))]);
|
||||
if stroke.is_some() {
|
||||
d.push((63, V::Small(st.stroke_width / scale)));
|
||||
d.extend([
|
||||
(61, V::Int(cap_code(st.stroke_cap))),
|
||||
(62, V::Int(join_code(st.stroke_join))),
|
||||
(63, V::Small(st.stroke_width / scale)),
|
||||
(64, real(miter_limit(st.stroke_miter_limit))),
|
||||
]);
|
||||
}
|
||||
dict(d)
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# File format parity
|
||||
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-11 · **Change:** minor (`.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: 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: 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) |
|
||||
| 2026-11-11 | minor | `.ai` and EPS import: a warp of type (an envelope made with a warp around type) whose own warp, VectorCraft's, doesn't draw as its drawn art is tried again as a mesh envelope on the warp grid the file saves, with the same drawn-art check, budget and page comparison as #6421: where it passes, the type is editable and keeps the file's shape, and the warp's settings (style, bend) are lost. A second try is only made while more checks are left than there are envelopes of type and warped text objects still to come. Type with strokes in an envelope with Distort Appearance on is also tried mapped from its box with strokes, where the size the file saves for it doesn't already give that box (a rule chosen by measuring real files). An envelope's grid (warp, mesh or top object) now reads its way round from all its rows and columns, not its first row alone: a mesh bent far round (its end columns leaning more than halfway over) reads the way it runs, not turned a quarter round, and a grid symmetric about its middle reads the same wherever it is on the page. Tests `tests_native::a_warp_of_type_opens_as_a_mesh_on_its_saved_grid_and_keeps_its_shape_when_its_text_changes`, `a_warp_of_stroked_type_maps_from_its_bounds_with_strokes_where_its_drawn_art_does`, `a_warp_of_type_without_a_saved_grid_keeps_its_drawn_art`, `a_warp_of_type_tried_again_leaves_the_next_envelope_its_check`, `ai::tests::a_mesh_envelope_bent_far_round_opens_the_way_it_runs`, `a_mesh_envelope_bent_halfway_round_opens_the_same_way_wherever_it_is` (part of #3179) |
|
||||
|
||||
Reference in New Issue
Block a user