From 2626791d7def71779407f8414c71bc2abf2cc830 Mon Sep 17 00:00:00 2001 From: Don Omondi <282319807+mockydevs@users.noreply.github.com> Date: Fri, 6 Nov 2026 18:01:54 -0500 Subject: [PATCH 01/10] EPS/.ai editing data: symbols defined before the art styles take them Illustrator 24 writes its symbol definitions ahead of %AI9_BeginArtStyles, so a styled object inside a symbol found no style and came in as its drawn look. The pre-pass that finds swatch names now also keeps the first top-level art styles section, which the reader parses before the art. --- crates/eps/src/import/ai/read.rs | 21 +++++++++++++++++---- crates/eps/src/import/ai/tests.rs | 19 +++++++++++++++++++ docs/file-format-parity.md | 4 +++- 3 files changed, 39 insertions(+), 5 deletions(-) diff --git a/crates/eps/src/import/ai/read.rs b/crates/eps/src/import/ai/read.rs index 78c8a83bc..79febd5f1 100644 --- a/crates/eps/src/import/ai/read.rs +++ b/crates/eps/src/import/ai/read.rs @@ -250,7 +250,7 @@ pub fn read_beside(data: &[u8], large_canvas: bool, ps: &[u8]) -> Result ShownNames { +fn swatch_names(data: &[u8]) -> (ShownNames, Option<&[u8]>) { /// The sections the reader reads on their own whose definitions and colors it keeps. const READ_APART: &[&str] = &["BeginPattern", "BeginBrushPattern", "BeginPluginObject", "BeginArtStyles", "BeginArtStyleList"]; /// Whether the marker `m` ends what began with `begin` and ends with `end`, `depth` of its kind @@ -277,6 +277,8 @@ fn swatch_names(data: &[u8]) -> ShownNames { let mut skip: Option<(&str, &str, usize, Option)> = None; let mut section: Option<(&str, &str, usize, bool)> = None; let (mut palette_at, mut palette) = (None, None); + // Where the first art styles section starts, and that section. + let (mut styles_at, mut styles) = (None, None); // A pattern's name is the first string of its definition. let mut head = false; // ASCII85 data in comment lines is skipped past, as the reader skips it. @@ -320,6 +322,9 @@ fn swatch_names(data: &[u8]) -> ShownNames { skip = Some(("", "%EndResource", 0, None)); } else if let Some((begin, end, depth, _)) = section.as_mut() { if closes(begin, end, depth, m) { + if let Some(at) = styles_at.take().filter(|_| *begin == "BeginArtStyles") { + styles = Some(lex.slice(at, before)); + } section = None; } } else if m == "BeginPalette" { @@ -327,6 +332,9 @@ fn swatch_names(data: &[u8]) -> ShownNames { } else if let Some((begin, end)) = types::SKIPPED.iter().find(|(b, _)| *b == m) { section = Some((begin, end, 0, READ_APART.contains(begin))); head = m == "BeginPattern"; + if m == "BeginArtStyles" && styles.is_none() { + styles_at = Some(lex.offset()); + } } else if matches!(m, "%PageTrailer" | "%Trailer") { break; } @@ -358,7 +366,7 @@ fn swatch_names(data: &[u8]) -> ShownNames { } palette.own_names().for_each(|n| names.reserve(n)); colors.names.iter().for_each(|n| names.reserve(n)); - names + (names, styles) } /// What the brushes and brush art read so far give a brushed stroke. @@ -597,10 +605,15 @@ pub(crate) fn is_look_slot(name: Option<&str>) -> bool { impl<'a> Reader<'a> { /// A reader of the editing data `data` as a whole, with the names its gradients and patterns - /// get in the document found first ([`swatch_names`]). + /// get in the document and its art styles found first ([`swatch_names`]): the symbols defined + /// before the art styles section use them too. fn with_names(data: &'a [u8]) -> Self { let mut r = Self::new(data); - r.global_names = swatch_names(data); + let (names, styles) = swatch_names(data); + r.global_names = names; + if let Some(section) = styles { + r.styles = super::style::art_styles(section); + } r } diff --git a/crates/eps/src/import/ai/tests.rs b/crates/eps/src/import/ai/tests.rs index 5e9172338..9768d53e7 100644 --- a/crates/eps/src/import/ai/tests.rs +++ b/crates/eps/src/import/ai/tests.rs @@ -5114,6 +5114,25 @@ fn an_object_with_a_style_this_reads_comes_in_as_itself() { assert!(!r.warnings.iter().any(|w| w.contains("drawn look")), "{:?}", r.warnings); } +#[test] +fn a_symbol_defined_before_the_art_styles_reads_its_styled_objects() { + // GUARDS: objects in a symbol defined ahead of `%AI9_BeginArtStyles` (as Illustrator 24 writes + // its editing data) found no style and came in as their drawn look. + let hidden_rect = rect_path(0.0, 0.0, 10.0, 10.0).replace('\n', "\n%_"); + let styled = format!("u\n{RED}{}f\nU\n%_{hidden_rect}f\n1 (Blue) XW\n", rect_path(0.0, 0.0, 10.0, 10.0)); + let r = read_ok(&format!("{}{ART_STYLES}{}", symbols(&symbol("Tag", &styled, 1)), layer(""))); + let art = &r.doc.symbols.first().expect("the symbol").art; + let object = match &art.kind { + NodeKind::Group { children, .. } if children.len() == 1 => &children[0], + _ => art, + }; + assert!(matches!(object.kind, NodeKind::Path { .. }), "the object, not its look: {art:#?}"); + assert_eq!(object.appearance.fill().and_then(|f| f.paint.color()), Some(Color::rgb(0.0, 0.0, 1.0))); + let effects: Vec<_> = object.appearance.effects.iter().map(|e| e.id.as_str()).collect(); + assert_eq!(effects, ["stylize.dropShadow"]); + assert!(!r.warnings.iter().any(|w| w.contains("drawn look")), "{:?}", r.warnings); +} + /// An art style named `name` (its bytes as written) that fills with colour `xa` (`c m y k r g b Xa`). fn fill_style(name: &str, xa: &str) -> String { fill_style_with(name, xa, "") diff --git a/docs/file-format-parity.md b/docs/file-format-parity.md index 45165b045..720664e21 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-06 · **Change:** minor (legacy `.ai` export: a flattened transparent image's clip follows its edge smoothly, #5264; 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-06 · **Change:** minor (EPS and `.ai` editing data: symbols defined before the art styles take them; 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 @@ -155,6 +155,7 @@ private editing data. VectorCraft: ### Illustrator's private editing data +the app's own editing copy of a file (an EPS after its `%%EOF`, a `.ai`'s `AIPrivateData` streams, a PDF's `AIPDFPrivateData` streams when it was saved with the editing data (#868), Zstandard or zlib, the EPS's in ASCII85) is not documented as a whole. VectorCraft reads its structure: layers and sublayers (names, order, visibility, lock, printing, preview, dimming, colour), groups as they were nested, compound paths (painted by their last path, or the last painted one, with the paths of groups inside them kept as their paths: #1410), clipping groups and layer clipping (several clipping paths in a clipping group, a layer or an opacity mask's art clip to their intersection, and a compound path of clipping paths stays one clip; clipping paths that would nest the art past the document's 32 levels stop the editing data from being read: #5789), object names, hidden and locked objects, fills and strokes (grey, CMYK, RGB, named and spot colours, spot colours defined in Lab with their Lab values (#1032), global process colours as global swatches at their tint; a stroke of width 0, `0 w`, on a path, a gradient-filled path, legacy type or in an art style comes in as a 0.25 pt hairline, as from PDF: #1671), linear and radial gradients, pattern fills with their patterns as pattern swatches (#1025), gradient and pattern names in the file up to their first newline (a name that another swatch, or a gradient or pattern defined earlier, already has is numbered "Sky 2"; a name without a newline is kept, and when another swatch has it, the pattern's swatch, or a named color sharing a gradient or pattern swatch's name, is numbered the same way), opacity, blend modes, isolation and knockout, embedded images where their matrix puts them, rotated ones too (#2269), with their alpha channel (CMYK images keep their ink amounts, as they do when a `.ai` opens from its PDF part, and an alpha channel that hides part of a CMYK image becomes a grayscale opacity mask), placed (linked) files as linked images in their place, each showing the low-resolution preview the file keeps of it until its file is found or relinked in the Links panel (#1264), with the folder the file keeps relative to the document's, where the link is looked for when its path is gone, guides, and every artboard where it is, with its center mark, cross hairs, video safe areas and video ruler pixel aspect ratio. VectorCraft reads `CropAreaActive`, an `/Int` in the dictionary that holds the artboard array, as the index of the active artboard in that array, and the file opens with that artboard active; without it, or when it names no artboard that opens, the first artboard is active. It also reads the Document Raster Effects Settings (resolution, color model, background, anti-alias, clipping mask, Add Around Object and Preserve Spot Colors), the author from the header's `%%For` (an EPS's own header when the editing data's has none), swatches for the header's `%%CMYKCustomColor` and `%%RGBCustomColor` colors that the document has none of, and `%%TemplateBox` in older files; a setting with a value of another type or out of range keeps its default. Objects on Path groups open live, with their path, their objects in order along it, Pivot and Rotate; one with a dragged spacing or other options opens as a plain group. A gradient with stops in the middle runs its whole ramp across its shape, and a gradient object does not pass its fill to the next path (#1390). An object whose art style is one fill and one stroke at most (solid colours, its own gradient, a variable-width profile) with Drop Shadow, Inner Glow, Outer Glow, Feather, Round Corners, Scribble, Offset Path, Outline Object, Outline Stroke, Convert to Shape, Tweak (only when it moves nothing) or Pucker & Bloat at 0 comes in as itself with them and their saved settings. A path, compound path or group (not a clipping group) whose only effect is Transform (not with copies or Random) or a Free Distort whose corners make a parallelogram comes in as itself with it too, when neither it nor anything in it paints with a gradient or pattern and everything in it is a path, compound path or group with no opacity mask and no effects of its own; Transform that scales, rotates or reflects needs nothing in it to be stroked. Twist as the only effect is read the same way on an unstroked path or compound path whose segments are all straight. One with more, other effects (Zig Zag, a Pucker & Bloat of another amount, a Free Distort with other corners), an effect setting that is not read (an unknown key, a missing or out-of-range value, a Scribble with a color of its own), dashes aligned to corners, a brush other than a Calligraphic one, or a Calligraphic brush with settings of its own or an angle that follows the path comes in as its drawn look (a group named after it). Objects in a symbol defined before the file's art styles section, as newer files write it, take the same styles as the layers' objects. A path with a Calligraphic brush otherwise comes in as the path, the brush on its stroke at the stroke's weight, which scales the brush (#372). A Calligraphic brush's Pressure, Tilt, Bearing and Rotation come in as those channels (a 6D Art Pen's brushes vary with the pressure, the bearing and the barrel's rotation); its Stylus Wheel varies at random. The file's Calligraphic, Scatter, Art and Bristle brushes replace the default ones in the Brushes panel, in the file's order, also when the file comes in as its PDF part (a brush library has no layers); pattern brushes and brushes it can't read are left out with a note. Type keeps its art style as a live appearance whatever its stack, so it can be retyped: each fill and stroke of the style's stack (solid colours, gradients and pattern fills, placed as the drawn look places them) with its own opacity, blend mode and effects, the effects on the whole object, and where the characters paint among them; the effects read are Warp, Offset Path, Outline Object, Outline Stroke, Transform (with copies, Transform Objects, Transform Patterns and Scale Strokes & Effects; not with Random), Free Distort, Pucker & Bloat, Twist, Zig Zag, Tweak (only when it moves nothing, the file holding none of its random outline), Drop Shadow, Inner Glow, Outer Glow, Feather, Round Corners, Scribble, Gaussian Blur, Bas Relief (its settings at their defaults) and Convert to Shape, and a style with others, or with an effect setting that isn't read, keeps the type's drawn look. Convert to Shape keeps its Rectangle, Rounded Rectangle or Ellipse, its Absolute or Relative size (a relative size in the file is added to each side) and its corner radius; a relative Ellipse, or a setting that isn't read, keeps the drawn look. The page's drawing of such a style isn't held to look like VectorCraft's where its drawn look lay. Gradients take their matrix (`Xm`, radials and skewed ones too), focal point and the order of stops sharing a ramp point; gradient meshes, a clipping path's own fill and its stroke, and the artboards' names are read too. `editingData: "always"` (`convert --editing-data`) opens a `.ai` or an Illustrator EPS from its editing data even where it draws differently from its page, which can be older, with a note. Type is made from the file's text document, whole and editable, shown or not: point type (its frame's matrix, which newer files leave out when it is neither turned nor scaled, read as none), area type (a story in several frames as threaded type) and type on a path, with its characters, fonts, size, leading, tracking, scaling, baseline shift, fill and stroke (grey, RGB or CMYK), alignment, indents and paragraph spacing (a character attribute a run doesn't set comes from its character style, then the style that one is based on, then the document's defaults); area type keeps its frame's Area Type Options rows and columns, the gutter between rows and the gutter between columns, and its first baseline (Fixed, Cap Height, Leading or x Height) with its minimum; the page's drawing of it (often in pieces) is left out. Vertical type keeps its orientation, its columns from right to left, and a run set to Tate-chu-yoko keeps that setting. When it makes the type of every text object that shows, the page's type is only compared with it, each text object keeps its own group, frame and layer, and a line break inside a paragraph stays one. Where it can't make a text object's type (a frame of another kind), that text object takes the page's type where the text document puts its lines, or else what is left of the page's type in painting order (no group around it); the page's type where made type draws stays that type's, and hidden type is left out with a note. Text wrap is read too: an object whose dictionary has the wrap options (offset and Invert Wrap), or any graphic of a legacy wraparound group (`*w` … `*W`, before or after its type, with no offset), is a wrap object, and area type below it in its layer flows around it when the file opens, threaded type included (#2763). Kerning, underline and the other character and paragraph options aren't read yet. A `.ai` saved without PDF compatibility (a placeholder page) opens from its editing data alone, its point type, area type and type on a path made from the text document. A `.ai` whose PDF part shows only text isn't taken for that placeholder when its page states an art, trim or bleed box: it opens from its editing data alone where that reads, else from its PDF part (#1387). A `.ai` whose PDF part draws nothing at all (an empty page, with or without those boxes) opens from its editing data alone where that reads too: there is no page art to check its layers against, and the empty page would lose all of it. Text objects with an appearance keep their own text and frame, type of hidden layers stays hidden, and type on later artboards lands on its text objects (#1391); area type whose frame is too short for a line of the fallback font shows its first line (the frame is made taller, with a warning in the open result) (#1392). Symbols are read into the Symbols panel, in the file's symbol list order, with their art, their registration point and the instances in their art, and their instances come in live with their matrix, name, hide, lock, opacity, blending mode, isolation, knockout and opacity mask; a symbol whose art isn't read whole (type, placed files, an image it leaves out or anything else it doesn't read) isn't kept. Symbols are read only with the layers: a file that comes in as its page or PDF part (editing data with no layers, layers that can't be read, or any of the cases below) opens with an empty Symbols panel. Instances of symbols it can't read, instances with appearance changes of their own (a dynamic symbol's), pattern fills whose pattern it can't read, placed files the data keeps no preview of and anything else it doesn't read make the file come in as its page (on a layer that doesn't show, they are left out of that layer), with a warning saying what. An unread object on a shown layer whose box can be found instead comes in as a group of the page's objects inside that box, named after it, with the rest of the layers and groups kept and a warning listing what came in as the page's art; the file still comes in as its page when live art or another unread object shares that page art, 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 its transparency (#4806). The layers' art is also compared with the page (by lightness, so colours written as their CMYK equivalents compare alike, and by whether a pixel is inked at all); the page is used when they differ by more than 5% (away from type, when all the type is made from the text document), where one draws an object the other doesn't (away from type, whose lines may be laid out afresh), and when an EPS whose page is its art's box has art printed outside it (#735). A `.ai` is not compared with its page, and its PDF part is not imported, when its text document makes the type of every text object (at least one shows, and none is drawn by the appearance of the layer or group it is in), no object is unread, no placed file has a second placement, no live envelope keeps its drawn art, it declares no large canvas, and its artboards are its pages' trim boxes one for one, in points or in the first page's `UserUnit`: it opens from its layers, with the bleed of its pages' bleed boxes. When they differ because some objects draw differently (an object in another color, or one that only one of them draws), each top-level object of the layers that holds such a difference comes in as a group of the page's objects inside its box and the layers stay, with a warning that names the layer or sublayer of each such object as the Layers panel shows it (`` for one without a name) and how many came in from it; an export or Save As to the file's own path then needs `acknowledgeLoss: true`, and a wrap object replaced this way keeps its wrap options. The file still comes in as its page when only edges or type differ, when a difference lies outside every top-level object, when more than half of the top-level objects or more than 64 hold one, when such an object is or holds type or holds a wrap object inside a group, when the page's type of a text object that stays lies inside such an object's box, when the page's art can't replace such an object for one of the reasons above for an unread object, when matching the differences to the objects would take too much work, or when the result still differs from the page. `editingData: "always"` keeps the objects as read, and placed files at the other point their data gives are tried as well. An EPS whose page is its art's box keeps that page when it comes in through its layers, not the editing data's artboard, and a text object counts as drawn by the page by its colours, not only its lightness, so a red stroke stacked over a dark red one stays (#1102). `textAs: "outlines"` opens a `.ai` whose type shows from its PDF part, which has the outlines; `editingData: false` opens any of them as its page or PDF part only, for print pipelines (#735). A file whose import left something out (hidden text, art or layers) is never written over by an export or Save As to its own path unless `acknowledgeLoss: true` (`convert --replace-lossy`), so the original keeps what the document lacks. Where a `.ai` can't be read that way (or its layers are turned off in the import options), we read its PDF-compatible part, so Illustrator-only live objects arrive as appearance, and plain groups arrive ungrouped: that part draws each object on its own and marks only layers, clipping groups and groups with opacity, blending or a mask (those come back). Art outside the artboards isn't in that part at all (Illustrator's PDF part leaves it out), so it doesn't open; the import says so in a note (#472). Its pages still go where the editing data puts the artboards (a grid stays a grid), with the art on each, when it has as many artboards as pages, of the same sizes (#1068); the page of the artboard `CropAreaActive` names is then the active artboard. A `.ai` whose editing data has artboards but no layers also opens on that page under the same check, and its pages stay where they are. A `.ai` whose PDF part's pages are cropped to its art (each page the box of all the art, which is the editing data's bounding box) instead of its artboards opens from its layers, with its own artboards, where they draw as that page does. Its pages are laid out so that art one of them draws past its artboard (notes beside a design, which the part doesn't clip) doesn't lie over the page before it in the row, where it was taken for that page's art: the check against the editing data then found art its layers don't have there and opened the file as its PDF part, without the art outside the artboards. That part also has no paragraphs: area type is written line by line, and comes back as area type only where its font is installed and lays the lines out with the same breaks, else as a point type object per line (#508). Art off the page that a PDF does draw opens on the pasteboard. the app's own editing copy of a file (an EPS after its `%%EOF`, a `.ai`'s `AIPrivateData` streams, a PDF's `AIPDFPrivateData` streams when it was saved with the editing data (#868), Zstandard or zlib, the EPS's in ASCII85) is not documented as a whole. VectorCraft reads its structure: layers and sublayers (names, order, visibility, lock, printing, preview, dimming, colour), groups as they were nested, compound paths (painted by their last path, or the last painted one, with the paths of groups inside them kept as their paths: #1410), clipping groups and layer clipping (several clipping paths in a clipping group, a layer or an opacity mask's art clip to their intersection, and a compound path of clipping paths stays one clip; clipping paths that would nest the art past the document's 32 levels stop the editing data from being read: #5789), object names, hidden and locked objects, fills and strokes (grey, CMYK, RGB, named and spot colours, spot colours defined in Lab with their Lab values (#1032), global process colours as global swatches at their tint; a stroke of width 0, `0 w`, on a path, a gradient-filled path, legacy type or in an art style comes in as a 0.25 pt hairline, as from PDF: #1671), linear and radial gradients, pattern fills with their patterns as pattern swatches (#1025), gradient and pattern names in the file up to their first newline (a name that another swatch, or a gradient or pattern defined earlier, already has is numbered "Sky 2"; a name without a newline is kept, and when another swatch has it, the pattern's swatch, or a named color sharing a gradient or pattern swatch's name, is numbered the same way), opacity, blend modes, isolation and knockout, embedded images where their matrix puts them, rotated ones too (#2269), with their alpha channel (CMYK images keep their ink amounts, as they do when a `.ai` opens from its PDF part, and an alpha channel that hides part of a CMYK image becomes a grayscale opacity mask), placed (linked) files as linked images in their place, each showing the low-resolution preview the file keeps of it until its file is found or relinked in the Links panel (#1264), with the folder the file keeps relative to the document's, where the link is looked for when its path is gone, guides, and every artboard where it is, with its center mark, cross hairs, video safe areas and video ruler pixel aspect ratio. VectorCraft reads `CropAreaActive`, an `/Int` in the dictionary that holds the artboard array, as the index of the active artboard in that array, and the file opens with that artboard active; without it, or when it names no artboard that opens, the first artboard is active. It also reads the Document Raster Effects Settings (resolution, color model, background, anti-alias, clipping mask, Add Around Object and Preserve Spot Colors), the author from the header's `%%For` (an EPS's own header when the editing data's has none), swatches for the header's `%%CMYKCustomColor` and `%%RGBCustomColor` colors that the document has none of, and `%%TemplateBox` in older files; a setting with a value of another type or out of range keeps its default. Objects on Path groups open live, with their path, their objects in order along it, Pivot and Rotate; one with a dragged spacing or other options opens as a plain group. A gradient with stops in the middle runs its whole ramp across its shape, and a gradient object does not pass its fill to the next path (#1390). An object whose art style is one fill and one stroke at most (solid colours, its own gradient, a variable-width profile) with Drop Shadow, Inner Glow, Outer Glow, Feather, Round Corners, Scribble, Offset Path, Outline Object, Outline Stroke, Convert to Shape, Tweak (only when it moves nothing) or Pucker & Bloat at 0 comes in as itself with them and their saved settings. A path, compound path or group (not a clipping group) whose only effect is Transform (not with copies or Random) or a Free Distort whose corners make a parallelogram comes in as itself with it too, when neither it nor anything in it paints with a gradient or pattern and everything in it is a path, compound path or group with no opacity mask and no effects of its own; Transform that scales, rotates or reflects needs nothing in it to be stroked. Twist as the only effect is read the same way on an unstroked path or compound path whose segments are all straight. One with more, other effects (Zig Zag, a Pucker & Bloat of another amount, a Free Distort with other corners), an effect setting that is not read (an unknown key, a missing or out-of-range value, a Scribble with a color of its own), dashes aligned to corners, a brush other than a Calligraphic or Art one, a Calligraphic brush with settings of its own or an angle that follows the path, or an Art brush other than the ones described next comes in as its drawn look (a group named after it). A path with a Calligraphic brush otherwise comes in as the path, the brush on its stroke at the stroke's weight, which scales the brush (#372), and so does a path with an Art brush, whose weight scales the art across the path, when all of these hold: the brush stretches its art along the path, unflipped, at 100% in Tints; the path's settings are the brush's own, with no entry that isn't read, no variable width and no effects; its stroke isn't a swatch; and the art is paths, compound paths and groups in solid fills, with no strokes, masks or effects, and has some length along the path. A stroke takes the brush the Brushes panel keeps under its name, and one whose name the panel doesn't keep comes in as its drawn look. Type with an Art brush keeps its drawn look under the type. A Calligraphic brush's Pressure, Tilt, Bearing and Rotation come in as those channels (a 6D Art Pen's brushes vary with the pressure, the bearing and the barrel's rotation); its Stylus Wheel varies at random. The file's Calligraphic, Scatter, Art and Bristle brushes replace the default ones in the Brushes panel, in the file's order, also when the file comes in as its PDF part (a brush library has no layers); pattern brushes and brushes it can't read are left out with a note. Type keeps its art style as a live appearance whatever its stack (an Art brush aside), so it can be retyped: each fill and stroke of the style's stack (solid colours, gradients and pattern fills, placed as the drawn look places them) with its own opacity, blend mode and effects, the effects on the whole object, and where the characters paint among them; the effects read are Warp, Offset Path, Outline Object, Outline Stroke, Transform (with copies, Transform Objects, Transform Patterns and Scale Strokes & Effects; not with Random), Free Distort, Pucker & Bloat, Twist, Zig Zag, Tweak (only when it moves nothing, the file holding none of its random outline), Drop Shadow, Inner Glow, Outer Glow, Feather, Round Corners, Scribble, Gaussian Blur, Bas Relief (its settings at their defaults) and Convert to Shape, and a style with others, or with an effect setting that isn't read, keeps the type's drawn look. Convert to Shape keeps its Rectangle, Rounded Rectangle or Ellipse, its Absolute or Relative size (a relative size in the file is added to each side) and its corner radius; a relative Ellipse, or a setting that isn't read, keeps the drawn look. The page's drawing of such a style isn't held to look like VectorCraft's where its drawn look lay. Gradients take their matrix (`Xm`, radials and skewed ones too), focal point and the order of stops sharing a ramp point; gradient meshes, a clipping path's own fill and its stroke, and the artboards' names are read too. `editingData: "always"` (`convert --editing-data`) opens a `.ai` or an Illustrator EPS from its editing data even where it draws differently from its page, which can be older, with a note. Type is made from the file's text document, whole and editable, shown or not: point type (its frame's matrix, which newer files leave out when it is neither turned nor scaled, read as none), area type (a story in several frames as threaded type) and type on a path, with its characters, fonts, size, leading, tracking, scaling, baseline shift, fill and stroke (grey, RGB or CMYK), alignment, indents and paragraph spacing (a character attribute a run doesn't set comes from its character style, then the style that one is based on, then the document's defaults); area type keeps its frame's Area Type Options rows and columns, the gutter between rows and the gutter between columns, and its first baseline (Fixed, Cap Height, Leading or x Height) with its minimum; the page's drawing of it (often in pieces) is left out. Vertical type keeps its orientation, its columns from right to left, and a run set to Tate-chu-yoko keeps that setting. When it makes the type of every text object that shows, the page's type is only compared with it, each text object keeps its own group, frame and layer, and a line break inside a paragraph stays one. Where it can't make a text object's type (a frame of another kind), that text object takes the page's type where the text document puts its lines, or else what is left of the page's type in painting order (no group around it); the page's type where made type draws stays that type's, and hidden type is left out with a note. Text wrap is read too: an object whose dictionary has the wrap options (offset and Invert Wrap), or any graphic of a legacy wraparound group (`*w` … `*W`, before or after its type, with no offset), is a wrap object, and area type below it in its layer flows around it when the file opens, threaded type included (#2763). Kerning, underline and the other character and paragraph options aren't read yet. A `.ai` saved without PDF compatibility (a placeholder page) opens from its editing data alone, its point type, area type and type on a path made from the text document. A `.ai` whose PDF part shows only text isn't taken for that placeholder when its page states an art, trim or bleed box: it opens from its editing data alone where that reads, else from its PDF part (#1387). A `.ai` whose PDF part draws nothing at all (an empty page, with or without those boxes) opens from its editing data alone where that reads too: there is no page art to check its layers against, and the empty page would lose all of it. Text objects with an appearance keep their own text and frame, type of hidden layers stays hidden, and type on later artboards lands on its text objects (#1391); area type whose frame is too short for a line of the fallback font shows its first line (the frame is made taller, with a warning in the open result) (#1392). Symbols are read into the Symbols panel, in the file's symbol list order, with their art, their registration point and the instances in their art, and their instances come in live with their matrix, name, hide, lock, opacity, blending mode, isolation, knockout and opacity mask; a symbol whose art isn't read whole (type, placed files, an image it leaves out or anything else it doesn't read) isn't kept. Symbols are read only with the layers: a file that comes in as its page or PDF part (editing data with no layers, layers that can't be read, or any of the cases below) opens with an empty Symbols panel. Instances of symbols it can't read, instances with appearance changes of their own (a dynamic symbol's), pattern fills whose pattern it can't read, placed files the data keeps no preview of and anything else it doesn't read make the file come in as its page (on a layer that doesn't show, they are left out of that layer), with a warning saying what. An unread object on a shown layer whose box can be found instead comes in as a group of the page's objects inside that box, named after it, with the rest of the layers and groups kept and a warning listing what came in as the page's art; the file still comes in as its page when live art or another unread object shares that page art, 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 its transparency (#4806). The layers' art is also compared with the page (by lightness, so colours written as their CMYK equivalents compare alike, and by whether a pixel is inked at all); the page is used when they differ by more than 5% (away from type, when all the type is made from the text document), where one draws an object the other doesn't (away from type, whose lines may be laid out afresh), and when an EPS whose page is its art's box has art printed outside it (#735). A `.ai` is not compared with its page, and its PDF part is not imported, when its text document makes the type of every text object (at least one shows, and none is drawn by the appearance of the layer or group it is in), no object is unread, no placed file has a second placement, no live envelope keeps its drawn art, it declares no large canvas, and its artboards are its pages' trim boxes one for one, in points or in the first page's `UserUnit`: it opens from its layers, with the bleed of its pages' bleed boxes. When they differ because some objects draw differently (an object in another color, or one that only one of them draws), each top-level object of the layers that holds such a difference comes in as a group of the page's objects inside its box and the layers stay, with a warning that names the layer or sublayer of each such object as the Layers panel shows it (`` for one without a name) and how many came in from it; an export or Save As to the file's own path then needs `acknowledgeLoss: true`, and a wrap object replaced this way keeps its wrap options. The file still comes in as its page when only edges or type differ, when a difference lies outside every top-level object, when more than half of the top-level objects or more than 64 hold one, when such an object is or holds type or holds a wrap object inside a group, when the page's type of a text object that stays lies inside such an object's box, when the page's art can't replace such an object for one of the reasons above for an unread object, when matching the differences to the objects would take too much work, or when the result still differs from the page. `editingData: "always"` keeps the objects as read, and placed files at the other point their data gives are tried as well. An EPS whose page is its art's box keeps that page when it comes in through its layers, not the editing data's artboard, and a text object counts as drawn by the page by its colours, not only its lightness, so a red stroke stacked over a dark red one stays (#1102). `textAs: "outlines"` opens a `.ai` whose type shows from its PDF part, which has the outlines; `editingData: false` opens any of them as its page or PDF part only, for print pipelines (#735). A file whose import left something out (hidden text, art or layers) is never written over by an export or Save As to its own path unless `acknowledgeLoss: true` (`convert --replace-lossy`), so the original keeps what the document lacks. Where a `.ai` can't be read that way (or its layers are turned off in the import options), we read its PDF-compatible part, so Illustrator-only live objects arrive as appearance, and plain groups arrive ungrouped: that part draws each object on its own and marks only layers, clipping groups and groups with opacity, blending or a mask (those come back). Art outside the artboards isn't in that part at all (Illustrator's PDF part leaves it out), so it doesn't open; the import says so in a note (#472). Its pages still go where the editing data puts the artboards (a grid stays a grid), with the art on each, when it has as many artboards as pages, of the same sizes (#1068); the page of the artboard `CropAreaActive` names is then the active artboard. A `.ai` whose editing data has artboards but no layers also opens on that page under the same check, and its pages stay where they are. A `.ai` whose PDF part's pages are cropped to its art (each page the box of all the art, which is the editing data's bounding box) instead of its artboards opens from its layers, with its own artboards, where they draw as that page does. Its pages are laid out so that art one of them draws past its artboard (notes beside a design, which the part doesn't clip) doesn't lie over the page before it in the row, where it was taken for that page's art: the check against the editing data then found art its layers don't have there and opened the file as its PDF part, without the art outside the artboards. That part also has no paragraphs: area type is written line by line, and comes back as area type only where its font is installed and lays the lines out with the same breaks, else as a point type object per line (#508). Art off the page that a PDF does draw opens on the pasteboard. ## Format by format @@ -203,6 +204,7 @@ the app's own editing copy of a file (an EPS after its `%%EOF`, a `.ai`'s `AIPri | Date | Change | Summary | |---|---|---| +| 2026-11-06 | minor | EPS and `.ai` editing data: the art styles are found before the art is read, so objects in symbols defined ahead of `%AI9_BeginArtStyles` (as Illustrator 24 writes it) come in as themselves with their style instead of their drawn look. Checked on two users' EPS files (used locally, not committed): one's Symbols panel gains live Feather and Round Corners, its drawn-look count drops from 5 to 2, and both render unchanged; own-fixture test in `import::ai::tests`; scores and hours unchanged | | 2026-11-06 | minor | Legacy `.ai` (version 7) export: the clipping path around a flattened transparent image followed 2 pt cells, a staircase that was visibly jagged at the image's resolution. It now follows the image's own pixels (in blocks only past 4 million pixels), grown by one pixel so the whole anti-aliased edge is inside, and runs through the middle of each pixel edge with Douglas–Peucker simplification, so slopes and curves are sloped lines instead of steps (#5264). Version 7 images have no alpha channel (§8.1 of the published format specification), so the image itself is still opaque. Test `legacy_ai::a_flattened_images_clip_follows_its_edge_smoothly` (crate `vectorcraft-engine`); scores unchanged | | 2026-11-06 | minor | Paste: cells copied from Excel, Google Sheets, LibreOffice Calc or Numbers (tab-separated text, quoted cells too) paste as a group of point type, one object per cell in columns and rows, over 0.5 pt gridlines; other text pastes as before; scores and estimates unchanged (#5296) | | 2026-11-06 | minor | SVG import: filter and mask regions clip (SVG 1.1 §15.5, §14.4). An object whose imported blur, shadow or glow reaches past its filter's region comes in inside a clip group cut to the region (#5876), and a mask's art is cut to the mask's region, in user space or the object's bounding box (#5875). Objects that fit their regions, and our own exports, are unchanged; primitive subregions are still ignored. Tests `import_live::a_blur_is_cut_at_its_filter_region`, `import_live::a_mask_counts_only_inside_its_region` (crate `vectorcraft-svg`); scores unchanged | From 95ac8d7cc7e168b24f9123fabfa20989dc0ee3af Mon Sep 17 00:00:00 2001 From: Don Omondi <282319807+mockydevs@users.noreply.github.com> Date: Fri, 6 Nov 2026 19:40:43 -0500 Subject: [PATCH 02/10] EPS/.ai editing data: blends with no spine written come in live A blend whose spine was never edited has only its keys in the editing data, no spine part. Its spine is the straight lines between the keys' centres in the record's order; read as such, the blend comes in live instead of as its drawn steps. --- crates/eps/src/import/ai/blend.rs | 25 ++++++++++++++++++++++--- crates/eps/src/import/ai/tests.rs | 20 ++++++++++++++++++++ docs/file-format-parity.md | 3 ++- 3 files changed, 44 insertions(+), 4 deletions(-) diff --git a/crates/eps/src/import/ai/blend.rs b/crates/eps/src/import/ai/blend.rs index 494a11cdc..6124c9eee 100644 --- a/crates/eps/src/import/ai/blend.rs +++ b/crates/eps/src/import/ai/blend.rs @@ -11,7 +11,7 @@ //! and the spine, each object's `/ArtDictionary` naming its part (`ShapeBlendPathType`: `K` a key, //! `S` the spine) and a key's id (`ShapeBlendPathID`); then the steps drawn, a group on plain //! lines; then `(Adobe Path Blends) 1 0 n XP` and `n` bytes as hex on `%` lines. Those bytes -//! start `DCBA`. +//! start `DCBA`. A blend whose spine was never edited has no spine part: only its keys. //! //! The reader reads the words after `DCBA` (little endian) this way. Word 1 is the spacing: 0 is //! Smooth Color, 1 is Specified Steps and 2 is Specified Distance. For Specified Distance, word 2 @@ -28,7 +28,7 @@ use vectorcraft_color::{Color, Paint}; use vectorcraft_doc::blend::{blend_expand, blend_steps}; use vectorcraft_doc::live::{BlendOrientation, BlendSpacing, BlendSpec}; use vectorcraft_doc::{AppearanceItem, Node, NodeId, NodeKind}; -use vectorcraft_geom::{PathData, Point}; +use vectorcraft_geom::{PathData, Point, SubPath}; /// The plugin that writes blends. pub(super) const PLUGIN: &str = "Adobe Path Blends"; // brand-ok: the plugin name the files use @@ -149,7 +149,12 @@ pub(super) fn live_blend(edit: &Node, drawn: &Node, record: &Record, roles: &Rol Arc::new(k) }) .collect(); - let spine = spine?; + // A blend whose spine was never edited has none written: its spine is the straight lines + // between the keys' centres, in the record's order. + let spine = match spine { + Some(spine) => spine, + None => straight_spine(&keys, ids, roles)?, + }; let key_anchors = on_spine(&keys, &spine)?; let spacing = match record.spacing { // Specified Steps: the steps between two keys are the drawn objects that aren't keys, @@ -204,6 +209,20 @@ fn on_spine(keys: &[Arc], spine: &PathData) -> Option> { Some(out) } +/// The straight lines between the centres of `keys`, taken in the order of the record's `ids`: a +/// blend's spine until it is edited or replaced, as [`vectorcraft_doc::blend::blend_spine`] lays +/// out a blend with no spine of its own. +fn straight_spine(keys: &[Arc], ids: &[String], roles: &Roles) -> Option { + let centres = ids + .iter() + .map(|id| { + let key = keys.iter().find(|k| roles.get(&k.id).and_then(|(_, named)| named.as_ref()) == Some(id))?; + centre(key) + }) + .collect::>>()?; + Some(PathData::single(SubPath::polyline(¢res, false))) +} + fn centre(n: &Node) -> Option { n.geometric_bounds().map(|b| b.center()).filter(|p| p.x.is_finite() && p.y.is_finite()) } diff --git a/crates/eps/src/import/ai/tests.rs b/crates/eps/src/import/ai/tests.rs index 9768d53e7..84329e395 100644 --- a/crates/eps/src/import/ai/tests.rs +++ b/crates/eps/src/import/ai/tests.rs @@ -6878,6 +6878,26 @@ fn a_blend_comes_in_as_a_live_blend() { assert!(!s.warnings.iter().any(|w| w == super::blend::DRAWN), "{:?}", s.warnings); } +/// A blend whose spine was never edited has no spine written, only its keys: its spine is the +/// straight line between the keys' centres, in the record's order (here the left key, named `B`, +/// first), and the blend comes in live. +#[test] +fn a_blend_with_no_spine_written_runs_straight_between_its_keys() { + let spine_part = format!("%_N\n{}", blend_part("S", None)); + let body = blend_file("", [Some("B"), Some("A")], &blend_record(&['B', 'A'])); + assert!(body.contains(&spine_part)); + let (blend, warnings) = read_blend(&body.replace(&spine_part, "")); + let NodeKind::Blend { children, spec } = &blend.kind else { panic!("a live blend: {:?} {warnings:?}", blend.kind) }; + assert!(!warnings.iter().any(|w| w == super::blend::DRAWN), "{warnings:?}"); + assert_eq!(spec.spacing, vectorcraft_doc::live::BlendSpacing::Steps(3)); + let spine = spec.spine.as_ref().and_then(|p| p.subpaths.first()).expect("a spine"); + let points: Vec<(f64, f64)> = spine.anchors.iter().map(|a| (a.p.x, a.p.y)).collect(); + assert_eq!(points.len(), 2, "{points:?}"); + assert!((points[0].0 - 10.0).abs() < 0.01 && (points[1].0 - 110.0).abs() < 0.01, "from the left key to the right one: {points:?}"); + assert_eq!(spec.key_anchors, vec![0, 1]); + assert_eq!(vectorcraft_doc::blend::blend_expand(children, spec).len(), 5); +} + /// The tags after a blend's drawn steps are the live blend's. #[test] fn a_live_blend_keeps_the_tags_of_its_drawn_steps() { diff --git a/docs/file-format-parity.md b/docs/file-format-parity.md index 720664e21..f21ec6b07 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-06 · **Change:** minor (EPS and `.ai` editing data: symbols defined before the art styles take them; 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-06 · **Change:** minor (EPS and `.ai` editing data: blends with no spine written come in live; 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 @@ -204,6 +204,7 @@ the app's own editing copy of a file (an EPS after its `%%EOF`, a `.ai`'s `AIPri | Date | Change | Summary | |---|---|---| +| 2026-11-06 | minor | EPS and `.ai` editing data: a blend whose spine was never edited, written with its keys and no spine part, comes in as a live blend along the straight lines between its keys' centres (in its record's order) instead of as its drawn steps. Checked on six users' Illustrator EPS files (used locally, not committed): every blend in four of them now comes in live, their renders unchanged; two still have blends the reader can't match to their drawn steps. Scores and estimates unchanged. | | 2026-11-06 | minor | EPS and `.ai` editing data: the art styles are found before the art is read, so objects in symbols defined ahead of `%AI9_BeginArtStyles` (as Illustrator 24 writes it) come in as themselves with their style instead of their drawn look. Checked on two users' EPS files (used locally, not committed): one's Symbols panel gains live Feather and Round Corners, its drawn-look count drops from 5 to 2, and both render unchanged; own-fixture test in `import::ai::tests`; scores and hours unchanged | | 2026-11-06 | minor | Legacy `.ai` (version 7) export: the clipping path around a flattened transparent image followed 2 pt cells, a staircase that was visibly jagged at the image's resolution. It now follows the image's own pixels (in blocks only past 4 million pixels), grown by one pixel so the whole anti-aliased edge is inside, and runs through the middle of each pixel edge with Douglas–Peucker simplification, so slopes and curves are sloped lines instead of steps (#5264). Version 7 images have no alpha channel (§8.1 of the published format specification), so the image itself is still opaque. Test `legacy_ai::a_flattened_images_clip_follows_its_edge_smoothly` (crate `vectorcraft-engine`); scores unchanged | | 2026-11-06 | minor | Paste: cells copied from Excel, Google Sheets, LibreOffice Calc or Numbers (tab-separated text, quoted cells too) paste as a group of point type, one object per cell in columns and rows, over 0.5 pt gridlines; other text pastes as before; scores and estimates unchanged (#5296) | From ed00a8870e34c654c15f69fa62f95ede5ab4c29b Mon Sep 17 00:00:00 2001 From: Don Omondi <282319807+mockydevs@users.noreply.github.com> Date: Fri, 6 Nov 2026 19:02:32 -0500 Subject: [PATCH 03/10] EPS/.ai editing data: a transparency set inside a clipping group stays after it Q restores the painting state but not the transparency (Xy): the writer sets Xy only when the next object's state differs from the last one it wrote. Restored at Q, type after a clipping group following a Screen glow drew in Screen and nearly vanished on a light background. --- crates/eps/src/import/ai/read/containers.rs | 7 +++++++ crates/eps/src/import/ai/tests.rs | 15 +++++++++++++++ docs/file-format-parity.md | 3 ++- 3 files changed, 24 insertions(+), 1 deletion(-) diff --git a/crates/eps/src/import/ai/read/containers.rs b/crates/eps/src/import/ai/read/containers.rs index 5113c1686..7d7c9ed9b 100644 --- a/crates/eps/src/import/ai/read/containers.rs +++ b/crates/eps/src/import/ai/read/containers.rs @@ -98,7 +98,14 @@ impl<'a> Reader<'a> { .. } = f; if matches!(kind, Kind::Clip) { + // `Q` restores the painting state, not the transparency: the writer sets `Xy` only when + // the next object's differs from the last one written, wherever that was, so a state set + // inside a clipping group stays after it (a glow set to Screen, then a clipping path set + // back to Normal: the type after the group has no `Xy` and is Normal). + let now = &self.gs; + let transparency = (now.opacity, now.blend, now.isolate, now.knockout, now.knockout_shape); self.gs = gs.clone(); + (self.gs.opacity, self.gs.blend, self.gs.isolate, self.gs.knockout, self.gs.knockout_shape) = transparency; self.pending_unread_fill = pending_unread_fill; self.pending_unread_stroke = pending_unread_stroke; } diff --git a/crates/eps/src/import/ai/tests.rs b/crates/eps/src/import/ai/tests.rs index 84329e395..08b69f23e 100644 --- a/crates/eps/src/import/ai/tests.rs +++ b/crates/eps/src/import/ai/tests.rs @@ -1678,6 +1678,21 @@ fn a_transparency_set_in_a_dictionary_s_art_stays_after_it() { assert_eq!((paths[1].opacity, paths[1].blend), (1.0, BlendMode::Normal), "the state the text's art set"); } +#[test] +fn a_transparency_set_in_a_clipping_group_stays_after_it() { + // GUARDS: `Q` ends a clipping group but doesn't take the transparency back to what it was at + // its `q`: the writer sets `Xy` only when the next object's state differs from the last one it + // wrote. A Screen glow, then a clipping group whose path is set back to Normal, then type with + // no `Xy`: restored at `Q`, the type drew in Screen and nearly vanished on a light background. + let clip = format!("q\n0 1 0 0 0 Xy\n{}h\nW\nn\n{RED}{}f\nQ\n", rect_path(0.0, 0.0, 200.0, 100.0), rect_path(40.0, 0.0, 50.0, 10.0)); + let body = format!("2 0.8 0 0 0 Xy\n{RED}{}f\n{clip}{RED}{}f\n", rect_path(0.0, 0.0, 10.0, 10.0), rect_path(20.0, 0.0, 30.0, 10.0)); + let a = art(&read_ok(&layer(&body)).doc); + assert_eq!(a.len(), 3, "{a:#?}"); + assert_eq!((a[0].opacity, a[0].blend), (0.8, BlendMode::Screen), "the glow's own state"); + assert!(matches!(a[1].kind, NodeKind::Group { clip: true, .. }), "{:?}", a[1].kind); + assert_eq!((a[2].opacity, a[2].blend), (1.0, BlendMode::Normal), "the state set inside the clipping group"); +} + #[test] fn gradients() { let defs = "%AI5_BeginGradient: (G)\n(G) 1 2 Bd\n[\n<00FF>\n0\n0 0 0 0 1 0 0 2 1 6 %_BS\n\ diff --git a/docs/file-format-parity.md b/docs/file-format-parity.md index f21ec6b07..c8bfd8387 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-06 · **Change:** minor (EPS and `.ai` editing data: blends with no spine written come in live; 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-06 · **Change:** minor (EPS and `.ai` editing data: a transparency set inside a clipping group stays after its `Q`; 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 @@ -204,6 +204,7 @@ the app's own editing copy of a file (an EPS after its `%%EOF`, a `.ai`'s `AIPri | Date | Change | Summary | |---|---|---| +| 2026-11-06 | minor | EPS and `.ai` editing data: `Q` restores the painting state but not the transparency (`Xy`), which the writer only sets when the next object's differs from the last one written. A Screen glow followed by a clipping group set back to Normal no longer leaves the type after the group in Screen: two users' Illustrator 26.5 and 30 EPS posters (used locally, not committed) now show their gold heading and crescent, and both open from their layers (2.5% and 2.7% from the page before, under the limit after). Scores and estimates unchanged. | | 2026-11-06 | minor | EPS and `.ai` editing data: a blend whose spine was never edited, written with its keys and no spine part, comes in as a live blend along the straight lines between its keys' centres (in its record's order) instead of as its drawn steps. Checked on six users' Illustrator EPS files (used locally, not committed): every blend in four of them now comes in live, their renders unchanged; two still have blends the reader can't match to their drawn steps. Scores and estimates unchanged. | | 2026-11-06 | minor | EPS and `.ai` editing data: the art styles are found before the art is read, so objects in symbols defined ahead of `%AI9_BeginArtStyles` (as Illustrator 24 writes it) come in as themselves with their style instead of their drawn look. Checked on two users' EPS files (used locally, not committed): one's Symbols panel gains live Feather and Round Corners, its drawn-look count drops from 5 to 2, and both render unchanged; own-fixture test in `import::ai::tests`; scores and hours unchanged | | 2026-11-06 | minor | Legacy `.ai` (version 7) export: the clipping path around a flattened transparent image followed 2 pt cells, a staircase that was visibly jagged at the image's resolution. It now follows the image's own pixels (in blocks only past 4 million pixels), grown by one pixel so the whole anti-aliased edge is inside, and runs through the middle of each pixel edge with Douglas–Peucker simplification, so slopes and curves are sloped lines instead of steps (#5264). Version 7 images have no alpha channel (§8.1 of the published format specification), so the image itself is still opaque. Test `legacy_ai::a_flattened_images_clip_follows_its_edge_smoothly` (crate `vectorcraft-engine`); scores unchanged | From 0d91749c741233ab8b73771930eb13071209273a Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Fri, 6 Nov 2026 17:13:38 -0800 Subject: [PATCH 04/10] .ai import: drawn looks in pattern and brush art count in the note An object read as its drawn look counted in the import's "came in as their drawn look" note when it was on the page or in a kept symbol, but not when it was in a pattern's art or a brush's art. A file whose only such objects were in a pattern or a brush opened without the note. - A kept pattern's drawn looks count, once for each pattern name: a definition that a later one of the same name replaces no longer counts. - A brush's art counts once a brush that is kept uses it; art no kept brush uses adds nothing. --- crates/eps/src/import/ai/read.rs | 8 +++++ crates/eps/src/import/ai/read/directives.rs | 2 ++ crates/eps/src/import/ai/read/document.rs | 10 +++++- crates/eps/src/import/ai/tests.rs | 38 +++++++++++++++++++++ 4 files changed, 57 insertions(+), 1 deletion(-) diff --git a/crates/eps/src/import/ai/read.rs b/crates/eps/src/import/ai/read.rs index 79febd5f1..025ccc480 100644 --- a/crates/eps/src/import/ai/read.rs +++ b/crates/eps/src/import/ai/read.rs @@ -475,6 +475,12 @@ struct Reader<'a> { warnings: Vec, /// Objects read as their drawn look (several fills or strokes, effects, brushes). drawn_looks: usize, + /// The objects read as their drawn look in the art of [`Reader::brush_art`], by name: counted in + /// [`Reader::drawn_looks`] once a brush that is kept uses the art. + brush_art_looks: HashMap, + /// The objects read as their drawn look in the art of the patterns kept, by the name the document + /// has the pattern by: a pattern defined again counts only its last definition. + pattern_looks: HashMap, /// Nodes read from `%_` lines. hidden_ids: HashSet, /// The blend part each object's dictionary names (`ShapeBlendPathType`, `ShapeBlendPathID`). @@ -679,6 +685,8 @@ impl<'a> Reader<'a> { color_model: None, warnings: Vec::new(), drawn_looks: 0, + brush_art_looks: HashMap::new(), + pattern_looks: HashMap::new(), hidden_ids: HashSet::new(), blend_roles: HashMap::new(), blend_record: None, diff --git a/crates/eps/src/import/ai/read/directives.rs b/crates/eps/src/import/ai/read/directives.rs index a5445bfbf..33af13372 100644 --- a/crates/eps/src/import/ai/read/directives.rs +++ b/crates/eps/src/import/ai/read/directives.rs @@ -507,6 +507,7 @@ impl<'a> Reader<'a> { } else { self.v7_patterns.remove(&head.name); } + self.pattern_looks.insert(def.name.clone(), sub.drawn_looks); self.doc.patterns.push(def); for (key, blob) in sub.doc.images { self.doc.images.entry(key).or_insert(blob); @@ -752,6 +753,7 @@ impl<'a> Reader<'a> { && sub.frames.len() == 1 && sub.doc.images.is_empty(); let Some(frame) = sub.frames.pop().filter(|f| whole && !f.children.is_empty()) else { return }; + self.brush_art_looks.insert(name.clone(), sub.drawn_looks); let mut art: Vec> = frame.children.iter().map(|n| Arc::new(self.doc.reid(n))).collect(); let node = match art.len() { 1 => art.pop().map(Arc::unwrap_or_clone), diff --git a/crates/eps/src/import/ai/read/document.rs b/crates/eps/src/import/ai/read/document.rs index 2fd918fe6..65d949f0f 100644 --- a/crates/eps/src/import/ai/read/document.rs +++ b/crates/eps/src/import/ai/read/document.rs @@ -914,13 +914,20 @@ impl<'a> Reader<'a> { } /// The brushes read ([`brush::finish_brushes`]) and how many weren't; `None` when the file has - /// no brush objects (the document keeps the default brushes). + /// no brush objects (the document keeps the default brushes). The objects read as their drawn + /// look in the art the brushes use count toward the note on drawn looks, once for each art. fn brush_library(&mut self) -> Option<(Vec, usize)> { if !self.brush_objects { return None; } let drafts = std::mem::take(&mut self.brush_drafts); let (lib, left_out) = brush::finish_brushes(drafts, &self.brush_art, self.brush_order.as_deref()); + let used: std::collections::HashSet = lib.iter().flat_map(|b| b.art()).map(|a| a.id).collect(); + for (name, art) in &self.brush_art { + if used.contains(&art.id) { + self.drawn_looks += self.brush_art_looks.get(name).copied().unwrap_or(0); + } + } Some((lib, left_out + self.brushes_unread)) } @@ -1087,6 +1094,7 @@ impl<'a> Reader<'a> { warnings.push(brush::left_out_note(unread)); } } + self.drawn_looks += self.pattern_looks.values().sum::(); if self.drawn_looks > 0 { warnings.push(format!( "objects with several fills or strokes, effects or brushes came in as their drawn look ({}): their appearance isn't editable yet", diff --git a/crates/eps/src/import/ai/tests.rs b/crates/eps/src/import/ai/tests.rs index 08b69f23e..f5eadb931 100644 --- a/crates/eps/src/import/ai/tests.rs +++ b/crates/eps/src/import/ai/tests.rs @@ -2396,6 +2396,44 @@ fn a_drawn_look_stands_for_its_object() { assert!(r.warnings.iter().any(|w| w.contains("drawn look (1)")), "{:?}", r.warnings); } +/// An object kept as its drawn look: the look (a group), then the object on `%_` lines and its style. +fn drawn_look_art() -> String { + let hidden_rect = rect_path(0.0, 0.0, 10.0, 10.0).replace('\n', "\n%_"); + format!( + "0 Ae\nu\n{RED}{}f\n{}S\nU\n%_{hidden_rect}n\n%_/ArtDictionary :\n%_/XMLUID : (Look) ; (AI10_ArtUID) ,\n%_;\n1 (Look style) XW\n", + rect_path(0.0, 0.0, 10.0, 10.0), + rect_path(0.0, 0.0, 10.0, 10.0), + ) +} + +/// A pattern's art holding an object kept as its drawn look counts in the note, as a symbol's does; +/// a definition the same name replaces later counts no longer. +#[test] +fn a_drawn_look_in_a_patterns_art_is_noted() { + let pattern = |art: &str| format!("%AI3_BeginPattern: (P)\n(P) 0 0 10 10\n0 A\n0 Xw\n{art}E\n%AI3_EndPattern\n"); + let looked = pattern(&drawn_look_art()); + let r = read_ok(&format!("{looked}{}", layer(""))); + assert_eq!(r.doc.patterns.len(), 1, "{:?}", r.warnings); + assert!(r.warnings.iter().any(|w| w.contains("drawn look (1)")), "{:?}", r.warnings); + let plain = pattern(&format!("{RED}{}f\n", rect_path(0.0, 0.0, 10.0, 10.0))); + let r = read_ok(&format!("{looked}{plain}{}", layer(""))); + assert_eq!(r.doc.patterns.len(), 1, "{:?}", r.warnings); + assert!(!r.warnings.iter().any(|w| w.contains("drawn look")), "{:?}", r.warnings); +} + +/// A brush's art holding an object kept as its drawn look counts in the note once a brush that is +/// kept uses the art; art no brush uses brings no note. +#[test] +fn a_drawn_look_in_a_brushs_art_is_noted_when_a_brush_uses_it() { + let section = format!("%AI8_BeginBrushPattern\n(Dot art)\n0 A\n{}%AI8_EndBrushPattern\n", drawn_look_art()); + let r = read_ok(&format!("{section}{}{}", plugin_object(SCATTER, "Spray", SPRAY), layer(""))); + let lib = vectorcraft_brush::library(&r.doc); + assert!(matches!(brush_named(&lib, "Spray"), vectorcraft_brush::BrushKind::Scatter(_)), "{:?}", r.warnings); + assert!(r.warnings.iter().any(|w| w.contains("drawn look (1)")), "{:?}", r.warnings); + let r = read_ok(&format!("{section}{}{}", plugin_object(CALLIGRAPHIC, "Nib", NIB), layer(""))); + assert!(!r.warnings.iter().any(|w| w.contains("drawn look")), "{:?}", r.warnings); +} + /// Editing data with one layer holding an image: `matrix` (`[a b c d tx ty]`), its size, colour /// space, kind and alpha channels, its samples, and the size the data comment declares. fn image(matrix: &str, (w, h): (u32, u32), (space, kind, alpha): (&str, u32, u32), data: &[u8], declared: usize) -> Vec { From 40a46e539deff6fc0e2fd2abd58053bbdb2bb23b Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Fri, 6 Nov 2026 13:55:55 -0800 Subject: [PATCH 05/10] Type: story-wide panel changes follow the story's current thread After Select All in threaded text, the panels read the frames from the list saved at Select All. A frame that had left the thread still took Paragraph, Tabs, Justification and Text Styles changes, a frame added later did not, and when the first saved frame had left, Character, OpenType and font changes went to that frame alone. text_story now reads the frames of the edited frame's thread from the document. Locked and hidden frames stay in the story. --- crates/ui-egui/src/panels/character.rs | 16 ++- crates/ui-egui/src/panels/character/tests.rs | 102 +++++++++++++++++++ docs/mcp.md | 2 + docs/target-app-parity.md | 1 + 4 files changed, 112 insertions(+), 9 deletions(-) diff --git a/crates/ui-egui/src/panels/character.rs b/crates/ui-egui/src/panels/character.rs index 34a3e006f..9dd969c5c 100644 --- a/crates/ui-egui/src/panels/character.rs +++ b/crates/ui-egui/src/panels/character.rs @@ -499,18 +499,16 @@ pub(crate) fn text_editing(app: &VectorcraftApp) -> Option<(NodeId, usize, usize } /// The frames of the threaded story whose whole text the Type tool has selected after Select All, -/// in thread order; `None` when its selection is in one type object. +/// in the order of the edited frame's thread as the document holds it now; `None` when its +/// selection is in one type object. pub(crate) fn text_story(app: &VectorcraftApp) -> Option> { let o = app.session.tool_options(); let st = app.session.active()?; - let ids: Vec = o - .get("story")? - .as_array()? - .iter() - .filter_map(Value::as_u64) - .map(NodeId) - .filter(|id| st.doc.node(*id).is_some_and(|n| matches!(n.kind, NodeKind::Text(_)))) - .collect(); + let id = NodeId(o.get("editing")?.as_u64()?); + if o.get("story")?.as_array()?.is_empty() { + return None; + } + let ids = st.doc.story_of(id); (ids.len() >= 2).then_some(ids) } diff --git a/crates/ui-egui/src/panels/character/tests.rs b/crates/ui-egui/src/panels/character/tests.rs index e6927f843..577a081ef 100644 --- a/crates/ui-egui/src/panels/character/tests.rs +++ b/crates/ui-egui/src/panels/character/tests.rs @@ -1329,3 +1329,105 @@ fn select_all_in_threaded_text_formats_and_copies_the_whole_story() { .collect(); assert_eq!(copied, [story]); } + +/// A story threaded through three frames in order, the first `height` tall. +fn three_frame_story(height: u32) -> (VectorcraftApp, u64, u64, u64) { + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + app.run("file.new", json!({"width": 900, "height": 300})).unwrap(); + let story = "The quick brown fox jumps over the lazy dog and keeps running far beyond the first frame. ".repeat(6); + let area = json!({"x": 10, "y": 10, "text": story, "size": 12, "area": {"width": 150, "height": height}}); + let a = app.run("text.create", area).unwrap()["id"].as_u64().unwrap(); + let b = app.run("text.thread.add", json!({"from": a, "rect": [200, 10, 150, 60]})).unwrap()["id"].as_u64().unwrap(); + let c = app.run("text.thread.add", json!({"from": b, "rect": [400, 10, 150, 200]})).unwrap()["id"].as_u64().unwrap(); + (app, a, b, c) +} + +/// Select All with the Type tool in frame `id` of a story. +fn select_all_from(app: &mut VectorcraftApp, id: u64) { + app.run("select.set", json!({"ids": [id]})).unwrap(); + app.session.select_tool("type", Default::default()).unwrap(); + app.session.set_tool_option("textSelection", &json!({"editing": id, "anchor": 0, "caret": 0})); + app.run("select.all", json!({})).unwrap(); +} + +fn story_frame(app: &VectorcraftApp, id: u64) -> vectorcraft_doc::TextObject { + match &app.session.active().unwrap().doc.node(NodeId(id)).unwrap().kind { + NodeKind::Text(t) => (**t).clone(), + _ => panic!("not text"), + } +} + +fn story_sizes(app: &VectorcraftApp, id: u64) -> Vec { + story_frame(app, id).runs.iter().map(|r| r.style.size).collect() +} + +/// The frames that Select All saved are not the frames of the story once the first of them has +/// left the thread: the edited frame's story takes the change. +#[test] +fn story_changes_follow_the_edited_frames_thread_after_the_first_saved_frame_leaves() { + // The first frame is too short for a line, so Select All edits the second. + let (mut app, a, b, c) = three_frame_story(1); + select_all_from(&mut app, c); + assert_eq!(app.session.tool_options()["story"], json!([a, b, c])); + assert_eq!(text_editing(&app).unwrap().0, NodeId(b)); + app.run("text.thread.break", json!({"id": a, "port": "out"})).unwrap(); + assert_eq!(text_editing(&app).unwrap().0, NodeId(b)); + + assert_eq!(text_story(&app), Some(vec![NodeId(b), NodeId(c)])); + format(&mut app, json!({"size": 9})); + assert!(story_sizes(&app, a).iter().all(|s| (*s - 12.0).abs() < 1e-9), "the frame that left keeps its size"); + for id in [b, c] { + assert!(story_sizes(&app, id).iter().all(|s| (*s - 9.0).abs() < 1e-9), "frame {id} takes the size"); + } +} + +/// A frame that left the thread after Select All takes no change, and a frame added to it does. +#[test] +fn story_changes_leave_out_a_frame_that_left_and_take_in_a_frame_that_joined() { + let (mut app, a, b, c) = three_frame_story(60); + select_all_from(&mut app, c); + assert_eq!(text_editing(&app).unwrap().0, NodeId(a)); + app.run("text.thread.break", json!({"id": b, "port": "out"})).unwrap(); + assert_eq!(text_story(&app), Some(vec![NodeId(a), NodeId(b)])); + let d = app.run("text.thread.add", json!({"from": a, "rect": [600, 10, 150, 60]})).unwrap()["id"].as_u64().unwrap(); + assert_eq!(text_story(&app), Some(vec![NodeId(a), NodeId(d), NodeId(b)])); + + crate::panels::paragraph::run_on_paragraphs(&mut app, "text.setFormat", json!({"spaceBefore": 4})).unwrap(); + for id in [a, d, b] { + assert!((story_frame(&app, id).para_at(0).space_before - 4.0).abs() < 1e-9, "frame {id} takes the space"); + } + assert!(story_frame(&app, c).para_at(0).space_before.abs() < 1e-9, "the frame that left keeps its paragraphs"); +} + +/// With the thread gone, the selection is in the one edited frame. +#[test] +fn story_changes_stay_in_the_edited_frame_once_its_thread_is_removed() { + let (mut app, a, b, c) = three_frame_story(60); + select_all_from(&mut app, c); + assert_eq!(text_editing(&app).unwrap().0, NodeId(a)); + app.run("text.thread.remove", json!({})).unwrap(); + assert_eq!(text_story(&app), None); + + crate::panels::paragraph::run_on_paragraphs(&mut app, "text.setFormat", json!({"spaceBefore": 4})).unwrap(); + assert!((story_frame(&app, a).para_at(0).space_before - 4.0).abs() < 1e-9); + for id in [b, c] { + assert!(story_frame(&app, id).para_at(0).space_before.abs() < 1e-9, "frame {id} keeps its paragraphs"); + } +} + +/// A selected story is one text: its locked and hidden frames take the change too. +#[test] +fn story_changes_take_in_locked_and_hidden_frames() { + let (mut app, a, b, c) = three_frame_story(60); + app.run("select.set", json!({"ids": [b]})).unwrap(); + app.run("object.lock", json!({})).unwrap(); + app.run("select.set", json!({"ids": [c]})).unwrap(); + app.run("object.hide", json!({})).unwrap(); + select_all_from(&mut app, a); + assert_eq!(text_story(&app), Some(vec![NodeId(a), NodeId(b), NodeId(c)])); + + format(&mut app, json!({"size": 9})); + for id in [a, b, c] { + assert_eq!(story_sizes(&app, id).iter().filter(|s| (**s - 9.0).abs() > 1e-9).count(), 0, "frame {id} takes the size"); + } +} diff --git a/docs/mcp.md b/docs/mcp.md index 12f216ef5..1adcf32b1 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -5071,6 +5071,8 @@ and Cut take. Typing, Delete and Cut replace the whole story as one undo step (` frames whose text goes), and a fill, stroke, `type.step` or Character or Paragraph panel change applies to the text of every frame (the Character panel sends `text.setRangeStyle {id, story: true}`, which styles every frame's text in one undo step). Remove Line Breaks, Remove Paragraph Breaks and Convert to Text also act on the text of every frame, as +one undo step. The panels change the frames that are in the edited frame's thread at the time of the change, locked and +hidden frames included, and not a frame that has left it. A caret move or a click ends the story selection. one undo step. A caret move or a click ends the story selection. Switching to the Eyedropper keeps the story selected, and switching back to a Type tool restores it. `appearance.copyFrom` and `paint.sampleColor` then apply to the text of every frame as one undo step, and `appearance.copyFrom` leaves out a frame that is its `source`. While the story stays diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 98132eaec..4d9254351 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -739,6 +739,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur - The function key shortcuts for Brushes, Color, Layers, Info, Gradient, Stroke, Attributes, Graphic Styles, Appearance, Align, Transform, Pathfinder, Transparency, and Symbols activate a background tab on the first press, hide its expanded dock or floating group when that tab is in front, and restore the group with all its tabs together on the next press; in an icon column they open and close the flyout while keeping the icons. A reassigned chord keeps that behavior, and Window > a hidden sibling restores the group. - Cmd/Ctrl+A follows the keyboard: in a panel, dialog or search field it selects the field's text, while the Type tool edits it selects all of that text (also `select.all` over MCP), and only otherwise every object. On macOS the menu bar's Select All, Cut, Copy and Paste (keys or clicks) act on a focused field too, and elsewhere the in-window menus' do: the field that had the keyboard when the menu opened gets them, with its selection, and the keyboard back (#516). In a frame of threaded text, Select All selects the whole story, highlighted in every frame: Copy and Cut take all of it, typing or Delete replaces it as one undo step, and a fill, a size step or a Character or Paragraph panel change applies to every frame's text (VectorCraft's own behavior; `textedit::select_all_in_a_threaded_frame_selects_the_whole_story`, `textedit::typing_over_the_whole_story_replaces_every_frames_text_in_one_step`). - With the whole story of threaded text selected by Select All, Remove Line Breaks, Remove Paragraph Breaks and Type › Bullets and Numbering › Convert to Text act on the text of every frame as one undo step, and the story stays selected after Convert to Text; a selection inside one frame changes only that frame (VectorCraft's own behavior; `textedit::remove_breaks_takes_the_whole_story_that_select_all_selected`, `textedit::convert_to_text_takes_the_whole_story_that_select_all_selected`). + - With the whole story of threaded text selected by Select All, the Character, Paragraph, OpenType, Tabs and Text Styles panels, the Justification dialog and the font menu change the frames that are in the edited frame's thread at the time of the change, locked and hidden frames included. A frame that has left the thread is not changed, and a frame added to the thread is (VectorCraft's own behavior; `panels::character::tests::story_changes_follow_the_edited_frames_thread_after_the_first_saved_frame_leaves`, `story_changes_leave_out_a_frame_that_left_and_take_in_a_frame_that_joined`, `story_changes_stay_in_the_edited_frame_once_its_thread_is_removed`, `story_changes_take_in_locked_and_hidden_frames`). - Switching from a Type tool to the Eyedropper keeps a whole story selected by Select All: a pick applies to the text of every frame as one undo step, and switching back restores the story selection (VectorCraft's own behavior; `textedit::switching_to_the_eyedropper_keeps_the_whole_story_selected`). - While the Type tool edits text, a character typed with AltGr is typed and runs no command. Firefox on Windows reports AltGr as Ctrl and Alt held, so in the web build AltGr+L (ł on a Polish layout) does not also open Layer Options for a new layer, and AltGr+Q (@ on a German layout) does not also run Reset Tracking and Kerning. A Ctrl+Alt chord that types no character runs its command. - On a layout without Latin letters, a one-key shortcut goes by the position of the key that typed the character, in the web build too, where the character arrives before its key press: on a Russian layout the P key (`з`) chooses the Pen, and two keys pressed in one frame each run their own shortcut. From c592b31a1eeefc09103a8a622f6e1669e7c70748 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Fri, 6 Nov 2026 14:50:11 -0800 Subject: [PATCH 06/10] Type: Tabs indent markers and Justification follow the whole story After Select All in threaded text, the Tabs panel's indent markers read the indents of the edited frame alone. When another frame's paragraphs had a different left indent, a marker drag changed them by the edited frame's values, or left them alone. The markers now take every paragraph of every frame in the story, so each paragraph keeps its own left indent or first-line position. The Justification dialog kept the frames of the story from the moment it opened. OK now acts on the frames in the edited frame's thread when it is pressed, so a frame that left the thread while the dialog was open is not changed, and a frame that joined it is. --- crates/ui-egui/src/dialogs/justification.rs | 66 +++++++++++++++++---- crates/ui-egui/src/panels/tabs.rs | 11 +++- crates/ui-egui/src/panels/tabs/tests.rs | 60 +++++++++++++++++++ docs/target-app-parity.md | 1 + 4 files changed, 126 insertions(+), 12 deletions(-) diff --git a/crates/ui-egui/src/dialogs/justification.rs b/crates/ui-egui/src/dialogs/justification.rs index d4f58602d..494ff0b69 100644 --- a/crates/ui-egui/src/dialogs/justification.rs +++ b/crates/ui-egui/src/dialogs/justification.rs @@ -9,7 +9,8 @@ //! Fields: `wordMin`, `wordDesired`, `wordMax` (0..1000), `letterMin`, `letterDesired`, //! `letterMax` (-100..500), `glyphMin`, `glyphDesired`, `glyphMax` (50..200), `autoLeading` //! (0..500), `singleWord` (`fullJustify`, `alignLeft`, `alignCenter`, `alignRight`) and `preview`, -//! with `__initial` (the values it opened with) and `__targets` (the type and range it acts on). +//! with `__initial` (the values it opened with), `__targets` (the type and range it acts on) and, +//! after Select All in threaded text, `__story` (the edited frame, whose story it acts on). //! //! Behaviour reference (public documentation): //! . @@ -29,6 +30,9 @@ pub const KIND: &str = "justification"; const INITIAL: &str = "__initial"; /// The field holding the `ids`, `start` and `end` the dialog's `text.setFormat` acts on. const TARGETS: &str = "__targets"; +/// The field holding the frame the Type tool edits when the dialog opened after Select All in +/// threaded text: the dialog then acts on that frame's story as the document holds it. +const STORY: &str = "__story"; const CMD: &str = "text.setFormat"; pub(super) const SPEC: DialogSpec = DialogSpec { @@ -85,13 +89,12 @@ pub fn open(app: &mut VectorcraftApp) -> Result { crate::panels::character::text_style(app).ok_or("select some type first")?; // While typing, the dialog acts on the paragraphs of the text being edited. let mut targets = json!({}); + let mut story = None; if let Some((id, a, b)) = crate::panels::character::text_editing(app) { crate::panels::character::end_typing(app); - targets = match crate::panels::character::text_story(app) { - // After Select All in threaded text: every paragraph of every frame. - Some(story) => json!({"ids": story.iter().map(|f| f.0).collect::>()}), - None => json!({"ids": [id.0], "start": a, "end": b}), - }; + targets = json!({"ids": [id.0], "start": a, "end": b}); + // After Select All in threaded text: every paragraph of every frame ([`params`]). + story = crate::panels::character::text_story(app).map(|_| id.0); } let mut values = Map::new(); for (_, prefix, _) in ROWS { @@ -115,6 +118,9 @@ pub fn open(app: &mut VectorcraftApp) -> Result { let mut fields = Value::Object(values.clone()); fields[INITIAL] = Value::Object(values); fields[TARGETS] = targets; + if let Some(id) = story { + fields[STORY] = json!(id); + } fields["preview"] = json!(false); app.ui.dialog = Some(Dialog::new(KIND, fields)); Ok(Value::Null) @@ -179,17 +185,30 @@ fn body(app: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) -> bool { ui.add_space(6.0); ui.colored_label(crate::theme::Tokens::get(ui.ctx()).error, why); } - None => form::preview(app, ui, d, tl!("Justification"), CMD, Value::Object(params(d))), + None => { + let p = params(app, d); + form::preview(app, ui, d, tl!("Justification"), CMD, Value::Object(p)); + } } false } /// `text.setFormat` parameters: the fields that differ from the values the dialog opened with, -/// on the dialog's type and range. -fn params(d: &Dialog) -> Map { +/// on the dialog's type and range. After Select All in threaded text the type is every frame of +/// the edited frame's thread as the document holds it now; when that frame is no longer threaded, +/// the range in that frame. +fn params(app: &VectorcraftApp, d: &Dialog) -> Map { let initial = d.fields.get(INITIAL).and_then(Value::as_object); let was = |key: &str| initial.and_then(|m| m.get(key)).and_then(Value::as_f64); let mut p = d.fields.get(TARGETS).and_then(Value::as_object).cloned().unwrap_or_default(); + if let (Some(id), Some(st)) = (d.fields.get(STORY).and_then(Value::as_u64), app.session.active()) { + let story = st.doc.story_of(vectorcraft_doc::NodeId(id)); + if story.len() >= 2 { + p.remove("start"); + p.remove("end"); + p.insert("ids".into(), json!(story.iter().map(|f| f.0).collect::>())); + } + } for (param, prefix, _) in ROWS { let mut changed = Map::new(); for (suffix, name) in COLUMNS { @@ -229,7 +248,7 @@ fn confirm(app: &mut VectorcraftApp, d: &Dialog) -> Result { if let Some(why) = problem(d) { return Err(why.into()); } - let p = params(d); + let p = params(app, d); if !changes(&p) { if app.session.in_interaction() { let _ = app.session.cancel_interaction(); @@ -352,6 +371,33 @@ mod tests { assert_eq!(paras(&app, id).0.justification.auto_leading, 120.0); } + /// After Select All in threaded text, OK changes the frames in the edited frame's thread when OK + /// is pressed: not a frame that left the thread while the dialog was open, and a frame that + /// joined it. + #[test] + fn justification_dialog_follows_the_story_thread_at_ok() { + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + app.run("file.new", json!({"width": 900, "height": 300})).unwrap(); + let story = "The quick brown fox jumps over the lazy dog and keeps running far beyond the first frame.\n".repeat(6); + let area = json!({"x": 10, "y": 10, "text": story, "size": 12, "area": {"width": 150, "height": 60}}); + let a = app.run("text.create", area).unwrap()["id"].as_u64().unwrap(); + let b = app.run("text.thread.add", json!({"from": a, "rect": [200, 10, 150, 60]})).unwrap()["id"].as_u64().unwrap(); + let c = app.run("text.thread.add", json!({"from": b, "rect": [400, 10, 150, 200]})).unwrap()["id"].as_u64().unwrap(); + app.run("select.set", json!({"ids": [a]})).unwrap(); + app.session.select_tool("type", Default::default()).unwrap(); + app.session.set_tool_option("textSelection", &json!({"editing": a, "anchor": 0, "caret": 0})); + app.run("select.all", json!({})).unwrap(); + app.run("ui.justification", json!({})).unwrap(); + app.run("text.thread.break", json!({"id": b, "port": "out"})).unwrap(); + let d = app.run("text.thread.add", json!({"from": a, "rect": [600, 10, 150, 60]})).unwrap()["id"].as_u64().unwrap(); + set(&mut app, "autoLeading", json!(140)); + super::super::confirm(&mut app).unwrap(); + for id in [a, d, b] { + assert_eq!(paras(&app, id).0.justification.auto_leading, 140.0, "frame {id} takes the change"); + } + assert_eq!(paras(&app, c).0.justification.auto_leading, 120.0, "the frame that left keeps its paragraphs"); + } + /// When paragraphs in scope disagree, only shared justification values are filled in. #[test] fn justification_dialog_mixed_paragraph_fields_show_blank() { diff --git a/crates/ui-egui/src/panels/tabs.rs b/crates/ui-egui/src/panels/tabs.rs index d1b4b57fc..67e589104 100644 --- a/crates/ui-egui/src/panels/tabs.rs +++ b/crates/ui-egui/src/panels/tabs.rs @@ -72,11 +72,18 @@ fn append_indent_targets(node: &Node, range: std::ops::Range, targets: &m })); } -/// Paragraphs touched by the Type selection, or all paragraphs of selected text objects. +/// Paragraphs touched by the Type selection (every paragraph of every frame after Select All in +/// threaded text), or all paragraphs of selected text objects. fn indent_targets(app: &VectorcraftApp, node: &Node) -> Vec { let mut targets = Vec::new(); if let Some((_, a, b)) = super::character::text_editing(app) { - if let NodeKind::Text(text) = &node.kind { + if let (Some(story), Some(st)) = (super::character::text_story(app), app.session.active()) { + for frame in story.iter().filter_map(|id| st.doc.node(*id)) { + if let NodeKind::Text(text) = &frame.kind { + append_indent_targets(frame, 0..text.paragraph_count(), &mut targets); + } + } + } else if let NodeKind::Text(text) = &node.kind { append_indent_targets(node, text.paragraphs_in(a, b), &mut targets); } } else if let Some(st) = app.session.active() { diff --git a/crates/ui-egui/src/panels/tabs/tests.rs b/crates/ui-egui/src/panels/tabs/tests.rs index 7a92ff80c..42aea4317 100644 --- a/crates/ui-egui/src/panels/tabs/tests.rs +++ b/crates/ui-egui/src/panels/tabs/tests.rs @@ -404,6 +404,66 @@ fn lower_marker_keeps_each_first_line_position_in_one_undo_step() { assert_eq!(indent_values(&app, 1), (40.0, 28.0)); } +/// After Select All in threaded text, a marker change keeps the left indent and first-line position +/// of each paragraph in every frame of the story, when the edited frame's paragraphs agree and +/// another frame's differ. +#[test] +fn indent_markers_after_select_all_keep_each_story_paragraphs_indents() { + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + app.run("file.new", json!({"width": 600, "height": 300})).unwrap(); + let story = (1..=12).map(|i| format!("line {i}")).collect::>().join("\n"); + let a = app.run("text.create", json!({"x": 10, "y": 10, "text": story, "size": 12, "area": {"width": 200, "height": 60}})).unwrap()["id"] + .as_u64() + .unwrap(); + app.run("text.setFormat", json!({"ids": [a], "leftIndent": 24, "firstLineIndent": 12})).unwrap(); + let b = app.run("text.thread.add", json!({"from": a, "rect": [300, 10, 200, 250]})).unwrap()["id"].as_u64().unwrap(); + app.run("text.setFormat", json!({"ids": [b], "leftIndent": 60, "firstLineIndent": -10})).unwrap(); + let paragraphs = |app: &VectorcraftApp| -> Vec<(u64, f64, f64)> { + let st = app.session.active().unwrap(); + [a, b] + .into_iter() + .flat_map(|id| match &st.doc.node(vectorcraft_doc::NodeId(id)).unwrap().kind { + NodeKind::Text(t) => { + (0..t.paragraph_count()).map(|i| (id, t.para_at(i).left_indent, t.para_at(i).first_line_indent)).collect::>() + } + _ => panic!("not text"), + }) + .collect() + }; + assert!(paragraphs(&app).iter().any(|p| p.1 == 24.0) && paragraphs(&app).iter().any(|p| p.1 == 60.0), "{:?}", paragraphs(&app)); + let select_all = |app: &mut VectorcraftApp| { + app.run("select.set", json!({"ids": [a]})).unwrap(); + app.session.select_tool("type", Default::default()).unwrap(); + app.session.set_tool_option("textSelection", &json!({"editing": a, "anchor": 0, "caret": 0})); + app.run("select.all", json!({})).unwrap(); + assert_eq!(super::super::character::text_story(app), Some(vec![vectorcraft_doc::NodeId(a), vectorcraft_doc::NodeId(b)])); + }; + select_all(&mut app); + + let before = paragraphs(&app); + let (node, para, span) = current_para(&app).unwrap(); + assert_eq!(node.id.0, a); + let targets = indent_targets(&app, &node); + set_marker(&mut app, IndentMarker::Upper, 70.0, ¶, span, &targets, false); + let after = paragraphs(&app); + assert_eq!(after.len(), before.len()); + for (was, now) in before.iter().zip(&after) { + assert_eq!((now.1, now.1 + now.2), (was.1, 70.0), "a paragraph of frame {} keeps its left indent: {after:?}", now.0); + } + app.run("edit.undo", json!({})).unwrap(); + assert_eq!(paragraphs(&app), before); + + select_all(&mut app); + let (node, para, span) = current_para(&app).unwrap(); + let targets = indent_targets(&app, &node); + set_marker(&mut app, IndentMarker::Lower, 40.0, ¶, span, &targets, false); + let after = paragraphs(&app); + assert_eq!(after.len(), before.len()); + for (was, now) in before.iter().zip(&after) { + assert_eq!((now.1, now.1 + now.2), (40.0, was.1 + was.2), "a paragraph of frame {} keeps its first-line position: {after:?}", now.0); + } +} + #[test] fn lower_marker_bounds_cover_each_touched_first_line() { let targets = [IndentTarget { id: 1, start: 0, left: 24.0, first_line: -20.0 }, IndentTarget { id: 1, start: 4, left: 24.0, first_line: 280.0 }]; diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 4d9254351..0af2f0670 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -739,6 +739,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur - The function key shortcuts for Brushes, Color, Layers, Info, Gradient, Stroke, Attributes, Graphic Styles, Appearance, Align, Transform, Pathfinder, Transparency, and Symbols activate a background tab on the first press, hide its expanded dock or floating group when that tab is in front, and restore the group with all its tabs together on the next press; in an icon column they open and close the flyout while keeping the icons. A reassigned chord keeps that behavior, and Window > a hidden sibling restores the group. - Cmd/Ctrl+A follows the keyboard: in a panel, dialog or search field it selects the field's text, while the Type tool edits it selects all of that text (also `select.all` over MCP), and only otherwise every object. On macOS the menu bar's Select All, Cut, Copy and Paste (keys or clicks) act on a focused field too, and elsewhere the in-window menus' do: the field that had the keyboard when the menu opened gets them, with its selection, and the keyboard back (#516). In a frame of threaded text, Select All selects the whole story, highlighted in every frame: Copy and Cut take all of it, typing or Delete replaces it as one undo step, and a fill, a size step or a Character or Paragraph panel change applies to every frame's text (VectorCraft's own behavior; `textedit::select_all_in_a_threaded_frame_selects_the_whole_story`, `textedit::typing_over_the_whole_story_replaces_every_frames_text_in_one_step`). - With the whole story of threaded text selected by Select All, Remove Line Breaks, Remove Paragraph Breaks and Type › Bullets and Numbering › Convert to Text act on the text of every frame as one undo step, and the story stays selected after Convert to Text; a selection inside one frame changes only that frame (VectorCraft's own behavior; `textedit::remove_breaks_takes_the_whole_story_that_select_all_selected`, `textedit::convert_to_text_takes_the_whole_story_that_select_all_selected`). + - With the whole story of threaded text selected by Select All, the Character, Paragraph, OpenType, Tabs and Text Styles panels, the Justification dialog and the font menu change the frames that are in the edited frame's thread at the time of the change, locked and hidden frames included. A frame that has left the thread is not changed, and a frame added to the thread is (VectorCraft's own behavior; `panels::character::tests::story_changes_follow_the_edited_frames_thread_after_the_first_saved_frame_leaves`, `story_changes_leave_out_a_frame_that_left_and_take_in_a_frame_that_joined`, `story_changes_stay_in_the_edited_frame_once_its_thread_is_removed`, `story_changes_take_in_locked_and_hidden_frames`, `panels::tabs::tests::indent_markers_after_select_all_keep_each_story_paragraphs_indents`, `dialogs::justification::tests::justification_dialog_follows_the_story_thread_at_ok`). - With the whole story of threaded text selected by Select All, the Character, Paragraph, OpenType, Tabs and Text Styles panels, the Justification dialog and the font menu change the frames that are in the edited frame's thread at the time of the change, locked and hidden frames included. A frame that has left the thread is not changed, and a frame added to the thread is (VectorCraft's own behavior; `panels::character::tests::story_changes_follow_the_edited_frames_thread_after_the_first_saved_frame_leaves`, `story_changes_leave_out_a_frame_that_left_and_take_in_a_frame_that_joined`, `story_changes_stay_in_the_edited_frame_once_its_thread_is_removed`, `story_changes_take_in_locked_and_hidden_frames`). - Switching from a Type tool to the Eyedropper keeps a whole story selected by Select All: a pick applies to the text of every frame as one undo step, and switching back restores the story selection (VectorCraft's own behavior; `textedit::switching_to_the_eyedropper_keeps_the_whole_story_selected`). - While the Type tool edits text, a character typed with AltGr is typed and runs no command. Firefox on Windows reports AltGr as Ctrl and Alt held, so in the web build AltGr+L (ł on a Polish layout) does not also open Layer Options for a new layer, and AltGr+Q (@ on a German layout) does not also run Reset Tracking and Kerning. A Ctrl+Alt chord that types no character runs its command. From 24a42c5bb07ba1eec80d8cfae8b95c4ef4d23f45 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Fri, 6 Nov 2026 15:56:44 -0800 Subject: [PATCH 07/10] Type: Tabs indent markers apply to every paragraph when threaded text reflows With mixed indents, the Tabs panel sends one text.setFormat per paragraph, addressed by frame and byte offset. Threaded text reflows after each step, so an indent that made earlier paragraphs wrap onto more lines moved text into the next frame, and the later steps landed on the wrong paragraphs or on none. The steps now run from the story's last paragraph to its first, so each step's offset comes before every change already made. --- crates/ui-egui/src/panels/tabs.rs | 6 +- crates/ui-egui/src/panels/tabs/tests.rs | 74 +++++++++++++++++++++++++ 2 files changed, 79 insertions(+), 1 deletion(-) diff --git a/crates/ui-egui/src/panels/tabs.rs b/crates/ui-egui/src/panels/tabs.rs index 67e589104..a1a8c7fb1 100644 --- a/crates/ui-egui/src/panels/tabs.rs +++ b/crates/ui-egui/src/panels/tabs.rs @@ -181,7 +181,9 @@ fn paint_markers(ui: &mut Ui, painter: &egui::Painter, tokens: &Tokens, view: Ma } } -/// Apply an absolute ruler position through paragraph-format commands. +/// Apply an absolute ruler position through paragraph-format commands. Paragraphs that need values +/// of their own get one command each, the story's last paragraph first: threaded text re-flows +/// after each command, and a re-flow moves only text after the paragraph that changed. fn set_marker(app: &mut VectorcraftApp, marker: IndentMarker, target: f64, para: &ParaStyle, span: f64, targets: &[IndentTarget], both: bool) { if targets.is_empty() || !target.is_finite() { return; @@ -196,6 +198,7 @@ fn set_marker(app: &mut VectorcraftApp, marker: IndentMarker, target: f64, para: super::character::end_typing(app); let commands: Vec = targets .iter() + .rev() .map(|p| { json!({"command": "text.setFormat", "params": { "ids": [p.id], "start": p.start, "end": p.start, "firstLineIndent": target - p.left @@ -222,6 +225,7 @@ fn set_marker(app: &mut VectorcraftApp, marker: IndentMarker, target: f64, para: super::character::end_typing(app); let commands: Vec = targets .iter() + .rev() .map(|p| { json!({"command": "text.setFormat", "params": { "ids": [p.id], "start": p.start, "end": p.start, diff --git a/crates/ui-egui/src/panels/tabs/tests.rs b/crates/ui-egui/src/panels/tabs/tests.rs index 42aea4317..3244ba243 100644 --- a/crates/ui-egui/src/panels/tabs/tests.rs +++ b/crates/ui-egui/src/panels/tabs/tests.rs @@ -464,6 +464,80 @@ fn indent_markers_after_select_all_keep_each_story_paragraphs_indents() { } } +/// After Select All in threaded text with mixed indents, a marker change that makes the first +/// frame's paragraphs wrap onto more lines pushes text into the next frame while the change is +/// applied. Every paragraph of the story still gets the new position: the upper marker keeps each +/// left indent, and the lower marker keeps each first-line position. +#[test] +fn indent_markers_after_select_all_follow_text_that_reflows_into_the_next_frame() { + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + app.run("file.new", json!({"width": 600, "height": 1000})).unwrap(); + let story = (1..=12).map(|i| format!("p{i} aaa bbb ccc")).collect::>().join("\n"); + let a = app.run("text.create", json!({"x": 10, "y": 10, "text": story, "size": 12, "area": {"width": 200, "height": 60}})).unwrap()["id"] + .as_u64() + .unwrap(); + let b = app.run("text.thread.add", json!({"from": a, "rect": [300, 10, 200, 950]})).unwrap()["id"].as_u64().unwrap(); + app.run("text.setFormat", json!({"ids": [b], "leftIndent": 10})).unwrap(); + // Each paragraph of the story by its first word, with its left indent and first-line position; + // a paragraph continued from the frame before is counted once. No paragraph of the story is + // empty, so an empty piece (after a frame's last line break) is not one. + let paragraphs = |app: &VectorcraftApp| -> Vec<(String, f64, f64)> { + let st = app.session.active().unwrap(); + [a, b] + .into_iter() + .flat_map(|id| match &st.doc.node(vectorcraft_doc::NodeId(id)).unwrap().kind { + NodeKind::Text(t) => t + .plain_text() + .split('\n') + .enumerate() + .filter(|(i, text)| !text.is_empty() && (*i > 0 || !t.first_para_continues)) + .map(|(i, text)| { + let p = t.para_at(i); + (text.split(' ').next().unwrap_or_default().to_string(), p.left_indent, p.left_indent + p.first_line_indent) + }) + .collect::>(), + _ => panic!("not text"), + }) + .collect() + }; + let in_a = |app: &VectorcraftApp| match &app.session.active().unwrap().doc.node(vectorcraft_doc::NodeId(a)).unwrap().kind { + NodeKind::Text(t) => t.plain_text().len(), + _ => panic!("not text"), + }; + // Select All in the first frame and move `marker` to `to` → the story's paragraphs before and + // after. + let apply = |app: &mut VectorcraftApp, marker, to| { + let before = paragraphs(app); + assert_eq!(before.len(), 12, "{before:?}"); + let shown = in_a(app); + app.run("select.set", json!({"ids": [a]})).unwrap(); + app.session.select_tool("type", Default::default()).unwrap(); + app.session.set_tool_option("textSelection", &json!({"editing": a, "anchor": 0, "caret": 0})); + app.run("select.all", json!({})).unwrap(); + let (node, para, span) = current_para(app).unwrap(); + let targets = indent_targets(app, &node); + set_marker(app, marker, to, ¶, span, &targets, false); + let after = paragraphs(app); + assert!(in_a(app) < shown, "the first frame holds less text once it wraps: {} of {shown} bytes, {after:?}", in_a(app)); + assert_eq!(after.iter().map(|p| &p.0).collect::>(), before.iter().map(|p| &p.0).collect::>()); + (before, after) + }; + let (before, after) = apply(&mut app, IndentMarker::Upper, 150.0); + assert!(before.iter().any(|p| p.1 == 0.0) && before.iter().any(|p| p.1 == 10.0), "{before:?}"); + for (was, now) in before.iter().zip(&after) { + assert_eq!((now.1, now.2), (was.1, 150.0), "{} keeps its left indent and starts at 150 pt: {after:?}", now.0); + } + app.run("edit.undo", json!({})).unwrap(); + assert_eq!(paragraphs(&app), before); + // First lines at 150 and 160 pt; a left indent of 180 pt leaves room for one word a line. + app.run("text.setFormat", json!({"ids": [a, b], "firstLineIndent": 150})).unwrap(); + let (before, after) = apply(&mut app, IndentMarker::Lower, 180.0); + assert!(before.iter().any(|p| p.2 == 150.0) && before.iter().any(|p| p.2 == 160.0), "{before:?}"); + for (was, now) in before.iter().zip(&after) { + assert_eq!((now.1, now.2), (180.0, was.2), "{} is indented 180 pt and keeps its first-line position: {after:?}", now.0); + } +} + #[test] fn lower_marker_bounds_cover_each_touched_first_line() { let targets = [IndentTarget { id: 1, start: 0, left: 24.0, first_line: -20.0 }, IndentTarget { id: 1, start: 4, left: 24.0, first_line: 280.0 }]; From 1c2a8c7f7adea6b1ebd405810e666fdcc740e82e Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Fri, 6 Nov 2026 15:56:44 -0800 Subject: [PATCH 08/10] Type: Justification after Select All covers a frame whose thread broke After Select All in threaded text, the Justification dialog kept the edited frame's selected range when that frame was no longer threaded at OK. A thread broken after the frame moves the rest of the story into it, so the paragraphs past the old range kept their values. The dialog now drops the range and changes every paragraph in that frame. --- crates/ui-egui/src/dialogs/justification.rs | 31 +++++++++++++++++++-- 1 file changed, 28 insertions(+), 3 deletions(-) diff --git a/crates/ui-egui/src/dialogs/justification.rs b/crates/ui-egui/src/dialogs/justification.rs index 494ff0b69..dc42dcff8 100644 --- a/crates/ui-egui/src/dialogs/justification.rs +++ b/crates/ui-egui/src/dialogs/justification.rs @@ -196,16 +196,16 @@ fn body(app: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) -> bool { /// `text.setFormat` parameters: the fields that differ from the values the dialog opened with, /// on the dialog's type and range. After Select All in threaded text the type is every frame of /// the edited frame's thread as the document holds it now; when that frame is no longer threaded, -/// the range in that frame. +/// every paragraph in that frame. fn params(app: &VectorcraftApp, d: &Dialog) -> Map { let initial = d.fields.get(INITIAL).and_then(Value::as_object); let was = |key: &str| initial.and_then(|m| m.get(key)).and_then(Value::as_f64); let mut p = d.fields.get(TARGETS).and_then(Value::as_object).cloned().unwrap_or_default(); if let (Some(id), Some(st)) = (d.fields.get(STORY).and_then(Value::as_u64), app.session.active()) { let story = st.doc.story_of(vectorcraft_doc::NodeId(id)); + p.remove("start"); + p.remove("end"); if story.len() >= 2 { - p.remove("start"); - p.remove("end"); p.insert("ids".into(), json!(story.iter().map(|f| f.0).collect::>())); } } @@ -398,6 +398,31 @@ mod tests { assert_eq!(paras(&app, c).0.justification.auto_leading, 120.0, "the frame that left keeps its paragraphs"); } + /// After Select All in threaded text, a thread broken after the edited frame while the dialog is + /// open leaves the whole story in that frame, and OK changes every paragraph it now holds. + #[test] + fn justification_dialog_takes_the_story_into_a_frame_that_left_its_thread() { + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + app.run("file.new", json!({"width": 900, "height": 300})).unwrap(); + let story = "The quick brown fox jumps over the lazy dog and keeps running far beyond the first frame.\n".repeat(6); + let area = json!({"x": 10, "y": 10, "text": story, "size": 12, "area": {"width": 150, "height": 60}}); + let a = app.run("text.create", area).unwrap()["id"].as_u64().unwrap(); + app.run("text.thread.add", json!({"from": a, "rect": [200, 10, 150, 400]})).unwrap(); + app.run("select.set", json!({"ids": [a]})).unwrap(); + app.session.select_tool("type", Default::default()).unwrap(); + app.session.set_tool_option("textSelection", &json!({"editing": a, "anchor": 0, "caret": 0})); + app.run("select.all", json!({})).unwrap(); + app.run("ui.justification", json!({})).unwrap(); + app.run("text.thread.break", json!({"id": a, "port": "out"})).unwrap(); + set(&mut app, "autoLeading", json!(140)); + super::super::confirm(&mut app).unwrap(); + let NodeKind::Text(t) = &app.session.active().unwrap().doc.node(NodeId(a)).unwrap().kind else { panic!("not text") }; + assert!(t.paragraph_count() >= 6, "the story flows into the frame: {} paragraphs", t.paragraph_count()); + for i in 0..t.paragraph_count() { + assert_eq!(t.para_at(i).justification.auto_leading, 140.0, "paragraph {i}"); + } + } + /// When paragraphs in scope disagree, only shared justification values are filled in. #[test] fn justification_dialog_mixed_paragraph_fields_show_blank() { From fe9d62e05e7a67fa7d306df8dc463998cc71d52a Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Fri, 6 Nov 2026 16:26:36 -0800 Subject: [PATCH 09/10] .ai import: legacy type keeps its writing direction, Tate-chu-yoko runs and upright Roman characters The legacy reader skipped `TV`, `Tv`, `Ty` and `TY` of section 15.3.4 of the version 7 file format specification, so type set with `1 TV` opened horizontal. - `TV` sets a text object's writing direction. VectorCraft keeps one direction for each text object, the one in force at its first character, with a warning when a later character has another. - `Ty` marks a Kumi block as a Tate-chu-yoko run, which is VectorCraft's own mapping, and `TY` does the same in horizontal type. A `TY` block in vertical type opens unmarked. Two blocks with nothing between them open as one block. - `Tv` sets Standard Vertical Roman Alignment. All four are modal, and an operand other than 0 or 1 leaves the value as it was. Docs: docs/file-format-parity.md. --- crates/engine/tests/import_fuzz.rs | 8 ++ crates/eps/src/import/ai/legacy_text.rs | 50 ++++++++++- crates/eps/src/import/ai/read/ops.rs | 11 ++- crates/eps/src/import/ai/tests.rs | 110 ++++++++++++++++++++++++ crates/ui-egui/src/i18n/bg.tsv | 1 + crates/ui-egui/src/i18n/bs.tsv | 1 + crates/ui-egui/src/i18n/ca.tsv | 1 + crates/ui-egui/src/i18n/cs.tsv | 1 + crates/ui-egui/src/i18n/da.tsv | 1 + crates/ui-egui/src/i18n/de.tsv | 1 + crates/ui-egui/src/i18n/el.tsv | 1 + crates/ui-egui/src/i18n/es.tsv | 1 + crates/ui-egui/src/i18n/fi.tsv | 1 + crates/ui-egui/src/i18n/fr.tsv | 1 + crates/ui-egui/src/i18n/hi.tsv | 1 + crates/ui-egui/src/i18n/hu.tsv | 1 + crates/ui-egui/src/i18n/id.tsv | 1 + crates/ui-egui/src/i18n/it.tsv | 1 + crates/ui-egui/src/i18n/ja.tsv | 1 + crates/ui-egui/src/i18n/ka.tsv | 1 + crates/ui-egui/src/i18n/ko.tsv | 1 + crates/ui-egui/src/i18n/nl.tsv | 1 + crates/ui-egui/src/i18n/pl.tsv | 1 + crates/ui-egui/src/i18n/pt-br.tsv | 1 + crates/ui-egui/src/i18n/ru.tsv | 1 + crates/ui-egui/src/i18n/sv.tsv | 1 + crates/ui-egui/src/i18n/tr.tsv | 1 + crates/ui-egui/src/i18n/uk.tsv | 1 + crates/ui-egui/src/i18n/vi.tsv | 1 + crates/ui-egui/src/i18n/zh-hans.tsv | 1 + crates/ui-egui/src/i18n/zh-hant.tsv | 1 + docs/file-format-parity.md | 8 ++ 32 files changed, 209 insertions(+), 5 deletions(-) diff --git a/crates/engine/tests/import_fuzz.rs b/crates/engine/tests/import_fuzz.rs index 93e0138a1..f1a1f62c0 100644 --- a/crates/engine/tests/import_fuzz.rs +++ b/crates/engine/tests/import_fuzz.rs @@ -4315,6 +4315,14 @@ fn arb_ai_token() -> impl Strategy { "/x 100 200 TW", "1 Tq", "7 Tq", + "1 TV", + "7 TV", + "1 Tv", + "/x Tv", + "1 Ty", + "1e308 Ty", + "1 TY", + "(x) TY", "1 1 1e308 -1e308 4e9 Th", "1 1 (2) 3 4 Th", "36 2 Xb", diff --git a/crates/eps/src/import/ai/legacy_text.rs b/crates/eps/src/import/ai/legacy_text.rs index 04e181ed4..4e6164e4b 100644 --- a/crates/eps/src/import/ai/legacy_text.rs +++ b/crates/eps/src/import/ai/legacy_text.rs @@ -2,7 +2,8 @@ //! text paths (`a b c d tx ty startPt Tp`, an area's frame, one for each linked area, or the path //! type runs along, `TP`), the modal character and paragraph attributes //! (`Tr Tf Ts Tz Tt TA Tl Ta Ti`, and §15.4.7 and §22's letter and word spacing `TC TW`, hanging -//! punctuation `Tq`, hyphenation `Th` and tab stops `Xb Xe XB`), the paint style, and the characters (`Tx`, `Tj`, +//! punctuation `Tq`, hyphenation `Th` and tab stops `Xb Xe XB`, and §15.3.4's writing direction `TV`, +//! Roman character rotation `Tv` and Kumi blocks `Ty TY`), the paint style, and the characters (`Tx`, `Tj`, //! overflow `TX`; `\r` ends a paragraph). Type set in rendering modes 4 to 7 is a mask (§5.7): the //! text object clips the objects of its clipping group. Final-form operators (the //! computed positions, §15.1) are skipped: the type is laid out again. Strings are decoded by the @@ -51,6 +52,9 @@ const LINKED_AREAS_LEFT_OUT: &str = "linked type areas past VectorCraft's limits /// The warning when type set in a mask rendering mode can't be a clipping path: type in linked /// areas (several frames), in wraparound text or in a compound path doesn't clip. const MASK_NOT_CLIPPED: &str = "type used as a mask in linked areas, wraparound text or a compound path doesn't clip"; +/// The warning when `TV` changes after a text object's first character: the object keeps the +/// direction of that character. +const DIRECTION_CHANGE_LEFT_OUT: &str = "type that changes writing direction inside a text object opens in the direction of its first character"; /// A text path (`Tp` … `TP`). #[derive(Debug, Default)] @@ -101,6 +105,10 @@ pub(super) struct LegacyText { /// Characters were set in a mask rendering mode (`Tr` 4 to 7): the text object is a clipping /// path (§5.7). clips: bool, + /// `TV` at its first character: VectorCraft keeps one writing direction for each text object. + vertical: bool, + /// A later character was set in the other direction. + direction_changed: bool, /// The state when it began (lock, visibility), and whether it began on a `%_` line. gs: GState, hidden: bool, @@ -176,6 +184,10 @@ pub(super) struct TypeState { tabs_version: u64, /// `Tr`. render: i64, + /// `TV`: vertical writing. + vertical: bool, + /// `TY`: a Kumi block recorded for later use, which horizontal type keeps as its mark. + kumi_recorded: bool, /// The encoding strings are in, by code (`TE`); None where it says nothing. encoding: Vec>, /// Re-encoded fonts' original names (`%AI3_BeginEncoding: _new old`, or `TZ`'s). @@ -197,6 +209,8 @@ impl Default for TypeState { para: ParaStyle::default(), tabs_version: 0, render: 0, + vertical: false, + kumi_recorded: false, encoding: vec![None; 256], fonts: vec![], font_encodings: vec![], @@ -546,6 +560,31 @@ impl Reader<'_> { h.before_last = letters(tail, 1.0, 15.0); h.hyphen_limit = if limit { letters(max, 0.0, 25.0) } else { 0 }; } + // `direction TV` (§15.3.4): 0 horizontal, 1 vertical. + "TV" => { + if let Some(on) = n0.and_then(flag) { + st.vertical = on; + } + } + // `rotation Tv`: in vertical writing, 0 rotates Roman characters 90 degrees clockwise and + // 1 keeps them upright. + "Tv" => { + if let Some(on) = n0.and_then(flag) { + st.style.standard_vertical_roman = on; + } + } + // `kumi Ty`: 1 begins a Kumi block and 0 ends it. + "Ty" => { + if let Some(on) = n0.and_then(flag) { + st.style.tate_chu_yoko = on; + } + } + // `kumi TY`: as `Ty`, for a block that does not appear in the set text. + "TY" => { + if let Some(on) = n0.and_then(flag) { + st.kumi_recorded = on; + } + } // A tab definition (§22.6): `numDots numTabs Xb` begins it, each // `leader decimal tabType tabPosition Xe` describes one tab stop, and `XB` ends it. The // stops it describes replace the paragraph's tab stops, sorted by position, one stop at @@ -603,8 +642,13 @@ impl Reader<'_> { } let decoded: String = decoded.chars().take(room).collect(); st.chars += decoded.chars().count(); + if t.runs.is_empty() { + t.vertical = st.vertical; + } + t.direction_changed |= st.vertical != t.vertical; let gs = &self.gs; let mut style = st.style.clone(); + style.tate_chu_yoko |= st.kumi_recorded && !t.vertical; // Characters in modes 4 to 7 make the whole text object the mask, not each run (§5.7); 7 // paints nothing, nor does 3 (invisible). t.clips |= (4..=7).contains(&st.render); @@ -669,6 +713,9 @@ impl Reader<'_> { if t.kind == 1 && (t.past_limit || all_areas > placed.len()) { self.warn(LINKED_AREAS_LEFT_OUT); } + if t.direction_changed { + self.warn(DIRECTION_CHANGE_LEFT_OUT); + } let mut paras = std::mem::take(&mut t.paras); if paras.is_empty() { paras.push(ParaRun { style: self.type_state.para.clone(), count: 1, tabs_version: 0 }); @@ -682,6 +729,7 @@ impl Reader<'_> { for (m, kind) in placed { let mut obj = TextObject::point(kurbo::Point::ZERO, "", last_style.clone()); obj.kind = kind; + obj.vertical = t.vertical; obj.xf = to_doc * m * FLIP; match story.take() { Some((runs, paras)) => { diff --git a/crates/eps/src/import/ai/read/ops.rs b/crates/eps/src/import/ai/read/ops.rs index 4d2ce92c3..9535aa062 100644 --- a/crates/eps/src/import/ai/read/ops.rs +++ b/crates/eps/src/import/ai/read/ops.rs @@ -360,10 +360,13 @@ impl<'a> Reader<'a> { "TO" => self.end_text(hidden)?, // `(text) justified TS`, special characters (§22.6): the reader adds its characters like `Tx`. "Tx" | "Tj" | "TX" | "TS" => self.text_body(w, &vals), - "Tr" | "Tf" | "Ts" | "Tz" | "Tt" | "TA" | "Tl" | "Ta" | "Ti" | "TC" | "TW" | "Tq" | "Th" | "Xb" | "Xe" | "XB" => self.text_attr(w, &vals), - // Attributes the reader doesn't keep (kerning pairs, Far-Eastern type) and final-form - // positions (§15.3). - "Tk" | "TK" | "Tc" | "Tw" | "Tm" | "Td" | "T*" | "TR" | "T+" | "T-" | "TV" | "Tv" | "Ty" | "TY" | "TG" | "Tg" | "Tu" | "Xu" => {} + "Tr" | "Tf" | "Ts" | "Tz" | "Tt" | "TA" | "Tl" | "Ta" | "Ti" | "TC" | "TW" | "Tq" | "Th" | "TV" | "Tv" | "Ty" | "TY" | "Xb" | "Xe" + | "XB" => { + self.text_attr(w, &vals); + } + // Attributes the reader doesn't keep (kerning pairs, line breaking, binding punctuation + // and Japanese layout rules) and final-form positions (§15.3). + "Tk" | "TK" | "Tc" | "Tw" | "Tm" | "Td" | "T*" | "TR" | "T+" | "T-" | "TG" | "Tg" | "Tu" | "Xu" => {} // `TM` (pop text matrix) and `Te` (end render), listed in §22.6, keep nothing either; the // reader takes `Te` with any number of operands. "TM" | "Te" => {} diff --git a/crates/eps/src/import/ai/tests.rs b/crates/eps/src/import/ai/tests.rs index f5eadb931..36a93a690 100644 --- a/crates/eps/src/import/ai/tests.rs +++ b/crates/eps/src/import/ai/tests.rs @@ -3381,6 +3381,106 @@ fn legacy_type_keeps_its_spacing_hanging_punctuation_and_hyphenation() { assert_eq!(texts[1].para_at(0), two); } +/// `TV` (§15.3.4) sets the writing direction of point type, area type, type on a path and linked +/// areas, and lasts into the next text object until it changes. +#[test] +fn legacy_type_keeps_its_writing_direction() { + let frame = "10 10 m\n110 10 L\n110 110 L\n10 110 L\nN"; + let area = format!("1 To\n1 0 0 1 10 110 0 Tp\n{frame}\nTP\n0 Tr\n0 0 0 1 k\n/_Helvetica 12 Tf\n(Area) Tx\nTO\n"); + let on_path = "2 To\n1 0 0 1 0 0 0 Tp\n10 10 m\n110 10 L\nN\nTP\n(Path) Tx\nTO\n"; + let linked = format!("1 To\n1 0 0 1 10 110 0 Tp\n{frame}\nTP\n1 0 0 1 200 110 0 Tp\n{frame}\nTP\n(Linked) Tx\nTO\n"); + let art = format!("{}{area}{on_path}{linked}{}", point_type("1 TV", "Point"), point_type("0 TV", "Flat")); + let texts = legacy_texts(&art); + let vertical: Vec = texts.iter().map(|t| t.vertical).collect(); + assert_eq!(vertical, [true, true, true, true, true, false]); + assert!(matches!(texts[1].kind, vectorcraft_doc::TextKind::Area { .. })); + assert!(matches!(texts[2].kind, vectorcraft_doc::TextKind::OnPath { .. })); + // Vertical point type runs down the canvas from `Tp`'s origin (10, 50), which opens at document + // (10, 50) on the 100 pt tall artboard, on the column's center line. + let long = &legacy_texts(&point_type("1 TV", "Point type"))[0]; + let bounds = long.xf.transform_rect_bbox(vectorcraft_text::layout(vectorcraft_text::FontDb::global(), long).bounds); + assert!(bounds.height() > bounds.width(), "{bounds:?}"); + assert!(bounds.x0 < 10.0 && bounds.x1 > 10.0, "{bounds:?}"); + assert!((bounds.y0 - 50.0).abs() < 1.0 && bounds.y1 > 60.0, "{bounds:?}"); + // A text object keeps the direction in force at its first character, with a warning when a later + // character has another; the mode lasts into the next text object. + let changed = point_type("1 TV", "First").replace("TO\n", "0 TV\n(Second) Tx\nTO\n"); + let r = legacy_read(&format!("{changed}{}", point_type("", "Next"))); + let changed = art_texts(&r.doc); + assert!(changed[0].vertical); + assert_eq!(changed[0].plain_text(), "FirstSecond"); + assert!(!changed[1].vertical); + assert_eq!(r.warnings, [DIRECTION_CHANGE_LEFT_OUT]); + // A direction that changes and changes back also warns. + let back = point_type("", "a").replace("TO\n", "1 TV\n(b) Tx\n0 TV\n(c) Tx\nTO\n"); + assert_eq!(legacy_read(&back).warnings, [DIRECTION_CHANGE_LEFT_OUT]); + // A `TV` that no character follows does not warn, and an object with no characters opens no + // type, while its `TV` lasts into the next object. + let trailing = point_type("", "a").replace("TO\n", "1 TV\nTO\n"); + assert!(legacy_read(&trailing).warnings.is_empty()); + let empty = legacy_texts(&format!("0 To\n1 0 0 1 10 50 0 Tp\nTP\n1 TV\nTO\n{}", point_type("", "Next"))); + assert_eq!((empty.len(), empty[0].vertical), (1, true)); + // `TV` before `To` counts, and `TV` after the last character only reaches the next object. + let before = legacy_texts(&format!("1 TV\n{}", point_type("", "Hi"))); + assert!(before[0].vertical); + let after = point_type("", "Hi").replace("TO\n", "1 TV\nTO\n"); + let after = legacy_texts(&format!("{after}{}", point_type("", "Next"))); + assert_eq!((after[0].vertical, after[1].vertical), (false, true)); +} + +/// The glyph origins of a legacy-format text object. +fn legacy_origins(t: &vectorcraft_doc::TextObject) -> Vec<(f64, f64)> { + vectorcraft_text::layout(vectorcraft_text::FontDb::global(), t).glyphs.iter().map(|g| (g.origin.x, g.origin.y)).collect() +} + +/// `Ty` (§15.3.4) marks the characters of a Kumi block in vertical and in horizontal type, `TY` +/// marks them in horizontal type, and `Tv` keeps Roman characters upright in vertical type; `Tv` +/// may come inside the text path. All three last into the next text object. +#[test] +fn legacy_type_keeps_kumi_blocks_and_upright_roman_characters() { + let runs = |art: &str| -> Vec<(String, bool, bool)> { + let texts = legacy_texts(art); + texts[0].runs.iter().map(|r| (r.text.clone(), r.style.tate_chu_yoko, r.style.standard_vertical_roman)).collect() + }; + let kumi = |begin: &str, end: &str| point_type(&format!("(A) Tx\n{begin}\n(12) Tx\n{end}"), "B"); + let marked = vec![("A".to_string(), false, false), ("12".to_string(), true, false), ("B".to_string(), false, false)]; + assert_eq!(runs(&format!("1 TV\n{}", kumi("1 Ty", "0 Ty"))), marked); + assert_eq!(runs(&kumi("1 Ty", "0 Ty")), marked); + assert_eq!(runs(&kumi("1 TY", "0 TY")), marked); + assert!(!legacy_texts(&kumi("1 TY", "0 TY"))[0].vertical); + // `TY` records a block that the set text doesn't show: vertical type opens without it, and the + // block starts again in the next horizontal object. + let unmarked = vec![("A12B".to_string(), false, false)]; + assert_eq!(runs(&format!("1 TV\n{}", kumi("1 TY", "0 TY"))), unmarked); + let recorded = legacy_texts(&format!("1 TV\n1 TY\n{}0 TV\n{}", point_type("", "A"), point_type("", "B"))); + assert_eq!((recorded[0].runs[0].style.tate_chu_yoko, recorded[1].runs[0].style.tate_chu_yoko), (false, true)); + let upright = point_type("(a) Tx\n1 Tv", "b"); + assert_eq!(runs(&format!("1 TV\n{upright}")), [("a".to_string(), false, false), ("b".to_string(), false, true)]); + let in_path = point_type("", "c").replace("0 Tp\nTP", "0 Tp\n1 Tv\nTP"); + assert_eq!(runs(&in_path), [("c".to_string(), false, true)]); + assert_eq!(runs(&point_type("1 Tv\n(a) Tx\n0 Tv", "b")), [("a".to_string(), false, true), ("b".to_string(), false, false)]); + // Each one lasts into the next text object. + for (op, kept) in [("Ty", (true, false)), ("TY", (true, false)), ("Tv", (false, true))] { + let texts = legacy_texts(&format!("{}{}", point_type(&format!("1 {op}"), "a"), point_type("", "b"))); + let st = &texts[1].runs[0].style; + assert_eq!((st.tate_chu_yoko, st.standard_vertical_roman), kept, "{op}"); + } + // Horizontal type keeps the mark and doesn't move the characters. + let plain = &legacy_texts(&point_type("", "A12B"))[0]; + let in_horizontal = &legacy_texts(&kumi("1 Ty", "0 Ty"))[0]; + assert_eq!(legacy_origins(in_horizontal), legacy_origins(plain)); + // Two blocks with nothing between them open as one block. + let vertical = |attrs: &str, text: &str| legacy_texts(&format!("1 TV\n{}", point_type(attrs, text))).remove(0); + let two = vertical("1 Ty\n(AB) Tx\n0 Ty\n1 Ty", "CD"); + let one = vertical("1 Ty", "ABCD"); + assert_eq!(legacy_origins(&two), legacy_origins(&one)); + // Two or three digits that no block marks are set across the column by the layout's own rule. + let digits = vertical("", "12"); + assert!(!digits.runs[0].style.tate_chu_yoko); + assert_eq!(legacy_origins(&digits), legacy_origins(&vertical("1 Ty", "12"))); + assert_ne!(legacy_origins(&vertical("", "AB")), legacy_origins(&vertical("1 Ty", "AB"))); +} + /// A legacy attribute whose operands are missing, are not numbers, or are flags other than 0 and /// 1 leaves the attribute as it was, and operands are read by their place. #[test] @@ -3396,6 +3496,15 @@ fn legacy_type_attributes_with_bad_operands_stay_as_they_were() { assert_eq!(p.justify, vectorcraft_doc::Justify::Right); assert_eq!((p.first_line_indent, p.left_indent, p.right_indent), (5.0, 6.0, 7.0)); assert_eq!(texts[0].runs[0].style.kerning, Some(0.0), "kerning stays off"); + let texts = legacy_texts(&point_type("1 TV\n1 Tv\n1 Ty\n2 TV\n/x TV\nTV\n-1 Tv\n(x) Tv\n0.5 Ty\n2 TY", "Hi")); + let st = &texts[0].runs[0].style; + assert!(texts[0].vertical && st.standard_vertical_roman && st.tate_chu_yoko); + let texts = legacy_texts(&point_type("2 TV\n-1 TV\n2 Tv\n-1 Tv\n0.5 Ty\n-1 Ty\n2 TY\n-1 TY\n1 TY\n2 TY\n0.5 TY", "Hi")); + let st = &texts[0].runs[0].style; + assert!(!texts[0].vertical && !st.standard_vertical_roman); + assert!(st.tate_chu_yoko, "`TY` stays on"); + let texts = legacy_texts(&point_type("2 TV\n-1 TV\n2 Tv\n-1 Tv\n0.5 Ty\n-1 Ty\n2 TY\n-1 TY", "Hi")); + assert!(!texts[0].runs[0].style.tate_chu_yoko); // A scale or leading pair with a bad operand leaves both as they were; one operand is the // horizontal scale (or the leading) of the format before version 7. let texts = legacy_texts(&point_type("50 80 Tz\n14 3 Tl\n/x 120 Tz\n/x 20 Tl", "Hi")); @@ -3843,6 +3952,7 @@ fn legacy_linked_area_frames_cap_their_copied_tab_stops() { /// The warning when linked areas are left out. const LINKED_AREAS_LEFT_OUT: &str = "linked type areas past VectorCraft's limits were left out"; +const DIRECTION_CHANGE_LEFT_OUT: &str = "type that changes writing direction inside a text object opens in the direction of its first character"; /// The frames of linked areas times the characters of their story are kept to the reader's /// allowance of characters: a story of more than half of it gets one frame, and of a third, two. diff --git a/crates/ui-egui/src/i18n/bg.tsv b/crates/ui-egui/src/i18n/bg.tsv index fce973e68..f6a8debdc 100644 --- a/crates/ui-egui/src/i18n/bg.tsv +++ b/crates/ui-egui/src/i18n/bg.tsv @@ -4533,6 +4533,7 @@ axis Both И двете @msg an image whose data is cut short was left out изображение с непълни данни беше пропуснато @msg linked pictures show the low-resolution preview saved in the file until their files are found: relink them in the Links panel свързаните картини показват записания във файла предварителен преглед с ниска резолюция, докато файловете им бъдат намерени: презакачете ги в панела „Връзки“ @msg linked type areas past VectorCraft's limits were left out свързаните текстови области извън ограниченията на VectorCraft бяха пропуснати +@msg type that changes writing direction inside a text object opens in the direction of its first character текст, който сменя посоката на писане в рамките на текстов обект, се отваря в посоката на първия си знак @msg type used as a mask in linked areas, wraparound text or a compound path doesn't clip текст, използван като маска в свързани области, обтичащ текст или съставен път, не изрязва @msg linked pictures with no preview in the file show a placeholder until their files are found: relink them in the Links panel свързаните картини без предварителен преглед във файла показват заместител, докато файловете им бъдат намерени: презакачете ги в панела „Връзки“ @msg area type in frames too short for a line of the font it shows in got taller frames ({_1}): their type wouldn't show otherwise текстовите рамки за област в редове, твърде къси за шрифта, в който се показват, получиха по-високи рамки ({_1}): текстът им иначе нямаше да се види diff --git a/crates/ui-egui/src/i18n/bs.tsv b/crates/ui-egui/src/i18n/bs.tsv index 16f779bed..a8db1ebea 100644 --- a/crates/ui-egui/src/i18n/bs.tsv +++ b/crates/ui-egui/src/i18n/bs.tsv @@ -4932,6 +4932,7 @@ imageTrace Outline Obris @msg an image whose data is cut short was left out izostavljena je slika čiji su podaci skraćeni @msg linked pictures show the low-resolution preview saved in the file until their files are found: relink them in the Links panel povezane slike prikazuju pregled niske rezolucije sačuvan u datoteci dok se njihove datoteke ne pronađu: ponovo ih povežite u panelu Veze @msg linked type areas past VectorCraft's limits were left out povezana područja teksta izvan ograničenja programa VectorCraft su izostavljena +@msg type that changes writing direction inside a text object opens in the direction of its first character tekst koji mijenja smjer pisanja unutar tekstualnog objekta otvara se u smjeru svog prvog znaka @msg type used as a mask in linked areas, wraparound text or a compound path doesn't clip tekst korišten kao maska u povezanim područjima, omotanom tekstu ili složenoj putanji ne odsijeca @msg linked pictures with no preview in the file show a placeholder until their files are found: relink them in the Links panel povezane slike bez pregleda u datoteci prikazuju čuvar mjesta dok se njihove datoteke ne pronađu: ponovo ih povežite u panelu Veze @msg area type in frames too short for a line of the font it shows in got taller frames ({_1}): their type wouldn't show otherwise tekst u području u okvirima prekratkim za red fonta u kojem se prikazuje dobio je više okvire ({_1}): inače se tekst ne bi prikazao diff --git a/crates/ui-egui/src/i18n/ca.tsv b/crates/ui-egui/src/i18n/ca.tsv index 491f45913..3898a677f 100644 --- a/crates/ui-egui/src/i18n/ca.tsv +++ b/crates/ui-egui/src/i18n/ca.tsv @@ -5188,6 +5188,7 @@ digits Malayalam Malaiàlam @msg library panel `{given}` isn't open el panell de biblioteca `{given}` no està obert @msg linked pictures show the low-resolution preview saved in the file until their files are found: relink them in the Links panel les imatges enllaçades mostren la previsualització de baixa resolució desada al fitxer fins que es trobin els seus fitxers: torneu-les a enllaçar al panell Enllaços @msg linked type areas past VectorCraft's limits were left out s’han omès les àrees de text enllaçades que superen els límits de VectorCraft +@msg type that changes writing direction inside a text object opens in the direction of its first character el text que canvia de direcció d’escriptura dins d’un objecte de text s’obre en la direcció del seu primer caràcter @msg type used as a mask in linked areas, wraparound text or a compound path doesn't clip el text usat com a màscara en àrees enllaçades, en text ajustat o en un traçat compost no retalla @msg linked pictures with no preview in the file show a placeholder until their files are found: relink them in the Links panel les imatges enllaçades sense previsualització al fitxer mostren un marcador de posició fins que es trobin els seus fitxers: torneu-les a enllaçar al panell Enllaços @msg Live Paint Merge: select a Live Paint group and the paths to add Fusió de pintura interactiva: seleccioneu un grup de pintura interactiva i els traçats que voleu afegir diff --git a/crates/ui-egui/src/i18n/cs.tsv b/crates/ui-egui/src/i18n/cs.tsv index 82675a485..eff1d2d34 100644 --- a/crates/ui-egui/src/i18n/cs.tsv +++ b/crates/ui-egui/src/i18n/cs.tsv @@ -4990,6 +4990,7 @@ imageTrace Outline Obrys @msg an image whose data is cut short was left out obrázek s useknutými daty byl vynechán @msg linked pictures show the low-resolution preview saved in the file until their files are found: relink them in the Links panel propojené obrázky se zobrazují jako náhled v nízkém rozlišení uložený v souboru, dokud se nenajdou jejich soubory: znovu je propojte v panelu Propojení @msg linked type areas past VectorCraft's limits were left out propojené textové oblasti nad limity aplikace VectorCraft byly vynechány +@msg type that changes writing direction inside a text object opens in the direction of its first character text, který mění směr psaní uvnitř textového objektu, se otevře ve směru svého prvního znaku @msg type used as a mask in linked areas, wraparound text or a compound path doesn't clip text použitý jako maska v propojených oblastech, obtékaném textu nebo složené cestě neořezává @msg linked pictures with no preview in the file show a placeholder until their files are found: relink them in the Links panel propojené obrázky bez náhledu v souboru se zobrazují jako zástupný symbol, dokud se nenajdou jejich soubory: znovu je propojte v panelu Propojení @msg area type in frames too short for a line of the font it shows in got taller frames ({_1}): their type wouldn't show otherwise text v oblasti v rámcích příliš nízkých pro řádek písma, ve kterém se zde zobrazuje, dostal vyšší rámce ({_1}): jinak by nebyl vidět diff --git a/crates/ui-egui/src/i18n/da.tsv b/crates/ui-egui/src/i18n/da.tsv index 0567c08dc..abfe581e7 100644 --- a/crates/ui-egui/src/i18n/da.tsv +++ b/crates/ui-egui/src/i18n/da.tsv @@ -4618,6 +4618,7 @@ imageTrace Outline Kontur @msg an image whose data is cut short was left out et billede, hvis data er afkortet, blev udeladt @msg linked pictures show the low-resolution preview saved in the file until their files are found: relink them in the Links panel linkede billeder viser den gemte forhåndsvisning i lav opløsning, indtil deres filer findes: link dem igen i Links-panelet @msg linked type areas past VectorCraft's limits were left out sammenkædede tekstområder ud over VectorCrafts grænser blev udeladt +@msg type that changes writing direction inside a text object opens in the direction of its first character tekst, der skifter skriveretning inde i et tekstobjekt, åbnes i retningen for sit første tegn @msg type used as a mask in linked areas, wraparound text or a compound path doesn't clip tekst brugt som maske i sammenkædede områder, ombrudt tekst eller en sammensat kurve beskærer ikke @msg linked pictures with no preview in the file show a placeholder until their files are found: relink them in the Links panel linkede billeder uden forhåndsvisning i filen viser en pladsholder, indtil deres filer findes: link dem igen i Links-panelet @msg area type in frames too short for a line of the font it shows in got taller frames ({_1}): their type wouldn't show otherwise rammetekst i rammer, der er for lave til en tekstlinje i den skrifttype, den viser i, fik højere rammer ({_1}): teksten ville ellers ikke blive vist diff --git a/crates/ui-egui/src/i18n/de.tsv b/crates/ui-egui/src/i18n/de.tsv index a75ab4aa8..4a4142d88 100644 --- a/crates/ui-egui/src/i18n/de.tsv +++ b/crates/ui-egui/src/i18n/de.tsv @@ -4600,6 +4600,7 @@ theme Appearance Erscheinungsbild @msg an image whose data is cut short was left out ein Bild mit abgeschnittenen Daten wurde ausgelassen @msg linked pictures show the low-resolution preview saved in the file until their files are found: relink them in the Links panel verknüpfte Bilder zeigen die in der Datei gespeicherte Vorschau in niedriger Auflösung, bis ihre Dateien gefunden werden: Verknüpfen Sie sie im Bedienfeld „Verknüpfungen“ neu @msg linked type areas past VectorCraft's limits were left out verkettete Textbereiche jenseits der Grenzen von VectorCraft wurden ausgelassen +@msg type that changes writing direction inside a text object opens in the direction of its first character Text, der innerhalb eines Textobjekts die Schreibrichtung wechselt, wird in der Richtung seines ersten Zeichens geöffnet @msg type used as a mask in linked areas, wraparound text or a compound path doesn't clip als Maske verwendeter Text in verketteten Bereichen, umfließendem Text oder einem zusammengesetzten Pfad beschneidet nicht @msg linked pictures with no preview in the file show a placeholder until their files are found: relink them in the Links panel verknüpfte Bilder ohne Vorschau in der Datei zeigen einen Platzhalter, bis ihre Dateien gefunden werden: Verknüpfen Sie sie im Bedienfeld „Verknüpfungen“ neu @msg area type in frames too short for a line of the font it shows in got taller frames ({_1}): their type wouldn't show otherwise Flächentext in Rahmen, die für eine Zeile der Schrift, in der er hier erscheint, zu niedrig sind, hat höhere Rahmen bekommen ({_1}): Sonst wäre der Text nicht zu sehen diff --git a/crates/ui-egui/src/i18n/el.tsv b/crates/ui-egui/src/i18n/el.tsv index d702f7da1..6b2a022d5 100644 --- a/crates/ui-egui/src/i18n/el.tsv +++ b/crates/ui-egui/src/i18n/el.tsv @@ -4633,6 +4633,7 @@ digits Malayalam Μαλαγιάλαμ @msg an image whose data is cut short was left out παραλείφθηκε εικόνα με περικομμένα δεδομένα @msg linked pictures show the low-resolution preview saved in the file until their files are found: relink them in the Links panel οι συνδεδεμένες εικόνες εμφανίζουν την προεπισκόπηση χαμηλής ανάλυσης που έχει αποθηκευτεί, μέχρι να βρεθούν τα αρχεία τους: επανασυνδέστε τις στον πίνακα Σύνδεσμοι @msg linked type areas past VectorCraft's limits were left out οι συνδεδεμένες περιοχές κειμένου πέρα από τα όρια του VectorCraft παραλείφθηκαν +@msg type that changes writing direction inside a text object opens in the direction of its first character το κείμενο που αλλάζει κατεύθυνση γραφής μέσα σε ένα αντικείμενο κειμένου ανοίγει με την κατεύθυνση του πρώτου χαρακτήρα του @msg type used as a mask in linked areas, wraparound text or a compound path doesn't clip το κείμενο που χρησιμοποιείται ως μάσκα σε συνδεδεμένες περιοχές, σε κείμενο με αναδίπλωση ή σε σύνθετη διαδρομή δεν αποκόπτει @msg linked pictures with no preview in the file show a placeholder until their files are found: relink them in the Links panel οι συνδεδεμένες εικόνες χωρίς προεπισκόπηση στο αρχείο εμφανίζουν ένα σύμβολο κράτησης θέσης, μέχρι να βρεθούν τα αρχεία τους: επανασυνδέστε τις στον πίνακα Σύνδεσμοι @msg area type in frames too short for a line of the font it shows in got taller frames ({_1}): their type wouldn't show otherwise οι περιοχές κειμένου με πλαίσια πολύ μικρά για μία γραμμή της γραμματοσειράς τους απέκτησαν μεγαλύτερο ύψος ({_1}), διαφορετικά το κείμενο δεν θα εμφανιζόταν diff --git a/crates/ui-egui/src/i18n/es.tsv b/crates/ui-egui/src/i18n/es.tsv index ed4ec9490..6ccd50b46 100644 --- a/crates/ui-egui/src/i18n/es.tsv +++ b/crates/ui-egui/src/i18n/es.tsv @@ -4684,6 +4684,7 @@ axis Both Ambos @msg an image whose data is cut short was left out se omitió una imagen con datos incompletos @msg linked pictures show the low-resolution preview saved in the file until their files are found: relink them in the Links panel las imágenes vinculadas muestran la vista previa de baja resolución guardada en el archivo hasta que se encuentren sus archivos: vuelva a vincularlas en el panel Vínculos @msg linked type areas past VectorCraft's limits were left out se omitieron las áreas de texto enlazadas que superan los límites de VectorCraft +@msg type that changes writing direction inside a text object opens in the direction of its first character el texto que cambia de dirección de escritura dentro de un objeto de texto se abre en la dirección de su primer carácter @msg type used as a mask in linked areas, wraparound text or a compound path doesn't clip el texto usado como máscara en áreas enlazadas, texto ceñido o un trazado compuesto no recorta @msg linked pictures with no preview in the file show a placeholder until their files are found: relink them in the Links panel las imágenes vinculadas sin vista previa en el archivo muestran un marcador de posición hasta que se encuentren sus archivos: vuelva a vincularlas en el panel Vínculos @msg area type in frames too short for a line of the font it shows in got taller frames ({_1}): their type wouldn't show otherwise el texto de área en marcos demasiado bajos para una línea de la fuente en que se muestra recibió marcos más altos ({_1}): de otro modo no se vería diff --git a/crates/ui-egui/src/i18n/fi.tsv b/crates/ui-egui/src/i18n/fi.tsv index 166c750ad..3cf8607c0 100644 --- a/crates/ui-egui/src/i18n/fi.tsv +++ b/crates/ui-egui/src/i18n/fi.tsv @@ -4892,6 +4892,7 @@ imageTrace Outline Ääriviiva @msg an image whose data is cut short was left out kuva, jonka data on katkennut, jätettiin pois @msg linked pictures show the low-resolution preview saved in the file until their files are found: relink them in the Links panel linkitetyt kuvat näyttävät tiedostoon tallennetun matalan tarkkuuden esikatselun, kunnes niiden tiedostot löytyvät: linkitä ne uudelleen Linkit-paneelissa @msg linked type areas past VectorCraft's limits were left out VectorCraftin rajat ylittävät linkitetyt tekstialueet jätettiin pois +@msg type that changes writing direction inside a text object opens in the direction of its first character tekstiobjektin sisällä kirjoitussuuntaa vaihtava teksti avautuu ensimmäisen merkkinsä suuntaan @msg type used as a mask in linked areas, wraparound text or a compound path doesn't clip maskina käytetty teksti linkitetyissä alueissa, rivitetyssä tekstissä tai yhdistelmäpolussa ei rajaa @msg linked pictures with no preview in the file show a placeholder until their files are found: relink them in the Links panel linkitetyt kuvat, joilla ei ole esikatselua tiedostossa, näkyvät paikkamerkkinä, kunnes niiden tiedostot löytyvät: linkitä ne uudelleen Linkit-paneelissa @msg area type in frames too short for a line of the font it shows in got taller frames ({_1}): their type wouldn't show otherwise aluetekstin kehykset, jotka olivat liian matalia yhdelle riville niiden fontilla, korotettiin ({_1}): muuten niiden teksti ei näkyisi diff --git a/crates/ui-egui/src/i18n/fr.tsv b/crates/ui-egui/src/i18n/fr.tsv index c248c9405..67a7cb29c 100644 --- a/crates/ui-egui/src/i18n/fr.tsv +++ b/crates/ui-egui/src/i18n/fr.tsv @@ -4672,6 +4672,7 @@ axis Both Les deux @msg an image whose data is cut short was left out une image aux données tronquées a été ignorée @msg linked pictures show the low-resolution preview saved in the file until their files are found: relink them in the Links panel les images liées affichent l’aperçu basse résolution enregistré dans le fichier tant que leurs fichiers ne sont pas trouvés : liez-les à nouveau dans le panneau Liens @msg linked type areas past VectorCraft's limits were left out les zones de texte liées au-delà des limites de VectorCraft ont été omises +@msg type that changes writing direction inside a text object opens in the direction of its first character le texte qui change de sens d’écriture dans un objet texte s’ouvre dans le sens de son premier caractère @msg type used as a mask in linked areas, wraparound text or a compound path doesn't clip le texte utilisé comme masque dans des zones liées, un habillage de texte ou un tracé transparent ne découpe pas @msg linked pictures with no preview in the file show a placeholder until their files are found: relink them in the Links panel les images liées sans aperçu dans le fichier affichent un espace réservé tant que leurs fichiers ne sont pas trouvés : liez-les à nouveau dans le panneau Liens @msg area type in frames too short for a line of the font it shows in got taller frames ({_1}): their type wouldn't show otherwise le texte captif dans des cadres trop bas pour une ligne de la police dans laquelle il s’affiche a reçu des cadres plus hauts ({_1}) : il ne s’afficherait pas autrement diff --git a/crates/ui-egui/src/i18n/hi.tsv b/crates/ui-egui/src/i18n/hi.tsv index e80177051..e1af82a3a 100644 --- a/crates/ui-egui/src/i18n/hi.tsv +++ b/crates/ui-egui/src/i18n/hi.tsv @@ -7554,6 +7554,7 @@ @msg type dimension must be finite प्रकार आयाम को परिमित होना चाहिए @msg type must be static or dynamic, not {v} type: प्रकार स्थिर या गतिशील होना चाहिए, न कि {v} @msg type objects can't be isolated प्रकार ऑब्जेक्ट पृथक नहीं किया जा सकता है +@msg type that changes writing direction inside a text object opens in the direction of its first character टेक्स्ट ऑब्जेक्ट के भीतर लेखन दिशा बदलने वाला टेक्स्ट अपने पहले वर्ण की दिशा में खुलता है @msg type used as a mask in linked areas, wraparound text or a compound path doesn't clip प्रकार जुड़े क्षेत्रों में एक मास्क के रूप में इस्तेमाल किया, रैपराउंड टेक्स्ट या एक मिश्रित पाथ क्लिप नहीं करता है @msg Unembedded to {_1} Unembed to {_1} @msg unexpected host trap अप्रत्याशित मेजबान जाल diff --git a/crates/ui-egui/src/i18n/hu.tsv b/crates/ui-egui/src/i18n/hu.tsv index 6c0037461..82f75da72 100644 --- a/crates/ui-egui/src/i18n/hu.tsv +++ b/crates/ui-egui/src/i18n/hu.tsv @@ -4696,6 +4696,7 @@ imageTrace Outline Körvonal @msg an image whose data is cut short was left out kimaradt egy csonka adatú kép @msg linked pictures show the low-resolution preview saved in the file until their files are found: relink them in the Links panel a csatolt képek a fájlban mentett kis felbontású előnézetet mutatják, amíg a fájljaik elő nem kerülnek: csatold őket újra a Csatolások panelen @msg linked type areas past VectorCraft's limits were left out a VectorCraft korlátain túli csatolt szövegterületek kimaradtak +@msg type that changes writing direction inside a text object opens in the direction of its first character a szövegobjektumon belül írásirányt váltó szöveg az első karakterének irányában nyílik meg @msg type used as a mask in linked areas, wraparound text or a compound path doesn't clip a csatolt területeken, körbefuttatott szövegben vagy összetett útvonalban maszkként használt szöveg nem vág ki @msg linked pictures with no preview in the file show a placeholder until their files are found: relink them in the Links panel a fájlban előnézet nélküli csatolt képek helyőrzőt mutatnak, amíg a fájljaik elő nem kerülnek: csatold őket újra a Csatolások panelen @msg area type in frames too short for a line of the font it shows in got taller frames ({_1}): their type wouldn't show otherwise a túl alacsony keretben lévő területszöveg, amelybe a megjelenítő betűtípus egy sora sem fér el, magasabb keretet kapott ({_1}): különben nem látszana diff --git a/crates/ui-egui/src/i18n/id.tsv b/crates/ui-egui/src/i18n/id.tsv index 33673568f..7230b6bfa 100644 --- a/crates/ui-egui/src/i18n/id.tsv +++ b/crates/ui-egui/src/i18n/id.tsv @@ -4618,6 +4618,7 @@ imageTrace Outline Kontur @msg an image whose data is cut short was left out gambar yang datanya terpotong tidak disertakan @msg linked pictures show the low-resolution preview saved in the file until their files are found: relink them in the Links panel gambar tertaut menampilkan pratinjau resolusi rendah yang tersimpan dalam berkas sampai berkasnya ditemukan: tautkan ulang di panel Tautan @msg linked type areas past VectorCraft's limits were left out area teks tertaut di luar batas VectorCraft dihilangkan +@msg type that changes writing direction inside a text object opens in the direction of its first character teks yang mengubah arah penulisan di dalam objek teks dibuka dengan arah karakter pertamanya @msg type used as a mask in linked areas, wraparound text or a compound path doesn't clip teks yang digunakan sebagai masker di area tertaut, teks terbungkus, atau jalur majemuk tidak memotong @msg linked pictures with no preview in the file show a placeholder until their files are found: relink them in the Links panel gambar tertaut tanpa pratinjau dalam berkas menampilkan penanda tempat sampai berkasnya ditemukan: tautkan ulang di panel Tautan @msg area type in frames too short for a line of the font it shows in got taller frames ({_1}): their type wouldn't show otherwise teks area dalam bingkai yang terlalu pendek untuk satu baris font yang ditampilkannya mendapat bingkai yang lebih tinggi ({_1}): tanpa itu teksnya tidak akan tampil diff --git a/crates/ui-egui/src/i18n/it.tsv b/crates/ui-egui/src/i18n/it.tsv index 1c22a0ac1..723fa4ab0 100644 --- a/crates/ui-egui/src/i18n/it.tsv +++ b/crates/ui-egui/src/i18n/it.tsv @@ -4726,6 +4726,7 @@ axis Both Entrambi @msg an image whose data is cut short was left out un'immagine con dati incompleti è stata omessa @msg linked pictures show the low-resolution preview saved in the file until their files are found: relink them in the Links panel le immagini collegate mostrano l'anteprima a bassa risoluzione salvata nel file finché i loro file non vengono trovati: ricollegale nel pannello Collegamenti @msg linked type areas past VectorCraft's limits were left out le aree di testo concatenate oltre i limiti di VectorCraft sono state omesse +@msg type that changes writing direction inside a text object opens in the direction of its first character il testo che cambia direzione di scrittura all’interno di un oggetto di testo si apre nella direzione del suo primo carattere @msg type used as a mask in linked areas, wraparound text or a compound path doesn't clip il testo usato come maschera in aree concatenate, testo attorno o un tracciato composto non ritaglia @msg linked pictures with no preview in the file show a placeholder until their files are found: relink them in the Links panel le immagini collegate senza anteprima nel file mostrano un segnaposto finché i loro file non vengono trovati: ricollegale nel pannello Collegamenti @msg area type in frames too short for a line of the font it shows in got taller frames ({_1}): their type wouldn't show otherwise il testo in area in cornici troppo basse per una riga del font in cui viene mostrato ha ricevuto cornici più alte ({_1}): altrimenti non sarebbe visibile diff --git a/crates/ui-egui/src/i18n/ja.tsv b/crates/ui-egui/src/i18n/ja.tsv index ab9c291f4..8bb101269 100644 --- a/crates/ui-egui/src/i18n/ja.tsv +++ b/crates/ui-egui/src/i18n/ja.tsv @@ -4413,6 +4413,7 @@ swatch Bright Violet ビビッドバイオレット @msg an image whose data is cut short was left out データが途中で切れている画像は除外されました @msg linked pictures show the low-resolution preview saved in the file until their files are found: relink them in the Links panel リンク画像は、元のファイルが見つかるまで、ファイルに保存された低解像度のプレビューで表示されます:リンクパネルで再リンクしてください @msg linked type areas past VectorCraft's limits were left out VectorCraft の上限を超えるリンクされたテキストエリアは省略されました +@msg type that changes writing direction inside a text object opens in the direction of its first character テキストオブジェクト内で書字方向が変わる文字は、最初の文字の方向で開きます @msg type used as a mask in linked areas, wraparound text or a compound path doesn't clip リンクされたエリア、テキストの回り込み、複合パス内でマスクとして使われた文字はクリップしません @msg linked pictures with no preview in the file show a placeholder until their files are found: relink them in the Links panel ファイルにプレビューのないリンク画像は、元のファイルが見つかるまでプレースホルダーで表示されます:リンクパネルで再リンクしてください @msg area type in frames too short for a line of the font it shows in got taller frames ({_1}): their type wouldn't show otherwise 表示に使われるフォントの 1 行に対して低すぎるフレーム内のエリア内文字は、フレームを高くしました ({_1}): そうしないと文字が表示されません diff --git a/crates/ui-egui/src/i18n/ka.tsv b/crates/ui-egui/src/i18n/ka.tsv index 96005c9cd..343b415a4 100644 --- a/crates/ui-egui/src/i18n/ka.tsv +++ b/crates/ui-egui/src/i18n/ka.tsv @@ -4787,6 +4787,7 @@ font-technology Variable ცვლადი Stacked დასტად @msg linked pictures show the low-resolution preview saved in the file until their files are found: relink them in the Links panel დაკავშირებული გამოსახულებები ფაილში შენახულ დაბალი გარჩევადობის მინიატურას აჩვენებს, სანამ მათი ფაილები არ მოიძებნება: დაუკავშირეთ ისინი ხელახლა ბმულების პანელში @msg linked type areas past VectorCraft's limits were left out VectorCraft-ის ლიმიტების მიღმა დაკავშირებული ტექსტის არეები გამოტოვებულია +@msg type that changes writing direction inside a text object opens in the direction of its first character ტექსტი, რომელიც ტექსტური ობიექტის შიგნით ცვლის წერის მიმართულებას, იხსნება მისი პირველი სიმბოლოს მიმართულებით @msg type used as a mask in linked areas, wraparound text or a compound path doesn't clip დაკავშირებულ არეებში, გარსშემოვლებულ ტექსტში ან შედგენილ ტრაექტორიაში ნიღბად გამოყენებული ტექსტი არ ჭრის @msg linked pictures with no preview in the file show a placeholder until their files are found: relink them in the Links panel დაკავშირებული გამოსახულებები, რომელთა მინიატურაც ფაილში არ არის, ადგილის შემნახველს აჩვენებს, სანამ მათი ფაილები არ მოიძებნება: დაუკავშირეთ ისინი ხელახლა ბმულების პანელში @msg area type in frames too short for a line of the font it shows in got taller frames ({_1}): their type wouldn't show otherwise არის ტექსტს ჩარჩოებში, რომლებიც მისი შრიფტის ერთი სტრიქონისთვის ზედმეტად დაბალი იყო, ჩარჩოები გაეზარდა ({_1}): სხვაგვარად ტექსტი არ გამოჩნდებოდა diff --git a/crates/ui-egui/src/i18n/ko.tsv b/crates/ui-egui/src/i18n/ko.tsv index 405899006..f4cc45b82 100644 --- a/crates/ui-egui/src/i18n/ko.tsv +++ b/crates/ui-egui/src/i18n/ko.tsv @@ -4340,6 +4340,7 @@ theme Appearance 외관 @msg an image whose data is cut short was left out 데이터가 잘린 이미지는 제외했습니다 @msg linked pictures show the low-resolution preview saved in the file until their files are found: relink them in the Links panel 연결된 그림은 해당 파일을 찾을 때까지 파일에 저장된 저해상도 미리 보기로 표시됩니다: 링크 패널에서 다시 연결하세요 @msg linked type areas past VectorCraft's limits were left out VectorCraft 한도를 넘는 연결된 문자 영역은 제외되었습니다 +@msg type that changes writing direction inside a text object opens in the direction of its first character 문자 개체 안에서 쓰기 방향이 바뀌는 문자는 첫 글자의 방향으로 열립니다 @msg type used as a mask in linked areas, wraparound text or a compound path doesn't clip 연결된 영역, 문자 감싸기 또는 컴파운드 패스에서 마스크로 사용된 문자는 클리핑하지 않습니다 @msg linked pictures with no preview in the file show a placeholder until their files are found: relink them in the Links panel 파일에 미리 보기가 없는 연결된 그림은 해당 파일을 찾을 때까지 자리 표시자로 표시됩니다: 링크 패널에서 다시 연결하세요 @msg area type in frames too short for a line of the font it shows in got taller frames ({_1}): their type wouldn't show otherwise 표시되는 글꼴의 한 줄보다 낮은 프레임의 영역 문자는 프레임을 더 높였습니다({_1}). 그렇지 않으면 문자가 표시되지 않습니다 diff --git a/crates/ui-egui/src/i18n/nl.tsv b/crates/ui-egui/src/i18n/nl.tsv index fc33adb90..2e8d6f34c 100644 --- a/crates/ui-egui/src/i18n/nl.tsv +++ b/crates/ui-egui/src/i18n/nl.tsv @@ -4630,6 +4630,7 @@ imageTrace Outline Contour @msg an image whose data is cut short was left out een afbeelding waarvan de gegevens zijn afgekapt, is weggelaten @msg linked pictures show the low-resolution preview saved in the file until their files are found: relink them in the Links panel gekoppelde afbeeldingen tonen het in het bestand opgeslagen voorbeeld met lage resolutie totdat hun bestanden zijn gevonden: koppel ze opnieuw in het paneel Koppelingen @msg linked type areas past VectorCraft's limits were left out gekoppelde tekstvlakken buiten de limieten van VectorCraft zijn weggelaten +@msg type that changes writing direction inside a text object opens in the direction of its first character tekst die binnen een tekstobject van schrijfrichting verandert, wordt geopend in de richting van het eerste teken @msg type used as a mask in linked areas, wraparound text or a compound path doesn't clip tekst die als masker wordt gebruikt in gekoppelde vlakken, tekstomloop of een samengesteld pad knipt niet uit @msg linked pictures with no preview in the file show a placeholder until their files are found: relink them in the Links panel gekoppelde afbeeldingen zonder voorbeeld in het bestand tonen een tijdelijke aanduiding totdat hun bestanden zijn gevonden: koppel ze opnieuw in het paneel Koppelingen @msg area type in frames too short for a line of the font it shows in got taller frames ({_1}): their type wouldn't show otherwise vlaktekst in kaders die te kort zijn voor één regel van het lettertype waarin de tekst wordt weergegeven, heeft hogere kaders gekregen ({_1}): anders zou de tekst niet zichtbaar zijn diff --git a/crates/ui-egui/src/i18n/pl.tsv b/crates/ui-egui/src/i18n/pl.tsv index 412ec2657..c9e1256c3 100644 --- a/crates/ui-egui/src/i18n/pl.tsv +++ b/crates/ui-egui/src/i18n/pl.tsv @@ -4816,6 +4816,7 @@ imageTrace Outline Kontur @msg an image whose data is cut short was left out pominięto obraz z uciętymi danymi @msg linked pictures show the low-resolution preview saved in the file until their files are found: relink them in the Links panel połączone obrazy pokazują zapisany w pliku podgląd niskiej rozdzielczości, dopóki ich pliki nie zostaną znalezione: połącz je ponownie w panelu Łącza @msg linked type areas past VectorCraft's limits were left out połączone obszary tekstu poza limitami VectorCraft zostały pominięte +@msg type that changes writing direction inside a text object opens in the direction of its first character tekst zmieniający kierunek pisania wewnątrz obiektu tekstowego otwiera się w kierunku swojego pierwszego znaku @msg type used as a mask in linked areas, wraparound text or a compound path doesn't clip tekst użyty jako maska w połączonych obszarach, oblewanym tekście lub ścieżce złożonej nie przycina @msg linked pictures with no preview in the file show a placeholder until their files are found: relink them in the Links panel połączone obrazy bez podglądu w pliku pokazują symbol zastępczy, dopóki ich pliki nie zostaną znalezione: połącz je ponownie w panelu Łącza @msg area type in frames too short for a line of the font it shows in got taller frames ({_1}): their type wouldn't show otherwise tekst akapitowy w ramkach niższych niż wiersz wyświetlanej czcionki otrzymał wyższe ramki ({_1}): inaczej tekst nie byłby widoczny diff --git a/crates/ui-egui/src/i18n/pt-br.tsv b/crates/ui-egui/src/i18n/pt-br.tsv index 4ed60aace..062b7d88b 100644 --- a/crates/ui-egui/src/i18n/pt-br.tsv +++ b/crates/ui-egui/src/i18n/pt-br.tsv @@ -4910,6 +4910,7 @@ axis Both Ambos @msg {path}: not a file {path}: não é um arquivo @msg linked pictures show the low-resolution preview saved in the file until their files are found: relink them in the Links panel as imagens vinculadas mostram a visualização de baixa resolução salva no arquivo até que seus arquivos sejam encontrados: revincule-as no painel Links @msg linked type areas past VectorCraft's limits were left out as áreas de texto encadeadas além dos limites do VectorCraft foram omitidas +@msg type that changes writing direction inside a text object opens in the direction of its first character o texto que muda de direção de escrita dentro de um objeto de texto abre na direção do primeiro caractere @msg type used as a mask in linked areas, wraparound text or a compound path doesn't clip o texto usado como máscara em áreas encadeadas, texto com quebra ou um caminho composto não recorta @msg linked pictures with no preview in the file show a placeholder until their files are found: relink them in the Links panel as imagens vinculadas sem visualização no arquivo mostram um espaço reservado até que seus arquivos sejam encontrados: revincule-as no painel Links @msg area type in frames too short for a line of the font it shows in got taller frames ({_1}): their type wouldn't show otherwise o texto de área em molduras baixas demais para uma linha da fonte em que aparece recebeu molduras mais altas ({_1}): senão o texto não apareceria diff --git a/crates/ui-egui/src/i18n/ru.tsv b/crates/ui-egui/src/i18n/ru.tsv index e61c6c73a..a83d9edf3 100644 --- a/crates/ui-egui/src/i18n/ru.tsv +++ b/crates/ui-egui/src/i18n/ru.tsv @@ -4721,6 +4721,7 @@ axis Both Оба @msg an image whose data is cut short was left out изображение с обрезанными данными пропущено @msg linked pictures show the low-resolution preview saved in the file until their files are found: relink them in the Links panel связанные изображения показываются как сохранённый в файле просмотр низкого разрешения, пока их файлы не найдены: восстановите связи на панели «Связи» @msg linked type areas past VectorCraft's limits were left out связанные текстовые области за пределами ограничений VectorCraft пропущены +@msg type that changes writing direction inside a text object opens in the direction of its first character текст, меняющий направление письма внутри текстового объекта, открывается в направлении своего первого символа @msg type used as a mask in linked areas, wraparound text or a compound path doesn't clip текст, используемый как маска в связанных областях, обтекающем тексте или составном контуре, не обрезает @msg linked pictures with no preview in the file show a placeholder until their files are found: relink them in the Links panel связанные изображения без просмотра в файле показываются как заполнитель, пока их файлы не найдены: восстановите связи на панели «Связи» @msg area type in frames too short for a line of the font it shows in got taller frames ({_1}): their type wouldn't show otherwise текст в области в рамках, слишком низких для строки шрифта, которым он отображается, получил более высокие рамки ({_1}): иначе он не был бы виден diff --git a/crates/ui-egui/src/i18n/sv.tsv b/crates/ui-egui/src/i18n/sv.tsv index f606a4bda..780d23bea 100644 --- a/crates/ui-egui/src/i18n/sv.tsv +++ b/crates/ui-egui/src/i18n/sv.tsv @@ -4583,6 +4583,7 @@ axis Both Båda @msg an image whose data is cut short was left out en bild med avkortade data utelämnades @msg linked pictures show the low-resolution preview saved in the file until their files are found: relink them in the Links panel länkade bilder visar förhandsgranskningen med låg upplösning som sparats i filen tills deras filer hittas: länka om dem i panelen Länkar @msg linked type areas past VectorCraft's limits were left out länkade textområden utöver VectorCrafts gränser utelämnades +@msg type that changes writing direction inside a text object opens in the direction of its first character text som byter skrivriktning inuti ett textobjekt öppnas i riktningen för sitt första tecken @msg type used as a mask in linked areas, wraparound text or a compound path doesn't clip text som används som mask i länkade områden, ombruten text eller en sammansatt bana beskär inte @msg linked pictures with no preview in the file show a placeholder until their files are found: relink them in the Links panel länkade bilder utan förhandsgranskning i filen visar en platshållare tills deras filer hittas: länka om dem i panelen Länkar @msg area type in frames too short for a line of the font it shows in got taller frames ({_1}): their type wouldn't show otherwise områdestext i ramar som var för låga för en rad i det typsnitt den visas med fick högre ramar ({_1}): annars skulle texten inte synas diff --git a/crates/ui-egui/src/i18n/tr.tsv b/crates/ui-egui/src/i18n/tr.tsv index 24ec88697..55a9cdc73 100644 --- a/crates/ui-egui/src/i18n/tr.tsv +++ b/crates/ui-egui/src/i18n/tr.tsv @@ -4418,6 +4418,7 @@ axis Both Her İkisi @msg an image whose data is cut short was left out verileri kısa kesilen bir resim dışarıda bırakıldı @msg linked pictures show the low-resolution preview saved in the file until their files are found: relink them in the Links panel bağlantılı resimler, dosyaları bulunana kadar dosyaya kaydedilen düşük çözünürlüklü önizlemeyi gösterir: bunları Bağlantılar panelinde yeniden bağlayın @msg linked type areas past VectorCraft's limits were left out VectorCraft sınırlarını aşan bağlantılı metin alanları dışarıda bırakıldı +@msg type that changes writing direction inside a text object opens in the direction of its first character bir metin nesnesi içinde yazı yönünü değiştiren metin, ilk karakterinin yönünde açılır @msg type used as a mask in linked areas, wraparound text or a compound path doesn't clip bağlantılı alanlarda, kaydırılan metinde veya bileşik yolda maske olarak kullanılan metin kırpmaz @msg linked pictures with no preview in the file show a placeholder until their files are found: relink them in the Links panel dosyada önizlemesi olmayan bağlantılı resimler, dosyaları bulunana kadar bir yer tutucu gösterir: bunları Bağlantılar panelinde yeniden bağlayın @msg area type in frames too short for a line of the font it shows in got taller frames ({_1}): their type wouldn't show otherwise çerçevelerdeki alan türü, daha uzun çerçevelerde gösterdiği yazı tipinin bir satırı için çok kısa ({_1}): bunların türü aksi halde gösterilmez diff --git a/crates/ui-egui/src/i18n/uk.tsv b/crates/ui-egui/src/i18n/uk.tsv index e4d8ebfac..bbfc332f9 100644 --- a/crates/ui-egui/src/i18n/uk.tsv +++ b/crates/ui-egui/src/i18n/uk.tsv @@ -4710,6 +4710,7 @@ axis Both Обидві @msg an image whose data is cut short was left out зображення з обрізаними даними пропущено @msg linked pictures show the low-resolution preview saved in the file until their files are found: relink them in the Links panel пов’язані зображення показуються як збережений у файлі перегляд низької роздільності, доки їхні файли не знайдено: відновіть зв’язки на панелі «Зв’язки» @msg linked type areas past VectorCraft's limits were left out пов’язані текстові області понад обмеження VectorCraft пропущено +@msg type that changes writing direction inside a text object opens in the direction of its first character текст, що змінює напрямок письма всередині текстового об’єкта, відкривається в напрямку свого першого символу @msg type used as a mask in linked areas, wraparound text or a compound path doesn't clip текст, використаний як маска у пов’язаних областях, тексті з обтіканням або складеному контурі, не обрізає @msg linked pictures with no preview in the file show a placeholder until their files are found: relink them in the Links panel пов’язані зображення без перегляду у файлі показуються як заповнювач, доки їхні файли не знайдено: відновіть зв’язки на панелі «Зв’язки» @msg area type in frames too short for a line of the font it shows in got taller frames ({_1}): their type wouldn't show otherwise текст в області в рамках, занизьких для рядка шрифту, яким він показується, отримав вищі рамки ({_1}): інакше його не було б видно diff --git a/crates/ui-egui/src/i18n/vi.tsv b/crates/ui-egui/src/i18n/vi.tsv index 6f082606c..47915fb17 100644 --- a/crates/ui-egui/src/i18n/vi.tsv +++ b/crates/ui-egui/src/i18n/vi.tsv @@ -4542,6 +4542,7 @@ theme Appearance Giao diện @msg line {line}: value for `{var}` is longer than {MAX_GRAPH_CSV} bytes dòng {line}: giá trị của `{var}` dài hơn {MAX_GRAPH_CSV} byte @msg linked pictures show the low-resolution preview saved in the file until their files are found: relink them in the Links panel ảnh liên kết hiển thị bản xem trước độ phân giải thấp đã lưu trong tệp cho đến khi tìm thấy tệp gốc: liên kết lại trong bảng Liên kết @msg linked type areas past VectorCraft's limits were left out các vùng chữ liên kết vượt quá giới hạn của VectorCraft đã bị bỏ qua +@msg type that changes writing direction inside a text object opens in the direction of its first character chữ đổi hướng viết bên trong một đối tượng văn bản sẽ mở theo hướng của ký tự đầu tiên @msg type used as a mask in linked areas, wraparound text or a compound path doesn't clip chữ dùng làm mặt nạ trong các vùng liên kết, chữ bao quanh hoặc đường dẫn phức không cắt xén @msg linked pictures with no preview in the file show a placeholder until their files are found: relink them in the Links panel ảnh liên kết không có bản xem trước trong tệp hiển thị ô giữ chỗ cho đến khi tìm thấy tệp gốc: liên kết lại trong bảng Liên kết @msg Live Paint Merge: select a Live Paint group and the paths to add Hợp nhất tô trực tiếp: chọn nhóm tô trực tiếp và các đường dẫn để thêm diff --git a/crates/ui-egui/src/i18n/zh-hans.tsv b/crates/ui-egui/src/i18n/zh-hans.tsv index eed6a5a0d..e646c956b 100644 --- a/crates/ui-egui/src/i18n/zh-hans.tsv +++ b/crates/ui-egui/src/i18n/zh-hans.tsv @@ -3473,6 +3473,7 @@ digits Malayalam 马拉雅拉姆数字 @plural {n} linked image changed on disk. Show the new version?|{n} linked images changed on disk. Show the new versions? 有 {n} 个链接图像已在磁盘上更改。要显示新版本吗? @msg linked pictures show the low-resolution preview saved in the file until their files are found: relink them in the Links panel 链接的图片在找到其文件之前显示文件中存储的低分辨率预览:请在“链接”面板中重新链接 @msg linked type areas past VectorCraft's limits were left out 超出 VectorCraft 限制的串接文字区域已省略 +@msg type that changes writing direction inside a text object opens in the direction of its first character 在文字对象内更改书写方向的文字按其第一个字符的方向打开 @msg type used as a mask in linked areas, wraparound text or a compound path doesn't clip 在串接区域、绕排文字或复合路径中用作蒙版的文字不会剪切 @msg linked pictures with no preview in the file show a placeholder until their files are found: relink them in the Links panel 文件中没有预览的链接图片在找到其文件之前显示占位符:请在“链接”面板中重新链接 @msg area type in frames too short for a line of the font it shows in got taller frames ({_1}): their type wouldn't show otherwise 框架高度不足以容纳所显示字体一行的区域文字已加高框架 ({_1}):否则文字不会显示 diff --git a/crates/ui-egui/src/i18n/zh-hant.tsv b/crates/ui-egui/src/i18n/zh-hant.tsv index 0c3af38f9..468ad851a 100644 --- a/crates/ui-egui/src/i18n/zh-hant.tsv +++ b/crates/ui-egui/src/i18n/zh-hant.tsv @@ -4503,6 +4503,7 @@ digits Malayalam 馬來亞拉姆數字 @plural {n} linked image changed on disk. Show the new version?|{n} linked images changed on disk. Show the new versions? 有 {n} 個連結影像已在磁碟上變更。要顯示新版本嗎? @msg linked pictures show the low-resolution preview saved in the file until their files are found: relink them in the Links panel 連結的圖片在找到其檔案之前顯示檔案中儲存的低解析度預覽:請在「連結」面板中重新連結 @msg linked type areas past VectorCraft's limits were left out 超出 VectorCraft 限制的串連文字區域已略過 +@msg type that changes writing direction inside a text object opens in the direction of its first character 在文字物件內變更書寫方向的文字會依其第一個字元的方向開啟 @msg type used as a mask in linked areas, wraparound text or a compound path doesn't clip 在串連區域、繞圖文字或複合路徑中用作遮色片的文字不會剪裁 @msg linked pictures with no preview in the file show a placeholder until their files are found: relink them in the Links panel 檔案中沒有預覽的連結圖片在找到其檔案之前顯示預留位置:請在「連結」面板中重新連結 @msg area type in frames too short for a line of the font it shows in got taller frames ({_1}): their type wouldn't show otherwise 框架高度不足以容納所顯示字體一行的區域文字已加高框架 ({_1}):否則文字不會顯示 diff --git a/docs/file-format-parity.md b/docs/file-format-parity.md index c8bfd8387..f60d46f62 100644 --- a/docs/file-format-parity.md +++ b/docs/file-format-parity.md @@ -132,6 +132,14 @@ private editing data. VectorCraft: (`TE`) for the codes they leave out unless the `TZ` sets `useDefault` to 0. Its area type with several linked areas opens as threaded frames, one for each area with its own matrix, and the story flows through them. + Its writing direction (`TV`) opens as vertical type. VectorCraft keeps one direction for each text object, the + one in force at its first character, with a warning when a later character has another. Vertical point type + starts at the origin of `Tp`'s matrix on the column's center line, which is VectorCraft's own placement. Its Kumi + blocks (`Ty`, and `TY` in horizontal type) open as Tate-chu-yoko runs, which is VectorCraft's own mapping. A `TY` + block in vertical type opens unmarked, and two blocks with nothing between them open as one block. Its upright + Roman characters (`Tv`) open with Standard Vertical Roman Alignment. VectorCraft's own layout rule also sets a + run of two or three half-width digits across the column in vertical type, inside or outside a block and with + either `Tv` value. Legacy `.ai` type set in rendering modes 4 to 7 (`Tr`, text used as a mask) is a clipping path of its clipping group, as a `W` path is, and the mode lasts into later text objects. Type in linked areas, in wraparound text or in a compound path doesn't clip, with a warning. A `Tr` operand that is not a whole number From f9bf685cbdf55d01aa2f00a167127724cf2c4959 Mon Sep 17 00:00:00 2001 From: Helen Kwok <107752876+helenkwok@users.noreply.github.com> Date: Fri, 6 Nov 2026 22:25:08 +0800 Subject: [PATCH 10/10] vectorcraft-web: window.vectorcraft takes its request functions from craft-web-api request, execute, commands and screenshot move onto the shared crate (storytold/craft-libs#413): a timeout on every call, the screenshot's PNG also in data, and docInspect and uiInspect beside them. A call the page gave up on gives up its request if the app has not started it, so the app skips it. openFile, captureDownloads, files, readFile and inspect stay VectorCraft's own, on the same object. --- Cargo.lock | 12 +++ apps/vectorcraft-web/Cargo.toml | 2 + apps/vectorcraft-web/src/api.rs | 144 +++++++++++++++++++------------- apps/vectorcraft-web/src/web.rs | 2 - docs/control-protocol.md | 8 +- 5 files changed, 104 insertions(+), 64 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 32cffe9fd..6f92cb310 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1093,6 +1093,17 @@ dependencies = [ "thiserror 2.0.21", ] +[[package]] +name = "craft-web-api" +version = "0.1.0" +source = "git+https://github.com/storytold/craft-libs?rev=fd5e2feff6ea25bed371a99cc732b3968d621505#fd5e2feff6ea25bed371a99cc732b3968d621505" +dependencies = [ + "js-sys", + "serde_json", + "wasm-bindgen", + "wasm-bindgen-futures", +] + [[package]] name = "craft-window" version = "0.1.0" @@ -6594,6 +6605,7 @@ dependencies = [ name = "vectorcraft-web" version = "0.21.0" dependencies = [ + "craft-web-api", "eframe", "egui", "js-sys", diff --git a/apps/vectorcraft-web/Cargo.toml b/apps/vectorcraft-web/Cargo.toml index 8decb13d8..ff4bcef86 100644 --- a/apps/vectorcraft-web/Cargo.toml +++ b/apps/vectorcraft-web/Cargo.toml @@ -16,6 +16,8 @@ path = "src/main.rs" [dependencies] vectorcraft-engine = { workspace = true, features = ["bundled-fonts"] } vectorcraft-ui-egui = { workspace = true } +# The page command API (storytold/craft-libs#413): the request plumbing behind window.vectorcraft. +craft-web-api = { git = "https://github.com/storytold/craft-libs", rev = "fd5e2feff6ea25bed371a99cc732b3968d621505", version = "0.1.0" } vectorcraft-format = { workspace = true } vectorcraft-text = { workspace = true } egui = { workspace = true } diff --git a/apps/vectorcraft-web/src/api.rs b/apps/vectorcraft-web/src/api.rs index 526e56be1..eddd04f1b 100644 --- a/apps/vectorcraft-web/src/api.rs +++ b/apps/vectorcraft-web/src/api.rs @@ -1,14 +1,18 @@ //! `window.vectorcraft`: the control channel for the page, as promises. //! +//! `request`, `execute`, `commands`, `docInspect`, `uiInspect` and `screenshot` come from +//! `craft-web-api` (storytold/craft-libs#413), the same in every Craft app; VectorCraft adds its own +//! functions on the same object. +//! //! The same JSON methods as the desktop control server (`docs/control-protocol.md`), so an agent //! driving VectorCraft in a browser tab (or an `