From 67462cf04926e1c1bd31c402d84667943c5863b6 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Wed, 11 Nov 2026 01:52:06 -0800 Subject: [PATCH 1/4] Glyphs: gid null inside a ligature reports the ligature When text.setGlyphAlternate leaves the text unchanged, it now reads the glyph from the unchanged text. A character after the first in a run that holds a glyph choice could be reported with its own glyph instead of the ligature drawn for it, which text.glyphAlternates reports. --- crates/engine/src/cmd/textedit.rs | 10 ++++++++++ crates/engine/src/tests/textedit.rs | 25 +++++++++++++++++++++++++ 2 files changed, 35 insertions(+) diff --git a/crates/engine/src/cmd/textedit.rs b/crates/engine/src/cmd/textedit.rs index 095cad361..79dcee713 100644 --- a/crates/engine/src/cmd/textedit.rs +++ b/crates/engine/src/cmd/textedit.rs @@ -1321,6 +1321,16 @@ fn set_glyph_alternate(s: &mut Session, p: &Value) -> Result { } } if held == choice && (choice.is_none() || alone) { + // The text is left as it is, so the glyph is the one the unchanged text draws. The probe + // splits a run that holds an earlier character's choice, and the split can break a ligature + // that takes in this character. + let drawn = match &choice { + None if probe.runs != t.runs => { + let lay = vectorcraft_text::layout(vectorcraft_text::FontDb::global(), &t); + drawn_glyph(&plain, &lay, ch, &bytes, &face).or_else(|| shaped_alone(&t, ch, &bytes, &face).1).unwrap_or(default) + } + _ => drawn, + }; return Ok(json!({"id": id.0, "start": bytes.start, "end": bytes.end, "gid": drawn})); } s.edit("Glyphs", |d, _| { diff --git a/crates/engine/src/tests/textedit.rs b/crates/engine/src/tests/textedit.rs index 359373911..444794619 100644 --- a/crates/engine/src/tests/textedit.rs +++ b/crates/engine/src/tests/textedit.rs @@ -3511,6 +3511,31 @@ fn a_glyph_choice_taken_away_reports_the_glyph_drawn() { assert_eq!(chosen(&mut s, caps, 1), json!(drawn)); } +/// A run that holds a choice for its first character and goes on past it, as a file may have, +/// draws Source Serif 4's fi ligature for the f and the i after that character. `gid: null` for the +/// i leaves the text as it is and reports the ligature, as `text.glyphAlternates` does. +#[test] +fn a_glyph_choice_taken_away_inside_a_ligature_reports_the_ligature() { + let db = vectorcraft_text::FontDb::global(); + let face = db.face("Source Serif 4", "Regular").expect("the bundled face"); + let mut s = session(); + let r = s.execute("text.create", &json!({"x": 100, "y": 100, "text": "xfin", "size": 40, "font": "Source Serif 4"})).unwrap(); + let id = NodeId(r["id"].as_u64().unwrap()); + let mut runs = obj(&s, id).runs; + runs[0].style.glyph_alternate = Some(face.choice('x', face.glyph_for('x'))); + let doc = std::sync::Arc::make_mut(&mut s.doc_mut().unwrap().doc); + let Some(NodeKind::Text(text)) = doc.node_mut(id).map(|n| &mut n.kind) else { panic!("text") }; + text.runs = runs.clone(); + let ligature = vectorcraft_text::layout(db, &obj(&s, id)).glyphs.iter().find(|g| g.byte == 1 && g.len == 2).expect("f + i is one glyph").gid; + let chosen = s.execute("text.glyphAlternates", &json!({"id": id.0, "start": 2})).unwrap()["chosen"].clone(); + assert_eq!(chosen, json!(ligature)); + let undo = s.doc().unwrap().history.undo.len(); + let r = s.execute("text.setGlyphAlternate", &json!({"id": id.0, "start": 2, "gid": null})).unwrap(); + assert_eq!(r["gid"], chosen, "the ligature drawn for the i"); + assert_eq!(s.doc().unwrap().history.undo.len(), undo, "no edit"); + assert_eq!(obj(&s, id).runs, runs, "the runs are kept"); +} + /// A fallback face draws Ka with the vowel sign I in one cluster, the vowel sign first. The glyph /// reported for Ka is the fallback face's glyph for Ka, laid out or overflowing, not the vowel /// sign's. From 16cc5c107c6bbfecb2c42c7394f691c86d89d7b9 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Wed, 11 Nov 2026 02:09:08 -0800 Subject: [PATCH 2/4] Docs: a row without paint leaves the series' stored paint, not its opacity The graph still stores an opacity or blend mode set on a series mark's row that has no paint, as it does for transparency.set. The sentence now says that such a row leaves only the stored paint unchanged. --- docs/mcp.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/mcp.md b/docs/mcp.md index 9c28fbe5b..f8c344981 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -588,7 +588,7 @@ the stored fill, or the stored stroke when no fill is stored and that stroke has outline, so these commands do not store a stroke paint or weight for a series that draws a band. Except for `paint.swap`, they still store a stroke opacity and blend mode set on a band, and the band's outline takes them. Graph designs keep their own paint. A row that had no -paint before the command and has none after it does not change what the graph stores. `paint.setFill`, +paint before the command and has none after it does not change the paint the graph stores. `paint.setFill`, `paint.setStroke`, `paint.none` and the commands that apply one paint kind to the other store the topmost row of the kind even when it is hidden, and store a stroke set on a band. `stroke.set`, `paint.editGradient` and the recolor commands do not store a series' paint. An `appearance.*` command or `graphicStyle.apply` given a series group's id, and From 3a44447aeb29f2da9d4726454c97701a3f521053 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Wed, 11 Nov 2026 02:17:45 -0800 Subject: [PATCH 3/4] Docs: remove the duplicated G9 .ai sentence that says .ai editing data is not written A merge left a second copy of the G9 `.ai`/EPS Missing bullet's opening inside the bullet. That copy says writing the editing data is out of scope, while the same bullet and file-format-parity.md say a PDF-compatible `.ai` carries an editing copy of the art. The bullet now has one copy, with the newer brush wording that matches the 2026-11-05 entry above it. --- docs/gaps.md | 9 ++------- 1 file changed, 2 insertions(+), 7 deletions(-) diff --git a/docs/gaps.md b/docs/gaps.md index f0619ff21..2edc2e575 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -211,19 +211,14 @@ macOS cursor delivery (#2292) is narrowed to a native host adapter using the exi art with strokes, gradients or patterns) and type with an Art brush keep the drawn look. - **Missing:** faithful layer and group structure from the editing data in real files (#951: 109 layers instead - of 14), open speed on large files (#758), live effects, Scatter, Art, Pattern and Bristle brushes on paths, a - path's own Calligraphic brush settings, and symbols as live objects (#637; plain art styles read Drop Shadow, Outer Glow and Feather as live effects; pattern fills and global colours are read since #1025, placed (linked) files since #1264). A PDF-compatible `.ai` carries an editing copy of the art in the form + of 14), open speed on large files (#758), live effects, Scatter, Pattern and Bristle brushes on paths, the Art + brushes listed above as keeping the drawn look, a path's own Calligraphic or Art brush settings, and symbols as live objects (#637; plain art styles read Drop Shadow, Outer Glow and Feather as live effects; pattern fills and global colours are read since #1025, placed (linked) files since #1264). A PDF-compatible `.ai` carries an editing copy of the art in the form VectorCraft's reader reads (layers, groups, paths, colors, gradients, patterns, images, transparency, opacity masks, clipping, type as text and symbols), with live effects kept only on paths, compound paths and groups that qualify (at most a fill and a stroke, a non-zero fill rule when filled, no large canvas) and whose appearance and effects the art-style writer supports, other objects' effects other than geometry effects left out, and live objects written as their art ([file-format-parity.md](file-format-parity.md)). Save As `.ai` › Version: Legacy (version 7) writes the PostScript form from the published version 7 specification, keeping layers (names, order, visibility, lock), groups, compound paths, clipping groups, gradients and - of 14), open speed on large files (#758), live effects, Scatter, Pattern and Bristle brushes on paths, the Art - brushes listed above as keeping the drawn look, a path's own Calligraphic or Art brush settings, and symbols as live objects (#637; plain art styles read Drop Shadow, Outer Glow and Feather as live effects; pattern fills and global colours are read since #1025, placed (linked) files since #1264). Writing Illustrator's own editing data is out of scope - (undocumented): a PDF-compatible `.ai` opens in Illustrator 2018 without its layers and groups (#868). Save As - `.ai` › Version: Legacy (version 7) writes the PostScript form from the published version 7 specification - instead, keeping layers (names, order, visibility, lock), groups, compound paths, clipping groups, gradients and images, and type as editable text objects (or outlines), one artboard per file (an artboard picker chooses which); transparency, live effects and object names don't survive it (transparent images are flattened or omitted, hidden objects go to a hidden layer, are omitted or made visible), and type is set again in the From c81ae88c85b270c4d2e808db900c88141c9ec924 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Wed, 11 Nov 2026 02:23:59 -0800 Subject: [PATCH 4/4] Flatten Transparency: a long rasterized area is placed whole The flattener limited an image's resolution only by its total pixels, while the renderer draws at most 32,768 pixels a side. A long area, such as a narrow see-through gradient strip down a 2 m banner at 1200 ppi, came back cut off at that length and was placed as if whole, so the rest of the strip was lost in Flatten Transparency and in PDF/X-1a, PDF/X-3 and PDF 1.3 exports. The resolution is now also limited by the longest side, as raster effects are. --- crates/engine/src/cmd/flatten/apply.rs | 3 +++ crates/engine/src/cmd/flatten/commands.rs | 2 +- crates/engine/src/tests/flatten.rs | 25 +++++++++++++++++++++++ docs/mcp.md | 4 +++- 4 files changed, 32 insertions(+), 2 deletions(-) diff --git a/crates/engine/src/cmd/flatten/apply.rs b/crates/engine/src/cmd/flatten/apply.rs index 555058e58..409db3f1b 100644 --- a/crates/engine/src/cmd/flatten/apply.rs +++ b/crates/engine/src/cmd/flatten/apply.rs @@ -484,6 +484,9 @@ fn region_fill(elems: &[Elem], sources: &[usize], o: &FlattenOptions, cmyk: bool fn render_raster(doc: &Document, art: &[Node], rect: Rect, ppi: f64, clip: Option, o: &FlattenOptions) -> Result { let mut scale = ppi / 72.0; scale = scale.min((MAX_PIXELS / (rect.width() * rect.height()).max(1.0)).sqrt()); + // No side longer than the renderer draws: the image grows by a pixel on each side and is + // rounded up to whole pixels below. + scale = scale.min((f64::from(vectorcraft_render::MAX_RASTER_SIDE) - 4.0) / rect.width().max(rect.height()).max(1.0)); let px = 1.0 / scale; let rect = rect.inflate(px, px); let clip = clip.map(|c| Some(po::offset_path(&c, px, po::Join::Miter, 4.0)).filter(|g| !g.is_empty()).unwrap_or(c)); diff --git a/crates/engine/src/cmd/flatten/commands.rs b/crates/engine/src/cmd/flatten/commands.rs index 53687713b..4053dcac7 100644 --- a/crates/engine/src/cmd/flatten/commands.rs +++ b/crates/engine/src/cmd/flatten/commands.rs @@ -10,7 +10,7 @@ pub fn specs() -> Vec { "Flatten Transparency…", ["Object"], None, - "{preset?: \"high\"|\"medium\"|\"low\" or a saved preset's name (see flattener.presets.list; default medium), balance?: 0..100 (raster/vector balance; 0 rasterizes everything), lineArtPpi?: 1..2400, gradientPpi?: 1..2400 (areas only gradients and meshes reach), textToOutlines?, strokesToOutlines?, clipComplexRegions? (clip images to the region outlines, else rectangles), antiAlias?, preserveAlpha? (composite over nothing instead of white), preserveOverprints? (areas showing one paint keep its colour and overprint; false clears overprints), options?: {the same keys}, ids?|id?} overlapping transparent objects become one group of flat-colour regions (CMYK colours composited ink by ink in CMYK documents), plus an image where gradients, patterns, images, masks or raster effects reach (only where a clip group's or clipping layer's clipping path shows them, over its fill; transparent objects such a path hides entirely are removed); objects without transparency stay; a frame of threaded text that is outlined or flattened leaves its thread with the text it shows → {ids, rasterized: images made, vector: regions made, options, warning? (an image in the art couldn't be decoded, as object.rasterize says)}", + "{preset?: \"high\"|\"medium\"|\"low\" or a saved preset's name (see flattener.presets.list; default medium), balance?: 0..100 (raster/vector balance; 0 rasterizes everything), lineArtPpi?: 1..2400, gradientPpi?: 1..2400 (areas only gradients and meshes reach; an image larger than 64 Mpx or 32,768 px a side is made at a lower resolution), textToOutlines?, strokesToOutlines?, clipComplexRegions? (clip images to the region outlines, else rectangles), antiAlias?, preserveAlpha? (composite over nothing instead of white), preserveOverprints? (areas showing one paint keep its colour and overprint; false clears overprints), options?: {the same keys}, ids?|id?} overlapping transparent objects become one group of flat-colour regions (CMYK colours composited ink by ink in CMYK documents), plus an image where gradients, patterns, images, masks or raster effects reach (only where a clip group's or clipping layer's clipping path shows them, over its fill; transparent objects such a path hides entirely are removed); objects without transparency stay; a frame of threaded text that is outlined or flattened leaves its thread with the text it shows → {ids, rasterized: images made, vector: regions made, options, warning? (an image in the art couldn't be decoded, as object.rasterize says)}", has_doc, flatten ), diff --git a/crates/engine/src/tests/flatten.rs b/crates/engine/src/tests/flatten.rs index 2e08b99c8..84e4724fe 100644 --- a/crates/engine/src/tests/flatten.rs +++ b/crates/engine/src/tests/flatten.rs @@ -350,6 +350,31 @@ fn gradients_rasterize_and_flat_areas_stay_vector() { } } +#[test] +fn a_long_rasterized_area_is_placed_whole() { + let _settings = settings_lock(); + let mut s = session(); + // 5569 pt long: more than the renderer's 32,768 pixels a side at the 64 Mpx limit's scale. + let g = rect(&mut s, 10.0, 10.0, 100.0, 5569.0, "#000000"); + run( + &mut s, + "paint.setFill", + json!({"ids": [g], "gradient": {"kind": "linear", "stops": [{"offset": 0, "color": "#ff0000"}, {"offset": 1, "color": "#0000ff"}]}}), + ); + run(&mut s, "transparency.set", json!({"ids": [g], "opacity": 40})); + let (ids, _, rasterized) = flatten(&mut s, json!({"ids": [g], "lineArtPpi": 1200, "gradientPpi": 1200})); + assert_eq!(rasterized, 1); + let mut placed = vec![]; + node(&s, ids[0]).walk(&mut |c| { + if let NodeKind::Image(im) = &c.kind { + placed.push((im.height, im.xf.transform_rect_bbox(Rect::new(0.0, 0.0, f64::from(im.width), f64::from(im.height))))); + } + }); + let [(height, b)] = placed[..] else { panic!("one image: {placed:?}") }; + assert!(height <= vectorcraft_render::MAX_RASTER_SIDE, "{height} pixels"); + assert!(b.y0 <= 10.0 && b.y1 >= 5579.0, "the image covers the strip: {b:?}"); +} + #[test] fn flatten_says_when_an_image_could_not_be_decoded() { let _settings = settings_lock(); diff --git a/docs/mcp.md b/docs/mcp.md index 9c28fbe5b..8a91a5fdc 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -3207,7 +3207,9 @@ the group holds only the art inside the clipping layer, drawn over the fill and layer as its own layers draw it (over nothing with `preserveAlpha`), and the art outside is flattened on its own. `preset` is `high`, `medium` (the default) or `low`; option keys (`balance` 0–100, `lineArtPpi`, `gradientPpi`, `textToOutlines`, `strokesToOutlines`, `clipComplexRegions`, `antiAlias`, `preserveAlpha`, `preserveOverprints`) -override it, at the top level or in `options`. `balance: 0` rasterizes everything; lower balances rasterize groups +override it, at the top level or in `options`. An image that would be larger than 64 million pixels or longer than +32,768 pixels a side at `lineArtPpi` or `gradientPpi` is made at the highest resolution that fits, so a long area is +rasterized whole. `balance: 0` rasterizes everything; lower balances rasterize groups that split into many regions. The result reports `{ids, vector, rasterized, options}`. When an image in the art can't be decoded, the image that replaces the art does not show it, and the result also carries `warning`: "an image couldn't be decoded and is missing from the picture", the note raster exports give.