From a26ac227942ee195109153ec0cdc43535bbc80f8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ju=CC=88rg=20Lehni?= Date: Fri, 23 Oct 2026 11:27:52 +0200 Subject: [PATCH] SVG: type written as outlines stacks colour glyphs under their run's paint As on the canvas: each run's bitmap, COLR and SVG glyphs come before the run's path, so a plain character set over a colour one stays on top. --- crates/svg/src/export.rs | 49 ++++++++++++++++--------------- crates/svg/tests/colour_glyphs.rs | 18 ++++++++++-- docs/file-format-parity.md | 3 +- 3 files changed, 43 insertions(+), 27 deletions(-) diff --git a/crates/svg/src/export.rs b/crates/svg/src/export.rs index 07961c265..e0be4bf30 100644 --- a/crates/svg/src/export.rs +++ b/crates/svg/src/export.rs @@ -1920,11 +1920,11 @@ impl Writer<'_> { } /// Text as glyph outlines: one compound path per run, painted like the run (gradients span - /// the whole text, as on the canvas). + /// the whole text), over its colour glyphs, as on the canvas. fn text_outlines(&mut self, n: &Node, t: &TextObject) { let db = vectorcraft_text::FontDb::global(); let lay = vectorcraft_text::layout(db, t); - let mut runs: Vec<(usize, kurbo::BezPath)> = vec![]; + let mut runs = vec![kurbo::BezPath::new(); t.runs.len()]; // Underline and strikethrough bars join their run's outlines; ruby glyphs are painted // with the run of the text they annotate. let bars = vectorcraft_text::decorations(&lay, db, t); @@ -1935,9 +1935,8 @@ impl Writer<'_> { .chain(lay.ruby.iter().map(|g| (g.run, &g.outline))) .chain(bars.iter().map(|(r, b)| (*r, b))) { - match runs.iter_mut().find(|(r, _)| *r == run) { - Some((_, bp)) => bp.extend(outline.iter()), - None => runs.push((run, outline.clone())), + if let Some(bp) = runs.get_mut(run) { + bp.extend(outline.iter()); } } let id = self.id_attr(n); @@ -1947,29 +1946,33 @@ impl Writer<'_> { // The glyphs stay in text space; the text's transform joins the document's meanwhile. let saved = self.xf; self.xf = saved * t.xf; - for (r, bp) in runs { - let Some(run) = t.runs.get(r) else { continue }; - let pd = PathData::from_bezpath(&bp); + for (i, (bp, run)) in runs.iter().zip(&t.runs).enumerate() { + // Colour glyphs paint themselves, under their run's paint: bitmap glyphs (emoji) as + // their images, COLR and SVG glyphs as their art. + for g in lay.glyphs.iter().filter(|g| g.run == i) { + if let Some((image, to_text)) = g.placed_image() { + let m = self.matrix(self.xf * to_text); + let href = self.blob_href(&image.blob); + self.line(&format!( + "", + image.width, + image.height, + xml_escape(&href) + )); + } + if let Some(art) = g.placed_colour(Affine::IDENTITY) { + self.node(&art); + } + } + if bp.elements().is_empty() { + continue; + } + let pd = PathData::from_bezpath(bp); // An unnamed id: the pieces carry no id attribute (the group has it). let glyphs = Node::path(NodeId(u64::MAX), pd.clone(), run.style.appearance()); let d = self.path_d(&pd, self.xf); self.shape(&glyphs, &d, &[&pd], FillRule::NonZero, Some(lay.bounds)); } - // Colour bitmap glyphs (emoji): their images, which the runs' paint doesn't paint. - for (image, to_text) in lay.glyphs.iter().filter_map(|g| g.placed_image()) { - let m = self.matrix(self.xf * to_text); - let href = self.blob_href(&image.blob); - self.line(&format!( - "", - image.width, - image.height, - xml_escape(&href) - )); - } - // COLR glyphs: their layers, as art. - for art in lay.glyphs.iter().filter_map(|g| g.placed_colour(Affine::IDENTITY)) { - self.node(&art); - } self.xf = saved; self.depth -= 1; self.line(""); diff --git a/crates/svg/tests/colour_glyphs.rs b/crates/svg/tests/colour_glyphs.rs index 987c362f0..4120c9edc 100644 --- a/crates/svg/tests/colour_glyphs.rs +++ b/crates/svg/tests/colour_glyphs.rs @@ -43,14 +43,14 @@ fn embedded_fonts_leave_bitmap_glyphs_to_the_viewer() { assert!(out.svg.contains(&format!("{COLOUR_CHAR}F")), "the character stays text"); } -/// `ch` in the COLR test font (v1 or v0), 24 pt, filled blue. -fn colr_doc(v1: bool, ch: char) -> Document { +/// `text` in the COLR test font (v1 or v0), 24 pt, filled blue. +fn colr_doc(v1: bool, text: impl std::fmt::Display) -> Document { use vectorcraft_text::test_fonts::{colr_family, colr_font}; vectorcraft_text::FontDb::global().add_font(colr_font(v1).unwrap()); let mut d = Document::new(200.0, 100.0); let fill = vectorcraft_doc::color::Paint::solid(vectorcraft_doc::color::Color::rgb8(0, 0, 255)); let st = CharStyle { font_family: colr_family(v1).into(), size: 24.0, fill, ..CharStyle::default() }; - let n = Node::new(d.alloc_id(), NodeKind::Text(Box::new(TextObject::point(Point::new(10.0, 50.0), &ch.to_string(), st)))); + let n = Node::new(d.alloc_id(), NodeKind::Text(Box::new(TextObject::point(Point::new(10.0, 50.0), &text.to_string(), st)))); let l = d.layers[0].id; d.insert(Some(l), 0, n).unwrap(); d @@ -82,3 +82,15 @@ fn embedded_fonts_leave_colr_glyphs_to_the_viewer() { assert!(out.warnings.iter().any(|w| w.contains("emoji")), "{:?}", out.warnings); assert!(out.svg.contains(COLOUR_CHAR), "the character stays text"); } + +/// Outlined text stacks as the canvas paints type: a run's colour glyphs under its paint, so a +/// plain character set over a colour one stays on top. +#[test] +fn outlined_colour_glyphs_stay_under_their_runs_paint() { + let outlined = ExportOptions { outline_text: true, ..Default::default() }; + let svg = export_full(&doc(), &outlined, None).svg; + assert!(svg.find("').unwrap()].contains("f00"), "the red layer first: {svg}"); +} diff --git a/docs/file-format-parity.md b/docs/file-format-parity.md index 28adc7139..6595e4a62 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-10-23 · **Change:** minor (SVG keeps attributes in other applications' XML namespaces on their objects from import to export, the rest of #522; PDF import: a line's spacing is measured from the file's own glyph widths and kept within a hundredth of an em of where the file puts each glyph, so type opens as one run where it is placed; previously: PDF text opens where the file draws it, #722; PDF/X-1a and PDF/X-3 flattening keeps a CMYK document's inks where it rasterizes, #2160; EPS and `.ai` editing data: type keeps stacked art styles as a live appearance; point type frames without a matrix read; `editingData: "always"` for EPS; PDF invisible text, as a scanned page's recognized text is, opens as type with no fill or stroke and is written back as invisible text, #2675; DXF circular cubic paths export as native CIRCLE/ARC where stroke semantics permit (R13–2018 for weighted strokes; R12 weighted strokes remain POLYLINE) (#1299); non-circular curves stay SPLINE; scores and hours unchanged; an Illustrator 3 file without layers, as image editors export their paths, opens with its art on one layer, storytold/photocraft#6036; EPS editing data: legacy mesh corners, vector opacity masks and compound member paint retained; raster page comparison filters minification without relaxing limits; scores and hours unchanged; batch exporting, #1935; legacy `.ai` runs as PostScript with a prolog of its own, #868; legacy `.ai` of version 7 saved, with type as text, hidden objects and an artboard picker, and its type read, #868; PDF.R1: shared PDF reader with windowed content scanning and explicit incomplete-content errors; scores and estimates unchanged; PDF.W1: guarded shared PDF writer; output behavior, scores and estimates unchanged; later 2026-10-14 merges: SVG `:not()` selectors, #1557, stacked selectors, #1703, linked files, #1870; DXF VPORT, #1789; nesting caps, #1852; hostile PDF, `.ai`, EPS, Affinity and native data, #1862, #1863, #1871–#1873; failed saves, #1685, #1693, #1694; type in symbols after reopening, #1738; scores unchanged; verified PngSuite downloads, #1729; EPS stitched shadings, #1653; oversized images in WMF/EMF refused from the header, #1776; PDF name trees with shared nodes, #1781; native size limits for files read from memory, #1783; private temporary files on Unix, #1778; ruzstd 0.9.1, #1807; scores unchanged; SVG import: CSS transforms and the `font` shorthand, #1565, percentage lengths in symbols, #1596, Inkscape flowed text and pages, #1568, #1601, filter warnings, #1598; type on a path from PDF and `.ai`, #1585, #1586; ICC tag alignment in JPEG, TIFF and PSD, #1584; saves to folders that refuse the temporary file, #1620 in part; scores unchanged; TIFF is available in Export for Screens and Asset Export with shared format settings; scores and hours unchanged; compact native saves for documents with a lot of path data, #1492; PDF and EPS paste as vector art, #1499; `.ai` gradient stops listed upwards, #1530; SVG text on basic shapes, #1527; PDF soft masks drawn as images, #1551; Type 42 fonts in EPS, #1553; clips that only seem to cover the page, #1542; images too large to decode are reported, #1540; a `.ai` page's art past its artboard no longer lies over the page before it when read as its PDF part; grayscale TIFFs with an alpha channel or extra channels place and open, #1466 in part; a faded or masked fill keeps its stroke apart on PDF and `.ai` import, #1434; SVG knockout groups without masks, #1435; Affinity import: a text frame too short for its first line is made taller, #1423; `.ai` editing data: a compound path takes its last path's paint and keeps paths grouped inside it, #1410; a `.ai` whose PDF part is only text opens from it, #1387; PDF and `.ai` import keep overprint, #1388; `.ai` editing data: gradients with middle stops, #1390; type of objects with an appearance, hidden layers and later artboards, #1391; area type in a frame too short for the fallback font, #1392; `.ai` editing data: placed (linked) files read, #1264; gradient-filled shapes import as paths, #1304; white constant alpha soft masks, #1286 in part; DXF control-point SPLINE closure read, #1299 follow-up; `.ai` and PDF trim and bleed boxes as artboard and bleed, #1266; `.ai` gradient matrices, meshes and artboard names from the editing data; `.ai` art styles, clipping fills and compound looks from the editing data; `editingData: "always"` opens a `.ai` from its editing data; a `.ai` whose PDF part is only text opens from it, #1387; PDF and `.ai` import keep overprint, #1388; `.ai` editing data: gradients with middle stops, #1390; type of objects with an appearance, hidden layers and later artboards, #1391; area type in a frame too short for the fallback font, #1392; `.ai` editing data: placed (linked) files read, #1264; gradient-filled shapes import as paths, #1304; white constant alpha soft masks, #1286 in part; DXF control-point SPLINE closure read, #1299 follow-up; `.ai` and PDF trim and bleed boxes as artboard and bleed, #1266; `.ai` gradient matrices, meshes and artboard names from the editing data; `.ai` art styles, clipping fills and compound looks from the editing data; a `.ai` whose PDF part is only text opens from it, #1387; PDF and `.ai` import keep overprint, #1388; `.ai` editing data: gradients with middle stops, #1390; type of objects with an appearance, hidden layers and later artboards, #1391; area type in a frame too short for the fallback font, #1392; `.ai` editing data: placed (linked) files read, #1264; gradient-filled shapes import as paths, #1304; white constant alpha soft masks, #1286 in part; DXF control-point SPLINE closure read, #1299 follow-up; `.ai` and PDF trim and bleed boxes as artboard and bleed, #1266; `.ai` gradient matrices, meshes and artboard names from the editing data; a `.ai` whose PDF part is only text opens from it, #1387; PDF and `.ai` import keep overprint, #1388; `.ai` editing data: gradients with middle stops, #1390; type of objects with an appearance, hidden layers and later artboards, #1391; area type in a frame too short for the fallback font, #1392; `.ai` editing data: placed (linked) files read, #1264; gradient-filled shapes import as paths, #1304; white constant alpha soft masks, #1286 in part; DXF control-point SPLINE closure read, #1299 follow-up; `.ai` and PDF trim and bleed boxes as artboard and bleed, #1266; SVG knockout groups of opaque art without masks; a `.ai` whose PDF part is only text opens from it, #1387; PDF and `.ai` import keep overprint, #1388; `.ai` editing data: gradients with middle stops, #1390; type of objects with an appearance, hidden layers and later artboards, #1391; area type in a frame too short for the fallback font, #1392; `.ai` editing data: placed (linked) files read, #1264; gradient-filled shapes import as paths, #1304; white constant alpha soft masks, #1286 in part; DXF control-point SPLINE closure read, #1299 follow-up; `.ai` and PDF trim and bleed boxes as artboard and bleed, #1266; `.ai` keeps its RGB numbers; PDF import keeps a stroke apart from a fill masked or faded by its group) · **Target:** Adobe Illustrator 2026 (30.x) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-23 · **Change:** minor (SVG: type written as outlines stacks its colour glyphs under their run's paint, as the canvas does; previously: SVG keeps attributes in other applications' XML namespaces on their objects from import to export, the rest of #522; PDF import: a line's spacing is measured from the file's own glyph widths and kept within a hundredth of an em of where the file puts each glyph, so type opens as one run where it is placed; previously: PDF text opens where the file draws it, #722; PDF/X-1a and PDF/X-3 flattening keeps a CMYK document's inks where it rasterizes, #2160; EPS and `.ai` editing data: type keeps stacked art styles as a live appearance; point type frames without a matrix read; `editingData: "always"` for EPS; PDF invisible text, as a scanned page's recognized text is, opens as type with no fill or stroke and is written back as invisible text, #2675; DXF circular cubic paths export as native CIRCLE/ARC where stroke semantics permit (R13–2018 for weighted strokes; R12 weighted strokes remain POLYLINE) (#1299); non-circular curves stay SPLINE; scores and hours unchanged; an Illustrator 3 file without layers, as image editors export their paths, opens with its art on one layer, storytold/photocraft#6036; EPS editing data: legacy mesh corners, vector opacity masks and compound member paint retained; raster page comparison filters minification without relaxing limits; scores and hours unchanged; batch exporting, #1935; legacy `.ai` runs as PostScript with a prolog of its own, #868; legacy `.ai` of version 7 saved, with type as text, hidden objects and an artboard picker, and its type read, #868; PDF.R1: shared PDF reader with windowed content scanning and explicit incomplete-content errors; scores and estimates unchanged; PDF.W1: guarded shared PDF writer; output behavior, scores and estimates unchanged; later 2026-10-14 merges: SVG `:not()` selectors, #1557, stacked selectors, #1703, linked files, #1870; DXF VPORT, #1789; nesting caps, #1852; hostile PDF, `.ai`, EPS, Affinity and native data, #1862, #1863, #1871–#1873; failed saves, #1685, #1693, #1694; type in symbols after reopening, #1738; scores unchanged; verified PngSuite downloads, #1729; EPS stitched shadings, #1653; oversized images in WMF/EMF refused from the header, #1776; PDF name trees with shared nodes, #1781; native size limits for files read from memory, #1783; private temporary files on Unix, #1778; ruzstd 0.9.1, #1807; scores unchanged; SVG import: CSS transforms and the `font` shorthand, #1565, percentage lengths in symbols, #1596, Inkscape flowed text and pages, #1568, #1601, filter warnings, #1598; type on a path from PDF and `.ai`, #1585, #1586; ICC tag alignment in JPEG, TIFF and PSD, #1584; saves to folders that refuse the temporary file, #1620 in part; scores unchanged; TIFF is available in Export for Screens and Asset Export with shared format settings; scores and hours unchanged; compact native saves for documents with a lot of path data, #1492; PDF and EPS paste as vector art, #1499; `.ai` gradient stops listed upwards, #1530; SVG text on basic shapes, #1527; PDF soft masks drawn as images, #1551; Type 42 fonts in EPS, #1553; clips that only seem to cover the page, #1542; images too large to decode are reported, #1540; a `.ai` page's art past its artboard no longer lies over the page before it when read as its PDF part; grayscale TIFFs with an alpha channel or extra channels place and open, #1466 in part; a faded or masked fill keeps its stroke apart on PDF and `.ai` import, #1434; SVG knockout groups without masks, #1435; Affinity import: a text frame too short for its first line is made taller, #1423; `.ai` editing data: a compound path takes its last path's paint and keeps paths grouped inside it, #1410; a `.ai` whose PDF part is only text opens from it, #1387; PDF and `.ai` import keep overprint, #1388; `.ai` editing data: gradients with middle stops, #1390; type of objects with an appearance, hidden layers and later artboards, #1391; area type in a frame too short for the fallback font, #1392; `.ai` editing data: placed (linked) files read, #1264; gradient-filled shapes import as paths, #1304; white constant alpha soft masks, #1286 in part; DXF control-point SPLINE closure read, #1299 follow-up; `.ai` and PDF trim and bleed boxes as artboard and bleed, #1266; `.ai` gradient matrices, meshes and artboard names from the editing data; `.ai` art styles, clipping fills and compound looks from the editing data; `editingData: "always"` opens a `.ai` from its editing data; a `.ai` whose PDF part is only text opens from it, #1387; PDF and `.ai` import keep overprint, #1388; `.ai` editing data: gradients with middle stops, #1390; type of objects with an appearance, hidden layers and later artboards, #1391; area type in a frame too short for the fallback font, #1392; `.ai` editing data: placed (linked) files read, #1264; gradient-filled shapes import as paths, #1304; white constant alpha soft masks, #1286 in part; DXF control-point SPLINE closure read, #1299 follow-up; `.ai` and PDF trim and bleed boxes as artboard and bleed, #1266; `.ai` gradient matrices, meshes and artboard names from the editing data; `.ai` art styles, clipping fills and compound looks from the editing data; a `.ai` whose PDF part is only text opens from it, #1387; PDF and `.ai` import keep overprint, #1388; `.ai` editing data: gradients with middle stops, #1390; type of objects with an appearance, hidden layers and later artboards, #1391; area type in a frame too short for the fallback font, #1392; `.ai` editing data: placed (linked) files read, #1264; gradient-filled shapes import as paths, #1304; white constant alpha soft masks, #1286 in part; DXF control-point SPLINE closure read, #1299 follow-up; `.ai` and PDF trim and bleed boxes as artboard and bleed, #1266; `.ai` gradient matrices, meshes and artboard names from the editing data; a `.ai` whose PDF part is only text opens from it, #1387; PDF and `.ai` import keep overprint, #1388; `.ai` editing data: gradients with middle stops, #1390; type of objects with an appearance, hidden layers and later artboards, #1391; area type in a frame too short for the fallback font, #1392; `.ai` editing data: placed (linked) files read, #1264; gradient-filled shapes import as paths, #1304; white constant alpha soft masks, #1286 in part; DXF control-point SPLINE closure read, #1299 follow-up; `.ai` and PDF trim and bleed boxes as artboard and bleed, #1266; SVG knockout groups of opaque art without masks; a `.ai` whose PDF part is only text opens from it, #1387; PDF and `.ai` import keep overprint, #1388; `.ai` editing data: gradients with middle stops, #1390; type of objects with an appearance, hidden layers and later artboards, #1391; area type in a frame too short for the fallback font, #1392; `.ai` editing data: placed (linked) files read, #1264; gradient-filled shapes import as paths, #1304; white constant alpha soft masks, #1286 in part; DXF control-point SPLINE closure read, #1299 follow-up; `.ai` and PDF trim and bleed boxes as artboard and bleed, #1266; `.ai` keeps its RGB numbers; PDF import keeps a stroke apart from a fill masked or faded by its group) · **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/mod.rs`). Illustrator's list comes from its public @@ -130,6 +130,7 @@ the app's own editing copy of a file (an EPS after its `%%EOF`, a `.ai`'s `AIPri | Date | Change | Summary | |---|---|---| +| 2026-10-23 | minor | SVG: type written as outlines stacks each run's colour glyphs (bitmap images, COLR and SVG art) under the run's paint, as the canvas does, so a plain character set over a colour one stays on top; scores unchanged | | 2026-10-23 | minor | PDF import: a line's spacing is measured from the file's own glyph widths, so type in a font the file doesn't carry, drawn with an installed font whose advances its widths round off (Helvetica on macOS: 2048 units an em), keeps one tracking value on its letters instead of a run per character (#722 follow-up); test with built-in Inter, independent of the fonts installed; scores and hours unchanged | | 2026-10-23 | minor | SVG: attributes in other applications' XML namespaces (`plotter:pen="2"` with `xmlns:plotter`) are kept on the objects and layers they belong to, saved with them in `.vectorcraft` files and written back on export, each namespace declared once on the root under the file's prefix where it is free. SVG's, XLink's, XML's, Inkscape's and Sodipodi's own aside; at most 64 per object of at most 64 KiB each, with a warning for what is left out (the rest of #522). Scores and hours unchanged | | 2026-10-23 | minor | PDF import: a line's spacing is measured from the file's own glyph widths, so type in a font the file doesn't carry, drawn with an installed font whose advances its widths round off (Helvetica on macOS: 2048 units an em), keeps one tracking value on its letters instead of a run per character (#722 follow-up); where the widths are a little off the font's advances (up to the 1% that still counts as the same font) and the differences add up along a line, each stretch of the line takes their share after every character, so its glyphs stay within a hundredth of an em of the file's in a run per stretch (one on ordinary text), instead of a run per character, untracked type points off, or a line-wide fit tenths of a point off; tests with built-in Inter and embedded Source Sans 3, independent of the fonts installed; scores and hours unchanged |