diff --git a/crates/svg/src/glyph.rs b/crates/svg/src/glyph.rs index 060c11229..2ee78b6c9 100644 --- a/crates/svg/src/glyph.rs +++ b/crates/svg/src/glyph.rs @@ -2,10 +2,9 @@ //! it, made as the importer makes art of any SVG file. The text crate asks for it through the //! reader the app registers ([`vectorcraft_text::set_svg_glyph_reader`]). //! -//! Where it differs from the `SVG ` table's rules: a size on the root (`width`, `height`) counts -//! for nothing; a non-square `viewBox` is scaled to fit the em by its larger side, not centred in -//! it; and a glyph element nested in others keeps what they apply (transforms, opacity). +//! Where it differs from the `SVG ` table's rules: images are left out (see [`LEFT_OUT`]). +use std::ops::Range; use std::sync::Arc; use std::sync::atomic::AtomicU64; @@ -26,10 +25,11 @@ const LEFT_OUT: [&str; 4] = ["text", "image", "foreignObject", "script"]; /// Glyph `gid`'s art from its SVG `document` (possibly gzipped), in a font of `upem` units per em: /// its element `glyph` (or the whole document when that is its root), in font units (y down), -/// a `viewBox` mapped onto the em square. The text's colour is `fill`: `currentColor`, and the -/// legacy `context-fill` and `context-stroke` (none without a `stroke`). Its nodes have no ids -/// (they aren't a document's). With the art, whether it uses the text's colours. `None` when the -/// document doesn't read, is too large or has no such element. +/// the root's `viewBox` (or else its size) fitted into the em square by its larger side, centred. +/// The text's colour is `fill`: `currentColor`, and the legacy `context-fill` and `context-stroke` +/// (none without a `stroke`). Its nodes have no ids (they aren't a document's). With the art, +/// whether it uses the text's colours. `None` when the document doesn't read, is too large or has +/// no such element. pub fn glyph_art(document: &[u8], gid: u32, upem: f64, fill: Color, stroke: Option) -> Option<(Node, bool)> { // The SVGZ budget left is the glyph's cap. let unpacked = AtomicU64::new(crate::MAX_SVGZ.saturating_sub(MAX_SVG_GLYPH_BYTES)); @@ -42,7 +42,7 @@ pub fn glyph_art(document: &[u8], gid: u32, upem: f64, fill: Color, stroke: Opti let [r, g, b, _] = c.to_rgba8(1.0); format!("#{r:02x}{g:02x}{b:02x}") }; - let Glyph { svg, whole, view_box } = glyph(&text, &id, &hex(fill))?; + let Glyph { svg, whole, frame } = glyph(&text, &id, &hex(fill))?; let coloured = ["currentColor", "currentcolor", "context-fill", "context-stroke"].iter().any(|k| svg.contains(k)); // usvg resolves `context-fill` and `context-stroke` only for markers. let svg = svg.replace("context-fill", &hex(fill)).replace("context-stroke", &stroke.map_or_else(|| "none".into(), hex)); @@ -50,7 +50,7 @@ pub fn glyph_art(document: &[u8], gid: u32, upem: f64, fill: Color, stroke: Opti let mut art = if whole { Node::group(NodeId(0), doc.layers.iter().map(|l| Arc::new(Node::group(NodeId(0), l.children().cloned().unwrap_or_default()))).collect()) } else { - // A top-level element is a layer, others an object, named after its id. + // The glyph, top-level, is a layer when a ``, else an object named after its id. let named = |n: &Node| n.name.as_deref() == Some(id.as_str()); match doc.layers.iter().find(|l| named(l)) { Some(layer) => Node::group(NodeId(0), layer.children().cloned().unwrap_or_default()), @@ -67,10 +67,10 @@ pub fn glyph_art(document: &[u8], gid: u32, upem: f64, fill: Color, stroke: Opti } } }; - // Without a size of its own, the document's viewport is its view box at one unit per unit: - // the em square is that box. - if let Some(side) = view_box.filter(|s| *s > 0.0 && s.is_finite()) { - art.transform(Affine::scale(upem / side), true); + // Without a size of its own, the document's viewport is its frame at one unit per unit. + if let Some((w, h)) = frame.filter(|(w, h)| *w > 0.0 && *h > 0.0 && w.is_finite() && h.is_finite()) { + let s = upem / w.max(h); + art.transform(Affine::translate(((upem - w * s) / 2.0, (upem - h * s) / 2.0)) * Affine::scale(s), true); } Some((anonymous(&art), coloured)) } @@ -80,27 +80,23 @@ struct Glyph { svg: String, /// The glyph is the whole document (its root has the glyph's id). whole: bool, - /// The larger side of the root's `viewBox`, if it has one. - view_box: Option, + /// The width and height of the root's `viewBox`, or else of its size, if it has either. + frame: Option<(f64, f64)>, } -/// `svg` with only what glyph `id` draws: without the other glyphs' top-level elements (a font -/// may keep all its glyphs in one document), the [`LEFT_OUT`] elements and event attributes, the -/// root without a size (the em square is its view box) and in the text's colour `color`. `None` -/// when it doesn't read or has no element `id`. +/// `svg` with only what glyph `id` draws, as if the root `use`d it: the rest of the document (a +/// font may keep all its glyphs in one) only defines what it refers to, and what its parents apply +/// doesn't reach it. Without the [`LEFT_OUT`] elements and event attributes, the root without a +/// size (the em square is its frame) and in the text's colour `color`. `None` when it doesn't read +/// or has no element `id`. fn glyph(svg: &str, id: &str, color: &str) -> Option { let xml = roxmltree::Document::parse_with_options(svg, roxmltree::ParsingOptions { allow_dtd: true, ..Default::default() }).ok()?; let root = xml.root_element(); let whole = root.attribute("id") == Some(id); - if !whole && !xml.descendants().any(|n| n.attribute("id") == Some(id)) { - return None; - } - let is_glyph = |v: &str| v.strip_prefix("glyph").is_some_and(|d| !d.is_empty() && d.bytes().all(|b| b.is_ascii_digit())); + let target = if whole { root } else { xml.descendants().find(|n| n.is_element() && n.attribute("id") == Some(id))? }; + let at = target.range(); // Edits: byte ranges to cut and what to put in their place, in order. - let mut edits: Vec<(std::ops::Range, String)> = vec![]; - for n in root.children().filter(|n| n.attribute("id").is_some_and(|v| v != id && is_glyph(v))) { - edits.push((n.range(), String::new())); - } + let mut edits: Vec<(Range, String)> = vec![]; for n in root.descendants().filter(|n| n.is_element() && LEFT_OUT.contains(&n.tag_name().name())) { edits.push((n.range(), String::new())); } @@ -115,27 +111,58 @@ fn glyph(svg: &str, id: &str, color: &str) -> Option { let start = root.range().start + 1; let name = start + svg.get(start..)?.find(|c: char| c.is_whitespace() || c == '>' || c == '/')?; edits.push((name..name, format!(" color=\"{color}\""))); + // The namespaces the glyph's parents declare, which it leaves: the root declares them too. + for ns in target.namespaces() { + if let Some(prefix) = ns.name().filter(|p| root.lookup_namespace_uri(Some(p)).is_none()) { + edits.push((name..name, format!(" xmlns:{prefix}=\"{}\"", crate::xml_escape(ns.uri())))); + } + } edits.sort_by_key(|(r, _)| (r.start, r.end)); - let mut out = String::with_capacity(svg.len()); - let mut at = 0; - for (r, with) in edits { + let out = if whole { + edited(svg, 0..svg.len(), &edits)? + } else { + // The root's content goes in `defs`, and the glyph after it, on its own. + let content = root.first_child()?.range().start; + let close = svg.get(..root.range().end)?.rfind("".into(), + edited(svg, content..at.start, &edits)?, + edited(svg, at.end..close, &edits)?, + "".into(), + edited(svg, at.clone(), &edits)?, + edited(svg, close..svg.len(), &edits)?, + ] + .concat() + }; + let number = |v: &str| v.trim().trim_end_matches("px").parse::().ok(); + let view_box = root.attribute("viewBox").and_then(|v| { + let n: Vec = v.split(|c: char| c.is_whitespace() || c == ',').filter(|s| !s.is_empty()).filter_map(|s| s.parse().ok()).collect(); + match n.as_slice() { + [_, _, w, h] => Some((*w, *h)), + _ => None, + } + }); + let size = || Some((number(root.attribute("width")?)?, number(root.attribute("height")?)?)); + Some(Glyph { svg: out, whole, frame: view_box.or_else(size) }) +} + +/// `svg[range]` with the `edits` (in order) that lie in it made: one around the range (a left-out +/// element holding the glyph) isn't, so it stays whole, defining only. +fn edited(svg: &str, range: Range, edits: &[(Range, String)]) -> Option { + let mut out = String::new(); + let mut at = range.start; + for (r, with) in edits.iter().filter(|(r, _)| range.start <= r.start && r.end <= range.end) { // An element inside one already cut goes with it. if r.start < at { continue; } out.push_str(svg.get(at..r.start)?); - out.push_str(&with); + out.push_str(with); at = r.end; } - out.push_str(svg.get(at..)?); - let view_box = root.attribute("viewBox").and_then(|v| { - let n: Vec = v.split(|c: char| c.is_whitespace() || c == ',').filter(|s| !s.is_empty()).filter_map(|s| s.parse().ok()).collect(); - match n.as_slice() { - [_, _, w, h] => Some(w.max(*h)), - _ => None, - } - }); - Some(Glyph { svg: out, whole, view_box }) + out.push_str(svg.get(at..range.end)?); + Some(out) } /// `n` with no ids, its members' neither. diff --git a/crates/svg/tests/svg_glyphs.rs b/crates/svg/tests/svg_glyphs.rs index 46636ba01..3d5f46fad 100644 --- a/crates/svg/tests/svg_glyphs.rs +++ b/crates/svg/tests/svg_glyphs.rs @@ -139,3 +139,49 @@ fn event_attributes_are_left_out() { art.walk(&mut |n| events.extend(n.extras().attrs.as_ref().map(|a| a.events.clone()).unwrap_or_default())); assert!(events.is_empty(), "{events:?}"); } + +/// A size on the root without a `viewBox` sets up the em square as a `viewBox` would. +#[test] +fn a_root_size_is_the_em_square_without_a_view_box() { + let doc = r##" + "##; + let b = art(doc, 2).unwrap().geometric_bounds().unwrap(); + assert!((b.x0 - 250.0).abs() < 1e-6 && (b.width() - 250.0).abs() < 1e-6, "{b:?}"); +} + +/// A `viewBox` that isn't square fits the em square by its larger side, centred along the other. +#[test] +fn a_view_box_that_isnt_square_is_centred_in_the_em_square() { + // 2000 wide, 1000 high: half a unit per unit, the box 500 high centred in the em's 1000. + let doc = r##" + "##; + let b = art(doc, 2).unwrap().geometric_bounds().unwrap(); + assert!(b.x0.abs() < 1e-6 && (b.y0 - 250.0).abs() < 1e-6 && (b.height() - 500.0).abs() < 1e-6, "{b:?}"); +} + +/// A glyph element inside others is drawn on its own, as if used: what its parents apply doesn't +/// reach it, and what it refers to still does. +#[test] +fn a_nested_glyph_is_drawn_on_its_own() { + let doc = r##" + + + "##; + let art = art(doc, 3).unwrap(); + let b = art.geometric_bounds().unwrap(); + assert!(b.x0.abs() < 1e-6 && (b.x1 - 500.0).abs() < 1e-6, "not moved by its parent: {b:?}"); + let mut opacities = vec![]; + art.walk(&mut |n| opacities.push(n.opacity)); + assert!(opacities.iter().all(|o| *o == 1.0), "not faded by its parent: {opacities:?}"); + assert!(fills(&art).iter().any(|p| matches!(p, Paint::Gradient(_))), "its gradient"); +} + +/// A namespace the glyph's parents declare still binds it when it is drawn on its own. +#[test] +fn a_nested_glyph_keeps_the_namespaces_its_parents_declare() { + let doc = r##" + + + "##; + assert!(art(doc, 3).is_some_and(|a| fills(&a).contains(&Paint::solid(Color::rgb8(255, 0, 0))))); +} diff --git a/docs/type-parity.md b/docs/type-parity.md index b953b6d79..c65fb4a2a 100644 --- a/docs/type-parity.md +++ b/docs/type-parity.md @@ -1,6 +1,6 @@ # Type parity -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-23 · **Change:** minor (OpenType SVG colour fonts draw and export as vector art; previously: hanging punctuation: Glyph Scaling leaves the hung marks at their width, Standard burasagari counts a hung line start as room, G4; a paragraph's Auto Leading also drives the Character panel's Leading, the Alt+↑/↓ leading nudge and Fill with Placeholder Text; scores unchanged; COLR colour fonts (v0 and v1) draw and export as vector art; previously: colour emoji fonts with bitmap glyphs (`sbix`: Apple Color Emoji; `CBDT`: Noto Color Emoji) draw on the canvas and export: PDF Type 3 glyphs or images, SVG images with outlined text, EPS and legacy `.ai` images, Create Outlines images; scores unchanged; previously: #3515: recent-font groups in font pickers; manual Tate-chu-yoko marks characters across one em of a vertical column; System Layout: Character panel menu sets whole-point advances for every text object in the document (off by default); ruby (ふりがな) for live text: `type.ruby` sets or removes a reading over a byte range, above or below (right of the column in vertical type), start/centre/end aligned, sized and overhanging or squeezed; saved natively, laid out with its line growing, drawn on the canvas and in SVG/PDF (outlines where ruby is present); scores unchanged; previously:; Fractional Widths: Character panel menu turns whole-point advances on or off per type object, on by default; scores unchanged; type on a path threads, mixed with area type in one thread; scores unchanged; No Break keeps marked characters on one line; scores unchanged; a click on the out port threads the text into a closed path, a new frame at the click or a dragged rectangle, #2572 in part; scores unchanged; native family labels fall back to localized legacy names when typographic names are English-only, #2208; scores unchanged; Cmd+Shift+C aligns centre off the Mac, #1940; tab leaders are drawn in the tab's gap, #1877; type in symbols, pattern tiles, graph designs and opacity masks gets its layout bounds back on opening a native file, #1738; score unchanged; Escape while typing ends the edit, #1686; Select › Same on type, #1737; `justify` when creating point type, #1748; scores unchanged; OpenType stylistic sets, alternates, figure styles, superscript and subscript as character features, #1607; a wide word in non-rectangular area type moves down, #1615; scores unchanged; Character Styles New/Redefine capture the first selected character at run boundaries and end prior typing before capture; boundary and typing-history input tests and full workspace CI pass; scores and hours unchanged; Character styles retain explicit Auto leading, propagate resets and preserve local leading overrides; scores and hours unchanged; Linux font folders from fontconfig, #1529; commands and MCP `add_text` report area type that does not fit its frame, #1471; Tabs panel stop drags, #1458; Fit Headline in a frame one line tall, #1444; frames and paths beyond a billion points hold no text, #1451; scores unchanged; missing glyphs are looked for again after a font rescan, #1376; scores unchanged; Full-name font matching (#1258); Windows font-service QA pending; scores unchanged; Middle Eastern digit types: Character panel menu › Digits, `{digits}`; the font menu's script filter lists Thai fonts; Thai, Lao, Khmer and Myanmar lines break between words; the caret steps over whole clusters such as Thai consonants with their marks; Thai typed in a font without Thai falls back to a font made for Thai; earlier: Proportional Metrics in vertical type, `vpal`, #966; Cmd+Shift+C aligns centre off the Mac, a double-click on area type's bottom handle turns on Auto Size, #1940; MCP `add_text` exposes the existing `vertical` option for point, area and path type; horizontal remains the default; scores and hours unchanged; Vertical fullwidth question and exclamation marks keep their nominal glyph upright when the font has no vertical alternate; scores and hours unchanged. tab leaders are drawn in the tab's gap, #1877; type in symbols, pattern tiles, graph designs and opacity masks gets its layout bounds back on opening a native file, #1738; score unchanged; Escape while typing ends the edit, #1686; Select › Same on type, #1737; `justify` when creating point type, #1748; scores unchanged; OpenType stylistic sets, alternates, figure styles, superscript and subscript as character features, #1607; a wide word in non-rectangular area type moves down, #1615; scores unchanged; Character Styles New/Redefine capture the first selected character at run boundaries and end prior typing before capture; boundary and typing-history input tests and full workspace CI pass; scores and hours unchanged; Character styles retain explicit Auto leading, propagate resets and preserve local leading overrides; scores and hours unchanged; Linux font folders from fontconfig, #1529; commands and MCP `add_text` report area type that does not fit its frame, #1471; Tabs panel stop drags, #1458; Fit Headline in a frame one line tall, #1444; frames and paths beyond a billion points hold no text, #1451; scores unchanged; missing glyphs are looked for again after a font rescan, #1376; scores unchanged; Full-name font matching (#1258); Windows font-service QA pending; scores unchanged; Middle Eastern digit types: Character panel menu › Digits, `{digits}`; the font menu's script filter lists Thai fonts; Thai, Lao, Khmer and Myanmar lines break between words; the caret steps over whole clusters such as Thai consonants with their marks; Thai typed in a font without Thai falls back to a font made for Thai; earlier: Proportional Metrics in vertical type, `vpal`, #966) · **Target:** Adobe Illustrator 2026 (30.x) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-23 · **Change:** minor (OpenType SVG glyphs follow the `SVG ` table's coordinate rules: a root size without a `viewBox` sets up the em, a `viewBox` that isn't square is centred in it, and a glyph element nested in others is drawn on its own; previously: OpenType SVG colour fonts draw and export as vector art; hanging punctuation: Glyph Scaling leaves the hung marks at their width, Standard burasagari counts a hung line start as room, G4; a paragraph's Auto Leading also drives the Character panel's Leading, the Alt+↑/↓ leading nudge and Fill with Placeholder Text; scores unchanged; COLR colour fonts (v0 and v1) draw and export as vector art; previously: colour emoji fonts with bitmap glyphs (`sbix`: Apple Color Emoji; `CBDT`: Noto Color Emoji) draw on the canvas and export: PDF Type 3 glyphs or images, SVG images with outlined text, EPS and legacy `.ai` images, Create Outlines images; scores unchanged; previously: #3515: recent-font groups in font pickers; manual Tate-chu-yoko marks characters across one em of a vertical column; System Layout: Character panel menu sets whole-point advances for every text object in the document (off by default); ruby (ふりがな) for live text: `type.ruby` sets or removes a reading over a byte range, above or below (right of the column in vertical type), start/centre/end aligned, sized and overhanging or squeezed; saved natively, laid out with its line growing, drawn on the canvas and in SVG/PDF (outlines where ruby is present); scores unchanged; previously:; Fractional Widths: Character panel menu turns whole-point advances on or off per type object, on by default; scores unchanged; type on a path threads, mixed with area type in one thread; scores unchanged; No Break keeps marked characters on one line; scores unchanged; a click on the out port threads the text into a closed path, a new frame at the click or a dragged rectangle, #2572 in part; scores unchanged; native family labels fall back to localized legacy names when typographic names are English-only, #2208; scores unchanged; Cmd+Shift+C aligns centre off the Mac, #1940; tab leaders are drawn in the tab's gap, #1877; type in symbols, pattern tiles, graph designs and opacity masks gets its layout bounds back on opening a native file, #1738; score unchanged; Escape while typing ends the edit, #1686; Select › Same on type, #1737; `justify` when creating point type, #1748; scores unchanged; OpenType stylistic sets, alternates, figure styles, superscript and subscript as character features, #1607; a wide word in non-rectangular area type moves down, #1615; scores unchanged; Character Styles New/Redefine capture the first selected character at run boundaries and end prior typing before capture; boundary and typing-history input tests and full workspace CI pass; scores and hours unchanged; Character styles retain explicit Auto leading, propagate resets and preserve local leading overrides; scores and hours unchanged; Linux font folders from fontconfig, #1529; commands and MCP `add_text` report area type that does not fit its frame, #1471; Tabs panel stop drags, #1458; Fit Headline in a frame one line tall, #1444; frames and paths beyond a billion points hold no text, #1451; scores unchanged; missing glyphs are looked for again after a font rescan, #1376; scores unchanged; Full-name font matching (#1258); Windows font-service QA pending; scores unchanged; Middle Eastern digit types: Character panel menu › Digits, `{digits}`; the font menu's script filter lists Thai fonts; Thai, Lao, Khmer and Myanmar lines break between words; the caret steps over whole clusters such as Thai consonants with their marks; Thai typed in a font without Thai falls back to a font made for Thai; earlier: Proportional Metrics in vertical type, `vpal`, #966; Cmd+Shift+C aligns centre off the Mac, a double-click on area type's bottom handle turns on Auto Size, #1940; MCP `add_text` exposes the existing `vertical` option for point, area and path type; horizontal remains the default; scores and hours unchanged; Vertical fullwidth question and exclamation marks keep their nominal glyph upright when the font has no vertical alternate; scores and hours unchanged. tab leaders are drawn in the tab's gap, #1877; type in symbols, pattern tiles, graph designs and opacity masks gets its layout bounds back on opening a native file, #1738; score unchanged; Escape while typing ends the edit, #1686; Select › Same on type, #1737; `justify` when creating point type, #1748; scores unchanged; OpenType stylistic sets, alternates, figure styles, superscript and subscript as character features, #1607; a wide word in non-rectangular area type moves down, #1615; scores unchanged; Character Styles New/Redefine capture the first selected character at run boundaries and end prior typing before capture; boundary and typing-history input tests and full workspace CI pass; scores and hours unchanged; Character styles retain explicit Auto leading, propagate resets and preserve local leading overrides; scores and hours unchanged; Linux font folders from fontconfig, #1529; commands and MCP `add_text` report area type that does not fit its frame, #1471; Tabs panel stop drags, #1458; Fit Headline in a frame one line tall, #1444; frames and paths beyond a billion points hold no text, #1451; scores unchanged; missing glyphs are looked for again after a font rescan, #1376; scores unchanged; Full-name font matching (#1258); Windows font-service QA pending; scores unchanged; Middle Eastern digit types: Character panel menu › Digits, `{digits}`; the font menu's script filter lists Thai fonts; Thai, Lao, Khmer and Myanmar lines break between words; the caret steps over whole clusters such as Thai consonants with their marks; Thai typed in a font without Thai falls back to a font made for Thai; earlier: Proportional Metrics in vertical type, `vpal`, #966) · **Target:** Adobe Illustrator 2026 (30.x) Type is Illustrator's largest single area by use (weight 13 of 105 across "Type core" and "Type advanced" in [target-app-parity.md](target-app-parity.md)). This checklist lists the features one by one. Status is from the @@ -33,7 +33,7 @@ against Illustrator's public documentation; **estimated** unless noted. | Full font-name matching (OpenType name ID 4) | done | #1258: loaded/cataloged faces and Missing Fonts searches; localized names and variable default faces; Windows font-service QA pending | | Variable fonts | partial | named instances as styles; Character panel › Variable Font sets each axis (`fontAxes`); the font menu's Variable filter is missing | | Fallback glyphs keep the text's weight and slant | done | #2491: a character from a fallback face is set in that family's face nearest the run's weight and slant; Bold Italic of a missing family is drawn Bold (Source Sans 3 has no Bold Italic) rather than Italic | -| Colour fonts (bitmap, COLR, OpenType SVG) | partial | bitmap glyphs (`sbix`, `CBDT`) done: the largest strike's PNG, transformed with the type, fill and stroke ignored; PDF Type 3 glyphs or images, SVG images with outlined text (live text leaves emoji to the viewer, their fonts not embedded), EPS and legacy `.ai` images, Create Outlines images; not `sbix` duplicate, flipped, JPEG or TIFF glyphs, or `CBDT` BGRA or mask data, ruby, envelopes or warps. COLR (v0 and v1) done: a glyph's layers as vector art (solid colours, linear and radial gradients, clip glyphs and boxes, blend modes, transforms, palette 0, the foreground colour from the run's fill, variable fonts at the face's instance), transformed with the type; drawn on the canvas, written as paths in SVG, EPS, legacy `.ai` and PDF with outlined text, as text in PDF (Type 3 glyphs), and Create Outlines makes groups of editable paths. Paints documents have no form for draw the glyph as an image (256 px per em): sweep gradients, Porter-Duff compositing, radial gradients from a circle that isn't the centre, repeating or reflecting gradients. Not ruby, envelopes or warps. OpenType SVG done: a glyph's SVG document (its element `glyph`) is read by the SVG importer into vector art in font units, `currentColor` in the run's fill (and the legacy `context-fill` and `context-stroke` in its fill and stroke), a `viewBox` mapped onto the em square, and then drawn, exported and outlined as COLR glyphs are; in PDF real text such glyphs are text that draws nothing, with their art drawn where they sit. Not images, scripts or text inside glyph documents, nor CPAL palette variables. Evidence: `tests_svg_glyphs`, `svg/tests/svg_glyphs.rs`, `tests_colourfonts::create_outlines_turns_svg_glyphs_into_groups`, `tests_colourfonts::pdf_real_text_draws_svg_glyphs_as_art_and_keeps_their_characters`, `tests_bitmaps`, `tests_colr`, `render::tests_bitmapglyphs`, `render::tests_colrglyphs`, `pdf::tests::colour_bitmap_glyphs_export_as_images`, `pdf::tests::colr_glyphs_export_as_vector_art`, `svg/tests/colour_glyphs.rs`, `eps` `colour_bitmap_glyphs_*` and `colr_glyphs_*`, `tests_pathops::create_outlines_turns_bitmap_glyphs_into_images`, `tests_pathops::create_outlines_turns_colr_glyphs_into_layer_groups` | +| Colour fonts (bitmap, COLR, OpenType SVG) | partial | bitmap glyphs (`sbix`, `CBDT`) done: the largest strike's PNG, transformed with the type, fill and stroke ignored; PDF Type 3 glyphs or images, SVG images with outlined text (live text leaves emoji to the viewer, their fonts not embedded), EPS and legacy `.ai` images, Create Outlines images; not `sbix` duplicate, flipped, JPEG or TIFF glyphs, or `CBDT` BGRA or mask data, ruby, envelopes or warps. COLR (v0 and v1) done: a glyph's layers as vector art (solid colours, linear and radial gradients, clip glyphs and boxes, blend modes, transforms, palette 0, the foreground colour from the run's fill, variable fonts at the face's instance), transformed with the type; drawn on the canvas, written as paths in SVG, EPS, legacy `.ai` and PDF with outlined text, as text in PDF (Type 3 glyphs), and Create Outlines makes groups of editable paths. Paints documents have no form for draw the glyph as an image (256 px per em): sweep gradients, Porter-Duff compositing, radial gradients from a circle that isn't the centre, repeating or reflecting gradients. Not ruby, envelopes or warps. OpenType SVG done: a glyph's SVG document (its element `glyph`) is read by the SVG importer into vector art in font units, `currentColor` in the run's fill (and the legacy `context-fill` and `context-stroke` in its fill and stroke), the root's `viewBox` (or else its size) fitted into the em square and centred, the glyph's element drawn on its own as if used (what its parents apply doesn't reach it), and then drawn, exported and outlined as COLR glyphs are; in PDF real text such glyphs are text that draws nothing, with their art drawn where they sit. Not images, scripts or text inside glyph documents, nor CPAL palette variables. Evidence: `tests_svg_glyphs`, `svg/tests/svg_glyphs.rs`, `tests_colourfonts::create_outlines_turns_svg_glyphs_into_groups`, `tests_colourfonts::pdf_real_text_draws_svg_glyphs_as_art_and_keeps_their_characters`, `tests_bitmaps`, `tests_colr`, `render::tests_bitmapglyphs`, `render::tests_colrglyphs`, `pdf::tests::colour_bitmap_glyphs_export_as_images`, `pdf::tests::colr_glyphs_export_as_vector_art`, `svg/tests/colour_glyphs.rs`, `eps` `colour_bitmap_glyphs_*` and `colr_glyphs_*`, `tests_pathops::create_outlines_turns_bitmap_glyphs_into_images`, `tests_pathops::create_outlines_turns_colr_glyphs_into_layer_groups` | | Every-line and Single-line composers | done | tuned by eye, never compared side by side | | Optical Margin Alignment | done | Type › Optical Margin Alignment, `type.opticalMarginAlignment`: per type object, with one setting for all the frames of a thread, in point and area type of left-to-right paragraphs, and at the brackets of type on a path whose first paragraph runs left to right; right-to-left paragraphs of point and area type, and type on a path whose first paragraph runs right to left, do not hang | | System Layout | done | Character panel menu › System Layout, `type.systemLayout`: the whole document, off by default and for documents from before it, saved only when on. On, every text object advances by whole points (at least one when a character takes space). New point, area and path type, threaded frames, placed text, graph and dimension labels and pasted plain text inherit it. Evidence: `tests::system_layout_rounds_positive_advances_to_whole_points`, `typecmd::area_tests::system_layout_sets_the_document_and_every_text_object`, `panels::character::tests::system_layout_menu_item_toggles_the_document` | @@ -70,6 +70,7 @@ Character Styles menu New/Redefine and footer New capture the first selected cha | Date | Change | Summary | |---|---|---| +| 2026-10-23 | minor | Colour fonts: OpenType SVG glyphs follow the `SVG ` table's coordinate rules: a root size without a `viewBox` sets up the em, a `viewBox` that isn't square is centred in it, and a glyph element nested in others is drawn on its own; scores unchanged | | 2026-10-23 | minor | Colour fonts: OpenType SVG colour fonts draw and export as vector art: each glyph's SVG document read by the SVG importer, in the run's colours where it uses `currentColor` (or the legacy `context-fill`/`context-stroke`); drawn, exported and outlined as COLR glyphs are; scores unchanged | | 2026-10-23 | minor | Hanging punctuation: Glyph Scaling of justified lines no longer widens the marks that hang (they hang exactly their published share), and Standard burasagari counts what hangs at a line's start before deciding a comma overflows; paragraph-level tests with both composers (`crates/text/src/tests_hang.rs`); scores unchanged | | 2026-10-20 | minor | Auto Leading outside the layout: the Character panel's Leading, the Alt+↑/↓ leading nudge and Fill with Placeholder Text follow the paragraph's Auto Leading; scores unchanged |