diff --git a/crates/eps/src/import/ate.rs b/crates/eps/src/import/ate.rs index 2250dea96..194c0ff1e 100644 --- a/crates/eps/src/import/ate.rs +++ b/crates/eps/src/import/ate.rs @@ -26,8 +26,8 @@ use std::collections::BTreeMap; use kurbo::{BezPath, CubicBez, ParamCurve, ParamCurveArclen}; use vectorcraft_color::{Color, Paint}; use vectorcraft_doc::{ - CharStyle, HyphenationOptions, JustificationOptions, Justify, Node, NodeId, NodeKind, ParaDirection, ParaStyle, PathAlign, PathEffect, TabAlign, - TabStop, TextKind, TextObject, TextRun, + CharStyle, HyphenationOptions, JustificationOptions, Justify, LineCap, LineJoin, Node, NodeId, NodeKind, ParaDirection, ParaStyle, PathAlign, + PathEffect, TabAlign, TabStop, TextKind, TextObject, TextRun, }; use vectorcraft_geom::{Affine, PathData, Point, Rect}; @@ -462,6 +462,12 @@ struct Style { fill: Option, stroke: Option, stroke_width: f64, + /// Key 61, the stroke's cap: 0 butt, 1 round, 2 projecting (square). + stroke_cap: LineCap, + /// Key 62, the stroke's join: 0 miter, 1 round, 2 bevel. + stroke_join: LineJoin, + /// Key 64, the stroke's miter limit (a multiple of the stroke weight). + stroke_miter_limit: f64, /// Key 12, the case: 1 Small Caps, 2 All Caps. caps: u8, /// Key 35 at 3: Tate-chu-yoko, the characters set across the column as one block. @@ -752,6 +758,21 @@ impl Texts { fill: if on("56") { Some(pick("53").and_then(paint).unwrap_or(Color::BLACK)) } else { None }, stroke: if matches!(pick("57"), Some(Val::Bool(true))) { pick("54").and_then(paint) } else { None }, stroke_width: num("63").filter(|v| *v >= 0.0 && *v < 1e4).unwrap_or(1.0), + // Numbered as PostScript's `setlinecap` and `setlinejoin` operators number them + // (PostScript Language Reference, 3rd ed.): in users' files the page strokes type with + // the cap and join these give, and with key 64 as its miter limit. No file had a bevel + // join (2). Any other value keeps the default. + stroke_cap: match num("61") { + Some(1.0) => LineCap::Round, + Some(2.0) => LineCap::Square, + _ => LineCap::Butt, + }, + stroke_join: match num("62") { + Some(1.0) => LineJoin::Round, + Some(2.0) => LineJoin::Bevel, + _ => LineJoin::Miter, + }, + stroke_miter_limit: num("64").filter(|v| v.is_finite() && *v >= 1.0).map_or(10.0, |v| v.min(500.0)), caps: num("12").filter(|v| *v == 1.0 || *v == 2.0).map_or(0, |v| v as u8), tate_chu_yoko: num("35") == Some(3.0), standard_vertical_roman: num("35") == Some(1.0), @@ -1278,6 +1299,9 @@ fn para_style(p: &Para, scale: f64) -> ParaStyle { fn char_style(s: &Style, scale: f64) -> CharStyle { let (family, style) = s.font.as_deref().map_or_else(|| (CharStyle::default().font_family, "Regular".to_string()), crate::family_style); let family = vectorcraft_text::FontDb::global().find_family(&family).unwrap_or(family); + // Type without a stroke keeps the stroke's defaults, as its weight is 0. + let (stroke_cap, stroke_join, stroke_miter_limit) = + if s.stroke.is_some() { (s.stroke_cap, s.stroke_join, s.stroke_miter_limit) } else { (LineCap::Butt, LineJoin::Miter, 10.0) }; CharStyle { font_family: family, font_style: style, @@ -1293,6 +1317,9 @@ fn char_style(s: &Style, scale: f64) -> CharStyle { fill: s.fill.map_or(Paint::None, Paint::solid), stroke: s.stroke.map_or(Paint::None, Paint::solid), stroke_width: if s.stroke.is_some() { s.stroke_width * scale } else { 0.0 }, + stroke_cap, + stroke_join, + stroke_miter_limit, all_caps: s.caps == 2, // At the default Small Caps size of Document Setup: the file's own isn't read. small_caps: (s.caps == 1).then(|| vectorcraft_doc::DocSetup::default().small_caps_size), @@ -1573,6 +1600,44 @@ mod tests { assert!(texts(b"/35 3").style(None).tate_chu_yoko && !texts(b"/35 3").style(None).standard_vertical_roman); } + /// Keys 61, 62 and 64 of a character style are its stroke's cap (0 butt, 1 round, 2 + /// projecting), join (0 miter, 1 round, 2 bevel) and miter limit, from the run, its character + /// style or the document's defaults. A value that isn't one of theirs keeps the default, a miter + /// limit past 500 is 500, and type without a stroke keeps the defaults. + #[test] + fn a_run_keeps_its_stroke_cap_join_and_miter_limit() { + let texts = Texts { + stories: vec![], + fonts: vec![], + defaults: read(b"/57 true /54 << /0 << /0 0 /1 [ 1.0 1.0 ] >> >> /64 4.0").unwrap(), + para_defaults: Val::Dict(BTreeMap::new()), + char_sheets: vec![read(b"/0 ([Normal Character Style]) /6 << >>").unwrap(), read(b"/0 (Rounded) /6 << /61 1 /62 1 >>").unwrap()], + para_sheets: vec![], + frames: vec![], + }; + let stroke = |run: &[u8]| { + let s = char_style(&texts.style(Some(&read(run).unwrap())), 1.0); + (s.stroke_cap, s.stroke_join, s.stroke_miter_limit) + }; + assert_eq!(stroke(b"/6 << /61 2 /62 2 /64 2.5 >>"), (LineCap::Square, LineJoin::Bevel, 2.5), "the run's own"); + assert_eq!(stroke(b"/5 1 /6 << >>"), (LineCap::Round, LineJoin::Round, 4.0), "its character style's and the defaults'"); + assert_eq!(stroke(b"/5 1 /6 << /62 0 >>"), (LineCap::Round, LineJoin::Miter, 4.0), "the run's own wins over its style"); + assert_eq!(stroke(b"/6 << >>"), (LineCap::Butt, LineJoin::Miter, 4.0), "the defaults'"); + assert_eq!(stroke(b"/6 << /64 1 >>").2, 1.0); + assert_eq!(stroke(b"/6 << /64 1e9 >>").2, 500.0); + for bad in [ + &b"/6 << /61 3 /62 3 /64 0.5 >>"[..], + b"/6 << /61 -1 /62 -1 /64 -4 >>", + b"/6 << /61 1.5 /62 0.5 /64 NaN >>", + b"/6 << /61 (1) /62 (1) /64 inf >>", + b"/6 << /61 true /62 [ 1 ] /64 (4) >>", + b"/6 << /61 1e308 /62 /Round /64 -inf >>", + ] { + assert_eq!(stroke(bad), (LineCap::Butt, LineJoin::Miter, 10.0), "{}", String::from_utf8_lossy(bad)); + } + assert_eq!(stroke(b"/5 1 /6 << /57 false >>"), (LineCap::Butt, LineJoin::Miter, 10.0), "no stroke"); + } + /// 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. @@ -1840,6 +1905,9 @@ mod tests { fill: Some(Color::BLACK), stroke: None, stroke_width: 1.0, + stroke_cap: LineCap::Butt, + stroke_join: LineJoin::Miter, + stroke_miter_limit: 10.0, caps: 0, tate_chu_yoko: false, standard_vertical_roman: false, diff --git a/crates/eps/src/import/tests_native.rs b/crates/eps/src/import/tests_native.rs index f71c037c9..867d36d1a 100644 --- a/crates/eps/src/import/tests_native.rs +++ b/crates/eps/src/import/tests_native.rs @@ -3291,6 +3291,50 @@ fn a_standard_baseline_run_keeps_standard_vertical_roman_alignment() { } } +#[test] +fn stroked_type_keeps_its_cap_join_and_miter_limit() { + // GUARDS: type strokes came in with a miter join at a limit of 10 whatever the file said, so + // type the file strokes with round or bevel joins, or a lower limit, drew spikes at its corners. + // Four runs over "abcdefgh": "ab" stroked with round caps and joins at a miter limit of 4, "cd" + // with projecting caps and bevel joins at 2.5, "ef" stroked without the keys (the defaults), and + // "gh" with the keys but no stroke. + use vectorcraft_doc::{LineCap, LineJoin}; + let doc = text_document(&[("abcdefgh\r", 0, (8200.0, 8180.0), (0.0, 0.0), 0.0)]); + let run = |n: u32, own: &str| { + format!( + "<< /0 << /0 << /0 () /5 0 /6 << /0 0 /1 14.0 {own}/53 << /99 /CAITextPaint /0 << /0 2 /1 [ 1.0 0.0 1.0 0.0 0.0 ] >> >> >> >> >> /1 {n} >>" + ) + }; + let one = run(9, ""); + assert!(doc.contains(&one)); + let stroke = "/54 << /99 /CAITextPaint /0 << /0 2 /1 [ 1.0 0.0 0.0 0.0 1.0 ] >> >> /57 true /63 3.0 "; + let runs = [ + run(2, &format!("{stroke}/61 1 /62 1 /64 4.0 ")), + run(2, &format!("{stroke}/61 2 /62 2 /64 2.5 ")), + run(2, stroke), + run(3, "/61 1 /62 1 /64 4.0 "), + ]; + let doc = doc.replacen(&one, &runs.join(" "), 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 strokes: Vec<(String, f64, LineCap, LineJoin, f64)> = texts[0] + .runs + .iter() + .map(|r| (r.text.clone(), r.style.stroke_width, r.style.stroke_cap, r.style.stroke_join, r.style.stroke_miter_limit)) + .collect(); + assert_eq!( + strokes, + [ + ("ab".to_string(), 3.0, LineCap::Round, LineJoin::Round, 4.0), + ("cd".to_string(), 3.0, LineCap::Square, LineJoin::Bevel, 2.5), + ("ef".to_string(), 3.0, LineCap::Butt, LineJoin::Miter, 10.0), + ("gh".to_string(), 0.0, LineCap::Butt, LineJoin::Miter, 10.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 ]"] { diff --git a/docs/file-format-parity.md b/docs/file-format-parity.md index 810d1568b..06fac1069 100644 --- a/docs/file-format-parity.md +++ b/docs/file-format-parity.md @@ -1,6 +1,6 @@ # File format parity -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-10 · **Change:** minor (`.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: 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 @@ -54,7 +54,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. Area type of one + and the paragraph's hyphenation options and tab stops. Stroked type keeps its stroke's cap, join and miter limit. 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` @@ -339,6 +339,7 @@ Stop opacity on translated artboards remains outside this regression coverage. | Date | Change | Summary | |---|---|---| +| 2026-11-11 | minor | `.ai` and EPS import: type stroked in the text document keeps its stroke's cap, join and miter limit (keys 61, 62 and 64 of its character attributes, numbered as PostScript's `setlinecap` and `setlinejoin`: 0 butt or miter, 1 round, 2 projecting or bevel). They were left at a miter join with a limit of 10 whatever the file said, so type the file strokes with round joins, or with the usual limit of 4 or less, drew spikes at its sharp corners, some many points long. In users' files, used locally and never committed, the page strokes the type with the cap, join and miter limit these keys give; no file had a bevel join, which is read by the same numbering. A value that isn't one of theirs keeps the default, a miter limit past 500 is 500, and type without a stroke keeps the defaults. Dashes are not read: no file had dashed type to tell which keys hold them. Tests `ate::tests::a_run_keeps_its_stroke_cap_join_and_miter_limit`, `tests_native::stroked_type_keeps_its_cap_join_and_miter_limit`; scores unchanged | | 2026-11-10 | minor | `.ai` and EPS import: an envelope of type (a warp, a mesh or a top object around type) kept its drawn art, its type hidden, because the type is laid out from the text document only after the layers are read. It now waits for that: its text slots get their type, and where VectorCraft's own envelope of that type draws as the drawn art the file saves (the same check and budget as other envelopes, seen on white and on black), it opens live in the drawn art's place, its type editable in Edit Contents, and the hidden copy goes. As with other live envelopes, the layers with one are then compared with the page, which weighs the envelope as the art it draws (not as type laid out again, which the comparison allows more for), and when they don't draw as it the drawn art and the hidden type are put back. Layers used though they don't draw as the page (the editing data trusted, or the objects that differ coming in as the page's art) leave envelopes of type their drawn art and hidden type too. A `.ai` whose layers would open alone, without its PDF part, imports the PDF part to compare them with, and should they still not draw as it, or should no envelope of type open live, they open alone as before, with the envelopes' drawn art. Type in a font that isn't available, slots without type of their own (threaded, bound to a text or a visibility variable, a wrap object, type a look draws), envelopes in a symbol's or a pattern's art, and envelopes that draw differently keep their drawn art, with the note (#3179). Tests `tests_native::an_envelope_of_type_that_draws_as_its_drawn_art_opens_live`, `an_envelope_of_type_that_draws_apart_from_its_drawn_art_keeps_it`, `an_envelope_of_white_type_that_draws_apart_from_its_drawn_art_keeps_it`, `an_envelope_of_type_waits_on_the_page_of_layers_that_would_open_alone`, `an_envelope_of_type_that_draws_apart_from_the_page_does_not_open_live`, `trusted_layers_that_draw_apart_from_the_page_leave_an_envelope_of_type_its_drawn_art`, `layers_with_objects_that_come_in_as_the_page_art_leave_an_envelope_of_type_its_drawn_art`, `an_envelope_of_type_bound_to_a_visibility_variable_keeps_its_drawn_art` | | 2026-11-10 | minor | `.ai` save: a variable font's record in the text document's font set names the type's place in the font's design space (`/4`, each `fvar` axis as 16.16 fixed: the named instance's setting, or the run's own Variable Font setting) and the font's version (`/5`). Without them, type in a named instance such as Bold opened at the font's default instance in the app that opens the file. Two settings of one font are now two fonts of the set; a static font's record is unchanged. The record's form comes from the public report in #6388. Test `ai_editing::tests::variable_font_instances_name_their_design_space` (#6388) | | 2026-11-10 | minor | `.ai` and EPS import: an envelope of type, which keeps its drawn art with its type hidden, now says so in the import notes ("type in an envelope comes in as the envelope's drawn art, not as editable type"); nothing said it before, while the other envelopes that keep their drawn art have a note. The type is still not editable in the envelope (part of #3179). Test `tests_native::envelope_options_keep_the_source_text_hidden_under_its_outlined_appearance` |