diff --git a/README.md b/README.md index 7e49943a31..b04c91e066 100644 --- a/README.md +++ b/README.md @@ -252,11 +252,12 @@ speaks English, German, Japanese, Korean, Vietnamese, Traditional and Simplified The scores are self-assessed from our code against public documentation; [target-app-parity.md](docs/target-app-parity.md#how-this-was-measured-2026-10-10) explains how far to trust them. +All 57 approximated Photoshop-style raster filters are available. The Effect Gallery includes filter thumbnails and an in-dialog preview. + **What's missing:** - reliable exchange of .ai files (layer structure in real files; the editing copy a save writes keeps live effects only on paths, compound paths and groups that qualify (at most a fill and a stroke, a non-zero fill rule when filled, no large canvas) and whose appearance and effects the art-style writer supports (see [docs/file-format-parity.md](docs/file-format-parity.md)), leaves out other objects' effects other than geometry effects, and writes live objects as their art); - an interaction-fidelity pass covering every tool's modifiers and small behaviours; - 3D and Materials beyond the initial live Revolve (Extrude & Bevel, Inflate, Rotate, materials); -- filter thumbnails and a preview area inside the Effect Gallery (the gallery itself and all 57 Photoshop-style raster filters, approximated, are in); - CJK composition for vertical type (vertical type itself has initial support, with a Japanese interface); - scripting, and the rest of Variables (image and graph kinds, dataset import); - generative AI features. diff --git a/crates/eps/src/ai_editing/tests.rs b/crates/eps/src/ai_editing/tests.rs index 671f639327..c1e1d664df 100644 --- a/crates/eps/src/ai_editing/tests.rs +++ b/crates/eps/src/ai_editing/tests.rs @@ -4108,20 +4108,21 @@ fn predefined_mojikumi_sets_are_reported_as_left_out() { } } -/// The text document has no Bunri-Kinshi: a paragraph with it on and an inseparable pair in it is -/// reported as left out, and comes back off; with it off, or with no pair, nothing is reported. +/// Bunri-Kinshi comes back on or off, with or without an inseparable pair in the paragraph, and +/// nothing is reported. #[test] -fn bunri_kinshi_is_reported_only_for_paragraphs_with_a_pair() { +fn bunri_kinshi_comes_back_with_or_without_a_pair() { use vectorcraft_doc::TextObject; - for (text, on, reported) in [ - ("Wait——now", true, true), - ("Wait——now", false, false), - ("Wait—now", true, false), - ("Wait……now", true, true), - ("Wait‥‥now", true, true), - ("あ〳〵い", true, true), - ("あ〴〵い", true, true), - ("あ〵〳い", true, false), + for (text, on) in [ + ("Wait——now", true), + ("Wait——now", false), + ("Wait—now", true), + ("Wait……now", true), + ("Wait‥‥now", true), + ("あ〳〵い", true), + ("あ〴〵い", true), + ("あ〵〳い", true), + ("あ〵〳い", false), ] { let mut t = TextObject::point(vectorcraft_geom::Point::new(20.0, 40.0), text, style(14.0, Color::BLACK)); t.para.bunri_kinshi = on; @@ -4130,7 +4131,7 @@ fn bunri_kinshi_is_reported_only_for_paragraphs_with_a_pair() { let n = text_node(&mut doc, t); doc.insert(Some(layer), usize::MAX, n).unwrap(); let out = export_ai_editing(&doc, &opts()).unwrap(); - assert_eq!(!out.warnings.is_empty(), reported, "{text} {on}: {:?}", out.warnings); + assert!(out.warnings.is_empty(), "{text} {on}: {:?}", out.warnings); let mut all = vec![]; paths(&read_back(&doc).layers, &mut all); let paras: Vec = all @@ -4140,10 +4141,87 @@ fn bunri_kinshi_is_reported_only_for_paragraphs_with_a_pair() { _ => None, }) .collect(); - assert_eq!(paras, [false], "{text} {on}"); + assert_eq!(paras, [on], "{text} {on}"); } } +/// Each paragraph's Kinsoku set, Kinsoku Shori Type, Burasagari and Bunri-Kinshi come back, and +/// nothing is reported: every value of each, mixed across the paragraphs of one object, Burasagari +/// and Bunri-Kinshi kept under Kinsoku None. The text document lists the Hard and Soft sets, and +/// every paragraph, the normal paragraph style and the document's defaults write all four keys. +/// Another attribute the text document leaves out is still reported. +#[test] +fn kinsoku_settings_come_back_on_each_paragraph() { + use vectorcraft_doc::{Burasagari, Kinsoku, KinsokuShoriType, ParaStyle, TextObject}; + let cases = [ + (Kinsoku::None, KinsokuShoriType::PushInFirst, Burasagari::Forced, true), + (Kinsoku::Soft, KinsokuShoriType::PushOutFirst, Burasagari::Standard, false), + (Kinsoku::Hard, KinsokuShoriType::PushOutOnly, Burasagari::None, true), + (Kinsoku::Hard, KinsokuShoriType::PushInFirst, Burasagari::Standard, false), + (Kinsoku::Soft, KinsokuShoriType::PushOutOnly, Burasagari::Forced, true), + ]; + let mut t = TextObject::point(vectorcraft_geom::Point::new(20.0, 40.0), "かな。\n「かな」、\nあ——い\nかな\nかな。", style(14.0, Color::BLACK)); + t.set_paragraph_styles( + cases + .iter() + .map(|&(kinsoku, kinsoku_type, burasagari, bunri_kinshi)| ParaStyle { + kinsoku, + kinsoku_type, + burasagari, + bunri_kinshi, + ..ParaStyle::default() + }) + .collect(), + ); + let mut doc = Document::new(200.0, 200.0); + let layer = doc.layers[0].id; + let n = text_node(&mut doc, t); + doc.insert(Some(layer), usize::MAX, n).unwrap(); + let out = export_ai_editing(&doc, &opts()).unwrap(); + assert!(out.warnings.is_empty(), "{:?}", out.warnings); + let back = read_back(&doc); + same_lines(&type_lines(&back), &type_lines(&doc)); + let mut all = vec![]; + paths(&back.layers, &mut all); + let read: Vec<_> = all + .iter() + .filter_map(|n| match &n.kind { + NodeKind::Text(t) => { + Some(t.paragraph_styles().iter().map(|p| (p.kinsoku, p.kinsoku_type, p.burasagari, p.bunri_kinshi)).collect::>()) + } + _ => None, + }) + .collect(); + assert_eq!(read, [cases.to_vec()]); + let decoded = text_document(&program(&doc)); + let hard = decoded.find("PhotoshopKinsokuHard").unwrap_or_else(|| panic!("{decoded}")); + let soft = decoded.find("PhotoshopKinsokuSoft").unwrap_or_else(|| panic!("{decoded}")); + assert!(hard < soft, "{decoded}"); + // Five paragraphs, then the normal paragraph style and the document's defaults (Hard, Push Out + // Only, no Burasagari, Bunri-Kinshi off). + for (key, count) in [ + ("/27 /nil", 1), + ("/27 0", 4), + ("/27 1", 2), + ("/25 0", 2), + ("/25 1", 1), + ("/25 2", 4), + ("/24 0", 3), + ("/24 1", 2), + ("/24 2", 2), + ("/23 true", 3), + ("/23 false", 4), + ] { + assert_eq!(decoded.matches(key).count(), count, "{key}: {decoded}"); + } + let mut lossy = doc.clone(); + let id = lossy.layers[0].children().unwrap()[0].id; + if let Some(NodeKind::Text(t)) = lossy.node_mut(id).map(|n| &mut n.kind) { + t.runs[0].style.underline = true; + } + assert_eq!(export_ai_editing(&lossy, &opts()).unwrap().warnings, [writer::TYPE_ATTRIBUTES]); +} + #[test] fn type_is_written_again_as_the_same_bytes() { let doc = typed(); diff --git a/crates/eps/src/ai_editing/writer/texts.rs b/crates/eps/src/ai_editing/writer/texts.rs index f1fb10af78..9bc831e537 100644 --- a/crates/eps/src/ai_editing/writer/texts.rs +++ b/crates/eps/src/ai_editing/writer/texts.rs @@ -22,15 +22,15 @@ use kurbo::{ParamCurveArclen, PathSeg}; use vectorcraft_color::{Color, Paint}; use vectorcraft_doc::text::MAX_AKI; use vectorcraft_doc::{ - AreaOptions, Burasagari, CharStyle, Document, FirstBaseline, Justify, LineCap, LineJoin, Mojikumi, Node, NodeId, NodeKind, ParaDirection, - ParaStyle, PathAlign, PathEffect, TextKind, TextObject, VerticalAlign, + AreaOptions, Burasagari, CharStyle, Document, FirstBaseline, Justify, Kinsoku, KinsokuShoriType, 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 its weight, cap, join and miter limit, capitals, small capitals at the document's size, Tate-chu-yoko, Standard Vertical Roman Alignment, Insert Aki, 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, Insert Aki, paragraph alignment, indents, space before and after, hyphenation on or off, right-to-left direction, Kinsoku set, Kinsoku Shori Type, Burasagari and Bunri-Kinshi (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; @@ -161,6 +161,10 @@ fn dict(pairs: Vec<(u32, V)>) -> V { V::Dict(pairs) } +/// The document's Kinsoku sets, in order, each by its name: a paragraph's key 27 is its set's index +/// here (0 Hard, 1 Soft), as the reader's `kinsoku_sets` reads them. +const KINSOKU_SETS: [&str; 2] = ["PhotoshopKinsokuHard", "PhotoshopKinsokuSoft"]; + /// A resource set: its resources, each `<< /0 resource >>`, and its display list. fn set(resources: Vec, display: bool) -> V { let mut d = vec![(0, V::List(resources.into_iter().map(|r| dict(vec![(0, r)])).collect()))]; @@ -401,7 +405,6 @@ pub(in crate::ai_editing) fn loses(t: &TextObject) -> bool { } let hangul = text.chars().any(|c| matches!(c, '\u{1100}'..='\u{11FF}' | '\u{3130}'..='\u{318F}' | '\u{A960}'..='\u{A97F}' | '\u{AC00}'..='\u{D7FF}' | '\u{FFA0}'..='\u{FFDC}')); - let pairs = text.chars().zip(text.chars().skip(1)).any(|(a, b)| vectorcraft_text::inseparable(a, b)); let held = ParaStyle { justify: p.justify, left_indent: p.left_indent, @@ -411,16 +414,16 @@ pub(in crate::ai_editing) fn loses(t: &TextObject) -> bool { space_after: p.space_after, hyphenate: p.hyphenate, direction: p.direction.filter(|d| *d == ParaDirection::RightToLeft), - // The composition of Japanese punctuation new type gets (Line-end Punctuation Half - // Width, Standard burasagari) isn't reported: it changes no other script's lines. + // The Mojikumi set new type gets (Line-end Punctuation Half Width) isn't reported: it + // changes no other script's lines. mojikumi: if p.mojikumi == Mojikumi::LineEndHalf { p.mojikumi } else { ParaStyle::default().mojikumi }, - burasagari: if p.burasagari == Burasagari::Standard { p.burasagari } else { ParaStyle::default().burasagari }, + kinsoku: p.kinsoku, + kinsoku_type: p.kinsoku_type, + burasagari: p.burasagari, + bunri_kinshi: p.bunri_kinshi, // The text document has no Korean Line Break Anywhere: it reads back off, which // changes only the lines of a paragraph with Hangul in it. korean_line_break_anywhere: p.korean_line_break_anywhere && !hangul, - // Nor Bunri-Kinshi: it reads back off, which changes only the lines of a paragraph - // with an inseparable pair in it. - bunri_kinshi: p.bunri_kinshi && !pairs, ..ParaStyle::default() }; *p != held @@ -756,6 +759,32 @@ impl TextDoc { (4, len(p.space_before)), (5, len(p.space_after)), (9, V::Bool(p.hyphenate)), + (23, V::Bool(p.bunri_kinshi)), + ( + 24, + V::Int(match p.burasagari { + Burasagari::None => 0, + Burasagari::Standard => 1, + Burasagari::Forced => 2, + }), + ), + ( + 25, + V::Int(match p.kinsoku_type { + KinsokuShoriType::PushInFirst => 0, + KinsokuShoriType::PushOutFirst => 1, + KinsokuShoriType::PushOutOnly => 2, + }), + ), + // The Kinsoku set: `/nil` for None, else its index in the document's sets ([`KINSOKU_SETS`]). + ( + 27, + match p.kinsoku { + Kinsoku::None => V::Name("nil"), + Kinsoku::Hard => V::Int(0), + Kinsoku::Soft => V::Int(1), + }, + ), ]; if rtl { d.push((33, V::Int(1))); @@ -1110,7 +1139,7 @@ impl TextDoc { (1, set(fonts, true)), (2, empty()), (3, empty()), - (4, empty()), + (4, set(KINSOKU_SETS.iter().map(|name| dict(vec![(0, V::Str((*name).into()))])).collect(), false)), (5, set(vec![normal_char], true)), (6, set(vec![normal_para], true)), (8, set(self.frames.iter().map(clone_v).collect(), false)), diff --git a/crates/eps/src/import/ai/read.rs b/crates/eps/src/import/ai/read.rs index d32ae4c177..67928785ad 100644 --- a/crates/eps/src/import/ai/read.rs +++ b/crates/eps/src/import/ai/read.rs @@ -581,6 +581,10 @@ struct Reader<'a> { blend_budget: usize, /// The group on `%_` lines an envelope's dictionary just took (see [`Reader::drop_envelope_art`]). envelope_art: Option, + /// The envelope dictionaries whose `AIDeformArtKey` block (`X=` … `X+`) is being read, as how + /// many frames were open with each one innermost, innermost last. The block's art is art: a + /// dictionary in it is read as it is in a container ([`Reader::end_obj`]). + envelope_blocks: Vec, /// Envelopes waiting for their drawn art, innermost last. envelopes: Vec, /// How many more envelopes and warped text objects this import may draw to check @@ -813,6 +817,7 @@ impl<'a> Reader<'a> { blend_record: None, blend_budget: BLEND_BUDGET, envelope_art: None, + envelope_blocks: Vec::new(), envelopes: Vec::new(), envelope_checks: super::envelope::MAX_CHECKED, envelope_spent: super::envelope::Spent::default(), diff --git a/crates/eps/src/import/ai/read/containers.rs b/crates/eps/src/import/ai/read/containers.rs index 8e670b3f72..830e3a48d0 100644 --- a/crates/eps/src/import/ai/read/containers.rs +++ b/crates/eps/src/import/ai/read/containers.rs @@ -482,6 +482,13 @@ impl<'a> Reader<'a> { return Ok(()); } let Some(f) = self.frames.pop() else { return Ok(()) }; + let depth = self.frames.len(); + // A block left open ends with its dictionary, and envelopes still waiting in it keep their + // drawn art: nothing after the dictionary is theirs. + self.envelope_blocks.retain(|d| *d <= depth); + while let Some(p) = self.envelopes.pop_if(|p| p.depth > depth) { + self.warn(crate::import::ai::envelope::drawn_note(p.typed)); + } let Frame { kind, children, base, hidden, gs, pending_unread_fill, pending_unread_stroke, after, mesh_ended, state, .. } = f; let Kind::Obj(mut o) = kind else { return Ok(()) }; obj::close(&mut o, &mut self.stack, base); @@ -496,7 +503,10 @@ impl<'a> Reader<'a> { } self.after_object = after; self.mesh_ended = mesh_ended; - if matches!(self.frames.last(), Some(Frame { kind: Kind::Obj(_), .. })) { + // An object's dictionary in an envelope's `AIDeformArtKey` block is about the art before it + // there, as it is in a container: an envelope there is one of the outer envelope's contents. + let in_block = o.ty == "ArtDictionary" && self.envelope_blocks.last() == Some(&depth); + if !in_block && matches!(self.frames.last(), Some(Frame { kind: Kind::Obj(_), .. })) { return self.push(V::Obj(Rc::new(*o))); } match o.ty.as_str() { diff --git a/crates/eps/src/import/ai/read/ops.rs b/crates/eps/src/import/ai/read/ops.rs index 8c032d5d83..721a6988ba 100644 --- a/crates/eps/src/import/ai/read/ops.rs +++ b/crates/eps/src/import/ai/read/ops.rs @@ -41,8 +41,19 @@ impl<'a> Reader<'a> { } /// `X=` in a `/Mask` dictionary: the opacity mask's art starts, read until its `X+` - /// ([`Self::end_mask`]). + /// ([`Self::end_mask`]). In an envelope's dictionary, its `AIDeformArtKey` block starts + /// ([`Reader::envelope_blocks`]). fn open_mask_object(&mut self, hidden: bool) -> Result<(), String> { + if let Some(Frame { kind: Kind::Obj(o), .. }) = self.frames.last() + && o.ty == "ArtDictionary" + && o.get("AIDeformOptionsKey").is_some() + { + // One entry for each open dictionary, however many `X=` it has. + if self.envelope_blocks.last() != Some(&self.frames.len()) { + self.envelope_blocks.push(self.frames.len()); + } + return Ok(()); + } if !matches!(self.frames.last(), Some(Frame { kind: Kind::Obj(o), .. }) if o.ty == "Mask") { // `X=` also introduces legacy text frame art, read by `text_frames`. return Ok(()); @@ -63,6 +74,22 @@ impl<'a> Reader<'a> { self.open(Kind::Mask, hidden) } + /// `X+`: a dictionary's art block ends. The block of a dictionary whose `X=` opened no frame (an + /// envelope's, in an opacity mask's art too) ends with the dictionary still innermost; an opacity + /// mask's ends its art ([`Self::end_mask`]). + fn end_block(&mut self) -> Result<(), String> { + if matches!(self.frames.last(), Some(Frame { kind: Kind::Obj(_), .. })) { + if self.envelope_blocks.last() == Some(&self.frames.len()) { + self.envelope_blocks.pop(); + } + return Ok(()); + } + if self.frames.iter().any(|f| matches!(f.kind, Kind::Mask)) { + return self.end_mask(); + } + Ok(()) + } + /// `LB`: a graph started in this layer that hasn't ended doesn't go on past it; the layer ends. fn layer_end(&mut self) -> Result<(), String> { if self.graph.as_ref().is_some_and(|g| g.frames >= self.frames.len()) { @@ -159,8 +186,7 @@ impl<'a> Reader<'a> { } ";" => return self.end_obj(), "X=" => return self.open_mask_object(hidden), - "X+" if self.frames.iter().any(|f| matches!(f.kind, Kind::Mask)) => return self.end_mask(), - "X+" => return Ok(()), + "X+" => return self.end_block(), _ => {} } self.state_block_continues(w); diff --git a/crates/eps/src/import/ai/tests.rs b/crates/eps/src/import/ai/tests.rs index 7013468d25..f133a9ee03 100644 --- a/crates/eps/src/import/ai/tests.rs +++ b/crates/eps/src/import/ai/tests.rs @@ -8620,6 +8620,224 @@ fn an_envelope_off_the_page_opens_live_when_it_draws_close_to_its_drawn_art() { assert!(r.warnings.is_empty(), "{:?}", r.warnings); } +/// `art` on `%_` lines, as the editing data writer hides it: a line already on one stays as it is. +fn hidden_once(art: &str) -> String { + art.lines().map(|l| if l.starts_with("%_") { format!("{l}\n") } else { format!("%_{l}\n") }).collect() +} + +/// A mesh envelope of `content` shaped by the grid `shape`, with Edit Contents on (`editing`: the +/// contents in the group before the dictionary, the grid in its `AIDeformArtKey` block) or off (the +/// other way round, the contents in a plain group), then `drawn` and the plugin's `XP`. An envelope +/// record in `content` keeps its own `%_` lines. +fn mesh_envelope(shape: &str, editing: bool, content: &str, drawn: &str) -> String { + let keys = ENVELOPE_KEYS.replace("0 /Bool (AIDeformEditModeKey)", &format!("{} /Bool (AIDeformEditModeKey)", u8::from(editing))); + let (before, block) = if editing { (content.to_string(), shape.to_string()) } else { (shape.to_string(), format!("u\n{content}U\n9 () XW\n")) }; + format!( + "{}%_/ArtDictionary :\n{}%_X=\n{}%_X+\n%_ (AIDeformArtKey) ,\n%_2 /Int (AIDeformStyle) ,\n%_;\n%_\n%_9 () XW\nu\n{drawn}U\n9 () XW\n(Adobe Deform Plugin) 1 0 0 XP\n", + hidden_once(&format!("u\n{before}U\n")), + commented(&keys), + hidden_once(&block), + ) +} + +/// The mesh envelope that maps the box 40 20 160 80 onto itself, and what it draws of +/// [`envelope_content`]. +fn flat_mesh() -> (String, vectorcraft_doc::live::EnvelopeKind, String) { + let flat = vectorcraft_doc::live::EnvelopeKind::Mesh { + rows: 1, + cols: 1, + points: vec![Point::new(40.0, 20.0), Point::new(160.0, 20.0), Point::new(40.0, 80.0), Point::new(160.0, 80.0)], + handles: vec![], + }; + let drawn = drawn_content(&flat); + (straight_mesh(1, 1, (120.0, 60.0)), flat, drawn) +} + +/// An envelope inside another envelope's contents opens as a live envelope inside the live outer +/// one, with Edit Contents on or off at either level: each keeps its own kind, contents and Edit +/// Contents, and neither leaves a copy of its contents drawn beside it. +#[test] +fn an_envelope_in_an_envelope_s_contents_opens_live_inside_it() { + use vectorcraft_doc::live::EnvelopeKind; + let (shape, flat, drawn) = flat_mesh(); + let EnvelopeKind::Mesh { points, .. } = &flat else { panic!("{flat:?}") }; + for (outer_editing, inner_editing) in [(false, false), (false, true), (true, false), (true, true)] { + let why = format!("outer editing {outer_editing}, inner editing {inner_editing}"); + let inner = mesh_envelope(&shape, inner_editing, &envelope_content(), &drawn); + let r = read_ok(&layer(&mesh_envelope(&shape, outer_editing, &hidden_once(&inner), &format!("u\n{drawn}U\n")))); + let a = art(&r.doc); + assert_eq!(a.len(), 1, "{why}: {a:#?}"); + let flat_points = |kind: &EnvelopeKind| match kind { + EnvelopeKind::Mesh { rows: 1, cols: 1, points, .. } => Some(points.clone()), + _ => None, + }; + let (content, kind, _, editing, _) = live_envelope(&a[0]); + assert_eq!((flat_points(kind), editing), (Some(points.clone()), outer_editing), "{why}: {kind:?}"); + assert_eq!(content.len(), 1, "{why}: {content:#?}"); + let (inner, kind, _, editing, _) = live_envelope(&content[0]); + assert_eq!((flat_points(kind), editing), (Some(points.clone()), inner_editing), "{why}: {kind:?}"); + assert_eq!(inner.len(), 1, "{why}: {inner:#?}"); + assert!(matches!(inner[0].kind, NodeKind::Path { .. }), "{why}: {inner:#?}"); + assert_eq!(fill(&inner[0]).color(), Some(Color::rgb(1.0, 0.0, 0.0)), "{why}"); + assert!(r.warnings.is_empty(), "{why}: {:?}", r.warnings); + } +} + +/// An outer envelope, Edit Contents off, whose `AIDeformArtKey` block holds `block` as it is, with no +/// plain group around it, shaped by the grid `shape` and drawn as `drawn`. +fn bare_block_envelope(shape: &str, block: &str, drawn: &str) -> String { + format!( + "{}%_/ArtDictionary :\n{}%_X=\n{}%_X+\n%_ (AIDeformArtKey) ,\n%_2 /Int (AIDeformStyle) ,\n%_;\n%_\n%_9 () XW\nu\n{drawn}U\n9 () XW\n(Adobe Deform Plugin) 1 0 0 XP\n", + hidden_once(&format!("u\n{shape}U\n")), + commented(ENVELOPE_KEYS), + hidden_once(block), + ) +} + +/// An envelope record straight in another envelope's `AIDeformArtKey` block, with no group around +/// it, opens live inside the live outer envelope too. Two of them there are both its contents. +#[test] +fn an_envelope_straight_in_an_envelope_s_block_opens_live_inside_it() { + let (shape, _, drawn) = flat_mesh(); + let inner = hidden_once(&mesh_envelope(&shape, false, &envelope_content(), &drawn)); + let r = read_ok(&layer(&bare_block_envelope(&shape, &inner, &format!("u\n{drawn}U\n")))); + let a = art(&r.doc); + assert_eq!(a.len(), 1, "{a:#?}"); + let (content, ..) = live_envelope(&a[0]); + assert_eq!(content.len(), 1, "{content:#?}"); + let (inner_content, ..) = live_envelope(&content[0]); + assert!(matches!(inner_content[0].kind, NodeKind::Path { .. }), "{inner_content:#?}"); + assert!(r.warnings.is_empty(), "{:?}", r.warnings); + // Two envelopes side by side in the block: both are the outer envelope's contents, each live. + let two = format!("{inner}{inner}"); + let r = read_ok(&layer(&bare_block_envelope(&shape, &two, &format!("u\n{drawn}U\nu\n{drawn}U\n")))); + let a = art(&r.doc); + assert_eq!(a.len(), 1, "{a:#?}"); + let (content, ..) = live_envelope(&a[0]); + assert_eq!(content.len(), 2, "{content:#?}"); + content.iter().for_each(|c| _ = live_envelope(c)); + assert!(r.warnings.is_empty(), "{:?}", r.warnings); +} + +/// An object's own dictionary straight in an envelope's block, with no group around them, names it +/// there. +#[test] +fn an_object_s_dictionary_straight_in_an_envelope_s_block_names_it() { + let (shape, _, drawn) = flat_mesh(); + let block = format!("{}/ArtDictionary :\n(Box) /String (AIArtName) ,\n;\n", envelope_content()); + let r = read_ok(&layer(&bare_block_envelope(&shape, &block, &format!("u\n{drawn}U\n")))); + let a = art(&r.doc); + assert_eq!(a.len(), 1, "{a:#?}"); + let (content, ..) = live_envelope(&a[0]); + assert_eq!(content.len(), 1, "{content:#?}"); + assert_eq!(content[0].name.as_deref(), Some("Box"), "{content:#?}"); +} + +/// An inner envelope that doesn't open live keeps its drawn art inside the live outer envelope, +/// drawn once, without its own source art: one whose drawn art differs from what it draws, and one +/// whose record doesn't read (a style number past the list). The outer envelope maps that drawn +/// art's box onto its grid. An outer envelope that doesn't match its drawn art keeps that drawn +/// art, and nothing of its record is drawn. +#[test] +fn a_nested_envelope_that_doesn_t_open_live_keeps_its_drawn_art() { + let (shape, _, drawn) = flat_mesh(); + let shifted = format!("{RED}{}f\n", rect_path(42.0, 20.0, 162.0, 80.0)); + let unread = mesh_envelope(&shape, false, &envelope_content(), &drawn).replace("%_2 /Int (AIDeformStyle)", "%_9 /Int (AIDeformStyle)"); + for (why, inner) in [("drawn elsewhere", mesh_envelope(&shape, false, &envelope_content(), &shifted)), ("unread", unread)] { + for bare in [false, true] { + let inner = hidden_once(&inner); + let outer_drawn = format!("u\n{drawn}U\n"); + let body = if bare { bare_block_envelope(&shape, &inner, &outer_drawn) } else { mesh_envelope(&shape, false, &inner, &outer_drawn) }; + let r = read_ok(&layer(&body)); + let a = art(&r.doc); + assert_eq!(a.len(), 1, "{why}, bare {bare}: {a:#?}"); + assert!(matches!(a[0].kind, NodeKind::Envelope { .. }), "{why}, bare {bare}: {:?}", r.warnings); + let (content, ..) = live_envelope(&a[0]); + assert_eq!(content.len(), 1, "{why}, bare {bare}: {content:#?}"); + // The inner envelope's drawn art: its group of one path, or, straight in the block, the + // path, as the plain group around a block's only object is the block's own. + let drawn_path = if bare { + &content[0] + } else { + let kids = content[0].children().unwrap_or_else(|| panic!("{why}: {content:#?}")); + assert_eq!(kids.len(), 1, "{why}: {content:#?}"); + &kids[0] + }; + assert!(matches!(drawn_path.kind, NodeKind::Path { .. }), "{why}, bare {bare}: {content:#?}"); + assert_eq!(r.warnings, [super::envelope::DRAWN], "{why}, bare {bare}"); + } + } + // The outer envelope's drawn art lies elsewhere: it stays, as one group, the inner record with it. + let inner = hidden_once(&mesh_envelope(&shape, false, &envelope_content(), &drawn)); + for bare in [false, true] { + let outer_drawn = format!("u\n{shifted}U\n"); + let body = if bare { bare_block_envelope(&shape, &inner, &outer_drawn) } else { mesh_envelope(&shape, false, &inner, &outer_drawn) }; + let r = read_ok(&layer(&body)); + let a = art(&r.doc); + assert_eq!(a.len(), 1, "bare {bare}: {a:#?}"); + assert!(matches!(a[0].kind, NodeKind::Group { .. }), "bare {bare}: {a:#?}"); + assert_eq!(a[0].geometric_bounds(), Some(Rect::new(42.0, 20.0, 162.0, 80.0)), "bare {bare}"); + assert_eq!(r.warnings, [super::envelope::DRAWN], "bare {bare}"); + } +} + +/// Broken nested records read without taking another record's place: an inner `X=` with no `X+` +/// (the inner envelope still opens live), an inner dictionary with no group before it (its drawn art +/// stays), and an `XP` repeated after the inner one. The outer envelope, whose own record is whole, +/// still opens live around what the inner one left. +#[test] +fn broken_nested_envelope_records_keep_the_outer_envelope() { + let (shape, _, drawn) = flat_mesh(); + let inner = hidden_once(&mesh_envelope(&shape, false, &envelope_content(), &drawn)); + let no_end = inner.replacen("%_X+\n", "", 1); + let no_group = inner.replacen(&hidden_once(&format!("u\n{shape}U\n")), "", 1); + let twice = inner.replace("%_(Adobe Deform Plugin) 1 0 0 XP\n", "%_(Adobe Deform Plugin) 1 0 0 XP\n%_(Adobe Deform Plugin) 1 0 0 XP\n"); + for (why, inner, live) in [("no X+", no_end, true), ("no group", no_group, false), ("XP twice", twice, true)] { + for bare in [false, true] { + let outer_drawn = format!("u\n{drawn}U\n"); + let body = if bare { bare_block_envelope(&shape, &inner, &outer_drawn) } else { mesh_envelope(&shape, false, &inner, &outer_drawn) }; + let r = read_ok(&layer(&body)); + let a = art(&r.doc); + assert_eq!(a.len(), 1, "{why}, bare {bare}: {a:#?}"); + let (content, ..) = live_envelope(&a[0]); + assert_eq!(content.len(), 1, "{why}, bare {bare}: {content:#?}"); + assert_eq!(matches!(content[0].kind, NodeKind::Envelope { .. }), live, "{why}, bare {bare}: {content:#?}"); + } + } +} + +/// An inner envelope whose `XP` is missing stops waiting when the outer envelope's dictionary ends: +/// a stray `XP` in a later group leaves that group's art as it is. +#[test] +fn an_inner_envelope_without_its_xp_does_not_take_later_art() { + let (shape, _, drawn) = flat_mesh(); + let inner = hidden_once(&mesh_envelope(&shape, false, &envelope_content(), &drawn)).replace("%_(Adobe Deform Plugin) 1 0 0 XP\n", ""); + let later = format!("u\nu\n{drawn}U\n(Adobe Deform Plugin) 1 0 0 XP\nU\n"); + let r = read_ok(&layer(&format!("{}{later}", bare_block_envelope(&shape, &inner, &format!("u\n{drawn}U\n"))))); + let a = art(&r.doc); + assert_eq!(a.len(), 2, "{a:#?}"); + live_envelope(&a[0]); + let kids = a[1].children().unwrap_or_else(|| panic!("{a:#?}")); + assert!(matches!(kids[0].kind, NodeKind::Group { .. }), "{a:#?}"); + assert_eq!(r.warnings, [super::envelope::DRAWN], "{:?}", r.warnings); +} + +/// An envelope in an opacity mask's art reads: its block's `X+` ends the block, not the mask's art. +#[test] +fn an_envelope_in_an_opacity_mask_s_art_reads() { + let (shape, _, drawn) = flat_mesh(); + let owner = format!("{RED}{}f\n", rect_path(10.0, 20.0, 170.0, 90.0)); + let envelope = hidden_once(&mesh_envelope(&shape, false, &envelope_content(), &drawn)); + let mask = format!( + "%_/ArtDictionary :\n%_/Mask :\n%_1 /Bool (Linked) ,\n%_X=\n%_u\n{envelope}%_U\n%_X+\n%_(Art) ,\n%_1 /Bool (Clipping) ,\n%_0 /Bool (Inverted) ,\n%_0 /Bool (Disabled) ,\n%_; (AI9_Mask) ,\n%_;\n" + ); + let r = read_ok(&layer(&format!("{owner}{mask}"))); + let a = art(&r.doc); + assert_eq!(a.len(), 1, "{a:#?}"); + let m = a[0].extras().mask.as_ref().unwrap_or_else(|| panic!("{a:#?}")); + assert_eq!(m.art.geometric_bounds(), Some(Rect::new(40.0, 20.0, 160.0, 80.0)), "{:#?}", m.art); +} + #[test] fn a_gradient_mesh_reads_as_a_patch() { let corner = |rgb: &str, x: i32, y: i32, next: (i32, i32), prev: (i32, i32)| { diff --git a/crates/ui-egui/src/i18n/ka.tsv b/crates/ui-egui/src/i18n/ka.tsv index 77a01d2c6a..59836e55a8 100644 --- a/crates/ui-egui/src/i18n/ka.tsv +++ b/crates/ui-egui/src/i18n/ka.tsv @@ -6708,7 +6708,6 @@ font-technology Variable ცვლადი @msg plug-in failed: {0} პლაგინის შეცდომა: {0} @msg plug-in manifest: {0} პლაგინის მანიფესტი: {0} @msg plug-in output: {0} პლაგინის შედეგი: {0} -@msg [{j}] `{_1}`: {why} [{j}] `{_1}`: {why} @msg plug-in parameters: {0} პლაგინის პარამეტრები: {0} @msg plug-in trapped: {0} პლაგინი ავარიულად შეჩერდა: {0} @msg print settings are not an object ბეჭდვის პარამეტრები ობიექტი არ არის @@ -7806,17 +7805,6 @@ imageTrace Outline კონტური @msg `name` must be the name of a script engine `name` უნდა იყოს სკრიპტის ძრავის სახელი @msg the main script engine is new for each run and is never kept, so it cannot be dropped მთავარი სკრიპტის ძრავა ყოველი გაშვებისთვის ახალია და არასოდეს ინახება, ამიტომ მისი მოცილება შეუძლებელია @msg there is no script engine named {name} სკრიპტის ძრავა სახელით {name} არ არსებობს -@msg `answers` must be a list of answers `answers` უნდა იყოს პასუხების სია -@msg `answers` has more than {MAX_ANSWERS} answers `answers`-ში {MAX_ANSWERS}-ზე მეტი პასუხია -@msg `answers` is larger than 1 MiB `answers` 1 MiB-ზე დიდია -@msg `answers[{i}]` {e} `answers[{i}]`: {e} -@msg must be an object with `kind` and `value` უნდა იყოს ობიექტი `kind`-ითა და `value`-ით -@msg must have exactly `kind` and `value` უნდა ჰქონდეს ზუსტად `kind` და `value` -@msg is an alert's answer, whose `value` must be "ok" alert-ის პასუხია, ამიტომ მისი `value` უნდა იყოს "ok" -@msg is a confirm's answer, whose `value` must be true or false confirm-ის პასუხია, ამიტომ მისი `value` უნდა იყოს true ან false -@msg is longer than 16 KiB 16 KiB-ზე გრძელია -@msg is a prompt's answer, whose `value` must be a string or null prompt-ის პასუხია, ამიტომ მისი `value` უნდა იყოს სტრიქონი ან null -@msg must have `kind` alert, confirm or prompt `kind` უნდა იყოს alert, confirm ან prompt @msg `answers` is only for a run with nobody to ask: in the app, the person answers each dialog `answers` მხოლოდ ისეთი გაშვებისთვისაა, სადაც შესაკითხი არავინაა: აპში თითოეულ დიალოგს ადამიანი პასუხობს @msg Running a script სკრიპტი მუშაობს @msg Copied the script report კოპირებულია სკრიპტის ანგარიში diff --git a/docs/control-protocol.md b/docs/control-protocol.md index d52720f29e..b0bd90eb59 100644 --- a/docs/control-protocol.md +++ b/docs/control-protocol.md @@ -1,6 +1,6 @@ # Control protocol -> **Last updated:** 2026-11-07 · **Change:** text style commands take a feature with a value (`"features": ["salt=2"]`), the font's alternate number, and shape runs that differ only in features as one piece of text; previously: Confining file access refuses a `:` in a name after the drive on Windows, an NTFS alternate data stream such as `x.svg:evil` (#3604); previously: Object → Expand… dimmed while the selection has an appearance Expand Appearance expands (`object.expand.info`'s `appearance`, #3089); previously: brush library panels (`window.brushLibrary`, `window.brushLibrary.other`, `ui.saveBrushLibrary`); previously: Transform Each opens on the values it was last confirmed with; its Copy button confirms with `copy: true` (#1732); previously: panel docking on craft-ui's layout: `ui.docking`, the parameters of `window.panel.float` and `window.panel.dock`; previously: `ui.screenshot`'s 64 MiB inline-PNG cap and its path-plus-data downgrade; symbol library panels (`window.symbolLibrary`, `window.symbolLibrary.other`, `ui.saveSymbolLibrary`); synthetic input budgets (#1709); bounded transport waits (#1726). +> **Last updated:** 2026-11-15 · **Change:** completed the `ui.resize` and `ui.focus` entries and removed a duplicate `ui.render` row; previously: text style commands take a feature with a value (`"features": ["salt=2"]`), the font's alternate number, and shape runs that differ only in features as one piece of text; previously: Confining file access refuses a `:` in a name after the drive on Windows, an NTFS alternate data stream such as `x.svg:evil` (#3604); previously: Object → Expand… dimmed while the selection has an appearance Expand Appearance expands (`object.expand.info`'s `appearance`, #3089); previously: brush library panels (`window.brushLibrary`, `window.brushLibrary.other`, `ui.saveBrushLibrary`); previously: Transform Each opens on the values it was last confirmed with; its Copy button confirms with `copy: true` (#1732); previously: panel docking on craft-ui's layout: `ui.docking`, the parameters of `window.panel.float` and `window.panel.dock`; previously: `ui.screenshot`'s 64 MiB inline-PNG cap and its path-plus-data downgrade; symbol library panels (`window.symbolLibrary`, `window.symbolLibrary.other`, `ui.saveSymbolLibrary`); synthetic input budgets (#1709); bounded transport waits (#1726). `vectorcraft --control ` listens on `127.0.0.1:` (loopback only). The port must be a number from 1 to 65535 (`--control=` works too), given once; otherwise the app prints an error and exits @@ -105,8 +105,8 @@ the two can still win that race. | `ui.dialog.set` / `.confirm` / `.cancel` | `{field, value, action?}` or `{fields: [{field, value}…], action?}` | fill and submit the open dialog. `ui.dialog.set` takes the fields the dialog has (`ui.inspect`) other than its `__` fields, the `__` fields that are controls of the dialog (such as `__section` and `__allArtboards` in Save PDF, `__constrain` in Transform Each and the shape dialogs, and `__search` and `__category` in Preferences; Save PDF's and Print's `__savePresetAs`, the preset name, only while Save Preset… asks for it, since OK then saves a preset), `discard` on a dialog with a discard button, `preview` on one with a live preview, the fields that the dialog reads but adds only when a control changes (such as `searchFolder`, or `copy` in the Transform dialogs), and the fields it passes to the command it runs: every parameter of the `command` dialog's command (`ui.paramDialog`), and the `textureImage` that Load Texture… sets in the Glass, Texturizer and Rough Pastels effects; any other field is an error that lists the dialog's fields, and the dialog stays as it was. Any other `__` field, held or not, holds what the dialog's OK acts on (such as the `__command` and `__params` of a `confirm` or `command` dialog) or the dialog's own state: setting it is the error "the `` dialog cannot set protected field ``", and the dialog stays as it was. `fields` sets the list (at most 256 items) in order (a missing `value` is `null`) and checks every item first: when one is refused, the call is an error and no field changes. `action: "confirm"` or `"cancel"` on `ui.dialog.set` then confirms or cancels the same dialog in the same request, as `ui.dialog.confirm` and `ui.dialog.cancel` do, and the reply is `{dialog, result}`: the dialog after the fields were set and the reply to confirm (`null` for cancel); any other `action` is an error and no field changes | | `ui.screenshot` | `{path?, data?}` | capture the window (PNG), written to `path`; `data: true` sends it back as `pngBase64` too. The inline PNG is capped at 64 MiB: with a `path` the file is still written and `pngBase64` is omitted when the image is larger, while a path-less `data: true` request fails if either the raw RGBA image or the encoded PNG exceeds the cap. Needs a presented frame: with the screen locked or the window minimized/covered it fails after ~8 s with an explanatory error | | `ui.render` | `{path?, scale?, artboard?, data?, traceViews?}` | render an artboard headlessly (PNG with the pixels of `app.export {format: "png", background: "white"}` at the same scale; `artboard`: its 0-based index, default the first; `traceViews: true`: Image Trace objects draw in their View, as on the canvas, instead of their tracing result): written to `path`, else (or with `data: true` as well) sent back as `pngBase64` | -| `ui.render` | `{path?, scale?, artboard?, data?}` | render an artboard headlessly (PNG with the pixels of `app.export {format: "png", background: "white"}` at the same scale; `artboard`: its 0-based index, default the first): written to `path`, else (or with `data: true` as well) sent back as `pngBase64` | -| `ui.resize` / `ui.focus` | | | +| `ui.resize` | `{width?, height?}` | request the window's inner size; omitted or null `width` and `height` default to 1440 and 900. Returns `null` | +| `ui.focus` | `{}` | request keyboard focus for the window. Returns `null` | | `app.open` / `app.save` / `app.export` / `app.quit` | `{path, importer?, params?}` / `{path?}` / `{path?, format?, artboard?, range?, scale?, …}` | `app.open` reads every format `document.open` reads (see `document.formats`) and returns its result (`{index, title, format, warnings, …}`; `warnings` say what didn't come in as it was, such as an EPS shown as its preview image and why its PostScript couldn't be read), or null when a dialog asks first or a library loads. The status bar (`ui.inspect` → `ui.status`), after a line about the document's linked files when there is one (Missing linked files below), counts the `warnings` and names the first that says reading the file left something out (the notes that make Save ask before writing over it), else the first; with several, hovering it lists them in that order, as many as fit in the window (at most 30), then how many more. After `app.save`, and `app.export` with a `path`, the status bar counts the reply's `warnings` and names the first, and with several, hovering it lists them in the reply's order (a save's notes start with what the format loses). With `importer` (an importer plug-in's format id, `plugin.`) it runs `file.openWith`: the file opens with that importer, also when a built-in format reads its extension, with the importer's parameters `params` (their defaults when omitted; no dialog asks for them). Without `importer`, `params` go to the importer that opens the file, and a built-in format does not read them. `app.export` encodes through the engine's `document.export` (same options; the document keeps its path) and writes `path` through the host; without `path` it returns `{dataBase64, format, bytes}`, as headless mode does. It refuses a key that no format takes, and an unknown key inside a PDF or EPS option object, before it writes anything (`docs/mcp.md`, Parameter keys). It accepts a key that only another format takes and does not use it: the reply's `warnings` and the status bar name the key ("`quality` was not used: PDF files don't read it"). `app.save` refuses a key that no save format takes, beside the path or in `options`, the same way. `app.quit`, `file.close` and `file.closeAll` first open a `saveChanges` dialog for each modified document (they return `{"pending": "saveChanges"}`): `ui.dialog.confirm` saves, `ui.dialog.set {field: "discard", value: true}` then confirm discards, `ui.dialog.cancel` cancels the whole close or quit | | `app.export` with `useArtboards` | `{path, useArtboards: true, range? \| artboards?, …}` | Export As: PNG, JPEG and WebP write one file per chosen artboard (default all; SVG does so for a `range`), `-.`, and return `{path, files: [path…]}`; a PDF keeps them as pages of `path`. `useArtboards: false` covers the bounds of the visible art. The menu's Export As… is `file.exportAs`: the `exportAs` dialog (`format`, `useArtboards`, `all`, `range`), then a save dialog and the format's options: `pngOptions` / `jpgOptions` / `webpOptions`, `svgOptions`, or `savePdf` for a PDF | | `ui.dialog.*` on `gradientStop` | `{field: "color" \| "opacity" \| "location", value}` | double-clicking a stop on the Gradient tool's annotator opens its popover (fields `index`, `x`, `y`, `tab`); set `color` (hex), `opacity` or `location` (percentages) and `ui.dialog.confirm` to apply them to the selected stop. `gradient.selectStop {index}` picks the stop the annotator, the panels and Delete/←/→ (`ui.key`) act on | @@ -1137,6 +1137,7 @@ hidden tab (whose frames the browser pauses) answers when it is shown again. | Date | Change | Summary | |---|---|---| +| 2026-11-15 | minor | Completed the `ui.resize` defaults and `ui.focus` contract; kept one `ui.render` entry, including `traceViews`. Documentation only. | | 2026-11-07 | minor | Documented OpenType feature values in text commands (`"salt=2"`, the font's alternate number) and that runs differing only in features shape as one piece of text. | | 2026-10-31 | minor | Confining file access: on Windows a `:` in a name after the drive (an NTFS alternate data stream) is refused with the device names (#3604). | | 2026-10-25 | minor | Panel docking on craft-ui's layout: `ui.docking`, the floating and dock parameters, native floating windows. | diff --git a/docs/mcp.md b/docs/mcp.md index 39c4e33e8a..f0db95249d 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -9444,7 +9444,7 @@ a pair. The setting works only with a Kinsoku Set: with Kinsoku Set None the par and the menu item is unavailable. Off by default, for new type and for paragraphs saved before the option. It is saved with the paragraph (`bunri_kinshi` in `para`) only when on, paragraph styles carry it (Paragraph Style Options › East Asian Composition Settings), and Reset Panel turns it off. A value that is not true or false is refused. One undo -step. `.ai` editing data does not hold it, and a save reports a paragraph with it on and a pair in it as left out. The help page: +step. An `.ai` save writes it with each paragraph, under Kinsoku Set None too, and it reopens as set. The help page: https://helpx.adobe.com/illustrator/using/formatting-asian-characters.html ```json diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 71e77136b4..c6a20ee3b5 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -1460,7 +1460,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur Type on a path with right-to-left text that does not fit shows the longest start of its text whose glyphs fit, also where tracking gives a letter a negative advance and a shorter start does not fit; the rest overflows or flows to the next threaded object (VectorCraft's own rule). Without hanging punctuation, and on a straight path or with no spacing, every start is measured without arranging it on the path again. With hanging punctuation, or spacing on a curve, when a letter has a negative advance, at most 32 of the starts that could fit are arranged one by one from the longest; when none of them fits, a binary search over the rest shows a start that fits, which is not always the longest (`overflowing_rtl_type_on_a_path_shows_the_longest_start_that_fits`, `the_longest_rtl_start_on_a_path_is_found_without_arranging_each_start`, `the_longest_rtl_start_on_a_path_is_the_one_each_start_gives` and `rtl_type_on_a_curve_whose_letters_take_the_line_back_is_arranged_a_bounded_number_of_times` in `crates/text/src/tests/bidi.rs`). Right-aligned and centered type on a curve with Spacing is placed by what its glyphs take where the line ends up, so text that fits is not cut off at the end bracket (`on_path_aligned_spaced_text_that_fits_shows_whole` in `crates/text/src/tests.rs`). The Paragraph panel menu's Kinsoku Shori Type (`text.setFormat {kinsokuType}`, with the East Asian options) picks Push In First, Push Out First or Push Out Only for each paragraph: a character the Kinsoku Set keeps off a line's start or end is set on the line, with the line's gaps next to CJK characters closed by at most a tenth of an em each to fit, or moved to the next line with the character before it. Push Out First pushes in only when pushing out would leave the line more than an em short. Push Out Only is the default, the setting is saved only when it is not, and paragraph styles carry it. The Paragraph panel menu's Bunri-Kinshi (`text.setFormat {bunriKinshi}`, with the East Asian options, and the East Asian Composition Settings section of Paragraph Style Options) keeps inseparable pairs on one line in each paragraph that has it: two em dashes, two ellipses, two two-dot leaders and the two halves of a vertical kana repeat mark (the pairs of W3C JLREQ's class cl-08, VectorCraft's set). Both composers and vertical type follow it, and justification adds no space inside a pair. It works with a Kinsoku Set only, is off by default and is saved only when on; editing the set of pairs is not available. - `.ai` import reads each paragraph's Kinsoku Set (None, Hard or Soft), Kinsoku Shori Type, Burasagari and Bunri-Kinshi from the file's editable text: the paragraph's own setting, else its paragraph style's or a style that one is based on, else the document's default. A paragraph with a custom Kinsoku set takes its paragraph style's set, and the importer warns about it. A value it cannot read takes its paragraph style's value too. `.ai` export does not write these settings. + `.ai` import reads each paragraph's Kinsoku Set (None, Hard or Soft), Kinsoku Shori Type, Burasagari and Bunri-Kinshi from the file's editable text: the paragraph's own setting, else its paragraph style's or a style that one is based on, else the document's default. A paragraph with a custom Kinsoku set takes its paragraph style's set, and the importer warns about it. A value it cannot read takes its paragraph style's value too. An `.ai` save writes all four settings for every paragraph, the normal paragraph style and the document's defaults, and lists the Hard and Soft sets, in the form that reader reads, so they reopen as set and are not reported as left out; Burasagari and Bunri-Kinshi keep their values under Kinsoku None. The Paragraph panel menu's Korean Line Break Anywhere (`text.setFormat {koreanLineBreakAnywhere}`, with the East Asian options, and the East Asian Composition Settings section of Paragraph Style Options) sets for each paragraph whether a line may break between any two Hangul syllables. When it is off, a word that contains Hangul breaks only at its spaces and after punctuation, or where the word is wider than the line, and takes no hyphen. New type has it off; paragraphs saved before the option read as on, so their lines do not move. The Paragraph panel menu's Reset Panel (`text.setStyle`, `text.setFormat` and `text.tabs.clear`) gives the selected type's paragraphs, or the paragraphs the Type tool's selection touches, Auto alignment, no indents or paragraph spacing, Hyphenate off with the default Hyphenation and Justification options, Auto direction, Bottom-to-Bottom Leading, the Line-end Punctuation Half Width Mojikumi Set, Regular burasagari, the Hard kinsoku set, Push Out Only, the Every-line Composer, Bunri-Kinshi, Roman Hanging Punctuation and Korean Line Break Anywhere off, no kashidas and no tab stops, as one undo step. The Paragraph panel menu's Hyphenation… (`ui.hyphenation`, `text.setFormat {wordsLongerThan, afterFirst, beforeLast, hyphenLimit, hyphenationZone, hyphenateCapitalized}`) sets each paragraph's hyphenation limits: the shortest word that breaks, the fewest letters on each side of the hyphen, the most hyphenated lines in a row, a zone at the right edge where the Single-line Composer breaks before a word instead of hyphenating it, and whether capitalized words break. The defaults are words longer than 5 letters, 3 letters on each side, no limit or zone, and capitalized words hyphenated; paragraph styles and the native file keep the options. @@ -1842,6 +1842,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur A `.ai` or EPS opened from its editing data with one object on a shown layer that the reader can't read (an unknown operator inside a group, a pattern fill whose pattern it doesn't have, a symbol instance, a placed file without a usable preview) keeps its layers and groups: in that object's place, its layer gets a group of the page's objects inside the object's box, named after it, with a warning that lists what came in as the page's art and an export or Save As to its own path asking first (`acknowledgeLoss`). The file still opens from its page or PDF part when no box can be found, when live art or another unread object shares the page art inside it, when the object spans two artboards or lies on an artboard that has no page, when no page art lies inside its box, when that page art is inside a page group with a clip, mask, transparency or appearance, when the object holds a frame of threaded type, when the page art uses a pattern, image or swatch that the page lacks or that differs from the editing data's, or when the page art doesn't carry the object's transparency (#4806; `crates/eps/src/import/tests_native.rs`, `crates/engine/src/cmd/fileio/tests_aiimport.rs`). A `.ai` or EPS opened from its editing data with one object on a shown layer that the reader can't read (an unknown operator inside a group, a pattern fill whose pattern it doesn't have, a symbol instance, a placed file without a usable preview) keeps its layers and groups: in that object's place, its layer gets a group of the page's objects inside the object's box, named after it, with a warning that lists what came in as the page's art and an export or Save As to its own path asking first (`acknowledgeLoss`). The file still opens from its page or PDF part when no box can be found, when live art or another unread object shares the page art inside it, when the object spans two artboards, or when the page art doesn't carry the object's transparency (#4806; `crates/eps/src/import/tests_native.rs`, `crates/engine/src/cmd/fileio/tests_aiimport.rs`). In a `.ai` opened from its editing data, an envelope made with a warp, a mesh or a top object opens as a live envelope of its contents, with its warp style, bend and distortions, its mesh points and handles or its top object's outline, its Envelope Options, its Fidelity and whether its contents are being edited. When Distort Appearance is on and the size the file saves for the contents matches their bounds with strokes, the contents map from that stroked box, until an edit in Edit Contents changes how far the strokes reach; VectorCraft's new envelopes map from the geometric box, as do contents with no width or no height, such as a single straight line. An envelope that can't be read, or that VectorCraft draws differently from the file's drawn art, keeps that drawn art, with a note. An envelope of type opens live, its type laid out from the text document as its contents, where VectorCraft's own envelope of that type draws as the drawn art the file saves (compared as for the other envelopes, seen on white and on black, so white type counts, and only where the layers with it are compared with the page and draw as it, the envelope weighed as the art it draws; where they don't, even with the drawn art of every live envelope put back, and are used all the same, trusted, with the objects that differ coming in as the page's art, or opened alone, it stays live where, in the pixels it and its drawn art ink (drawn together without the rest of the layers), the layers with it differ from the page in no more than its own check allows and the envelopes that stay differ there in no more than half of what the page check allows, and nothing above it clips, masks or moves it); elsewhere, and where the type's font isn't available, a text or visibility variable binds the type, or the envelope is in a symbol's or a pattern's art, it keeps its drawn art, with its type hidden and a note that says the type isn't editable (#3179; `crates/eps/src/import/ai/tests.rs`, `crates/eps/src/import/tests_native.rs`). A warp of type whose own warp, VectorCraft's, doesn't draw as its drawn art is tried again as a mesh envelope on the grid the file saves for it: its type stays editable and keeps the file's shape, but the warp's settings (style, bend) are lost, as the document has no place for them on a mesh. Type with strokes in an envelope with Distort Appearance on is also 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, where the size the file saves for it doesn't already give that box; after those tries (and before a grid moved onto its drawn art), it is also tried mapped from the box of what it paints, where that is another box, as users' files map some such type: its glyph outlines with their fills and strokes baked as the envelope bakes them, so geometry effects on them count (a stroke a Transform effect moves paints where it is moved to), each measured by its visual bounds (exact for miter joins, a tip counting where the miter limit keeps it, and for round joins; an upper bound for bevel joins, miters the limit cuts, width profiles and square-capped dashes), and a contour of zero length, which paints nothing, left out; effects on the type object itself are left out, as VectorCraft applies them after the envelope bends the type. The drawn-art check decides which one opens. An envelope's grid reads its way round from all its rows and columns, so a mesh bent far round reads the way it runs. While an envelope's contents are edited, its mesh is kept in its dictionary, and in some files that mesh lies away from the drawn art by a move, the contents with the drawn art (as if the mesh hadn't followed the envelope when it moved): a mesh or top object saved in Edit Contents whose mesh draws the contents as large as the drawn art but elsewhere is tried again, after every other way, with its mesh moved onto that art, and opens there where it then draws as it. The drawn art of these files strokes type in an envelope along its bent outlines at the stroke's own weight, Distort Appearance on or off, and VectorCraft draws it so (#6644): an envelope of stroked type whose grid squeezes or stretches it opens live where it draws as that art. + In a `.ai` opened from its editing data, an envelope in another envelope's contents opens as a live envelope inside the live outer one, with Edit Contents on or off at either level, also where its record lies straight in the outer envelope's `AIDeformArtKey` block with no group around it; each level edits its own contents without removing its distortion, as the help page describes for one envelope (https://helpx.adobe.com/illustrator/desktop/manage-objects/reshape-transform-objects/edit-the-contents-of-envelopes.html). Each envelope is compared with its own drawn art: an inner one that doesn't match keeps its drawn art as the outer envelope's contents, and an outer one that doesn't match keeps its own drawn art. An inner record left without its `XP` stops waiting when the outer dictionary ends, and an envelope in an opacity mask's art reads as part of the mask (`an_envelope_in_an_envelope_s_contents_opens_live_inside_it` and the tests after it in `crates/eps/src/import/ai/tests.rs`). In a `.ai` opened from its editing data, Convert to Shape on a whole path or type object opens as a live Rectangle, Rounded Rectangle or Ellipse effect with its Absolute or Relative size, its corner radius, its place among the object's effects and whether it is hidden. A relative size in the file is added to each side. A relative Ellipse, or a setting VectorCraft doesn't read, keeps the drawn art (`crates/eps/src/import/ai/style/tests.rs`, `crates/eps/src/import/ai/tests.rs`, `crates/eps/src/import/tests_native.rs`). In a `.ai` opened from its editing data, Bas Relief in an art style takes its Detail, Smoothness and Light direction from the style's settings block, and an object other than type with it opens as itself with the live effect. A settings block that VectorCraft can't read (malformed, another effect's, a value out of range or a key it doesn't know) keeps the object's drawn look (part of #637; [raster effects reference](https://helpx.adobe.com/illustrator/desktop/special-effects-styles/apply-filter-effects/raster-effects-reference.html), [Photoshop File Formats Specification](https://www.adobe.com/devnet-apps/photoshop/fileformatashtml/), [scripting the filter gallery](https://community.adobe.com/questions-712/scripting-support-for-the-filter-gallery-1064646); `crates/eps/src/import/ai/style/tests.rs`, `crates/eps/src/import/ai/tests.rs`). PDF export and `.ai` saves write a page with a side longer than 14,400 pt with a `UserUnit` (PDF 1.6), its longer side divided by 14,400 and rounded up to the next thousandth, so that no side is longer than 14,400 units (ISO 32000-1, Annex C.2); a PDF 1.5 file with such a page is written as PDF 1.6 with PDF 1.5's encryption, PDF/X files and PDF 1.3 and 1.4 files write it in points (in PDF 1.3 and 1.4, a page past 32,767 pt that needs a real number past 32,767 is refused with an error naming it), a page whose short side is 3 pt or longer but would be under 3 units at that `UserUnit` gets a smaller one that keeps the short side at 3 units, and the export's warnings give each such page's size and how it is written (#6382). The Save PDF dialog's "Write pages longer than 200 in at full size in points" (`longPagesInPoints`, on by default; `.ai` saves leave it off) writes such pages in points without a `UserUnit` at every PDF version, in files without a PDF/X or PDF/A standard, so readers that ignore `UserUnit` show them at full size.