From 27ab77377b02f23f104ab2826b257f302d12818b Mon Sep 17 00:00:00 2001
From: Taha-Mahmoodi <85902429+Taha-Mahmoodi@users.noreply.github.com>
Date: Wed, 4 Nov 2026 00:27:13 +0430
Subject: [PATCH 01/86] Fix #3626: an imported document takes the title the
file carries
An SVG's root
and a PDF's XMP dc:title (else Info Title) title the imported document; a file that gives none, or only the Untitled-N name, takes its file name without the extension. Native files and documents restored from editing data keep their own title.
---
crates/engine/src/cmd/fileinfo.rs | 12 +++
crates/engine/src/cmd/fileio.rs | 1 +
crates/engine/src/cmd/fileio/formats.rs | 7 +-
crates/engine/src/cmd/fileio/load.rs | 22 ++++--
crates/engine/src/cmd/fileio/tests.rs | 3 +-
.../engine/src/cmd/fileio/tests_dxfimport.rs | 6 +-
.../engine/src/cmd/fileio/tests_epsimport.rs | 2 +-
crates/engine/src/cmd/fileio/tests_svg.rs | 2 +-
crates/engine/src/tests/fileinfo.rs | 73 ++++++++++++++++++-
crates/pdf/src/file_info.rs | 4 +
crates/pdf/src/tests/file_info.rs | 16 +++-
crates/svg/src/import.rs | 7 +-
crates/svg/src/import/layers.rs | 10 +++
docs/file-format-parity.md | 3 +-
docs/gaps.md | 3 +-
docs/mcp.md | 17 +++--
docs/target-app-parity.md | 5 +-
17 files changed, 167 insertions(+), 26 deletions(-)
diff --git a/crates/engine/src/cmd/fileinfo.rs b/crates/engine/src/cmd/fileinfo.rs
index 36ccd7cdb0..3a37098b48 100644
--- a/crates/engine/src/cmd/fileinfo.rs
+++ b/crates/engine/src/cmd/fileinfo.rs
@@ -141,6 +141,18 @@ fn text(k: &str, v: &Value, multiline: bool) -> Result {
Ok(s.to_string())
}
+/// The title `raw`, read from a file, makes a document: runs of white space (line breaks too)
+/// become single spaces, other control characters are dropped, at most [`MAX_TEXT`] characters
+/// are kept, as `file.info` takes a title. `None` when nothing is left, or when it is the
+/// Untitled-N name New gives (what a document that never had a title writes to its files).
+pub(crate) fn imported_title(raw: &str) -> Option {
+ let words: Vec =
+ raw.split_whitespace().map(|w| w.chars().filter(|c| !c.is_control()).collect::()).filter(|w| !w.is_empty()).collect();
+ let title: String = words.join(" ").chars().take(MAX_TEXT).collect();
+ let title = title.trim_end();
+ super::fileio::is_own_title(title).then(|| title.to_string())
+}
+
/// Keywords from a list or a comma- or semicolon-separated string: trimmed, each once (ignoring
/// case), empty ones dropped.
fn keywords(v: &Value) -> Result> {
diff --git a/crates/engine/src/cmd/fileio.rs b/crates/engine/src/cmd/fileio.rs
index 76f408d221..cbc8506fca 100644
--- a/crates/engine/src/cmd/fileio.rs
+++ b/crates/engine/src/cmd/fileio.rs
@@ -125,6 +125,7 @@ pub use commands::specs;
pub use export_names::artboard_file_names;
pub(crate) use export_names::{default_name, merge, unique_file_names, with_black_output, write_or_return};
use export_names::{distinct_names_by, write_encoded};
+pub(crate) use formats::is_own_title;
use formats::{ARTBOARD, ARTBOARDS, RANGE, USE_ARTBOARDS, import_name, writable};
pub use formats::{
DOC_EXTS, DOCX_EXTS, FORMATS, Format, FormatOption, OPEN_EXTS, PLACE_EXTS, RTF_EXTS, TEXT_EXTS, UNASSOCIATED_EXTS, export_extensions, extension,
diff --git a/crates/engine/src/cmd/fileio/formats.rs b/crates/engine/src/cmd/fileio/formats.rs
index 09e8de8294..30778f6a6e 100644
--- a/crates/engine/src/cmd/fileio/formats.rs
+++ b/crates/engine/src/cmd/fileio/formats.rs
@@ -778,7 +778,12 @@ pub(crate) fn import_name(base: &str, taken: impl Fn(&str) -> bool) -> String {
/// (the title PDF, PNG and SVG metadata carry). A title of its own stays, and native files keep
/// the stored title.
pub fn has_own_title(doc: &vectorcraft_doc::Document) -> bool {
- !doc.title.trim().is_empty() && !doc.title.strip_prefix("Untitled-").is_some_and(|n| !n.is_empty() && n.bytes().all(|b| b.is_ascii_digit()))
+ is_own_title(&doc.title)
+}
+
+/// Is `title` one a person or a file gave, not blank and not the Untitled-N name New gives?
+pub(crate) fn is_own_title(title: &str) -> bool {
+ !title.trim().is_empty() && !title.strip_prefix("Untitled-").is_some_and(|n| !n.is_empty() && n.bytes().all(|b| b.is_ascii_digit()))
}
pub fn titled_for_export(doc: &vectorcraft_doc::Document, path: Option<&str>, format: &str) -> Option {
diff --git a/crates/engine/src/cmd/fileio/load.rs b/crates/engine/src/cmd/fileio/load.rs
index a318616d54..8a646a8e91 100644
--- a/crates/engine/src/cmd/fileio/load.rs
+++ b/crates/engine/src/cmd/fileio/load.rs
@@ -7,9 +7,10 @@ use vectorcraft_doc::{Document, ImageBlob, ImageObject, Node, NodeKind};
use vectorcraft_geom::Affine;
use vectorcraft_render::effects::pixel::LoadedTexture;
+use super::super::fileinfo::imported_title;
use super::super::{Result, Session, bad, bool_or, clipboard, parse_range, str_param};
use super::pdfimport::LoadOptions;
-use super::{Format, SAVE_FORMATS, absolute_path, file_stamp, format, psdread, read_file, readable_format_for_name};
+use super::{Format, SAVE_FORMATS, absolute_path, file_stamp, file_stem, format, psdread, read_file, readable_format_for_name};
use crate::cmd::links::Resolved;
use crate::{EngineError, Prefs};
@@ -392,10 +393,12 @@ pub fn load_with(name: &str, bytes: &[u8], opts: &LoadOptions) -> Result
};
// A PDF, `.ai` or `.ait` read from its pages or its editing copy, not restored from the document
// it carries, opens with the File Info it gives (a PostScript `.ai` gives none).
+ let mut pdf_title = None;
if matches!(format.id, "pdf" | "ai" | "ait")
&& !restored
&& let Some(info) = vectorcraft_pdf::file_info(bytes, opts.password.as_deref())
{
+ pdf_title = info.title.clone();
super::super::fileinfo::read_pdf(&mut doc.metadata, info);
}
// Threaded type read from a file flows through its frames (the threaded type of a restored
@@ -412,11 +415,18 @@ pub fn load_with(name: &str, bytes: &[u8], opts: &LoadOptions) -> Result
if let Some(mode) = opts.color_mode.filter(|m| *m != doc.color_mode) {
super::super::colormgmt::set_color_mode(&mut doc, mode, true, None, opts.grays);
}
- // Imports are named after the file; a native document keeps its own title (the tab shows the
- // file name once it has a path).
- if !matches!(format.id, "vectorcraft" | "template") || doc.title.is_empty() {
- doc.title = title;
- }
+ // The document is titled as the file titles it: a native document keeps its own title, one
+ // restored from the editing data a file carries and an SVG keep the title they hold (the SVG's
+ // ``), a PDF, `.ai` or `.ait` takes its XMP `dc:title` or Info `Title`. Without one the
+ // document takes the file name without its extension, as an export does (#3626). The tab shows
+ // the file name once the document has a path.
+ let own = match format.id {
+ "vectorcraft" | "template" => Some(doc.title.clone()).filter(|t| !t.is_empty()),
+ _ if restored || format.id == "svg" => imported_title(&doc.title),
+ "pdf" | "ai" | "ait" => pdf_title.as_deref().and_then(imported_title),
+ _ => None,
+ };
+ doc.title = own.unwrap_or_else(|| file_stem(&title));
warnings.extend(doc.images.values().filter_map(|b| size_note(&b.bytes)));
Ok(Loaded { doc, format, warnings, restored, converted, preview_only, editing_data })
}
diff --git a/crates/engine/src/cmd/fileio/tests.rs b/crates/engine/src/cmd/fileio/tests.rs
index 69158c04b8..d3714e82e8 100644
--- a/crates/engine/src/cmd/fileio/tests.rs
+++ b/crates/engine/src/cmd/fileio/tests.rs
@@ -67,7 +67,8 @@ fn opens_every_readable_format() {
] {
let r = open(&mut s, name, &bytes);
assert_eq!(r["format"], format, "{name}");
- assert_eq!(r["title"], if format == "vectorcraft" { "Untitled-1" } else { name }, "{name}");
+ // The document that wrote the file never had a title of its own, so the file gives none and the name (without its extension) titles it.
+ assert_eq!(r["title"], if format == "vectorcraft" { "Untitled-1" } else { "a" }, "{name}");
assert!(s.doc().unwrap().doc.node_count() >= 2, "{name}: the rectangle came through");
}
for (name, kind) in [
diff --git a/crates/engine/src/cmd/fileio/tests_dxfimport.rs b/crates/engine/src/cmd/fileio/tests_dxfimport.rs
index ec7b295c23..c7de9d9e16 100644
--- a/crates/engine/src/cmd/fileio/tests_dxfimport.rs
+++ b/crates/engine/src/cmd/fileio/tests_dxfimport.rs
@@ -120,7 +120,11 @@ const MM: f64 = 72.0 / 25.4;
fn document_open_reads_a_dxf_drawing() {
let mut s = Session::new();
let r = open(&mut s, &plan(), json!({})).unwrap();
- assert_eq!((r["format"].as_str(), r["title"].as_str()), (Some("dxf"), Some("plan.dxf")));
+ assert_eq!(
+ (r["format"].as_str(), r["title"].as_str()),
+ (Some("dxf"), Some("plan")),
+ "a file that carries no title opens as its name without the extension"
+ );
assert_eq!(layer_names(&s), ["Walls", "Notes"]);
let st = s.doc().unwrap();
// Millimetres at 1:1, the artboard the art's size.
diff --git a/crates/engine/src/cmd/fileio/tests_epsimport.rs b/crates/engine/src/cmd/fileio/tests_epsimport.rs
index 8c4141902c..0e9b8e805f 100644
--- a/crates/engine/src/cmd/fileio/tests_epsimport.rs
+++ b/crates/engine/src/cmd/fileio/tests_epsimport.rs
@@ -64,7 +64,7 @@ fn other_eps_files_are_read_by_the_postscript_interpreter() {
// The page is the bounding box: the art's bounds.
assert_eq!(d.artboards[0].rect.size(), vectorcraft_geom::Size::new(101.0, 51.0), "the stroke's half width rounds up to whole points");
assert_eq!(objects(&s), 1);
- assert_eq!(s.doc().unwrap().title(), "plain.eps");
+ assert_eq!(s.doc().unwrap().title(), "plain");
}
/// A CMYK image the PostScript interpreter reads keeps its ink amounts when the EPS opens and when
diff --git a/crates/engine/src/cmd/fileio/tests_svg.rs b/crates/engine/src/cmd/fileio/tests_svg.rs
index 73ee604a98..ff3a73432c 100644
--- a/crates/engine/src/cmd/fileio/tests_svg.rs
+++ b/crates/engine/src/cmd/fileio/tests_svg.rs
@@ -138,7 +138,7 @@ fn preserve_editing_reopens_the_native_document() {
let doc = &s.doc().unwrap().doc;
assert_eq!(doc.artboards.len(), 2, "both artboards came back");
assert_eq!(doc.artboards[1].name, "Back");
- assert_eq!(doc.title, "both.svg");
+ assert_eq!(doc.title, "both");
// Without it the SVG is imported (one artboard).
let text = svg(&mut s, json!({}));
s.execute("document.open", &json!({"name": "plain.svg", "dataBase64": vectorcraft_format::base64_encode(text.as_bytes())})).unwrap();
diff --git a/crates/engine/src/tests/fileinfo.rs b/crates/engine/src/tests/fileinfo.rs
index c37cbfe90d..905cf910f3 100644
--- a/crates/engine/src/tests/fileinfo.rs
+++ b/crates/engine/src/tests/fileinfo.rs
@@ -161,7 +161,7 @@ fn an_exported_pdf_opens_with_its_file_info() {
assert_eq!(back["description"], "A poster\nfor the fair");
assert_eq!(back["keywords"], json!(["fair", "Poster", "print"]));
assert!(back["created"].as_str().is_some_and(|c| c.ends_with('Z')), "the file's creation date: {}", back["created"]);
- assert_eq!(back["title"], "poster.pdf", "the title stays the file name");
+ assert_eq!(back["title"], "Poster", "the title is the one the PDF carries, not its file name");
assert_eq!(back["copyrightStatus"], "copyrighted");
assert_eq!(back["copyrightNotice"], "(c) 2026 Ada");
assert_eq!(back["copyrightUrl"], "https://example.com/rights");
@@ -277,6 +277,77 @@ fn a_pdf_compatible_ai_opens_with_the_file_info_of_its_xmp_packet() {
assert_eq!(m.copyright_url, "");
}
+/// A one-page PDF whose trailer names an information dictionary with the entries `info`, and whose
+/// catalog has a metadata stream holding an XMP `dc:title` of `xmp_title`.
+fn pdf_titled(info: Option<&str>, xmp_title: Option<&str>) -> Vec {
+ use vectorcraft_testkit::pdf::{PdfPage, first_extra, pdf_with_catalog};
+ let first = first_extra(1);
+ let mut objects = vec![];
+ let mut catalog = String::new();
+ if let Some(t) = xmp_title {
+ let xmp = format!(
+ "\
+ \
+ Zugangswege {t} \
+ "
+ );
+ objects.push(format!("<< /Type /Metadata /Subtype /XML /Length {} >>\nstream\n{xmp}\nendstream", xmp.len()));
+ catalog = format!("/Metadata {first} 0 R ");
+ }
+ let info_at = first + objects.len();
+ if let Some(i) = info {
+ objects.push(format!("<< {i} >>"));
+ }
+ let refs: Vec<&str> = objects.iter().map(String::as_str).collect();
+ let bytes = pdf_with_catalog(&[PdfPage::new(100.0, 100.0, "0 0 1 rg 10 10 50 50 re f")], &refs, &catalog, None);
+ if info.is_none() {
+ return bytes;
+ }
+ // The trailer follows the cross-reference table, so a longer trailer moves no object.
+ String::from_utf8(bytes).unwrap().replacen("/Root 1 0 R", &format!("/Root 1 0 R /Info {info_at} 0 R"), 1).into_bytes()
+}
+
+/// The title `document.open` gives a file called `name` with content `bytes`.
+fn opened_title(name: &str, bytes: &[u8]) -> String {
+ let mut s = Session::new();
+ let r = s.execute("document.open", &json!({"name": name, "dataBase64": vectorcraft_format::base64_encode(bytes)})).unwrap();
+ assert_eq!(r["fileInfoTitle"], s.doc().unwrap().doc.title.as_str());
+ s.doc().unwrap().doc.title.clone()
+}
+
+#[test]
+fn import_uses_the_files_own_title() {
+ // SVG: the root's ``, its white space made single spaces; not a shape's tooltip.
+ let svg = |inner: &str| {
+ format!(
+ "{inner}Tooltip "
+ )
+ };
+ assert_eq!(opened_title("box.svg", svg("\n Access\n paths ").as_bytes()), "Access paths");
+ assert_eq!(
+ opened_title("box.svg", svg("Zugangswege Rechenzentrum – Prüfdiagramm ").as_bytes()),
+ "Zugangswege Rechenzentrum – Prüfdiagramm"
+ );
+ // PDF: the XMP `dc:title` (its `x-default` item) beats the Info `Title`; the Info `Title` stands alone.
+ assert_eq!(opened_title("plan.pdf", &pdf_titled(Some("/Title (Info title)"), Some("XMP title"))), "XMP title");
+ assert_eq!(opened_title("plan.pdf", &pdf_titled(None, Some("XMP title"))), "XMP title");
+ assert_eq!(opened_title("plan.pdf", &pdf_titled(Some("/Title (Info title)"), None)), "Info title");
+ assert_eq!(opened_title("plan.ai", &pdf_titled(Some("/Title (Info title)"), None)), "Info title");
+ // A file that gives none (or a blank one): its name without the extension, in a folder too.
+ assert_eq!(opened_title("box.svg", svg("").as_bytes()), "box");
+ assert_eq!(opened_title("/work/box.svg", svg(" ").as_bytes()), "box");
+ assert_eq!(opened_title("plan.pdf", &pdf_titled(None, None)), "plan");
+ assert_eq!(opened_title("plan.pdf", &pdf_titled(Some("/Author (Ada)"), None)), "plan");
+ // The Untitled-N name New gives is no title (a document that never had one writes it).
+ assert_eq!(opened_title("plan.pdf", &pdf_titled(Some("/Title (Untitled-2)"), None)), "plan");
+ // A native file keeps the title it has.
+ let mut s = session();
+ let native = vectorcraft_format::base64_encode(&b64(&s.execute("document.export", &json!({"format": "vectorcraft"})).unwrap()));
+ let mut t = Session::new();
+ t.execute("document.open", &json!({"name": "other.vectorcraft", "dataBase64": native})).unwrap();
+ assert_eq!(t.doc().unwrap().doc.title, "Poster");
+}
+
#[test]
fn png_exports_carry_text_chunks() {
let mut s = session();
diff --git a/crates/pdf/src/file_info.rs b/crates/pdf/src/file_info.rs
index 983a7dcea7..1cb0a45273 100644
--- a/crates/pdf/src/file_info.rs
+++ b/crates/pdf/src/file_info.rs
@@ -24,6 +24,8 @@ const PDF: &str = "http://ns.adobe.com/pdf/1.3/"; // brand-ok: the XMP namespace
/// The File Info fields a PDF gives, each `None` when the file gives none (or only blank text).
#[derive(Clone, Debug, Default, PartialEq, Eq)]
pub struct FileInfo {
+ /// XMP `dc:title` (its `x-default` item, else its first), else Info `Title`.
+ pub title: Option,
/// XMP `dc:creator` (its names joined with ", "), else Info `Author`.
pub author: Option,
/// XMP `dc:description` (its `x-default` item, else its first), else Info `Subject`.
@@ -53,6 +55,7 @@ pub(crate) fn file_info_in(pdf: &Pdf) -> FileInfo {
let mut f = packet(pdf).and_then(|p| xmp(&p)).unwrap_or_default();
let info = pdf.metadata();
let text = |b: &Option>| b.as_deref().map(text_string).map(|s| s.trim().to_string()).filter(|s| !s.is_empty());
+ f.title = f.title.or_else(|| text(&info.title));
f.author = f.author.or_else(|| text(&info.author));
f.description = f.description.or_else(|| text(&info.subject));
f.keywords = f.keywords.or_else(|| text(&info.keywords).map(|k| split(&k)).filter(|k| !k.is_empty()));
@@ -90,6 +93,7 @@ fn xmp(packet: &[u8]) -> Option {
let creators = get(DC, "creator");
let subjects = get(DC, "subject");
Some(FileInfo {
+ title: first(DC, "title"),
author: (!creators.is_empty()).then(|| creators.join(", ")),
description: first(DC, "description"),
keywords: if subjects.is_empty() { first(PDF, "Keywords").map(|k| split(&k)).filter(|k| !k.is_empty()) } else { Some(subjects) },
diff --git a/crates/pdf/src/tests/file_info.rs b/crates/pdf/src/tests/file_info.rs
index 7ce2f42f47..f7c5d694b6 100644
--- a/crates/pdf/src/tests/file_info.rs
+++ b/crates/pdf/src/tests/file_info.rs
@@ -45,7 +45,7 @@ fn description(attrs: &str, body: &str) -> String {
}
const INFO: &str = "/Author (Grace Hopper) /Subject /Keywords (navy; compilers, , cobol) \
- /CreationDate (D:20261005140300+02'00') /ModDate (D:20261006090000Z) /Title (Ignored title)";
+ /CreationDate (D:20261005140300+02'00') /ModDate (D:20261006090000Z) /Title (Compiler manual)";
/// Unix seconds of 2026-10-05 12:03:00 UTC (14:03 at +02:00).
const CREATED: i64 = 1_791_201_780;
@@ -58,6 +58,7 @@ fn the_information_dictionary_gives_file_info() {
assert_eq!(
f,
FileInfo {
+ title: Some("Compiler manual".into()),
author: Some("Grace Hopper".into()),
description: Some("Cata log".into()),
keywords: Some(vec!["navy".into(), "compilers".into(), "cobol".into()]),
@@ -79,7 +80,8 @@ fn the_information_dictionary_gives_file_info() {
fn the_xmp_packet_gives_file_info() {
let body = description(
"xmpRights:Marked=\"False\" xmp:ModifyDate=\"2026-10-06T09:00:00.25Z\"",
- "Ada Lovelace Charles Babbage \
+ "Plakat Poster \
+ Ada Lovelace Charles Babbage \
Ein Plakat A poster\nfor the fair \
fair print \
(c) 2026 Ada \
@@ -90,6 +92,7 @@ fn the_xmp_packet_gives_file_info() {
assert_eq!(
f,
FileInfo {
+ title: Some("Poster".into()),
author: Some("Ada Lovelace, Charles Babbage".into()),
description: Some("A poster\nfor the fair".into()),
keywords: Some(vec!["fair".into(), "print".into()]),
@@ -138,6 +141,13 @@ fn the_xmp_packet_wins_where_both_give_a_field() {
assert_eq!(f.created, Some(1_791_331_200 + 5 * 3600 + 30 * 60));
assert_eq!(f.description.as_deref(), Some("Cata log"));
assert_eq!(f.modified, Some(MODIFIED));
+ // The title is the packet's where it gives one (a blank `x-default` item leaves the next one),
+ // else the dictionary's.
+ assert_eq!(f.title.as_deref(), Some("Compiler manual"));
+ let body =
+ description("", " Manuel ");
+ let f = file_info(&pdf(Some(INFO), Some(&packet(&body))), None).unwrap();
+ assert_eq!(f.title.as_deref(), Some("Manuel"));
}
#[test]
@@ -197,11 +207,13 @@ fn an_xmp_packet_nested_too_deep_leaves_the_information_dictionary() {
#[test]
fn a_written_pdf_gives_its_file_info_back() {
let mut d = Document::new(100.0, 100.0);
+ d.title = "Fair poster".into();
d.metadata.author = "Ada Lovelace".into();
d.metadata.description = "A poster\nfor the fair".into();
d.metadata.keywords = vec!["fair".into(), "print".into()];
let bytes = export(&d, &PdfOptions { created: Some(CREATED), ..PdfOptions::uncompressed() }).unwrap();
let f = file_info(&bytes, None).unwrap();
+ assert_eq!(f.title.as_deref(), Some("Fair poster"));
assert_eq!(f.author.as_deref(), Some("Ada Lovelace"));
assert_eq!(f.description.as_deref(), Some("A poster\nfor the fair"));
assert_eq!(f.keywords, Some(vec!["fair".to_string(), "print".into()]));
diff --git a/crates/svg/src/import.rs b/crates/svg/src/import.rs
index 11795b83fa..aa625de695 100644
--- a/crates/svg/src/import.rs
+++ b/crates/svg/src/import.rs
@@ -38,7 +38,7 @@ pub(super) use importer::PT_PER_IN;
use importer::SymbolMade;
pub(super) use layers::DEFAULT_FONT_SIZE;
pub(crate) use layers::SUBLAYER;
-use layers::{RootUnits, inkscape_pages, promote_sublayers, root_layer_name};
+use layers::{RootUnits, inkscape_pages, promote_sublayers, root_layer_name, root_title};
pub(super) use prepass::Edits;
pub use prepass::{MAX_FOREIGN, MAX_FOREIGN_VALUE};
use prepass::{labels, prepass};
@@ -142,6 +142,11 @@ pub(crate) fn import(svg: &str, opts: &ImportOptions, max_nodes: usize) -> Resul
let k = units.k;
let mut doc = Document::new(size.width() as f64 * k, size.height() as f64 * k);
doc.units = units.unit;
+ // The file's own title, when it gives one; else the document keeps the placeholder name
+ // `Document::new` gave it, for the caller to name after the file.
+ if let Some(title) = root_title(xml.root_element()) {
+ doc.title = title;
+ }
let mut im = Importer {
doc,
warnings,
diff --git a/crates/svg/src/import/layers.rs b/crates/svg/src/import/layers.rs
index 04e3887945..6115d2e445 100644
--- a/crates/svg/src/import/layers.rs
+++ b/crates/svg/src/import/layers.rs
@@ -16,6 +16,16 @@ pub(super) fn root_layer_name(root: XNode) -> Option {
root.attribute("data-name").or(id).map(str::trim).filter(|n| !n.is_empty()).map(str::to_string)
}
+/// The title the file gives root element `root`: the text of its first `` child (the one
+/// SVG 1.1 §5.4 and SVG 2 §5.4 give a document or element; a `` inside a shape or group is
+/// that element's tooltip, not the document's), its runs of white space made single spaces. `None`
+/// without one, or when it is blank.
+pub(super) fn root_title(root: XNode) -> Option {
+ let title = root.children().find(|c| c.is_element() && c.tag_name().name() == "title" && c.tag_name().namespace() == Some(super::SVG_NS))?;
+ let text: Vec<&str> = title.descendants().filter_map(|n| n.text().filter(|_| n.is_text())).flat_map(str::split_whitespace).collect();
+ (!text.is_empty()).then(|| text.join(" "))
+}
+
/// Is element `g` a `` marked as a layer ([`SUBLAYER`])? Only one with an id counts: the id (or
/// the label beside it) is its name.
pub(super) fn marked_layer(g: XNode) -> bool {
diff --git a/docs/file-format-parity.md b/docs/file-format-parity.md
index 6f02703ca5..ba1d1738e4 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-01 · **Change:** minor (native format: dynamic symbols, #4938) · **Target:** Adobe Illustrator 2026 (30.x)
+> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-03 · **Change:** minor (an imported document takes the title its file carries; previously: native format: dynamic symbols, #4938) · **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
@@ -161,6 +161,7 @@ the app's own editing copy of a file (an EPS after its `%%EOF`, a `.ai`'s `AIPri
| Date | Change | Summary |
|---|---|---|
+| 2026-11-03 | minor | An imported document takes the title its file carries: an SVG's root ``, a PDF's, `.ai`'s or `.ait`'s XMP `dc:title` else its Info `Title`; a file that gives none opens with its file name without the extension (#3626). |
| 2026-11-01 | minor | Native format: a symbol's Symbol Type (`dynamic`) and a dynamic symbol instance's appearance changes to objects of its art (`symbol_overrides`) round-trip; both are left out when unset, so other documents save unchanged (#4938). `.ai` reading and writing of dynamic symbols remains. |
| 2026-11-01 | minor | Exports draw each artboard's background color behind its art (#4937): PNG, JPEG, GIF, TIFF, WebP, BMP, Targa and PSD (a layer of its own; on a transparent background too), PDF, SVG and EPS pages, a `.ai` file's PDF pages and PostScript and PDF print; `includeArtboardFill: false` leaves it out. Files with editing data (Preserve Editing, `.ai`, EPS, native) keep it as the artboard's color and reopen without extra art. Not read from the reference app's own `.ai` data, nor written to it. DXF, EMF and WMF leave it out |
| 2026-10-31 | minor | PDF import finds an installed font whose PDF name holds hyphens (`Noto-Sans-Bold` is Noto Sans, Bold: the longest part before a hyphen that names an installed family) and a composite font by its CIDFont's name rather than its base name, which ends in the CMap's (`Name-Identity-H`, [ISO 32000-1 §9.7.6.1](https://opensource.adobe.com/dc-acrobat-sdk-docs/pdfstandards/PDF32000_2008.pdf)); a missing font is split only before a style part (`Helvetica-BoldOblique`), so it is reported as `MS PGothic`, not `MS`; scores unchanged (estimated) |
diff --git a/docs/gaps.md b/docs/gaps.md
index 8b707fd9f6..016703c688 100644
--- a/docs/gaps.md
+++ b/docs/gaps.md
@@ -1,6 +1,6 @@
# Gaps
-> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-03 · **Change:** minor (new gradients follow document color mode; scores and estimates unchanged) · **Target:** Adobe Illustrator 2026 (30.x)
+> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-03 · **Change:** minor (an imported document takes the title its file carries; previously: new gradients follow document color mode; scores and estimates unchanged) · **Target:** Adobe Illustrator 2026 (30.x)
## Feature submission evidence
@@ -732,6 +732,7 @@ trap, and the defaults (0.25 pt, 100%, 40%) are VectorCraft's own, the pages giv
| Date | Change | Summary |
|---|---|---|
+| 2026-11-03 | minor | An imported document takes the title its file carries (an SVG's root ``, a PDF's XMP `dc:title` else its Info `Title`), else its file name without the extension, instead of the file name with it (#3626); no scored gap or estimate reassessed. |
| 2026-11-02 | minor | G15: the renderer keeps a compound path's assembled path between frames and drops unused geometry entries after a few frames however few there are. Estimate unchanged. |
| 2026-11-02 | minor | G15: hit testing culls paths and compound paths by their control points' box and tests a compound path's outline and fill against only its members near the click; the same objects are hit. Estimate unchanged. |
| 2026-11-03 | minor | Symbol editing shows draggable 9-slice guides within the artwork (#5280). Guides outside the artwork remain unsupported. The G5 estimate remains 14–24 h. |
diff --git a/docs/mcp.md b/docs/mcp.md
index 0ca4e32353..4911590619 100644
--- a/docs/mcp.md
+++ b/docs/mcp.md
@@ -3908,13 +3908,16 @@ They also write the `copyrightNotice` as `dc:rights`, the `copyrightUrl` as `xmp
`copyrightStatus` as `xmpRights:Marked` (`True` for copyrighted, `False` for public domain, none for unknown) to the
XMP packet.
A PDF, `.ai` or `.ait` opened from its pages or its editing copy (`document.open`,
-`file.newFromTemplate`, `file.revert`; not one restored from the document it carries) opens with the `author`,
-`description`, `keywords`, `copyrightStatus`, `copyrightNotice`, `copyrightUrl`, `created` and `modified` that its XMP
-packet (`dc:creator`, `dc:description`, `dc:subject` or `pdf:Keywords`, `xmpRights:Marked`, `dc:rights`,
-`xmpRights:WebStatement`, `xmp:CreateDate`, `xmp:ModifyDate`) and its document information dictionary (`Author`,
-`Subject`, `Keywords`, `CreationDate`, `ModDate`) give, the XMP packet's value where both give one. A value that
-`file.info` would refuse is left out, and the title is set as for any other open (the file name, or a new untitled
-name for a template).
+`file.newFromTemplate`, `file.revert`; not one restored from the document it carries) opens with the `title`,
+`author`, `description`, `keywords`, `copyrightStatus`, `copyrightNotice`, `copyrightUrl`, `created` and `modified`
+that its XMP packet (`dc:title`, `dc:creator`, `dc:description`, `dc:subject` or `pdf:Keywords`, `xmpRights:Marked`,
+`dc:rights`, `xmpRights:WebStatement`, `xmp:CreateDate`, `xmp:ModifyDate`) and its document information dictionary
+(`Title`, `Author`, `Subject`, `Keywords`, `CreationDate`, `ModDate`) give, the XMP packet's value where both give
+one (for `dc:title` and `dc:description`, the `x-default` language item first). An SVG opens with the `title` of its
+root `` element (not a shape's or group's). A title is read with its white space made single spaces and
+without control characters, at most 65,536 characters, as `file.info` takes one. A file that gives no title, a blank
+one or the `Untitled-N` name New gives, opens with its file name without the extension (`box.svg` opens as `box`), and a native file keeps its own
+title. A value that `file.info` would refuse is left out, and a template opens with a new untitled name.
`document.inspect` (`inspect_document`) and the `document.open` (`open_file`) reply report the File Info title as
`fileInfoTitle`. Their `title` is the document's tab name, which comes from the file name once the document is saved
to or opened from a file (with ` [Converted]` or ` [Recovered]` where that applies).
diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md
index b4370a3a0c..b544fdf895 100644
--- a/docs/target-app-parity.md
+++ b/docs/target-app-parity.md
@@ -1,6 +1,6 @@
# Parity with Adobe Illustrator
-> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-03 · **Change:** minor (new gradients follow document color mode; scores and estimates unchanged) · **Target:** Adobe Illustrator 2026 (30.x; public release notes through 30.7, August 2026; Units › East Asian Type acts, with Q and Ha units; scores unchanged) · **Target:** Adobe Illustrator 2026 (30.x; public release notes through 30.7, August 2026)
+> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-03 · **Change:** minor (an imported document takes the title its file carries; previously: new gradients follow document color mode; scores and estimates unchanged) · **Target:** Adobe Illustrator 2026 (30.x; public release notes through 30.7, August 2026; Units › East Asian Type acts, with Q and Ha units; scores unchanged) · **Target:** Adobe Illustrator 2026 (30.x; public release notes through 30.7, August 2026)
## Feature submission evidence
@@ -1172,7 +1172,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur
- File › Place with Bounding Box (`file.place {crop: "bounding"}`) measures the art that shows, with its strokes and effects, as an export does: hidden objects, guides and template layers do not count, a group whose only part on the page or artboard is hidden is not placed, and symbol instances are measured by their art, for `at`, `rect`, the place cursor and `file.place.info` (`crates/engine/src/tests/place.rs`).
- Separations Preview shows placed and embedded raster images on the visible plates, as it shows vector art (#4153): an RGB image's pixels are separated into process inks with the proof setup's intent, a CMYK image shows its own inks, a hidden plate's ink leaves the image, and with one plate visible the image shows that plate's ink as a grey. Raster images carry no spot ink, so a spot plate shown alone shows them as paper; a colorized image shows its color's plates. Emoji and other bitmap glyphs follow the same rule. The plate-filtered pixels and their reduced-size copies are kept apart from the image's own pixels, for each set of visible plates, proof setup and color settings, and with the preview off nothing changes. Evidence: `tests::colorize::separations_preview_shows_an_rgb_images_process_inks`, `separations_preview_shows_a_cmyk_images_own_inks`, `separations_preview_keeps_an_images_pixels_per_plate_set`. Print preview fidelity remains the open gap, so scores and hours are unchanged.
- Desktop print transport uses `craft-print` for CUPS and modern Windows. Sheet composition, copies, separations and colour management remain in VectorCraft. The Print dialog's Media list also carries the Japanese papers — JIS B4 257 × 364 mm, JIS B5 182 × 257 mm and JIS B6 128 × 182 mm beside the ISO B4/B5 (250 × 353 and 176 × 250 mm, kept so settings and presets saved before them keep their paper), and はがき 100 × 148 mm, 往復はがき 148 × 200 mm, 封筒 長形3号 120 × 235 mm, 長形4号 90 × 225 mm, 角形2号 240 × 332 mm, 角形3号 216 × 277 mm and 四六判 127 × 188 mm; the JSON id or the dialog label names a paper, any case and spacing (`B5 (JIS)` or `b5jis`) (`crates/pdf/src/print/settings.rs`, `crates/pdf/src/tests/print.rs`). The print-preview and physical-device fidelity gaps remain, so print scores and hours are unchanged.
- - A PDF, PDF-compatible `.ai` or `.ait` opened from its pages or its editing copy opens with the File Info that its XMP packet and its document information dictionary give: the author, description, keywords, copyright status, notice and URL, and the created and modified dates, the XMP packet's value where both give one; the title is not changed (`crates/pdf/src/tests/file_info.rs`, `crates/engine/src/tests/fileinfo.rs`).
+ - A PDF, PDF-compatible `.ai` or `.ait` opened from its pages or its editing copy opens with the File Info that its XMP packet and its document information dictionary give: the title (`dc:title`, its `x-default` item first, else Info `Title`), the author, description, keywords, copyright status, notice and URL, and the created and modified dates, the XMP packet's value where both give one (`crates/pdf/src/tests/file_info.rs`, `crates/engine/src/tests/fileinfo.rs`). An SVG opens with the title of its root ``; a file that gives no title opens with its file name without the extension, and a native file keeps its own title (#3626).
- PDF export and PDF-compatible `.ai` and `.ait` saves write the document's File Info created date as the file's creation date, or the time of writing when the document has none, and the time of writing as its modification date; the page information mark still prints the time of writing (`crates/engine/src/tests/fileinfo.rs`, `crates/pdf/src/tests.rs`, `crates/pdf/src/tests/marks.rs`).
- PDF export and PDF-compatible `.ai` and `.ait` saves write File Info's copyright notice, URL and status to the XMP packet (`dc:rights`, `xmpRights:WebStatement`, `xmpRights:Marked`), so a PDF or `.ai` opened from its pages keeps them (`crates/pdf/src/tests/file_info.rs`, `crates/engine/src/tests/fileinfo.rs`).
- Opening a PDF or PDF-compatible `.ai` whose XMP packet nests elements more than 256 deep no longer overflows the stack: File Info leaves the packet out and takes the document information dictionary's values (`crates/pdf/src/tests/file_info.rs`).
@@ -1565,6 +1565,7 @@ A double-click on a Repeat (Radial, Grid or Mirror) with the Selection tool ente
| Date | Change | Summary |
|---|---|---|
+| 2026-11-03 | minor | File Info on open: an imported document takes the title its file carries (an SVG's root ``, a PDF's, `.ai`'s or `.ait`'s XMP `dc:title` else its Info `Title`), else its file name without the extension, instead of the file name with it (#3626); scores and estimates unchanged. |
| 2026-11-03 | minor | Enclosing Lasso is an add-on with no default shortcut or Basic toolbar slot. Scores and estimates remain unchanged. |
| 2026-11-02 | minor | Wheel input over closed font-family fields changes selected text (#5253); public documentation and application choices distinguished; scores and estimates unchanged. |
| 2026-11-03 | minor | Find Font's replacement-source chooser offers Document, Recent and System (#5217); scores and estimates unchanged. |
From 15993912dee1c8960a0618b3314d3b0ad9c40686 Mon Sep 17 00:00:00 2001
From: Taha-Mahmoodi <85902429+Taha-Mahmoodi@users.noreply.github.com>
Date: Wed, 4 Nov 2026 03:22:46 +0430
Subject: [PATCH 02/86] Fix #3626: .svgz keeps its title and the file-name
fallback is cleaned (review)
---
crates/engine/src/cmd/fileinfo.rs | 17 +++++++++++++++--
crates/engine/src/cmd/fileio/load.rs | 6 +++---
crates/engine/src/tests/fileinfo.rs | 22 ++++++++++++++++++++++
3 files changed, 40 insertions(+), 5 deletions(-)
diff --git a/crates/engine/src/cmd/fileinfo.rs b/crates/engine/src/cmd/fileinfo.rs
index 3a37098b48..e805757b12 100644
--- a/crates/engine/src/cmd/fileinfo.rs
+++ b/crates/engine/src/cmd/fileinfo.rs
@@ -146,11 +146,24 @@ fn text(k: &str, v: &Value, multiline: bool) -> Result {
/// are kept, as `file.info` takes a title. `None` when nothing is left, or when it is the
/// Untitled-N name New gives (what a document that never had a title writes to its files).
pub(crate) fn imported_title(raw: &str) -> Option {
+ let title = cleaned_title(raw);
+ super::fileio::is_own_title(&title).then_some(title)
+}
+
+/// `raw` as `file.info` takes a title (see [`imported_title`]), empty when nothing is left. An
+/// Untitled-N name stays.
+fn cleaned_title(raw: &str) -> String {
let words: Vec =
raw.split_whitespace().map(|w| w.chars().filter(|c| !c.is_control()).collect::()).filter(|w| !w.is_empty()).collect();
let title: String = words.join(" ").chars().take(MAX_TEXT).collect();
- let title = title.trim_end();
- super::fileio::is_own_title(title).then(|| title.to_string())
+ title.trim_end().to_string()
+}
+
+/// The title a document without one of its own takes from its file name's `stem`: cleaned as an
+/// imported title is, so `file.info` takes it, and the Untitled-1 name a new document starts
+/// with when nothing is left of it.
+pub(crate) fn stem_title(stem: &str) -> String {
+ Some(cleaned_title(stem)).filter(|t| !t.is_empty()).unwrap_or_else(|| "Untitled-1".to_string())
}
/// Keywords from a list or a comma- or semicolon-separated string: trimmed, each once (ignoring
diff --git a/crates/engine/src/cmd/fileio/load.rs b/crates/engine/src/cmd/fileio/load.rs
index 8a646a8e91..ac46299de5 100644
--- a/crates/engine/src/cmd/fileio/load.rs
+++ b/crates/engine/src/cmd/fileio/load.rs
@@ -7,7 +7,7 @@ use vectorcraft_doc::{Document, ImageBlob, ImageObject, Node, NodeKind};
use vectorcraft_geom::Affine;
use vectorcraft_render::effects::pixel::LoadedTexture;
-use super::super::fileinfo::imported_title;
+use super::super::fileinfo::{imported_title, stem_title};
use super::super::{Result, Session, bad, bool_or, clipboard, parse_range, str_param};
use super::pdfimport::LoadOptions;
use super::{Format, SAVE_FORMATS, absolute_path, file_stamp, file_stem, format, psdread, read_file, readable_format_for_name};
@@ -422,11 +422,11 @@ pub fn load_with(name: &str, bytes: &[u8], opts: &LoadOptions) -> Result
// the file name once the document has a path.
let own = match format.id {
"vectorcraft" | "template" => Some(doc.title.clone()).filter(|t| !t.is_empty()),
- _ if restored || format.id == "svg" => imported_title(&doc.title),
+ _ if restored || matches!(format.id, "svg" | "svgz") => imported_title(&doc.title),
"pdf" | "ai" | "ait" => pdf_title.as_deref().and_then(imported_title),
_ => None,
};
- doc.title = own.unwrap_or_else(|| file_stem(&title));
+ doc.title = own.unwrap_or_else(|| stem_title(&file_stem(&title)));
warnings.extend(doc.images.values().filter_map(|b| size_note(&b.bytes)));
Ok(Loaded { doc, format, warnings, restored, converted, preview_only, editing_data })
}
diff --git a/crates/engine/src/tests/fileinfo.rs b/crates/engine/src/tests/fileinfo.rs
index 905cf910f3..4cb511b99c 100644
--- a/crates/engine/src/tests/fileinfo.rs
+++ b/crates/engine/src/tests/fileinfo.rs
@@ -348,6 +348,28 @@ fn import_uses_the_files_own_title() {
assert_eq!(t.doc().unwrap().doc.title, "Poster");
}
+#[test]
+fn compressed_svg_keeps_its_title() {
+ let svgz = |inner: &str| {
+ vectorcraft_svg::compress(&format!(
+ "{inner} "
+ ))
+ };
+ assert_eq!(opened_title("box.svgz", &svgz("Access paths ")), "Access paths");
+ assert_eq!(opened_title("box.svgz", &svgz("")), "box");
+}
+
+#[test]
+fn file_name_title_is_cleaned_like_an_imported_one() {
+ let svg = b" ";
+ // A name `file.info` would reject (white space only) or that keeps control characters.
+ assert_eq!(opened_title(" .svg", svg), "Untitled-1");
+ assert_eq!(opened_title("\u{1}\u{7f}.svg", svg), "Untitled-1");
+ assert_eq!(opened_title("bo\u{1}x two\t.svg", svg), "box two");
+ // The Untitled-N name stays the stem.
+ assert_eq!(opened_title("Untitled-7.svg", svg), "Untitled-7");
+}
+
#[test]
fn png_exports_carry_text_chunks() {
let mut s = session();
From 99be3dca579c9de2c237445d038326372a874ac9 Mon Sep 17 00:00:00 2001
From: Taha-Mahmoodi <85902429+Taha-Mahmoodi@users.noreply.github.com>
Date: Wed, 4 Nov 2026 10:54:36 +0430
Subject: [PATCH 03/86] Fix #3626: the imported title goes into File Info only;
the display name is unchanged
An SVG's root and a PDF's, .ai's or .ait's XMP dc:title (else Info Title) are now
DocMetadata::title, the File Info title, not Document::title. Document::title stays what it was
on main: the file's name with its extension (a native file keeps its own), so the tab, the window
title, Save As, the default export names and Save for Web are unchanged.
- Document::info_title() is metadata.title else the document's title. file.info reports and
compares it, document.inspect and the document.open reply report it as fileInfoTitle, and the
writers of a title (PDF Title and XMP dc:title, SVG and Dublin Core, PNG text, EPS, .ai
and EMF) and has_own_title (DisplayDocTitle) use it. The PDF page-information mark and the
default file names still use the document's name.
- Changing the title in File Info sets the document's title as it always did and drops the one the
file gave; the unchanged title the dialog sends back is no edit.
- metadata.title is saved in the native format only when set (older versions ignore it) and is read
back from the editing data of an SVG, PDF, .ai or EPS; a template opens untitled.
- Reverts the file-stem title, the stem_title cleaning and the svgz branch that only existed to make
Document::title a stem, and the five assertions that change had made.
---
crates/doc/src/lib.rs | 2 +-
crates/doc/src/metadata.rs | 43 ++++-
crates/engine/src/cmd/fileinfo.rs | 45 +++--
crates/engine/src/cmd/fileio/encode.rs | 2 +-
crates/engine/src/cmd/fileio/eps.rs | 2 +-
crates/engine/src/cmd/fileio/formats.rs | 4 +-
crates/engine/src/cmd/fileio/legacy_ai.rs | 2 +-
crates/engine/src/cmd/fileio/load.rs | 33 ++--
crates/engine/src/cmd/fileio/save.rs | 2 +-
crates/engine/src/cmd/fileio/tests.rs | 3 +-
.../engine/src/cmd/fileio/tests_dxfimport.rs | 6 +-
.../engine/src/cmd/fileio/tests_epsimport.rs | 2 +-
crates/engine/src/cmd/fileio/tests_svg.rs | 2 +-
crates/engine/src/cmd/webexport.rs | 2 +-
crates/engine/src/inspect.rs | 4 +-
crates/engine/src/tests/fileinfo.rs | 169 ++++++++++++------
crates/metafile/src/lib.rs | 2 +-
crates/pdf/src/export/entry.rs | 6 +-
crates/pdf/src/tests/file_info.rs | 10 ++
crates/svg/src/export.rs | 2 +-
crates/svg/src/export/output.rs | 2 +-
crates/svg/src/import.rs | 8 +-
crates/svg/tests/it/title_control_chars.rs | 26 +++
docs/file-format-parity.md | 4 +-
docs/gaps.md | 4 +-
docs/mcp.md | 13 +-
docs/target-app-parity.md | 6 +-
27 files changed, 272 insertions(+), 134 deletions(-)
diff --git a/crates/doc/src/lib.rs b/crates/doc/src/lib.rs
index b59f661c21..5033e52b47 100644
--- a/crates/doc/src/lib.rs
+++ b/crates/doc/src/lib.rs
@@ -253,7 +253,7 @@ pub struct Document {
/// they were copied from instead of the current layer.
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
pub paste_remembers_layers: bool,
- /// File → File Info (the title is [`Document::title`]).
+ /// File → File Info (its title is [`Document::info_title`]).
#[serde(default, skip_serializing_if = "skip::is_default")]
pub metadata: DocMetadata,
/// Effect → Document Raster Effects Settings besides the resolution
diff --git a/crates/doc/src/metadata.rs b/crates/doc/src/metadata.rs
index 151365c5d2..908b649e4d 100644
--- a/crates/doc/src/metadata.rs
+++ b/crates/doc/src/metadata.rs
@@ -1,11 +1,15 @@
//! File → File Info: the document's descriptive metadata (author, description, keywords, rating,
//! copyright) and its created and modified dates, which the SVG, PDF and PNG writers carry. The
-//! document title is [`crate::Document::title`]. Every field is defaulted, so documents saved
-//! before File Info existed load unchanged.
+//! document title is [`Document::title`], the name it shows in its tab and offers Save As when it
+//! has no file; a title the file it was opened from gave itself is [`DocMetadata::title`], and
+//! [`Document::info_title`] is the title File Info shows and exports write. Every field is
+//! defaulted, so documents saved before File Info existed load unchanged.
use chrono::{Datelike, Timelike};
use serde::{Deserialize, Serialize};
+use crate::Document;
+
/// File Info → Copyright Status.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
@@ -44,6 +48,13 @@ pub const MAX_RATING: u8 = 5;
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
#[serde(default, rename_all = "camelCase")]
pub struct DocMetadata {
+ /// The title the file this document was opened from gives itself (an SVG's root ``, a
+ /// PDF's XMP `dc:title` or Info `Title`), which File Info shows and exports write in place of
+ /// [`Document::title`], the document's display name. `None`: the file gave none, so the
+ /// display name is the title. Left out of a saved document when unset, so other documents
+ /// save as they did.
+ #[serde(skip_serializing_if = "Option::is_none")]
+ pub title: Option,
pub author: String,
/// The author's position or job title.
pub author_title: String,
@@ -83,6 +94,16 @@ impl DocMetadata {
}
}
+impl Document {
+ /// The title File Info shows and the files an export writes carry (PDF `Title` and XMP
+ /// `dc:title`, SVG ``, PNG `Title`): the title the file this document was opened from
+ /// gave itself ([`DocMetadata::title`]), else [`Document::title`]. The display name (the tab, the
+ /// default file name) is always [`Document::title`].
+ pub fn info_title(&self) -> &str {
+ self.metadata.title.as_deref().filter(|t| !t.trim().is_empty()).unwrap_or(&self.title)
+ }
+}
+
/// The time exported files record: PDF `CreationDate` and `ModDate`, the PNG `Creation Time`
/// entry, a zip entry's date, and a saved document's `created` and `modified`. It is the clock,
/// unless `SOURCE_DATE_EPOCH` is set, which replaces it so that the same commands produce the same
@@ -191,6 +212,24 @@ mod tests {
assert!(DocMetadata::default().png_text("").is_empty());
}
+ #[test]
+ fn the_info_title_is_the_files_own_title_else_the_display_name() {
+ let mut d = Document::new(10.0, 10.0);
+ d.title = "box.svg".into();
+ assert_eq!(d.info_title(), "box.svg");
+ d.metadata.title = Some("Access paths".into());
+ assert_eq!((d.title.as_str(), d.info_title()), ("box.svg", "Access paths"));
+ d.metadata.title = Some(" ".into());
+ assert_eq!(d.info_title(), "box.svg");
+ // Saved only when set: other documents' metadata is as before, and the field comes back.
+ d.metadata.title = None;
+ assert!(!serde_json::to_string(&d.metadata).unwrap().contains("title"));
+ d.metadata.title = Some("Access paths".into());
+ let json = serde_json::to_string(&d.metadata).unwrap();
+ assert_eq!(serde_json::from_str::(&json).unwrap().title.as_deref(), Some("Access paths"));
+ assert_eq!(serde_json::from_str::("{}").unwrap().title, None);
+ }
+
/// `SOURCE_DATE_EPOCH` replaces the clock in exported output only when it is a non-negative
/// decimal integer; anything else leaves the clock to decide, so an export is never refused.
#[test]
diff --git a/crates/engine/src/cmd/fileinfo.rs b/crates/engine/src/cmd/fileinfo.rs
index e805757b12..9a59d0f2f8 100644
--- a/crates/engine/src/cmd/fileinfo.rs
+++ b/crates/engine/src/cmd/fileinfo.rs
@@ -1,7 +1,8 @@
//! File → File Info (`file.info`): the document title and its descriptive metadata (author,
//! description, keywords, rating, copyright), with the read-only created and modified dates. The
-//! SVG (with `metadata`), PDF and PNG writers carry it, and a PDF or `.ai` opens with the File Info
-//! it gives ([`read_pdf`]).
+//! title is the one the file the document was opened from gave itself ([`DocMetadata::title`]),
+//! else the document's name. The SVG (with `metadata`), PDF and PNG writers carry it, and a PDF or
+//! `.ai` opens with the File Info it gives ([`read_pdf`]).
use serde_json::{Map, Value, json};
use vectorcraft_doc::metadata::{MAX_RATING, iso8601};
@@ -22,7 +23,7 @@ const READ_ONLY: [&str; 6] = ["created", "modified", "colorMode", "units", "artb
pub(crate) fn info_json(d: &Document) -> Value {
let m = &d.metadata;
json!({
- "title": d.title,
+ "title": d.info_title(),
"author": m.author,
"authorTitle": m.author_title,
"description": m.description,
@@ -48,14 +49,22 @@ pub(crate) fn file_info(s: &mut Session, p: &Value) -> Result {
_ => return Err(bad(C, "params must be an object")),
};
let d = &s.doc()?.doc;
- let (mut title, mut m) = (d.title.clone(), d.metadata.clone());
+ let (mut title, mut m) = (d.info_title().to_string(), d.metadata.clone());
for (k, v) in o {
apply(&mut title, &mut m, k, v)?;
}
let d = &s.doc()?.doc;
- if title != d.title || m != d.metadata {
+ // A title File Info changes becomes the document's own, as it always has (the tab and Save As
+ // offer it where the document has no file), and replaces the one its file gave.
+ let retitled = title != d.info_title();
+ if retitled {
+ m.title = None;
+ }
+ if retitled || m != d.metadata {
s.edit("File Info", |d, _| {
- d.title = title;
+ if retitled {
+ d.title = title;
+ }
d.metadata = m;
Ok(())
})?;
@@ -64,8 +73,8 @@ pub(crate) fn file_info(s: &mut Session, p: &Value) -> Result {
}
/// The File Info a PDF or PDF-compatible `.ai` gives ([`vectorcraft_pdf::file_info`]) into `m`: each
-/// field that `file.info` would take (a value it refuses is left out), and the created and
-/// modified dates.
+/// field that `file.info` would take (a value it refuses is left out), its title ([`imported_title`])
+/// as the File Info title, and the created and modified dates.
pub(crate) fn read_pdf(m: &mut DocMetadata, f: vectorcraft_pdf::FileInfo) {
let status = f.copyrighted.map(|c| if c { CopyrightStatus::Copyrighted } else { CopyrightStatus::PublicDomain });
let fields = [
@@ -83,6 +92,7 @@ pub(crate) fn read_pdf(m: &mut DocMetadata, f: vectorcraft_pdf::FileInfo) {
let _ = apply(&mut title, m, k, &v);
}
}
+ m.title = f.title.as_deref().and_then(imported_title);
m.created = f.created.or(m.created);
m.modified = f.modified.or(m.modified);
}
@@ -141,29 +151,16 @@ fn text(k: &str, v: &Value, multiline: bool) -> Result {
Ok(s.to_string())
}
-/// The title `raw`, read from a file, makes a document: runs of white space (line breaks too)
+/// The File Info title `raw`, read from a file, makes: runs of white space (line breaks too)
/// become single spaces, other control characters are dropped, at most [`MAX_TEXT`] characters
/// are kept, as `file.info` takes a title. `None` when nothing is left, or when it is the
/// Untitled-N name New gives (what a document that never had a title writes to its files).
pub(crate) fn imported_title(raw: &str) -> Option {
- let title = cleaned_title(raw);
- super::fileio::is_own_title(&title).then_some(title)
-}
-
-/// `raw` as `file.info` takes a title (see [`imported_title`]), empty when nothing is left. An
-/// Untitled-N name stays.
-fn cleaned_title(raw: &str) -> String {
let words: Vec =
raw.split_whitespace().map(|w| w.chars().filter(|c| !c.is_control()).collect::()).filter(|w| !w.is_empty()).collect();
let title: String = words.join(" ").chars().take(MAX_TEXT).collect();
- title.trim_end().to_string()
-}
-
-/// The title a document without one of its own takes from its file name's `stem`: cleaned as an
-/// imported title is, so `file.info` takes it, and the Untitled-1 name a new document starts
-/// with when nothing is left of it.
-pub(crate) fn stem_title(stem: &str) -> String {
- Some(cleaned_title(stem)).filter(|t| !t.is_empty()).unwrap_or_else(|| "Untitled-1".to_string())
+ let title = title.trim_end();
+ super::fileio::is_own_title(title).then(|| title.to_string())
}
/// Keywords from a list or a comma- or semicolon-separated string: trimmed, each once (ignoring
diff --git a/crates/engine/src/cmd/fileio/encode.rs b/crates/engine/src/cmd/fileio/encode.rs
index b125e48a88..a3da6316c5 100644
--- a/crates/engine/src/cmd/fileio/encode.rs
+++ b/crates/engine/src/cmd/fileio/encode.rs
@@ -589,7 +589,7 @@ fn encode_files(doc: &Document, f: &Format, p: &Value) -> Result {
let bytes = renderer.export_region(doc, region, format, &settings).map_err(EngineError::Other)?;
// File Info as PNG text chunks.
let bytes = if matches!(format, RasterFormat::Png | RasterFormat::Png8) {
- vectorcraft_render::encode::png::with_text(bytes, &doc.metadata.png_text(&doc.title))
+ vectorcraft_render::encode::png::with_text(bytes, &doc.metadata.png_text(doc.info_title()))
} else {
bytes
};
diff --git a/crates/engine/src/cmd/fileio/eps.rs b/crates/engine/src/cmd/fileio/eps.rs
index f90dacfc71..7a71f4b2ec 100644
--- a/crates/engine/src/cmd/fileio/eps.rs
+++ b/crates/engine/src/cmd/fileio/eps.rs
@@ -326,7 +326,7 @@ fn encode_document(doc: &Document, p: &Value, saving: bool) -> Result {
let o = EpsOptions {
region,
origin,
- title: doc.title.clone(),
+ title: doc.info_title().to_owned(),
created,
native: Some(native(&editing, if saving { None } else { board }, set.include_linked)),
..set.eps.clone()
diff --git a/crates/engine/src/cmd/fileio/formats.rs b/crates/engine/src/cmd/fileio/formats.rs
index 30778f6a6e..289f11139b 100644
--- a/crates/engine/src/cmd/fileio/formats.rs
+++ b/crates/engine/src/cmd/fileio/formats.rs
@@ -778,7 +778,7 @@ pub(crate) fn import_name(base: &str, taken: impl Fn(&str) -> bool) -> String {
/// (the title PDF, PNG and SVG metadata carry). A title of its own stays, and native files keep
/// the stored title.
pub fn has_own_title(doc: &vectorcraft_doc::Document) -> bool {
- is_own_title(&doc.title)
+ is_own_title(doc.info_title())
}
/// Is `title` one a person or a file gave, not blank and not the Untitled-N name New gives?
@@ -790,6 +790,8 @@ pub fn titled_for_export(doc: &vectorcraft_doc::Document, path: Option<&str>, fo
let path = path.filter(|_| !has_own_title(doc) && !matches!(format, "vectorcraft" | "template" | "ai"))?;
let mut d = doc.clone();
d.title = file_stem(path);
+ // A File Info title that is no title of its own (blank, or an Untitled-N name) would hide it.
+ d.metadata.title = None;
Some(d)
}
diff --git a/crates/engine/src/cmd/fileio/legacy_ai.rs b/crates/engine/src/cmd/fileio/legacy_ai.rs
index d4820242d9..97e1258f67 100644
--- a/crates/engine/src/cmd/fileio/legacy_ai.rs
+++ b/crates/engine/src/cmd/fileio/legacy_ai.rs
@@ -142,7 +142,7 @@ pub(super) fn encode(cmd: &str, f: &Format, doc: &Document, p: &Value) -> Result
}
}
}
- let o = vectorcraft_eps::LegacyAiOptions { title: doc.title.clone(), created, hidden, outline_text };
+ let o = vectorcraft_eps::LegacyAiOptions { title: doc.info_title().to_owned(), created, hidden, outline_text };
let out = vectorcraft_eps::export_legacy_ai(&d, &o).map_err(|e| bad(cmd, e))?;
for w in placed.iter().chain(&out.warnings) {
note(&mut enc.warnings, w);
diff --git a/crates/engine/src/cmd/fileio/load.rs b/crates/engine/src/cmd/fileio/load.rs
index 335a3200d1..ba01841c68 100644
--- a/crates/engine/src/cmd/fileio/load.rs
+++ b/crates/engine/src/cmd/fileio/load.rs
@@ -7,10 +7,10 @@ use vectorcraft_doc::{Document, ImageBlob, ImageObject, Node, NodeKind};
use vectorcraft_geom::Affine;
use vectorcraft_render::effects::pixel::LoadedTexture;
-use super::super::fileinfo::{imported_title, stem_title};
+use super::super::fileinfo::imported_title;
use super::super::{Result, Session, bad, bool_or, clipboard, parse_range, str_param};
use super::pdfimport::LoadOptions;
-use super::{Format, SAVE_FORMATS, absolute_path, file_stamp, file_stem, format, psdread, read_file, readable_format_for_name};
+use super::{Format, SAVE_FORMATS, absolute_path, file_stamp, format, psdread, read_file, readable_format_for_name};
use crate::cmd::links::Resolved;
use crate::{EngineError, Prefs};
@@ -391,12 +391,10 @@ pub fn load_with(name: &str, bytes: &[u8], opts: &LoadOptions) -> Result
};
// A PDF, `.ai` or `.ait` read from its pages or its editing copy, not restored from the document
// it carries, opens with the File Info it gives (a PostScript `.ai` gives none).
- let mut pdf_title = None;
if matches!(format.id, "pdf" | "ai" | "ait")
&& !restored
&& let Some(info) = vectorcraft_pdf::file_info(bytes, opts.password.as_deref())
{
- pdf_title = info.title.clone();
super::super::fileinfo::read_pdf(&mut doc.metadata, info);
}
// Threaded type read from a file flows through its frames (the threaded type of a restored
@@ -413,18 +411,18 @@ pub fn load_with(name: &str, bytes: &[u8], opts: &LoadOptions) -> Result
if let Some(mode) = opts.color_mode.filter(|m| *m != doc.color_mode) {
super::super::colormgmt::set_color_mode(&mut doc, mode, true, None, opts.grays);
}
- // The document is titled as the file titles it: a native document keeps its own title, one
- // restored from the editing data a file carries and an SVG keep the title they hold (the SVG's
- // ``), a PDF, `.ai` or `.ait` takes its XMP `dc:title` or Info `Title`. Without one the
- // document takes the file name without its extension, as an export does (#3626). The tab shows
- // the file name once the document has a path.
- let own = match format.id {
- "vectorcraft" | "template" => Some(doc.title.clone()).filter(|t| !t.is_empty()),
- _ if restored || matches!(format.id, "svg" | "svgz") => imported_title(&doc.title),
- "pdf" | "ai" | "ait" => pdf_title.as_deref().and_then(imported_title),
- _ => None,
- };
- doc.title = own.unwrap_or_else(|| stem_title(&file_stem(&title)));
+ // The title the file gives itself (an SVG's ``, a PDF's, `.ai`'s or `.ait`'s XMP `dc:title`
+ // or Info `Title`, which the readers put in File Info; the one a native document holds) is the
+ // File Info title, cleaned as `file.info` takes one. A file restored from the editing data it
+ // carries gives the title that document had, unless that is the file's name. The document's own
+ // name stays what it has always been: the file's name (#3626).
+ let held = restored.then_some(doc.title.as_str()).filter(|t| *t != title);
+ doc.metadata.title = doc.metadata.title.as_deref().or(held).and_then(imported_title);
+ // Imports are named after the file; a native document keeps its own title (the tab shows the
+ // file name once it has a path).
+ if !matches!(format.id, "vectorcraft" | "template") || doc.title.is_empty() {
+ doc.title = title;
+ }
warnings.extend(doc.images.values().filter_map(|b| size_note(&b.bytes)));
Ok(Loaded { doc, format, warnings, restored, converted, preview_only, editing_data })
}
@@ -540,6 +538,7 @@ fn open_loaded(s: &mut Session, loaded: Loaded, links: Resolved, path: Option Res
// With the artboards' background colors, as the pages draw them.
let art = super::export::with_artboard_fills(art, ¶ms, 0..art.artboards.len()).map_err(|e| bad(cmd, e))?;
let o = vectorcraft_eps::AiEditingOptions {
- title: doc.title.clone(),
+ title: doc.info_title().to_owned(),
created: doc.metadata.created.or_else(vectorcraft_doc::metadata::output_time),
};
let (data, lost) = vectorcraft_eps::ai_private_data(&art, &o).map_err(|e| bad(cmd, e))?;
diff --git a/crates/engine/src/cmd/fileio/tests.rs b/crates/engine/src/cmd/fileio/tests.rs
index d3714e82e8..69158c04b8 100644
--- a/crates/engine/src/cmd/fileio/tests.rs
+++ b/crates/engine/src/cmd/fileio/tests.rs
@@ -67,8 +67,7 @@ fn opens_every_readable_format() {
] {
let r = open(&mut s, name, &bytes);
assert_eq!(r["format"], format, "{name}");
- // The document that wrote the file never had a title of its own, so the file gives none and the name (without its extension) titles it.
- assert_eq!(r["title"], if format == "vectorcraft" { "Untitled-1" } else { "a" }, "{name}");
+ assert_eq!(r["title"], if format == "vectorcraft" { "Untitled-1" } else { name }, "{name}");
assert!(s.doc().unwrap().doc.node_count() >= 2, "{name}: the rectangle came through");
}
for (name, kind) in [
diff --git a/crates/engine/src/cmd/fileio/tests_dxfimport.rs b/crates/engine/src/cmd/fileio/tests_dxfimport.rs
index c7de9d9e16..ec7b295c23 100644
--- a/crates/engine/src/cmd/fileio/tests_dxfimport.rs
+++ b/crates/engine/src/cmd/fileio/tests_dxfimport.rs
@@ -120,11 +120,7 @@ const MM: f64 = 72.0 / 25.4;
fn document_open_reads_a_dxf_drawing() {
let mut s = Session::new();
let r = open(&mut s, &plan(), json!({})).unwrap();
- assert_eq!(
- (r["format"].as_str(), r["title"].as_str()),
- (Some("dxf"), Some("plan")),
- "a file that carries no title opens as its name without the extension"
- );
+ assert_eq!((r["format"].as_str(), r["title"].as_str()), (Some("dxf"), Some("plan.dxf")));
assert_eq!(layer_names(&s), ["Walls", "Notes"]);
let st = s.doc().unwrap();
// Millimetres at 1:1, the artboard the art's size.
diff --git a/crates/engine/src/cmd/fileio/tests_epsimport.rs b/crates/engine/src/cmd/fileio/tests_epsimport.rs
index 0e9b8e805f..8c4141902c 100644
--- a/crates/engine/src/cmd/fileio/tests_epsimport.rs
+++ b/crates/engine/src/cmd/fileio/tests_epsimport.rs
@@ -64,7 +64,7 @@ fn other_eps_files_are_read_by_the_postscript_interpreter() {
// The page is the bounding box: the art's bounds.
assert_eq!(d.artboards[0].rect.size(), vectorcraft_geom::Size::new(101.0, 51.0), "the stroke's half width rounds up to whole points");
assert_eq!(objects(&s), 1);
- assert_eq!(s.doc().unwrap().title(), "plain");
+ assert_eq!(s.doc().unwrap().title(), "plain.eps");
}
/// A CMYK image the PostScript interpreter reads keeps its ink amounts when the EPS opens and when
diff --git a/crates/engine/src/cmd/fileio/tests_svg.rs b/crates/engine/src/cmd/fileio/tests_svg.rs
index ff3a73432c..73ee604a98 100644
--- a/crates/engine/src/cmd/fileio/tests_svg.rs
+++ b/crates/engine/src/cmd/fileio/tests_svg.rs
@@ -138,7 +138,7 @@ fn preserve_editing_reopens_the_native_document() {
let doc = &s.doc().unwrap().doc;
assert_eq!(doc.artboards.len(), 2, "both artboards came back");
assert_eq!(doc.artboards[1].name, "Back");
- assert_eq!(doc.title, "both");
+ assert_eq!(doc.title, "both.svg");
// Without it the SVG is imported (one artboard).
let text = svg(&mut s, json!({}));
s.execute("document.open", &json!({"name": "plain.svg", "dataBase64": vectorcraft_format::base64_encode(text.as_bytes())})).unwrap();
diff --git a/crates/engine/src/cmd/webexport.rs b/crates/engine/src/cmd/webexport.rs
index af0f313ea1..f31c82e29e 100644
--- a/crates/engine/src/cmd/webexport.rs
+++ b/crates/engine/src/cmd/webexport.rs
@@ -644,7 +644,7 @@ fn flatten(rgba: &[u8], matte: [u8; 3]) -> impl Iterator- + '_ {
/// The File Info text `s.metadata` lets into the files: `(keyword, text)`.
fn metadata(doc: &Document, s: &WebSettings) -> Vec<(&'static str, String)> {
- doc.metadata.png_text(&doc.title).into_iter().filter(|(k, _)| s.metadata.keeps(k)).map(|(k, v)| (k, v.into_owned())).collect()
+ doc.metadata.png_text(doc.info_title()).into_iter().filter(|(k, _)| s.metadata.keeps(k)).map(|(k, v)| (k, v.into_owned())).collect()
}
/// Encode straight RGBA pixels (`w`×`h`) as `s` says; `doc` gives the metadata.
diff --git a/crates/engine/src/inspect.rs b/crates/engine/src/inspect.rs
index 14df8529d7..a60ffe7b14 100644
--- a/crates/engine/src/inspect.rs
+++ b/crates/engine/src/inspect.rs
@@ -270,8 +270,8 @@ fn document_base(s: &Session) -> Value {
json!({
"uid": st.uid,
"title": st.title(),
- // The File Info title (`file.info`), where `title` comes from the file name once the document has one.
- "fileInfoTitle": d.title,
+ // The File Info title (`file.info`): the title the file gave itself, else the document's name. `title` is the tab's name.
+ "fileInfoTitle": d.info_title(),
"path": st.path,
"dirty": st.is_dirty(),
"revision": st.revision,
diff --git a/crates/engine/src/tests/fileinfo.rs b/crates/engine/src/tests/fileinfo.rs
index 4cb511b99c..249af96abc 100644
--- a/crates/engine/src/tests/fileinfo.rs
+++ b/crates/engine/src/tests/fileinfo.rs
@@ -161,7 +161,8 @@ fn an_exported_pdf_opens_with_its_file_info() {
assert_eq!(back["description"], "A poster\nfor the fair");
assert_eq!(back["keywords"], json!(["fair", "Poster", "print"]));
assert!(back["created"].as_str().is_some_and(|c| c.ends_with('Z')), "the file's creation date: {}", back["created"]);
- assert_eq!(back["title"], "Poster", "the title is the one the PDF carries, not its file name");
+ assert_eq!(back["title"], "Poster", "File Info shows the title the PDF carries");
+ assert_eq!(s.doc().unwrap().title(), "poster.pdf", "the document stays named after the file");
assert_eq!(back["copyrightStatus"], "copyrighted");
assert_eq!(back["copyrightNotice"], "(c) 2026 Ada");
assert_eq!(back["copyrightUrl"], "https://example.com/rights");
@@ -307,67 +308,129 @@ fn pdf_titled(info: Option<&str>, xmp_title: Option<&str>) -> Vec
{
String::from_utf8(bytes).unwrap().replacen("/Root 1 0 R", &format!("/Root 1 0 R /Info {info_at} 0 R"), 1).into_bytes()
}
-/// The title `document.open` gives a file called `name` with content `bytes`.
-fn opened_title(name: &str, bytes: &[u8]) -> String {
+/// A document called `name` with content `bytes` opened (as `document.open` with no path: the tab
+/// shows the document's name), `s` holding it.
+fn open_named(s: &mut Session, name: &str, bytes: &[u8]) -> Value {
+ s.execute("document.open", &json!({"name": name, "dataBase64": vectorcraft_format::base64_encode(bytes)})).unwrap()
+}
+
+/// The document's name (its tab) and its File Info title.
+fn titles(s: &mut Session) -> [String; 2] {
+ let info = s.execute("file.info", &json!({})).unwrap();
+ [s.doc().unwrap().title(), info["title"].as_str().unwrap().to_string()]
+}
+
+/// What opening `bytes` as a file called `name` gives: the document's name and its File Info title.
+fn opened(name: &str, bytes: &[u8]) -> [String; 2] {
let mut s = Session::new();
- let r = s.execute("document.open", &json!({"name": name, "dataBase64": vectorcraft_format::base64_encode(bytes)})).unwrap();
- assert_eq!(r["fileInfoTitle"], s.doc().unwrap().doc.title.as_str());
- s.doc().unwrap().doc.title.clone()
+ let r = open_named(&mut s, name, bytes);
+ let t = titles(&mut s);
+ assert_eq!((r["title"].as_str(), r["fileInfoTitle"].as_str()), (Some(t[0].as_str()), Some(t[1].as_str())), "the open reply");
+ assert_eq!(s.doc().unwrap().doc.title, t[0], "the tab is the document's title");
+ t
+}
+
+fn svg_titled(inner: &str) -> String {
+ format!(
+ "{inner}Tooltip "
+ )
}
#[test]
-fn import_uses_the_files_own_title() {
- // SVG: the root's ``, its white space made single spaces; not a shape's tooltip.
- let svg = |inner: &str| {
- format!(
- "{inner}Tooltip "
- )
- };
- assert_eq!(opened_title("box.svg", svg("\n Access\n paths ").as_bytes()), "Access paths");
+fn import_puts_the_files_own_title_in_file_info_only() {
+ // SVG: the root's ``, its white space made single spaces (not a shape's tooltip), is the
+ // File Info title; the document keeps the file's name, with its extension.
+ assert_eq!(opened("box.svg", svg_titled("\n Access\n paths ").as_bytes()), ["box.svg", "Access paths"]);
assert_eq!(
- opened_title("box.svg", svg("Zugangswege Rechenzentrum – Prüfdiagramm ").as_bytes()),
- "Zugangswege Rechenzentrum – Prüfdiagramm"
+ opened("/work/box.svg", svg_titled("Zugangswege Rechenzentrum – Prüfdiagramm ").as_bytes()),
+ ["box.svg", "Zugangswege Rechenzentrum – Prüfdiagramm"]
);
// PDF: the XMP `dc:title` (its `x-default` item) beats the Info `Title`; the Info `Title` stands alone.
- assert_eq!(opened_title("plan.pdf", &pdf_titled(Some("/Title (Info title)"), Some("XMP title"))), "XMP title");
- assert_eq!(opened_title("plan.pdf", &pdf_titled(None, Some("XMP title"))), "XMP title");
- assert_eq!(opened_title("plan.pdf", &pdf_titled(Some("/Title (Info title)"), None)), "Info title");
- assert_eq!(opened_title("plan.ai", &pdf_titled(Some("/Title (Info title)"), None)), "Info title");
- // A file that gives none (or a blank one): its name without the extension, in a folder too.
- assert_eq!(opened_title("box.svg", svg("").as_bytes()), "box");
- assert_eq!(opened_title("/work/box.svg", svg(" ").as_bytes()), "box");
- assert_eq!(opened_title("plan.pdf", &pdf_titled(None, None)), "plan");
- assert_eq!(opened_title("plan.pdf", &pdf_titled(Some("/Author (Ada)"), None)), "plan");
+ assert_eq!(opened("plan.pdf", &pdf_titled(Some("/Title (Info title)"), Some("XMP title"))), ["plan.pdf", "XMP title"]);
+ assert_eq!(opened("plan.pdf", &pdf_titled(None, Some("XMP title"))), ["plan.pdf", "XMP title"]);
+ assert_eq!(opened("plan.pdf", &pdf_titled(Some("/Title (Info title)"), None)), ["plan.pdf", "Info title"]);
+ assert_eq!(opened("plan.ai", &pdf_titled(Some("/Title (Info title)"), None)), ["plan.ai", "Info title"]);
+ // Compressed SVG too.
+ let svgz = |inner: &str| vectorcraft_svg::compress(&svg_titled(inner));
+ assert_eq!(opened("box.svgz", &svgz("Access paths ")), ["box.svgz", "Access paths"]);
+ // A file that gives none (or a blank one) gives File Info the document's name.
+ assert_eq!(opened("box.svg", svg_titled("").as_bytes()), ["box.svg", "box.svg"]);
+ assert_eq!(opened("box.svg", svg_titled(" ").as_bytes()), ["box.svg", "box.svg"]);
+ assert_eq!(opened("plan.pdf", &pdf_titled(None, None)), ["plan.pdf", "plan.pdf"]);
+ assert_eq!(opened("plan.pdf", &pdf_titled(Some("/Author (Ada)"), None)), ["plan.pdf", "plan.pdf"]);
// The Untitled-N name New gives is no title (a document that never had one writes it).
- assert_eq!(opened_title("plan.pdf", &pdf_titled(Some("/Title (Untitled-2)"), None)), "plan");
- // A native file keeps the title it has.
- let mut s = session();
- let native = vectorcraft_format::base64_encode(&b64(&s.execute("document.export", &json!({"format": "vectorcraft"})).unwrap()));
+ assert_eq!(opened("plan.pdf", &pdf_titled(Some("/Title (Untitled-2)"), None)), ["plan.pdf", "plan.pdf"]);
+ // A template opens as a new untitled document, whatever title its file gave.
+ let [name, info] = opened("plan.ait", &pdf_titled(Some("/Title (Info title)"), None));
+ assert!(name.starts_with("Untitled-") && info == name, "{name} / {info}");
+}
+
+#[test]
+fn a_file_with_a_title_opened_from_a_path_is_named_after_the_file() {
+ let dir = std::env::temp_dir().join(format!("vc-infotitle-open-{}", std::process::id()));
+ std::fs::create_dir_all(&dir).unwrap();
+ let path = dir.join("box.svg");
+ std::fs::write(&path, svg_titled("Access paths ")).unwrap();
+ let mut s = Session::new();
+ let r = s.execute("document.open", &json!({"path": path.to_str().unwrap()})).unwrap();
+ assert_eq!((r["title"].as_str(), r["fileInfoTitle"].as_str()), (Some("box.svg"), Some("Access paths")));
+ let r = s.execute("document.inspect", &json!({"depth": 0})).unwrap();
+ assert_eq!((r["title"].as_str(), r["fileInfoTitle"].as_str()), (Some("box.svg"), Some("Access paths")));
+ // The default names of what the document exports are made from the file's name, not its title.
+ assert_eq!(s.doc().unwrap().doc.title, "box.svg");
+ assert_eq!(crate::cmd::fileio::default_name(&s.doc().unwrap().doc, "png"), "box.png");
+ let _ = std::fs::remove_dir_all(dir);
+}
+
+#[test]
+fn file_info_edits_a_files_own_title_and_the_document_follows() {
+ let mut s = Session::new();
+ open_named(&mut s, "box.svg", svg_titled("Access paths ").as_bytes());
+ // The dialog sends back the title it was given: nothing changes (no undo step).
+ let before = s.doc().unwrap().revision;
+ let shown = s.execute("file.info", &json!({})).unwrap();
+ s.execute("file.info", &shown).unwrap();
+ assert_eq!(s.doc().unwrap().revision, before, "the title it gave is no edit");
+ assert_eq!(titles(&mut s), ["box.svg", "Access paths"]);
+ // Another title is the document's own, as ever, and the file's is gone.
+ let r = s.execute("file.info", &json!({"title": "Plan B"})).unwrap();
+ assert_eq!(r["title"], "Plan B");
+ assert_eq!(titles(&mut s), ["Plan B", "Plan B"]);
+ assert_eq!(s.doc().unwrap().doc.metadata.title, None);
+ // Undo brings the file's title back, and the name.
+ s.execute("edit.undo", &json!({})).unwrap();
+ assert_eq!(titles(&mut s), ["box.svg", "Access paths"]);
+}
+
+#[test]
+fn the_files_own_title_is_kept_by_saves_and_written_by_exports() {
+ let mut s = Session::new();
+ open_named(&mut s, "box.svg", svg_titled("Access paths ").as_bytes());
+ // The native file: its own title is the document's name, the File Info title survives.
+ let native = b64(&s.execute("document.export", &json!({"format": "vectorcraft"})).unwrap());
let mut t = Session::new();
- t.execute("document.open", &json!({"name": "other.vectorcraft", "dataBase64": native})).unwrap();
- assert_eq!(t.doc().unwrap().doc.title, "Poster");
-}
-
-#[test]
-fn compressed_svg_keeps_its_title() {
- let svgz = |inner: &str| {
- vectorcraft_svg::compress(&format!(
- "{inner} "
- ))
- };
- assert_eq!(opened_title("box.svgz", &svgz("Access paths ")), "Access paths");
- assert_eq!(opened_title("box.svgz", &svgz("")), "box");
-}
-
-#[test]
-fn file_name_title_is_cleaned_like_an_imported_one() {
- let svg = b" ";
- // A name `file.info` would reject (white space only) or that keeps control characters.
- assert_eq!(opened_title(" .svg", svg), "Untitled-1");
- assert_eq!(opened_title("\u{1}\u{7f}.svg", svg), "Untitled-1");
- assert_eq!(opened_title("bo\u{1}x two\t.svg", svg), "box two");
- // The Untitled-N name stays the stem.
- assert_eq!(opened_title("Untitled-7.svg", svg), "Untitled-7");
+ open_named(&mut t, "other.vectorcraft", &native);
+ assert_eq!(titles(&mut t), ["box.svg", "Access paths"]);
+ // An SVG with the editing data it carries, reopened under another name.
+ let svg = s.execute("document.serialize", &json!({"format": "svg", "preserveEditing": true, "metadata": true})).unwrap()["text"]
+ .as_str()
+ .unwrap()
+ .to_string();
+ assert!(svg.contains("Access paths ") && svg.contains("Access paths "), "{svg}");
+ let mut u = Session::new();
+ let r = open_named(&mut u, "again.svg", svg.as_bytes());
+ assert_eq!(r["restored"], true);
+ assert_eq!(titles(&mut u), ["again.svg", "Access paths"]);
+ // PDF: the Info `Title` and the XMP `dc:title`, shown by the viewer.
+ for pdf in pdf_texts(&mut s) {
+ assert!(pdf.contains("/Title(Access paths)") && xmp_title(&pdf).contains("Access paths"), "{}", xmp_title(&pdf));
+ assert!(pdf.contains("/ViewerPreferences<>"), "{pdf}");
+ }
+ // PNG text.
+ let png = b64(&s.execute("document.export", &json!({"format": "png"})).unwrap());
+ assert!(crate::cmd::fileio::pngtext::text(&png).iter().any(|(k, v)| k == "Title" && v == "Access paths"));
+ // The default file names are made from the document's name.
+ assert_eq!(crate::cmd::fileio::default_name(&s.doc().unwrap().doc, "pdf"), "box.pdf");
}
#[test]
diff --git a/crates/metafile/src/lib.rs b/crates/metafile/src/lib.rs
index 5326e2f307..a517d516a6 100644
--- a/crates/metafile/src/lib.rs
+++ b/crates/metafile/src/lib.rs
@@ -84,7 +84,7 @@ pub fn export(doc: &Document, region: Rect, kind: Kind) -> Result emf::write(&ops, r, &doc.title),
+ Kind::Emf => emf::write(&ops, r, doc.info_title()),
Kind::Wmf => wmf::write(&ops, r, &mut warnings),
};
Ok(Output { bytes, warnings })
diff --git a/crates/pdf/src/export/entry.rs b/crates/pdf/src/export/entry.rs
index 6a4294c699..54d1f207a9 100644
--- a/crates/pdf/src/export/entry.rs
+++ b/crates/pdf/src/export/entry.rs
@@ -48,7 +48,9 @@ fn export_with_report_inner(doc: &Document, opts: &PdfOptions, left_out: &mut Ha
let doc = baked.as_ref().unwrap_or(doc);
let pages = pages(doc, opts)?;
- let title = crate::pdfx::title(set.standard, opts.title.clone().unwrap_or_else(|| doc.title.clone()));
+ // The file's Title is File Info's; the page information names the document, as its tab does.
+ let title = crate::pdfx::title(set.standard, opts.title.clone().unwrap_or_else(|| doc.info_title().to_owned()));
+ let name = opts.title.as_deref().unwrap_or(&doc.title);
// The time of writing (none on the web) dates the editing data and the page information.
let now = vectorcraft_doc::metadata::output_time();
let created_at = opts.created.or(now);
@@ -86,7 +88,7 @@ fn export_with_report_inner(doc: &Document, opts: &PdfOptions, left_out: &mut Ha
vectorcraft_doc::cancel::check().map_err(|_| PdfError::Cancelled)?;
vectorcraft_doc::cancel::progress(written as u64 + 1, total, &format!("Writing page {} of {total}", written + 1));
let (m, r) = (boxes.media, boxes.bleed);
- let info = if marks.page_info { crate::marks::page_info(doc, &title, i, now) } else { String::new() };
+ let info = if marks.page_info { crate::marks::page_info(doc, name, i, now) } else { String::new() };
let art = crate::marks::art(doc, &marks, &boxes, bleed, &info);
// The page is the media box, y down from its top-left corner (like ours), in units of
// `unit` points.
diff --git a/crates/pdf/src/tests/file_info.rs b/crates/pdf/src/tests/file_info.rs
index f7c5d694b6..4c837ba67f 100644
--- a/crates/pdf/src/tests/file_info.rs
+++ b/crates/pdf/src/tests/file_info.rs
@@ -220,6 +220,16 @@ fn a_written_pdf_gives_its_file_info_back() {
assert_eq!((f.created, f.modified), (Some(CREATED), Some(CREATED)));
}
+#[test]
+fn a_written_pdf_carries_the_file_info_title_not_the_documents_name() {
+ let mut d = Document::new(100.0, 100.0);
+ d.title = "poster.svg".into();
+ d.metadata.title = Some("Fair poster".into());
+ let bytes = export(&d, &PdfOptions { created: Some(CREATED), ..PdfOptions::uncompressed() }).unwrap();
+ assert_eq!(file_info(&bytes, None).unwrap().title.as_deref(), Some("Fair poster"));
+ assert!(!String::from_utf8_lossy(&bytes).contains("poster.svg"));
+}
+
#[test]
fn a_written_pdf_gives_its_copyright_back() {
use vectorcraft_doc::CopyrightStatus;
diff --git a/crates/svg/src/export.rs b/crates/svg/src/export.rs
index 13c4268472..e905a7189a 100644
--- a/crates/svg/src/export.rs
+++ b/crates/svg/src/export.rs
@@ -186,7 +186,7 @@ fn write(doc: &Document, opts: &ExportOptions, native: Option<&[u8]>, id_prefix:
// An artboard exported on its own is named after it (#3544), else after the document.
// A name that is blank once the characters XML can't hold are dropped falls back too.
let board = opts.artboard.filter(|_| opts.artboard_title).and_then(|i| doc.artboards.get(i)).map(|a| xml_text(&a.name));
- let title = board.filter(|n| !n.trim().is_empty()).unwrap_or_else(|| xml_text(&doc.title));
+ let title = board.filter(|n| !n.trim().is_empty()).unwrap_or_else(|| xml_text(doc.info_title()));
if !title.is_empty() {
out.push_str(&w.indent(1));
out.push_str(&format!("{title} {nl}"));
diff --git a/crates/svg/src/export/output.rs b/crates/svg/src/export/output.rs
index 01d33c833b..e3ad5722a1 100644
--- a/crates/svg/src/export/output.rs
+++ b/crates/svg/src/export/output.rs
@@ -88,7 +88,7 @@ fn dublin_core(doc: &Document) -> Vec<(&'static str, String)> {
let info = &doc.metadata;
let mut terms = vec![("format", "image/svg+xml".to_string())];
let created = info.created.map(vectorcraft_doc::metadata::iso8601).unwrap_or_default();
- let fields = [("title", doc.title.as_str()), ("creator", info.author.as_str()), ("description", info.description.as_str())];
+ let fields = [("title", doc.info_title()), ("creator", info.author.as_str()), ("description", info.description.as_str())];
let rights = [("rights", info.copyright_notice.as_str()), ("rights", info.copyright_url.as_str()), ("date", created.as_str())];
let keywords = info.keywords.iter().map(|k| ("subject", k.as_str()));
terms.extend(fields.into_iter().chain(keywords).chain(rights).filter(|(_, v)| !v.trim().is_empty()).map(|(k, v)| (k, v.to_string())));
diff --git a/crates/svg/src/import.rs b/crates/svg/src/import.rs
index aa625de695..78e1f16a46 100644
--- a/crates/svg/src/import.rs
+++ b/crates/svg/src/import.rs
@@ -142,11 +142,9 @@ pub(crate) fn import(svg: &str, opts: &ImportOptions, max_nodes: usize) -> Resul
let k = units.k;
let mut doc = Document::new(size.width() as f64 * k, size.height() as f64 * k);
doc.units = units.unit;
- // The file's own title, when it gives one; else the document keeps the placeholder name
- // `Document::new` gave it, for the caller to name after the file.
- if let Some(title) = root_title(xml.root_element()) {
- doc.title = title;
- }
+ // The file's own title, when it gives one, is the document's File Info title; the document's
+ // name stays what `Document::new` gave it, for the caller to name after the file.
+ doc.metadata.title = root_title(xml.root_element());
let mut im = Importer {
doc,
warnings,
diff --git a/crates/svg/tests/it/title_control_chars.rs b/crates/svg/tests/it/title_control_chars.rs
index 3318ed346f..2318eebc67 100644
--- a/crates/svg/tests/it/title_control_chars.rs
+++ b/crates/svg/tests/it/title_control_chars.rs
@@ -69,3 +69,29 @@ fn dublin_core_metadata_control_chars_are_filtered() {
assert!(svg.contains("kw "));
assert!(svg.contains("(c) me "));
}
+
+/// The root's `` is the File Info title of the imported document ([`Document::info_title`]);
+/// its name stays what `Document::new` gave it, and a shape's `` is that shape's tooltip.
+#[test]
+fn the_roots_title_is_the_file_info_title_not_the_documents_name() {
+ let svg = |inner: &str| {
+ format!(r#"{inner}Tip "#)
+ };
+ let d = import(&svg("\n Access\n paths ")).unwrap();
+ assert_eq!((d.title.as_str(), d.metadata.title.as_deref(), d.info_title()), ("Untitled-1", Some("Access paths"), "Access paths"));
+ for none in [svg(""), svg(" ")] {
+ let d = import(&none).unwrap();
+ assert_eq!((d.title.as_str(), d.metadata.title.as_deref(), d.info_title()), ("Untitled-1", None, "Untitled-1"));
+ }
+}
+
+/// Exports write the File Info title, where the document has one, in place of its name.
+#[test]
+fn exports_write_the_file_info_title() {
+ let mut d = Document::new(200.0, 200.0);
+ d.title = "box.svg".into();
+ d.metadata.title = Some("Access paths".into());
+ let svg = export(&d, &ExportOptions { metadata: true, ..ExportOptions::default() });
+ assert!(svg.contains("Access paths ") && svg.contains("Access paths "), "{svg}");
+ assert!(!svg.contains("box.svg"), "{svg}");
+}
diff --git a/docs/file-format-parity.md b/docs/file-format-parity.md
index 5b5101a5f2..f7ff2021c9 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-03 · **Change:** minor (an imported document takes the title its file carries; previously: native format: dynamic symbols, #4938) · **Target:** Adobe Illustrator 2026 (30.x)
+> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-03 · **Change:** minor (an imported document's own title goes into File Info only; previously: native format: dynamic symbols, #4938) · **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
@@ -166,7 +166,7 @@ the app's own editing copy of a file (an EPS after its `%%EOF`, a `.ai`'s `AIPri
| Date | Change | Summary |
|---|---|---|
-| 2026-11-03 | minor | An imported document takes the title its file carries: an SVG's root ``, a PDF's, `.ai`'s or `.ait`'s XMP `dc:title` else its Info `Title`; a file that gives none opens with its file name without the extension (#3626). |
+| 2026-11-03 | minor | An imported document's own title goes into File Info only (#3626): an SVG's root ``, a PDF's, `.ai`'s or `.ait`'s XMP `dc:title` else its Info `Title` is File Info's title (`metadata.title` in the native format, left out when unset so other documents save unchanged and older versions ignore it; written by exports); the document's name, which the tab, Save As and the default export names use, stays the file's name. |
| 2026-11-01 | minor | Native format: a symbol's Symbol Type (`dynamic`) and a dynamic symbol instance's appearance changes to objects of its art (`symbol_overrides`) round-trip; both are left out when unset, so other documents save unchanged (#4938). `.ai` reading and writing of dynamic symbols remains. |
| 2026-11-01 | minor | Exports draw each artboard's background color behind its art (#4937): PNG, JPEG, GIF, TIFF, WebP, BMP, Targa and PSD (a layer of its own; on a transparent background too), PDF, SVG and EPS pages, a `.ai` file's PDF pages and PostScript and PDF print; `includeArtboardFill: false` leaves it out. Files with editing data (Preserve Editing, `.ai`, EPS, native) keep it as the artboard's color and reopen without extra art. Not read from the reference app's own `.ai` data, nor written to it. DXF, EMF and WMF leave it out |
| 2026-10-31 | minor | PDF import finds an installed font whose PDF name holds hyphens (`Noto-Sans-Bold` is Noto Sans, Bold: the longest part before a hyphen that names an installed family) and a composite font by its CIDFont's name rather than its base name, which ends in the CMap's (`Name-Identity-H`, [ISO 32000-1 §9.7.6.1](https://opensource.adobe.com/dc-acrobat-sdk-docs/pdfstandards/PDF32000_2008.pdf)); a missing font is split only before a style part (`Helvetica-BoldOblique`), so it is reported as `MS PGothic`, not `MS`; scores unchanged (estimated) |
diff --git a/docs/gaps.md b/docs/gaps.md
index bd4f5488f3..463fe76b82 100644
--- a/docs/gaps.md
+++ b/docs/gaps.md
@@ -1,6 +1,6 @@
# Gaps
-> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-03 · **Change:** minor (an imported document takes the title its file carries; previously: new gradients follow document color mode; scores and estimates unchanged) · **Target:** Adobe Illustrator 2026 (30.x)
+> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-03 · **Change:** minor (an imported document's own title goes into File Info only; previously: new gradients follow document color mode; scores and estimates unchanged) · **Target:** Adobe Illustrator 2026 (30.x)
## Feature submission evidence
@@ -733,7 +733,7 @@ trap, and the defaults (0.25 pt, 100%, 40%) are VectorCraft's own, the pages giv
| Date | Change | Summary |
|---|---|---|
-| 2026-11-03 | minor | An imported document takes the title its file carries (an SVG's root ``, a PDF's XMP `dc:title` else its Info `Title`), else its file name without the extension, instead of the file name with it (#3626); no scored gap or estimate reassessed. |
+| 2026-11-03 | minor | An imported document's own title (an SVG's root ``, a PDF's XMP `dc:title` else its Info `Title`) goes into File Info only; the document's name stays the file's name (#3626); no scored gap or estimate reassessed. |
| 2026-11-02 | minor | G15: the renderer keeps a compound path's assembled path between frames and drops unused geometry entries after a few frames however few there are. Estimate unchanged. |
| 2026-11-02 | minor | G15: hit testing culls paths and compound paths by their control points' box and tests a compound path's outline and fill against only its members near the click; the same objects are hit. Estimate unchanged. |
| 2026-11-03 | minor | Symbol editing shows draggable 9-slice guides within the artwork (#5280). Guides outside the artwork remain unsupported. The G5 estimate remains 14–24 h. |
diff --git a/docs/mcp.md b/docs/mcp.md
index 72bf8f8885..8c2e228bd7 100644
--- a/docs/mcp.md
+++ b/docs/mcp.md
@@ -3940,9 +3940,16 @@ that its XMP packet (`dc:title`, `dc:creator`, `dc:description`, `dc:subject` or
(`Title`, `Author`, `Subject`, `Keywords`, `CreationDate`, `ModDate`) give, the XMP packet's value where both give
one (for `dc:title` and `dc:description`, the `x-default` language item first). An SVG opens with the `title` of its
root `` element (not a shape's or group's). A title is read with its white space made single spaces and
-without control characters, at most 65,536 characters, as `file.info` takes one. A file that gives no title, a blank
-one or the `Untitled-N` name New gives, opens with its file name without the extension (`box.svg` opens as `box`), and a native file keeps its own
-title. A value that `file.info` would refuse is left out, and a template opens with a new untitled name.
+without control characters, at most 65,536 characters, as `file.info` takes one. A value that `file.info` would
+refuse is left out. A file that gives no title, a blank one or the `Untitled-N` name New gives leaves File Info with
+the document's name as its title, and a template opens with a new untitled name.
+The title a file gave is File Info's alone: the document's name, which is what its tab shows until it has a file and
+what Save As and the default export names start from, stays the file's name with its extension (`box.svg`), or the
+title a native file holds. File Info's `title` is the one the file gave, else the document's name. It is saved with the
+document (a native file, and the editing data of an SVG, PDF, `.ai` or EPS that carries it), and the files an export
+writes carry it (PDF `Title` and XMP `dc:title`, SVG `` and Dublin Core, PNG text, EPS and `.ai` `%%Title`),
+where the document has one. Changing the `title` with `file.info` sets the document's name, as it always did, and drops
+the title the file gave.
`document.inspect` (`inspect_document`) and the `document.open` (`open_file`) reply report the File Info title as
`fileInfoTitle`. Their `title` is the document's tab name, which comes from the file name once the document is saved
to or opened from a file (with ` [Converted]` or ` [Recovered]` where that applies).
diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md
index 412dbba039..23627465dc 100644
--- a/docs/target-app-parity.md
+++ b/docs/target-app-parity.md
@@ -1,6 +1,6 @@
# Parity with Adobe Illustrator
-> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-03 · **Change:** minor (Type core options status and Kashidas feature revision corrected; scores and estimates unchanged) · **Target:** Adobe Illustrator 2026 (30.x; public release notes through 30.7, August 2026; an imported document takes the title its file carries; previously: new gradients follow document color mode; scores and estimates unchanged) · **Target:** Adobe Illustrator 2026 (30.x; public release notes through 30.7, August 2026; Units › East Asian Type acts, with Q and Ha units; scores unchanged) · **Target:** Adobe Illustrator 2026 (30.x; public release notes through 30.7, August 2026)
+> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-03 · **Change:** minor (an imported document's own title goes into File Info only; previously: Type core options status and Kashidas feature revision corrected; scores and estimates unchanged) · **Target:** Adobe Illustrator 2026 (30.x; public release notes through 30.7, August 2026)
## Feature submission evidence
@@ -1180,7 +1180,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur
- File › Place with Bounding Box (`file.place {crop: "bounding"}`) measures the art that shows, with its strokes and effects, as an export does: hidden objects, guides and template layers do not count, a group whose only part on the page or artboard is hidden is not placed, and symbol instances are measured by their art, for `at`, `rect`, the place cursor and `file.place.info` (`crates/engine/src/tests/place.rs`).
- Separations Preview shows placed and embedded raster images on the visible plates, as it shows vector art (#4153): an RGB image's pixels are separated into process inks with the proof setup's intent, a CMYK image shows its own inks, a hidden plate's ink leaves the image, and with one plate visible the image shows that plate's ink as a grey. Raster images carry no spot ink, so a spot plate shown alone shows them as paper; a colorized image shows its color's plates. Emoji and other bitmap glyphs follow the same rule. The plate-filtered pixels and their reduced-size copies are kept apart from the image's own pixels, for each set of visible plates, proof setup and color settings, and with the preview off nothing changes. Evidence: `tests::colorize::separations_preview_shows_an_rgb_images_process_inks`, `separations_preview_shows_a_cmyk_images_own_inks`, `separations_preview_keeps_an_images_pixels_per_plate_set`. Print preview fidelity remains the open gap, so scores and hours are unchanged.
- Desktop print transport uses `craft-print` for CUPS and modern Windows. Sheet composition, copies, separations and colour management remain in VectorCraft. The Print dialog's Media list also carries the Japanese papers — JIS B4 257 × 364 mm, JIS B5 182 × 257 mm and JIS B6 128 × 182 mm beside the ISO B4/B5 (250 × 353 and 176 × 250 mm, kept so settings and presets saved before them keep their paper), and はがき 100 × 148 mm, 往復はがき 148 × 200 mm, 封筒 長形3号 120 × 235 mm, 長形4号 90 × 225 mm, 角形2号 240 × 332 mm, 角形3号 216 × 277 mm and 四六判 127 × 188 mm; the JSON id or the dialog label names a paper, any case and spacing (`B5 (JIS)` or `b5jis`) (`crates/pdf/src/print/settings.rs`, `crates/pdf/src/tests/print.rs`). The print-preview and physical-device fidelity gaps remain, so print scores and hours are unchanged.
- - A PDF, PDF-compatible `.ai` or `.ait` opened from its pages or its editing copy opens with the File Info that its XMP packet and its document information dictionary give: the title (`dc:title`, its `x-default` item first, else Info `Title`), the author, description, keywords, copyright status, notice and URL, and the created and modified dates, the XMP packet's value where both give one (`crates/pdf/src/tests/file_info.rs`, `crates/engine/src/tests/fileinfo.rs`). An SVG opens with the title of its root ``; a file that gives no title opens with its file name without the extension, and a native file keeps its own title (#3626).
+ - A PDF, PDF-compatible `.ai` or `.ait` opened from its pages or its editing copy opens with the File Info that its XMP packet and its document information dictionary give: the title (`dc:title`, its `x-default` item first, else Info `Title`), the author, description, keywords, copyright status, notice and URL, and the created and modified dates, the XMP packet's value where both give one (`crates/pdf/src/tests/file_info.rs`, `crates/engine/src/tests/fileinfo.rs`). An SVG opens with the title of its root ``. The title is File Info's alone (`DocMetadata::title`): the document's name stays the file's name with its extension, a file that gives no title leaves File Info with that name, and exports (PDF, SVG, PNG, EPS, `.ai`) and native saves carry it; changing the title in File Info sets the document's name as before (#3626).
- PDF export and PDF-compatible `.ai` and `.ait` saves write the document's File Info created date as the file's creation date, or the time of writing when the document has none, and the time of writing as its modification date; the page information mark still prints the time of writing (`crates/engine/src/tests/fileinfo.rs`, `crates/pdf/src/tests.rs`, `crates/pdf/src/tests/marks.rs`).
- PDF export and PDF-compatible `.ai` and `.ait` saves write File Info's copyright notice, URL and status to the XMP packet (`dc:rights`, `xmpRights:WebStatement`, `xmpRights:Marked`), so a PDF or `.ai` opened from its pages keeps them (`crates/pdf/src/tests/file_info.rs`, `crates/engine/src/tests/fileinfo.rs`).
- Opening a PDF or PDF-compatible `.ai` whose XMP packet nests elements more than 256 deep no longer overflows the stack: File Info leaves the packet out and takes the document information dictionary's values (`crates/pdf/src/tests/file_info.rs`).
@@ -1573,7 +1573,7 @@ A double-click on a Repeat (Radial, Grid or Mirror) with the Selection tool ente
| Date | Change | Summary |
|---|---|---|
-| 2026-11-03 | minor | File Info on open: an imported document takes the title its file carries (an SVG's root ``, a PDF's, `.ai`'s or `.ait`'s XMP `dc:title` else its Info `Title`), else its file name without the extension, instead of the file name with it (#3626); scores and estimates unchanged. |
+| 2026-11-03 | minor | File Info on open: an imported document's own title (an SVG's root ``, a PDF's, `.ai`'s or `.ait`'s XMP `dc:title` else its Info `Title`) goes into File Info only; the document's name (tab, Save As, default export names) stays the file's name (#3626); scores and estimates unchanged. |
| 2026-11-03 | minor | Enclosing Lasso is an add-on with no default shortcut or Basic toolbar slot. Scores and estimates remain unchanged. |
| 2026-11-02 | minor | Wheel input over closed font-family fields changes selected text (#5253); public documentation and application choices distinguished; scores and estimates unchanged. |
| 2026-11-03 | minor | Find Font's replacement-source chooser offers Document, Recent and System (#5217); scores and estimates unchanged. |
From 3a1421e95ee5b33ab813c865482d48536183f60f Mon Sep 17 00:00:00 2001
From: Taha-Mahmoodi <85902429+Taha-Mahmoodi@users.noreply.github.com>
Date: Sat, 7 Nov 2026 13:51:03 +0430
Subject: [PATCH 04/86] docs: restore the status lines damaged by merges
(main's line plus this PR's note)
---
docs/file-format-parity.md | 2 +-
docs/gaps.md | 2 +-
docs/target-app-parity.md | 2 +-
3 files changed, 3 insertions(+), 3 deletions(-)
diff --git a/docs/file-format-parity.md b/docs/file-format-parity.md
index 384ce0a882..a2a37c1ff7 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-03 · **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; an imported document's own title goes into File Info only; previously: native format: dynamic symbols, #4938) · **Target:** Adobe Illustrator 2026 (30.x)
+> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-06 · **Change:** minor (an imported file's own title goes into File Info only (#3626); 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
diff --git a/docs/gaps.md b/docs/gaps.md
index 91d447aa27..74cc72c256 100644
--- a/docs/gaps.md
+++ b/docs/gaps.md
@@ -1,6 +1,6 @@
# Gaps
-> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-03 · **Change:** minor (Indian-language hyphenation review and license allowlist, #4947; opening a slow file shows a progress window, part of #5930; Hindi catalog and contributor loanword preferences; agent wording pass complete; wider native proofreading welcome; an image zoomed in on is separated, colorized or proofed only where it shows (#4155); scores and estimates unchanged; a document's first frame renders on the render worker, so opening a heavy file no longer freezes the window; scores and estimates unchanged; large images in CMYK documents are shrunk before they are separated into ink planes (#4155); scores and estimates unchanged; `.ai` paths with a Calligraphic brush open brushed, with the 6D Art Pen's channels, and Windows reads the pen's tilt and barrel rotation, #372; scores and estimates unchanged; Compound Shapes add-on, beyond Illustrator; scores and estimates unchanged; Contour add-on, beyond Illustrator; scores and estimates unchanged; a slow first frame is waited for instead of restarting without the graphics adapter, and the menu bar no longer waits for the installed fonts' scan; scores and estimates unchanged) · **Target:** Adobe Illustrator 2026 (30.x; an imported document's own title goes into File Info only; previously: new gradients follow document color mode; scores and estimates unchanged) · **Target:** Adobe Illustrator 2026 (30.x)
+> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-07 · **Change:** minor (an imported file's own title goes into File Info only (#3626); Indian-language hyphenation review and license allowlist, #4947; opening a slow file shows a progress window, part of #5930; Hindi catalog and contributor loanword preferences; agent wording pass complete; wider native proofreading welcome; an image zoomed in on is separated, colorized or proofed only where it shows (#4155); scores and estimates unchanged; a document's first frame renders on the render worker, so opening a heavy file no longer freezes the window; scores and estimates unchanged; large images in CMYK documents are shrunk before they are separated into ink planes (#4155); scores and estimates unchanged; `.ai` paths with a Calligraphic brush open brushed, with the 6D Art Pen's channels, and Windows reads the pen's tilt and barrel rotation, #372; scores and estimates unchanged; Compound Shapes add-on, beyond Illustrator; scores and estimates unchanged; Contour add-on, beyond Illustrator; scores and estimates unchanged; a slow first frame is waited for instead of restarting without the graphics adapter, and the menu bar no longer waits for the installed fonts' scan; scores and estimates unchanged) · **Target:** Adobe Illustrator 2026 (30.x)
## Feature submission evidence
diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md
index 0c353bb930..041226f90c 100644
--- a/docs/target-app-parity.md
+++ b/docs/target-app-parity.md
@@ -1,6 +1,6 @@
# Parity with Adobe Illustrator
-> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-03 · **Change:** minor (the canvas keeps selection outlines and anchors between frames; Indian-language hyphenation review and license allowlist, #4947; Stroke gradients: an arrowhead aligned to the tip is painted to the corners of its base, #5964; Drop Shadow and glow dialogs: spinner arrows, #5367, #5368; Stroke Weight Up and Down shortcuts, #5797; Hindi catalog and contributor loanword preferences; full wording review in progress; Properties in Edit Artboards: a Transform section with the reference point above the Artboards section, #5219; the Control bar's Stroke: and Opacity: links scrub their fields, #4088; an image zoomed in on is separated, colorized or proofed only where it shows (#4155); a document's first frame renders on the render worker, so opening a heavy file no longer freezes the window, #4155; Control bar Paragraph section with type selected, #5372; translating unchanged type reuses its glyph and character-stroke outlines; scores and estimates unchanged) · **Target:** Adobe Illustrator 2026 (30.x; public release notes through 30.7, August 2026; an imported document's own title goes into File Info only; previously: Type core options status and Kashidas feature revision corrected; scores and estimates unchanged) · **Target:** Adobe Illustrator 2026 (30.x; public release notes through 30.7, August 2026)
+> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-07 · **Change:** minor (an imported file's own title goes into File Info only (#3626); the canvas keeps selection outlines and anchors between frames; Indian-language hyphenation review and license allowlist, #4947; Stroke gradients: an arrowhead aligned to the tip is painted to the corners of its base, #5964; Drop Shadow and glow dialogs: spinner arrows, #5367, #5368; Stroke Weight Up and Down shortcuts, #5797; Hindi catalog and contributor loanword preferences; full wording review in progress; Properties in Edit Artboards: a Transform section with the reference point above the Artboards section, #5219; the Control bar's Stroke: and Opacity: links scrub their fields, #4088; an image zoomed in on is separated, colorized or proofed only where it shows (#4155); a document's first frame renders on the render worker, so opening a heavy file no longer freezes the window, #4155; Control bar Paragraph section with type selected, #5372; translating unchanged type reuses its glyph and character-stroke outlines; scores and estimates unchanged) · **Target:** Adobe Illustrator 2026 (30.x; public release notes through 30.7, August 2026)
## Feature submission evidence
From b563976b4d8db6be40f11268cababeb60ece8b00 Mon Sep 17 00:00:00 2001
From: Taha-Mahmoodi <85902429+Taha-Mahmoodi@users.noreply.github.com>
Date: Sat, 7 Nov 2026 14:13:48 +0430
Subject: [PATCH 05/86] Text: OpenType feature values (salt=2), a GSUB reader
and runs that differ only in features shaped as one piece
Features take a value (tag=N) in style commands, SVG and native files. Runs that
differ only in their feature lists are shaped in one buffer with ranged features, so
an alternate on one letter of an Arabic word keeps the word joined. FontFace reads
GSUB once (alternate sets, singles, the feature reaching a glyph), names unencoded
glyphs, and alternates_in_context finds a character's alternates in its word.
text.setRangeStyle takes glyphAlternate: null.
---
crates/doc/src/text/char_style.rs | 4 +-
crates/engine/src/cmd/textedit.rs | 25 +-
crates/engine/src/cmd/textstyles/tests.rs | 6 +-
crates/engine/src/cmd/typecmd.rs | 2 +-
crates/engine/src/tests/textedit.rs | 83 +++++
crates/svg/src/css.rs | 11 +-
crates/svg/tests/it/text_layout.rs | 13 +
crates/text/src/context_alternates.rs | 147 +++++++++
crates/text/src/features.rs | 208 ++++++++++--
crates/text/src/fontdb.rs | 3 +
crates/text/src/fontdb/alternates.rs | 156 +++------
crates/text/src/fontdb/build.rs | 2 +
crates/text/src/fontdb/face.rs | 5 +
crates/text/src/fontdb/gsub.rs | 298 ++++++++++++++++++
crates/text/src/lib.rs | 12 +-
crates/text/src/shape.rs | 56 +++-
crates/text/src/test_fonts.rs | 84 +++++
crates/text/src/tests.rs | 1 +
crates/text/src/tests/glyph_alternates.rs | 293 +++++++++++++++++
.../ui-egui/src/dialogs/char_style_options.rs | 12 +-
crates/ui-egui/src/panels/opentype.rs | 24 +-
docs/control-protocol.md | 20 +-
docs/mcp.md | 36 ++-
23 files changed, 1317 insertions(+), 184 deletions(-)
create mode 100644 crates/text/src/context_alternates.rs
create mode 100644 crates/text/src/fontdb/gsub.rs
create mode 100644 crates/text/src/tests/glyph_alternates.rs
diff --git a/crates/doc/src/text/char_style.rs b/crates/doc/src/text/char_style.rs
index 67ccb60a44..6ad201561d 100644
--- a/crates/doc/src/text/char_style.rs
+++ b/crates/doc/src/text/char_style.rs
@@ -51,7 +51,9 @@ pub struct CharStyle {
#[serde(default)]
pub all_caps: bool,
/// OpenType features that differ from the defaults (OpenType panel), as tags: `"dlig"` turns
- /// a feature on, `"-liga"` off.
+ /// a feature on, `"-liga"` off, and `"salt=2"` turns a feature with alternates on at the font's
+ /// alternate number 2 (the value the shaper gets; a plain `"salt"` is value 1). Runs that
+ /// differ only in this list are shaped together, each with its own features.
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub features: Vec,
/// Character style (Character Styles panel) these attributes come from; None = Normal.
diff --git a/crates/engine/src/cmd/textedit.rs b/crates/engine/src/cmd/textedit.rs
index fba84acafe..6aa546767b 100644
--- a/crates/engine/src/cmd/textedit.rs
+++ b/crates/engine/src/cmd/textedit.rs
@@ -40,7 +40,7 @@ pub fn specs() -> Vec {
"Character",
[],
None,
- "{id? (default: the one selected type object), start?: byte, end?: byte (default: all text), story?: bool = false (all the text of every frame of the threaded story `id` is a frame of, in one undo step; no `start` or `end`), font?, style?, size?: pt, leading?: pt|\"auto\", tracking?, kerning?: 1/1000 em|\"auto\"|\"optical\", baselineShift?: pt, hScale?: %, vScale?: %, rotation?: deg, fill?: colour|\"none\", stroke?: colour|\"none\" (#rrggbbaa and #rgba are refused in both), keepModel?: bool (fill and stroke, as paint.setFill), strokeWidth?: pt, strokeOptions?: {weight?, cap?, join?, miterLimit?, align?, dash?, dashOffset?, alignDashes?} (as stroke.set: the character stroke), underline?, strikethrough?, allCaps?: bool, smallCaps?: bool, position?: \"normal\"|\"superscript\"|\"subscript\" (sizes from Document Setup), features?: [\"dlig\", \"-liga\", …], featureChanges?: [\"tnum\", \"-onum\", …] (OpenType features turned on, or with - off, on top of each character's own; its other features are kept), charAlign?: \"romanBaseline\"|\"emBoxTop\"|\"emBoxCenter\"|\"emBoxBottom\"|\"icfTop\"|\"icfBottom\", proportionalMetrics?: bool (full-width glyphs on the font's proportional widths: `palt` in horizontal type, `vpal` in vertical type), tateChuYoko?: bool (Character panel menu › Tate-chu-yoko, with Show East Asian Options: in vertical type those characters stand side by side across one em of the column; kept on the characters and ignored in horizontal type), tcyUpDown?, tcyLeftRight?: pt -1296..1296 (Character panel menu › Tate-chu-yoko Settings: in vertical type a block of tate-chu-yoko characters moves up by a positive Up/Down and down by a negative one, right by a positive Left/Right and left by a negative one, as one piece with the values of its first character; 0 by default; automatic digit blocks and horizontal type do not move; neither turns tateChuYoko on), standardVerticalRoman?: bool (Character panel menu › Standard Vertical Roman Alignment, with Show East Asian Options: in vertical type those half-width characters, such as Latin letters and digits, stand upright one below the other instead of being rotated; spaces and tate-chu-yoko blocks are not changed; kept on the characters and ignored in horizontal type; off by default), noBreak?: bool (Character panel menu › No Break: these characters stay on one line, including across a space inside the run), kashidas?: bool (Character panel menu › Kashidas: selected characters allow insertion only when both joined neighbors have it on; on by default), charDirection?: \"default\"|\"leftToRight\"|\"rightToLeft\" (Character panel menu › Character Direction: the characters are set left to right or right to left inside their paragraph, as a Unicode bidi embedding; default: as the bidi algorithm sets them), digits?: \"default\"|\"arabic\"|\"hindi\"|\"farsi\"|\"devanagari\"|\"bengali\"|\"gurmukhi\"|\"gujarati\"|\"odia\"|\"tamil\"|\"telugu\"|\"kannada\"|\"malayalam\" (Character panel menu › Digits: the numeral system every digit is drawn in, whatever was typed: Western, Arabic-Indic (hindi), Eastern Arabic-Indic (farsi) or an Indic script's own digits), fontHeightReference?: \"emBox\"|\"capHeight\"|\"xHeight\"|\"icfBox\" (Character panel › Set Font Height Reference: the height of the font that size measures, the em box by default; with a font that has no such height, or one outside 0.1 to 2 em, the em box), language?: name|tag (Character panel › Language: one of Document Setup's languages by name or BCP 47 tag, case-insensitive, such as \"Turkish\" or \"tr\"; the characters are shaped in it, so the font uses that language's forms (OpenType `locl`); saved as the tag, English: USA, the default, as none; new type takes the document's language), fontAxes?: {\"wght\": 650, …} (Character panel › Variable Font: the setting of each variation axis named, in the axis's units and clamped to its range; null: back to the style's own setting; other axes keep theirs; tags the font has no axis for are left out; a font or style change clears them; text.fontList {family, style} lists the axes)} style a character range (size/leading/baselineShift are document points and hScale includes the object transform; runs are split at the range ends) → {id, runs, warning?: text (the font or style set isn't available: its type is drawn in the fallback font or the family's closest style)}",
+ "{id? (default: the one selected type object), start?: byte, end?: byte (default: all text), story?: bool = false (all the text of every frame of the threaded story `id` is a frame of, in one undo step; no `start` or `end`), font?, style?, size?: pt, leading?: pt|\"auto\", tracking?, kerning?: 1/1000 em|\"auto\"|\"optical\", baselineShift?: pt, hScale?: %, vScale?: %, rotation?: deg, fill?: colour|\"none\", stroke?: colour|\"none\" (#rrggbbaa and #rgba are refused in both), keepModel?: bool (fill and stroke, as paint.setFill), strokeWidth?: pt, strokeOptions?: {weight?, cap?, join?, miterLimit?, align?, dash?, dashOffset?, alignDashes?} (as stroke.set: the character stroke), underline?, strikethrough?, allCaps?: bool, smallCaps?: bool, position?: \"normal\"|\"superscript\"|\"subscript\" (sizes from Document Setup), features?: [\"dlig\", \"-liga\", \"salt=2\", …] (swsh, ss01–ss20, salt, titl, pnum, lnum, sups, subs, numr, dnom and aalt take the font's alternate number as `tag=N`, N 0–65535: \"salt=2\" draws the second alternate of each glyph that has one, \"salt\" is \"salt=1\", \"-salt\" and \"salt=0\" are off; the range is shaped with its word, so a letter's alternate keeps it joined to its neighbours), featureChanges?: [\"tnum\", \"-onum\", \"salt=2\", …] (OpenType features turned on, or with - off, on top of each character's own, a value replacing the feature's last one; its other features are kept), charAlign?: \"romanBaseline\"|\"emBoxTop\"|\"emBoxCenter\"|\"emBoxBottom\"|\"icfTop\"|\"icfBottom\", proportionalMetrics?: bool (full-width glyphs on the font's proportional widths: `palt` in horizontal type, `vpal` in vertical type), tateChuYoko?: bool (Character panel menu › Tate-chu-yoko, with Show East Asian Options: in vertical type those characters stand side by side across one em of the column; kept on the characters and ignored in horizontal type), tcyUpDown?, tcyLeftRight?: pt -1296..1296 (Character panel menu › Tate-chu-yoko Settings: in vertical type a block of tate-chu-yoko characters moves up by a positive Up/Down and down by a negative one, right by a positive Left/Right and left by a negative one, as one piece with the values of its first character; 0 by default; automatic digit blocks and horizontal type do not move; neither turns tateChuYoko on), standardVerticalRoman?: bool (Character panel menu › Standard Vertical Roman Alignment, with Show East Asian Options: in vertical type those half-width characters, such as Latin letters and digits, stand upright one below the other instead of being rotated; spaces and tate-chu-yoko blocks are not changed; kept on the characters and ignored in horizontal type; off by default), noBreak?: bool (Character panel menu › No Break: these characters stay on one line, including across a space inside the run), kashidas?: bool (Character panel menu › Kashidas: selected characters allow insertion only when both joined neighbors have it on; on by default), charDirection?: \"default\"|\"leftToRight\"|\"rightToLeft\" (Character panel menu › Character Direction: the characters are set left to right or right to left inside their paragraph, as a Unicode bidi embedding; default: as the bidi algorithm sets them), digits?: \"default\"|\"arabic\"|\"hindi\"|\"farsi\"|\"devanagari\"|\"bengali\"|\"gurmukhi\"|\"gujarati\"|\"odia\"|\"tamil\"|\"telugu\"|\"kannada\"|\"malayalam\" (Character panel menu › Digits: the numeral system every digit is drawn in, whatever was typed: Western, Arabic-Indic (hindi), Eastern Arabic-Indic (farsi) or an Indic script's own digits), fontHeightReference?: \"emBox\"|\"capHeight\"|\"xHeight\"|\"icfBox\" (Character panel › Set Font Height Reference: the height of the font that size measures, the em box by default; with a font that has no such height, or one outside 0.1 to 2 em, the em box), language?: name|tag (Character panel › Language: one of Document Setup's languages by name or BCP 47 tag, case-insensitive, such as \"Turkish\" or \"tr\"; the characters are shaped in it, so the font uses that language's forms (OpenType `locl`); saved as the tag, English: USA, the default, as none; new type takes the document's language), fontAxes?: {\"wght\": 650, …} (Character panel › Variable Font: the setting of each variation axis named, in the axis's units and clamped to its range; null: back to the style's own setting; other axes keep theirs; tags the font has no axis for are left out; a font or style change clears them; text.fontList {family, style} lists the axes), glyphAlternate?: null (takes away the glyph choice text.setGlyphAlternate made on the characters, in the same undo step as the other attributes; any other value is refused)} style a character range (size/leading/baselineShift are document points and hScale includes the object transform; runs are split at the range ends) → {id, runs, warning?: text (the font or style set isn't available: its type is drawn in the fallback font or the family's closest style)}",
has_doc,
set_range_style
),
@@ -464,6 +464,8 @@ pub(crate) struct CharChange {
language: Option>,
font_axes: Option,
font_height_reference: Option,
+ /// Take away the glyph choice ([`CharStyle::glyph_alternate`]) of the characters (`glyphAlternate: null`).
+ clear_glyph_alternate: bool,
}
/// `charDirection: "default"|"leftToRight"|"rightToLeft"` → the Character Direction to set (`None`
@@ -565,7 +567,8 @@ pub(crate) fn change_font_axes(st: &mut CharStyle, change: &AxisChange) {
}
}
-/// `features: ["dlig", "-liga", …]` → the canonical tag list (differences from the defaults).
+/// `features: ["dlig", "-liga", "salt=2", …]` → the canonical tag list (differences from the
+/// defaults; `salt=1` is written `salt`, `salt=0` and `-salt` leave it out).
pub(crate) fn features_param(p: &Value, cmd: &str) -> Result>> {
let Some(v) = p.get("features").filter(|v| !v.is_null()) else { return Ok(None) };
let tags: Vec<&str> = v.as_array().ok_or_else(|| bad(cmd, "features must be a list of tags"))?.iter().filter_map(Value::as_str).collect();
@@ -573,7 +576,8 @@ pub(crate) fn features_param(p: &Value, cmd: &str) -> Result >
return Err(bad(
cmd,
format!(
- "unknown OpenType feature `{t}` (liga, calt, dlig, smcp, frac, onum, tnum, ordn, swsh, ss01–ss20, salt, titl, pnum, lnum, sups, subs, numr, dnom; prefix - to turn off)"
+ "unknown OpenType feature `{t}` (liga, calt, dlig, smcp, frac, onum, tnum, ordn, swsh, ss01–ss20, salt, titl, pnum, lnum, sups, subs, numr, dnom, aalt; prefix - to turn off; swsh, ss01–ss20, salt, titl, pnum, lnum, sups, subs, numr, dnom and aalt take an alternate's number as `tag=N` with N from 0 to {}, as `salt=2`)",
+ vectorcraft_text::MAX_FEATURE_VALUE
),
));
}
@@ -583,9 +587,9 @@ pub(crate) fn features_param(p: &Value, cmd: &str) -> Result >
/// The most entries a `featureChanges` list may have.
const MAX_FEATURE_CHANGES: usize = 256;
-/// A feature tag without its `-` or `+` prefix.
+/// A feature tag without its `-` or `+` prefix and its `=N` value.
fn feature_name(tag: &str) -> &str {
- tag.strip_prefix(['-', '+']).unwrap_or(tag)
+ vectorcraft_text::OtFeatures::tag_name(tag)
}
/// `featureChanges: ["tnum", "-onum", …]` → the features to turn on (or, with `-`, off) on top of
@@ -752,6 +756,13 @@ impl CharChange {
language: language_param(p, cmd)?,
font_axes: font_axes_param(p, cmd)?,
font_height_reference: font_height_reference_param(p, cmd)?,
+ clear_glyph_alternate: match p.get("glyphAlternate") {
+ None => false,
+ Some(Value::Null) => true,
+ Some(_) => {
+ return Err(bad(cmd, "`glyphAlternate` takes only null, which takes a glyph choice away (text.setGlyphAlternate makes one)"));
+ }
+ },
};
if c.size.is_some_and(|v| v <= 0.0) {
return Err(bad(cmd, "size must be positive"));
@@ -804,6 +815,7 @@ impl CharChange {
&& self.language.is_none()
&& self.font_axes.is_none()
&& self.font_height_reference.is_none()
+ && !self.clear_glyph_alternate
}
pub(crate) fn localize(&mut self, t: &TextObject, cmd: &str) -> Result<()> {
@@ -943,6 +955,9 @@ impl CharChange {
if let Some(v) = &self.language {
st.language.clone_from(v);
}
+ if self.clear_glyph_alternate {
+ st.glyph_alternate = None;
+ }
}
}
diff --git a/crates/engine/src/cmd/textstyles/tests.rs b/crates/engine/src/cmd/textstyles/tests.rs
index 7c0fd99638..821bd7d999 100644
--- a/crates/engine/src/cmd/textstyles/tests.rs
+++ b/crates/engine/src/cmd/textstyles/tests.rs
@@ -309,12 +309,12 @@ fn character_style_color_and_features_reach_text_and_follow_swatch_edits() {
// The command takes the color of the link at the tint, whatever color is sent.
tinted["tint"] = json!(0.4);
let attrs = json!({"fill": brand, "stroke": tinted, "stroke_width": 2.0, "overprint_fill": true, "overprint_stroke": false,
- "features": ["dlig", "-liga", "tnum", "lnum", "-onum", "-pnum", "ss02", "-ss01"]});
+ "features": ["dlig", "-liga", "tnum", "lnum", "-onum", "-pnum", "ss02", "-ss01", "salt=2"]});
s.execute("charStyle.new", &json!({"name": "Ink", "attrs": attrs})).unwrap();
s.execute("charStyle.apply", &json!({"name": "Ink", "id": id})).unwrap();
let want_stroke = s.doc().unwrap().doc.tint_paint("Brand", 0.4).unwrap();
assert_eq!(run_paints(&s, id), (brand, want_stroke.clone(), 2.0, true, false));
- assert_eq!(runs(&s, id)[0].style.features, ["dlig", "-liga", "tnum", "lnum", "-onum", "-pnum", "ss02", "-ss01"]);
+ assert_eq!(runs(&s, id)[0].style.features, ["dlig", "-liga", "tnum", "lnum", "-onum", "-pnum", "ss02", "-ss01", "salt=2"]);
let def = |s: &Session| s.doc().unwrap().doc.char_styles.iter().find(|d| d.name == "Ink").unwrap().attrs.clone();
assert_eq!(def(&s)["stroke"], serde_json::to_value(&want_stroke).unwrap());
let saved = vectorcraft_format::load(&vectorcraft_format::save(&s.doc().unwrap().doc, false)).unwrap();
@@ -355,6 +355,8 @@ fn character_style_color_and_features_are_checked() {
(json!({"stroke_width": 5.0e6}), "`stroke_width` must be from 0 to 4000000"),
(json!({"features": ["dlig", "zzzz"]}), "unknown OpenType feature `zzzz`"),
(json!({"features": ["--liga"]}), "unknown OpenType feature `--liga`"),
+ (json!({"features": ["salt=x"]}), "unknown OpenType feature `salt=x`"),
+ (json!({"features": ["-salt=2"]}), "unknown OpenType feature `-salt=2`"),
(json!({"features": many}), "features lists at most 256 tags"),
(json!({"fill": {"type": "solid"}}), "missing field"),
] {
diff --git a/crates/engine/src/cmd/typecmd.rs b/crates/engine/src/cmd/typecmd.rs
index 519d282e84..c0706fbb2c 100644
--- a/crates/engine/src/cmd/typecmd.rs
+++ b/crates/engine/src/cmd/typecmd.rs
@@ -123,7 +123,7 @@ pub fn specs() -> Vec {
"Character",
[],
None,
- "{ids?|id?, font?, style? (font alone: the style is kept when the new family has it, else its closest), size?: pt, leading?: pt|\"auto\", tracking?: 1/1000 em, justify?: \"auto\" (the start of each paragraph's direction)|\"left\"|\"center\"|\"right\"|\"justifyAll\", fill?: colour|\"none\", stroke?: colour|\"none\" (characters without a stroke weight get 1 pt; #rrggbbaa and #rgba are refused in both), keepModel?: bool (fill and stroke, as paint.setFill), strokeWidth?: pt (the character stroke's weight, in document points as stroke.set), features?: [\"dlig\", \"-liga\", …] OpenType, featureChanges?: [\"tnum\", \"-onum\", …] (OpenType features turned on, or with - off, on top of each character's own; its other features are kept), start?: byte, end?: byte} (size/leading are document points after object scaling; with a range: the character attributes style that range and justify the paragraphs it touches; without: all the text) → {ids, overflow?: [id…] (area type and type on a path whose text no longer fits; left out when all fits), warning?: text (the font or style set isn't available: its type is drawn in the fallback font or the family's closest style)}",
+ "{ids?|id?, font?, style? (font alone: the style is kept when the new family has it, else its closest), size?: pt, leading?: pt|\"auto\", tracking?: 1/1000 em, justify?: \"auto\" (the start of each paragraph's direction)|\"left\"|\"center\"|\"right\"|\"justifyAll\", fill?: colour|\"none\", stroke?: colour|\"none\" (characters without a stroke weight get 1 pt; #rrggbbaa and #rgba are refused in both), keepModel?: bool (fill and stroke, as paint.setFill), strokeWidth?: pt (the character stroke's weight, in document points as stroke.set), features?: [\"dlig\", \"-liga\", \"salt=2\", …] OpenType (swsh, ss01–ss20, salt, titl, pnum, lnum, sups, subs, numr, dnom and aalt take the font's alternate number as `tag=N`, N 0–65535: \"salt=2\" draws the second alternate of each glyph that has one, \"salt\" is \"salt=1\", \"-salt\" and \"salt=0\" are off), featureChanges?: [\"tnum\", \"-onum\", \"salt=2\", …] (OpenType features turned on, or with - off, on top of each character's own, a value replacing the feature's last one; its other features are kept), start?: byte, end?: byte} (size/leading are document points after object scaling; with a range: the character attributes style that range and justify the paragraphs it touches; without: all the text) → {ids, overflow?: [id…] (area type and type on a path whose text no longer fits; left out when all fits), warning?: text (the font or style set isn't available: its type is drawn in the fallback font or the family's closest style)}",
has_doc,
set_style
),
diff --git a/crates/engine/src/tests/textedit.rs b/crates/engine/src/tests/textedit.rs
index ed3e2eac39..3578a67dbd 100644
--- a/crates/engine/src/tests/textedit.rs
+++ b/crates/engine/src/tests/textedit.rs
@@ -139,6 +139,54 @@ fn feature_changes_keep_each_runs_other_features() {
assert_eq!(features(&s), [vec!["smcp", "numr", "liga", "dnom"], vec!["swsh", "numr", "liga", "dnom"], vec!["-onum", "swsh", "dnom"]]);
}
+/// A feature with a value (`salt=2`): `features` and `featureChanges` take it, the next value
+/// replaces it, `salt=1` is a plain `salt` and `salt=0` and `-salt` are off, a value this build
+/// does not know is an error, one undo step each, and the native format keeps the plain string.
+#[test]
+fn feature_values_are_set_replaced_and_saved() {
+ let mut s = session();
+ let id = text(&mut s, "abcd");
+ let features = |s: &Session| obj(s, id).runs.iter().map(|r| (r.text.clone(), r.style.features.clone())).collect::>();
+ let range = |s: &mut Session, p: Value| {
+ let mut p = p;
+ p["id"] = json!(id.0);
+ p["start"] = json!(1);
+ p["end"] = json!(2);
+ s.execute("text.setRangeStyle", &p).map(|_| ())
+ };
+ let undo = s.doc().unwrap().history.undo.len();
+ range(&mut s, json!({"features": ["salt=2"]})).unwrap();
+ assert_eq!(s.doc().unwrap().history.undo.len(), undo + 1, "one undo step");
+ assert_eq!(features(&s), [("a".into(), vec![]), ("b".into(), vec!["salt=2".to_string()]), ("cd".into(), vec![])]);
+ range(&mut s, json!({"features": ["salt=3"]})).unwrap();
+ assert_eq!(features(&s)[1].1, ["salt=3"], "the next value replaces it");
+ range(&mut s, json!({"featureChanges": ["salt=2", "ss01"]})).unwrap();
+ assert_eq!(features(&s)[1].1, ["ss01", "salt=2"], "written in the canonical order");
+ range(&mut s, json!({"featureChanges": ["salt=1"]})).unwrap();
+ assert_eq!(features(&s)[1].1, ["ss01", "salt"], "salt=1 is a plain salt");
+ range(&mut s, json!({"featureChanges": ["salt=2"]})).unwrap();
+ let saved = vectorcraft_format::save(&s.doc().unwrap().doc, false);
+ let back = vectorcraft_format::load(&saved).unwrap();
+ let Some(NodeKind::Text(t)) = back.node(id).map(|n| &n.kind) else { panic!("the text survives") };
+ assert_eq!(t.runs[1].style.features, ["ss01", "salt=2"], "the native format keeps the string");
+ assert!(String::from_utf8_lossy(&saved).contains("\"salt=2\""));
+ range(&mut s, json!({"featureChanges": ["-salt"]})).unwrap();
+ assert_eq!(features(&s)[1].1, ["ss01"], "-salt turns it off");
+ range(&mut s, json!({"featureChanges": ["salt=2"], "features": null})).unwrap();
+ range(&mut s, json!({"featureChanges": ["salt=0"]})).unwrap();
+ assert_eq!(features(&s)[1].1, ["ss01"], "salt=0 turns it off");
+ s.execute("edit.undo", &json!({})).unwrap();
+ assert_eq!(features(&s)[1].1, ["ss01", "salt=2"], "undo takes it back");
+ let before = features(&s);
+ for tag in ["salt=", "salt=x", "salt=65536", "-salt=2", "liga=2", "salt=2=3", "zzzz=2"] {
+ for key in ["features", "featureChanges"] {
+ let e = range(&mut s, json!({ key: [tag] })).unwrap_err().to_string();
+ assert!(e.contains(&format!("`{tag}`")), "{key} {tag}: {e}");
+ }
+ }
+ assert_eq!(features(&s), before, "the refused commands change nothing");
+}
+
/// `text.setStyle {stroke, strokeWidth}` sets the character stroke of all the text or of a range, as
/// `text.setRangeStyle` does, and a call with `fill` and `stroke` sets both in one undo step.
#[test]
@@ -2810,6 +2858,41 @@ fn a_glyph_alternate_replaces_one_character_through_undo_and_reopen() {
assert!(s.execute("text.glyphAlternates", &json!({"id": id.0, "start": 2})).unwrap_err().to_string().contains("is not available"));
}
+/// `glyphAlternate: null` in `text.setRangeStyle` takes a glyph choice away in the same undo step
+/// as the other style it sets (a feature: what the Glyphs panel does for a variant a feature
+/// draws); anything but null is refused.
+#[test]
+fn set_range_style_takes_a_glyph_choice_away_with_a_feature() {
+ use vectorcraft_text::test_fonts::{alternates_family, alternates_font, unencoded_glyphs};
+ let _fonts = crate::adding_fonts();
+ let db = vectorcraft_text::FontDb::global();
+ db.add_font(alternates_font(true).expect("the test font builds"));
+ let [u1, ..] = unencoded_glyphs().expect("unencoded glyphs");
+ let face = db.face(alternates_family(true), "Regular").expect("the test face");
+ let mut s = session();
+ let r = s.execute("text.create", &json!({"x": 100, "y": 100, "text": "xay", "size": 20, "font": alternates_family(true)})).unwrap();
+ let id = NodeId(r["id"].as_u64().unwrap());
+ s.execute("text.setGlyphAlternate", &json!({"id": id.0, "start": 1, "gid": u1})).unwrap();
+ let gids = |s: &Session| vectorcraft_text::layout(db, &obj(s, id)).glyphs.iter().map(|g| g.gid).collect::>();
+ assert_eq!(gids(&s)[1], u1);
+ let undo = s.doc().unwrap().history.undo.len();
+ let e = s.execute("text.setRangeStyle", &json!({"id": id.0, "start": 1, "end": 2, "glyphAlternate": 5})).unwrap_err().to_string();
+ assert!(e.contains("takes only null"), "{e}");
+ s.execute("text.setRangeStyle", &json!({"id": id.0, "start": 1, "end": 2, "glyphAlternate": null, "featureChanges": ["ss01"]})).unwrap();
+ assert_eq!(s.doc().unwrap().history.undo.len(), undo + 1, "one undo step");
+ assert!(obj(&s, id).runs.iter().all(|r| r.style.glyph_alternate.is_none()), "the choice is gone");
+ assert_eq!(
+ gids(&s)[1],
+ face.substitutions(face.glyph_for('a')).iter().find(|x| &x.feature == b"ss01").map(|x| x.glyphs[0]).unwrap(),
+ "ss01 draws its glyph"
+ );
+ s.execute("edit.undo", &json!({})).unwrap();
+ assert_eq!(gids(&s)[1], u1, "undone: the choice again");
+ // With nothing else to set it is still a change.
+ s.execute("text.setRangeStyle", &json!({"id": id.0, "start": 1, "end": 2, "glyphAlternate": null})).unwrap();
+ assert_eq!(gids(&s)[1], face.glyph_for('a'));
+}
+
/// The glyph already drawn, chosen again, and null for a character drawn with its default glyph
/// leave the document and its undo history as they are. An empty run before the character does
/// not stand in for the character's own choice.
diff --git a/crates/svg/src/css.rs b/crates/svg/src/css.rs
index 78113dcd8c..76a04bea84 100644
--- a/crates/svg/src/css.rs
+++ b/crates/svg/src/css.rs
@@ -174,11 +174,18 @@ pub(crate) fn type_props(st: &CharStyle, len: &dyn Fn(f64) -> String) -> Props {
.iter()
.map(|t| match t.strip_prefix('-') {
Some(off) => (off, 0),
- None => (t.as_str(), 1),
+ // `tag=N` picks the font's alternate N: CSS takes the number as the feature's value.
+ None => {
+ let on = t.strip_prefix('+').unwrap_or(t);
+ match on.split_once('=').map(|(tag, n)| (tag, n.parse::())) {
+ Some((tag, Ok(n))) => (tag, n),
+ _ => (on, 1),
+ }
+ }
})
// A feature tag is four letters or digits: anything else could end the CSS string.
.filter(|(tag, _)| tag.len() == 4 && tag.bytes().all(|b| b.is_ascii_alphanumeric()))
- .map(|(tag, on)| format!("\"{tag}\" {on}"))
+ .map(|(tag, value)| format!("\"{tag}\" {value}"))
.collect();
// Proportional Metrics: `palt` (SVG writes vertical type as outlines).
if st.proportional_metrics {
diff --git a/crates/svg/tests/it/text_layout.rs b/crates/svg/tests/it/text_layout.rs
index eee6e2fbf5..6d0d6489e5 100644
--- a/crates/svg/tests/it/text_layout.rs
+++ b/crates/svg/tests/it/text_layout.rs
@@ -123,6 +123,19 @@ fn proportional_metrics_write_palt() {
assert!(tags[2].contains("dlig") && tags[2].contains("palt"), "{}", tags[2]);
}
+/// A feature with a value (`salt=2`) is written as that value, `-tag` as 0, a plain tag as 1; a
+/// tag whose value is not a number is left out.
+#[test]
+fn feature_values_write_their_number() {
+ let valued = CharStyle { features: vec!["salt=2".into(), "-liga".into(), "ss01".into(), "swsh=12".into(), "salt=x".into()], ..style(12.0) };
+ let d = area_doc(vec![run("ab ", style(12.0)), run("cd", valued)], 280.0, Justify::Left);
+ let svg = export(&d, &ExportOptions::default());
+ let tags = tspans(&svg);
+ assert_eq!(tags.len(), 2, "{svg}");
+ assert!(tags[1].contains("font-feature-settings:"salt" 2, "liga" 0, "ss01" 1, "swsh" 12"), "{}", tags[1]);
+ assert!(!tags[1].contains("salt=x") && !tags[0].contains("salt"), "{svg}");
+}
+
/// Character Direction: live text writes each run set Left to Right as a bidi embedding
/// (`direction` and `unicode-bidi`). Type with a run set Right to Left is written as outlines.
#[test]
diff --git a/crates/text/src/context_alternates.rs b/crates/text/src/context_alternates.rs
new file mode 100644
index 0000000000..034f65f1d5
--- /dev/null
+++ b/crates/text/src/context_alternates.rs
@@ -0,0 +1,147 @@
+//! The alternate glyphs of one character as it stands in its text: the glyph the shaper makes for
+//! it in its word (a medial letter's medial form, not its isolated one), the alternates the font
+//! gives that glyph, and for each the OpenType feature value that draws it on that character alone
+//! (`salt=2`, `ss03`). Applying that feature to the character's range swaps the glyph in place
+//! while the text stays real Unicode and the word is shaped as one piece, so the letters still
+//! join. Adobe's Glyphs panel does the same for its Show › Alternates for Current Selection:
+//!
+
+use kurbo::Point;
+use vectorcraft_doc::{CharStyle, TextObject};
+
+use crate::{FontDb, GlyphAlternate, OtFeatures, edit, layout};
+
+/// The most characters on each side of the selected one that are shaped with it: the word it is in.
+const MAX_WORD: usize = 256;
+
+/// The most alternates tried against the text.
+const MAX_CANDIDATES: usize = 512;
+
+/// Does feature `tag` (as a style lists it, with its prefix and value) choose a glyph among a
+/// glyph's alternates: `aalt`, `salt`, `swsh` or a stylistic set `ss01` to `ss20`?
+pub fn is_alternate_feature(tag: &str) -> bool {
+ let name = OtFeatures::tag_name(tag);
+ matches!(name, "aalt" | "salt" | "swsh") || (name.len() == 4 && name.starts_with("ss") && OtFeatures::known_tag(name))
+}
+
+/// The tags to change a character's features by, as `featureChanges` takes them, so that it has
+/// `tag` as its only feature that chooses an alternate: each such feature of `features` turned
+/// off, and then `tag` (`salt=2`) when it is given. Empty when the character already has exactly
+/// that.
+pub fn alternate_changes(features: &[String], tag: Option<&str>) -> Vec {
+ let mut changes: Vec = vec![];
+ for t in features.iter().filter(|t| is_alternate_feature(t) && !t.starts_with('-')) {
+ let off = format!("-{}", OtFeatures::tag_name(t));
+ if tag.is_none_or(|n| OtFeatures::tag_name(n) != OtFeatures::tag_name(t)) && !changes.contains(&off) {
+ changes.push(off);
+ }
+ }
+ if let Some(tag) = tag {
+ let now = OtFeatures::from_tags(features.iter().map(String::as_str));
+ let wanted = OtFeatures::from_tags([tag]);
+ let name = OtFeatures::tag_name(tag);
+ if now.feature_value(name) != wanted.feature_value(name) {
+ changes.push(tag.to_string());
+ }
+ }
+ changes
+}
+
+/// One glyph [`alternates_in_context`] lists.
+#[derive(Clone, Debug, PartialEq, Eq)]
+pub struct ContextAlternate {
+ pub gid: u32,
+ /// The feature that lists the glyph among the default glyph's alternates (`aalt` first).
+ pub feature: [u8; 4],
+ /// The feature tag that draws the glyph on the character in its word (`salt=2`), when one does.
+ pub apply: Option,
+}
+
+/// What [`alternates_in_context`] finds.
+#[derive(Clone, Debug, PartialEq, Eq)]
+pub struct ContextAlternates {
+ /// The face that supplied the glyph ([`crate::PositionedGlyph::font_id`]).
+ pub font_id: u32,
+ /// The glyph the shaper makes for the character with none of its own alternate features: its
+ /// form in its position.
+ pub default: u32,
+ /// The glyph it is drawn with now.
+ pub current: u32,
+ /// The alternates of [`Self::default`] in the font's order (`aalt`, `salt`, `ss01`… as
+ /// [`crate::FontFace::alternates`]), without it unless `aalt` lists it.
+ pub alternates: Vec,
+}
+
+/// The glyph of the layout `text` that draws the character at `byte`, as (face id, glyph id).
+fn glyph_at(db: &FontDb, text: &TextObject, byte: usize) -> Option<(u32, u32, usize)> {
+ layout(db, text).glyphs.into_iter().find(|g| g.byte <= byte && byte < g.byte.saturating_add(g.len)).map(|g| (g.font_id, g.gid, g.len))
+}
+
+/// The alternate glyphs of the character at `byte` of `text` as it stands in its word, from the
+/// glyph the shaper makes for it there, and the feature value that draws each on that character
+/// alone. The character's own alternate features (`salt`, `ss01`…) are left out when the default
+/// glyph is found; each alternate's feature is tried on the character in its word, and the one
+/// that draws that glyph there is its `apply` (`salt` before the stylistic sets, `swsh` and
+/// `aalt`; a glyph no feature of the font draws in this position has none). `None` when `byte` is
+/// not a character of the text or the layout holds no glyph for it. Only the word is shaped (up to
+/// 256 characters each side), so the cost does not grow with the text.
+pub fn alternates_in_context(db: &FontDb, text: &TextObject, byte: usize) -> Option {
+ let plain = text.plain_text();
+ let ch = plain.get(byte..)?.chars().next()?;
+ let end = byte + ch.len_utf8();
+ let breaks = |c: char| c.is_whitespace() || matches!(c, '\u{2028}' | '\u{2029}');
+ let start = plain[..byte].char_indices().rev().take(MAX_WORD).take_while(|(_, c)| !breaks(*c)).last().map_or(byte, |(i, _)| i);
+ let stop = plain[end..].char_indices().take(MAX_WORD).take_while(|(_, c)| !breaks(*c)).last().map_or(end, |(i, c)| end + i + c.len_utf8());
+ let mut probe = TextObject::point(Point::ZERO, "", CharStyle::default());
+ probe.runs = edit::slice_runs(&text.runs, start, stop);
+ probe.vertical = text.vertical;
+ probe.para = text.para.clone();
+ let (a, b) = (byte - start, end - start);
+ let (_, current, _) = glyph_at(db, &probe, a)?;
+ let own = |probe: &TextObject, tag: Option<&str>| {
+ let mut p = probe.clone();
+ edit::style_range(&mut p.runs, a, b, |st| {
+ st.glyph_alternate = None;
+ st.features.retain(|t| !is_alternate_feature(t));
+ st.features.extend(tag.map(str::to_string));
+ });
+ glyph_at(db, &p, a)
+ };
+ let (default_font, default, len) = own(&probe, None)?;
+ let face = db.face_by_id(default_font)?;
+ // A glyph that more than the character makes (a ligature) has no alternates of its own here.
+ let subs = if len == ch.len_utf8() { face.substitutions(default) } else { vec![] };
+ let mut alternates: Vec = vec![];
+ let mut tried: Vec<(String, u32)> = vec![];
+ for sub in &subs {
+ let name = String::from_utf8_lossy(&sub.feature).to_string();
+ let values = if sub.glyphs.is_empty() { 0 } else { u16::try_from(sub.glyphs.len()).unwrap_or(u16::MAX) };
+ for value in 1..=values {
+ let tag = if value == 1 { name.clone() } else { format!("{name}={value}") };
+ if !is_alternate_feature(&tag) || !OtFeatures::known_tag(&tag) || tried.len() >= MAX_CANDIDATES || tried.iter().any(|(t, _)| *t == tag) {
+ continue;
+ }
+ // A single substitution gives its glyph at any value: only the plain tag is tried.
+ if sub.kind == crate::SubstitutionKind::Single && value > 1 {
+ continue;
+ }
+ if let Some((font, gid, _)) = own(&probe, Some(&tag))
+ && font == default_font
+ {
+ tried.push((tag, gid));
+ }
+ }
+ }
+ for GlyphAlternate { gid, feature } in crate::fontdb::listed(&subs, default) {
+ let rank = |tag: &str| match OtFeatures::tag_name(tag) {
+ "salt" => 0,
+ "swsh" => 2,
+ "aalt" => 3,
+ _ => 1,
+ };
+ let mut drawing: Vec<&(String, u32)> = tried.iter().filter(|(_, g)| *g == gid).collect();
+ drawing.sort_by_key(|(t, _)| rank(t));
+ alternates.push(ContextAlternate { gid, feature, apply: drawing.first().map(|(t, _)| t.clone()) });
+ }
+ Some(ContextAlternates { font_id: default_font, default, current, alternates })
+}
diff --git a/crates/text/src/features.rs b/crates/text/src/features.rs
index ddf9e5a03c..9f2b555e23 100644
--- a/crates/text/src/features.rs
+++ b/crates/text/src/features.rs
@@ -43,20 +43,32 @@ pub struct OtFeatures {
pub swash: bool,
/// The features of [`EXTRA_FEATURES`] turned on, one bit each (off by default): stylistic
/// sets, alternates, titling, proportional and lining figures, superscript, subscript,
- /// numerators and denominators.
+ /// numerators and denominators, `aalt`.
pub extra: u32,
+ /// The value of each feature of [`EXTRA_FEATURES`] that is on, as a tag `salt=2` gives it: for
+ /// a feature with alternates, which of them the font draws (the HarfBuzz feature value, 1 for
+ /// the first). 0 stands for 1, a plain `salt`. Only read for a feature whose bit of
+ /// [`Self::extra`] is on; use [`Self::set_extra`] to change a bit and its value together.
+ pub extra_values: [u16; EXTRA_FEATURES.len()],
+ /// The value `swsh=N` gives swashes ([`Self::swash`]), as [`Self::extra_values`] for `salt`.
+ pub swash_value: u16,
}
+/// The largest value a `tag=N` feature takes: a font's glyph ids are 16 bits, so is an alternate's
+/// position in a set.
+pub const MAX_FEATURE_VALUE: u16 = u16::MAX;
+
/// The most entries of a style's feature list read: every segment of its text reads the list again.
pub const MAX_STYLE_FEATURES: usize = 256;
/// Features beyond the named fields, off unless asked for, each bit `i` of [`OtFeatures::extra`]:
/// stylistic sets `ss01`–`ss20`, stylistic alternates `salt`, titling `titl`, proportional `pnum`
-/// and lining `lnum` figures, superscript `sups`, subscript `subs`, numerators `numr` and
-/// denominators `dnom`.
-pub const EXTRA_FEATURES: [&str; 28] = [
+/// and lining `lnum` figures, superscript `sups`, subscript `subs`, numerators `numr`,
+/// denominators `dnom` and access all alternates `aalt`.
+///
+pub const EXTRA_FEATURES: [&str; 29] = [
"ss01", "ss02", "ss03", "ss04", "ss05", "ss06", "ss07", "ss08", "ss09", "ss10", "ss11", "ss12", "ss13", "ss14", "ss15", "ss16", "ss17", "ss18",
- "ss19", "ss20", "salt", "titl", "pnum", "lnum", "sups", "subs", "numr", "dnom",
+ "ss19", "ss20", "salt", "titl", "pnum", "lnum", "sups", "subs", "numr", "dnom", "aalt",
];
impl Default for OtFeatures {
@@ -73,12 +85,53 @@ impl Default for OtFeatures {
ordinals: false,
swash: false,
extra: 0,
+ extra_values: [0; EXTRA_FEATURES.len()],
+ swash_value: 0,
}
}
}
-fn f(tag: &[u8; 4], on: bool) -> Feature {
- Feature::new(Tag::new(tag), on as u32, ..)
+/// A feature of `value` over the whole text: 1 and 0 turn it on and off, more picks the font's
+/// alternate of that number.
+fn f(tag: &[u8; 4], value: impl Into) -> Feature {
+ Feature::new(Tag::new(tag), value.into(), ..)
+}
+
+/// What a feature tag of a style's list says about one feature ([`parse_tag`]).
+struct ParsedTag<'a> {
+ /// The tag without a `-` or `+` prefix or a `=N` value.
+ name: &'a str,
+ /// On (`tag`, `+tag`, `tag=N` for `N` from 1) or off (`-tag`, `tag=0`).
+ on: bool,
+ /// The value `tag=N` gives; 1 for a plain tag.
+ value: u16,
+}
+
+/// Can feature `name` take a value (`salt=2`)? The features of [`EXTRA_FEATURES`] and `swsh` can:
+/// each substitutes glyphs, and the value says which alternate.
+fn takes_value(name: &str) -> bool {
+ name == "swsh" || EXTRA_FEATURES.contains(&name)
+}
+
+/// Read a feature tag: `tag`, `+tag`, `-tag` or, for a feature that takes a value, `tag=N` (`N`
+/// from 0 to [`MAX_FEATURE_VALUE`] in decimal digits; `-tag=N` is refused). `None` for a tag this
+/// set does not understand.
+fn parse_tag(tag: &str) -> Option> {
+ let (off, rest) = match tag.strip_prefix('-') {
+ Some(r) => (true, r),
+ None => (false, tag.strip_prefix('+').unwrap_or(tag)),
+ };
+ let (name, value) = match rest.split_once('=') {
+ None => (rest, None),
+ Some((name, digits)) => {
+ if off || digits.is_empty() || digits.len() > 5 || !digits.bytes().all(|b| b.is_ascii_digit()) || !takes_value(name) {
+ return None;
+ }
+ (name, Some(u16::try_from(digits.parse::().ok()?).ok()?))
+ }
+ };
+ let known = matches!(name, "liga" | "calt" | "dlig" | "smcp" | "frac" | "onum" | "tnum" | "ordn" | "swsh") || EXTRA_FEATURES.contains(&name);
+ known.then_some(ParsedTag { name, on: !off && value != Some(0), value: value.unwrap_or(1) })
}
impl OtFeatures {
@@ -104,6 +157,8 @@ impl OtFeatures {
ordinals: true,
swash: true,
extra: u32::MAX >> (32 - EXTRA_FEATURES.len()),
+ extra_values: [0; EXTRA_FEATURES.len()],
+ swash_value: 0,
};
change(&mut off);
change(&mut on);
@@ -137,28 +192,68 @@ impl OtFeatures {
(self.swash, d.swash, "swsh"),
] {
if on != def {
- v.push(if on { tag.to_string() } else { format!("-{tag}") });
+ v.push(match (on, tag, self.swash_value) {
+ (true, "swsh", n @ 2..) => format!("{tag}={n}"),
+ (true, ..) => tag.to_string(),
+ (false, ..) => format!("-{tag}"),
+ });
}
}
- v.extend(EXTRA_FEATURES.iter().enumerate().filter(|(i, _)| self.extra & (1 << i) != 0).map(|(_, t)| t.to_string()));
+ v.extend(EXTRA_FEATURES.iter().enumerate().filter(|(i, _)| self.extra & (1 << i) != 0).map(|(i, t)| match self.extra_values.get(i) {
+ None | Some(0 | 1) => t.to_string(),
+ Some(n) => format!("{t}={n}"),
+ }));
v
}
- /// Is `tag` (optionally prefixed with `-` or `+`) one this set understands?
+ /// Is `tag` one this set understands? A feature tag, optionally prefixed with `-` or `+`, or
+ /// `tag=N` (`N` from 0 to [`MAX_FEATURE_VALUE`], no prefix but `+`) for a feature of
+ /// [`EXTRA_FEATURES`] or `swsh`, which picks the font's alternate number `N` (`salt=2`; 0 is
+ /// off, 1 is a plain tag).
pub fn known_tag(tag: &str) -> bool {
+ parse_tag(tag).is_some()
+ }
+
+ /// The feature a tag of a style's list names, without its `-` or `+` prefix or `=N` value
+ /// (`salt` for `-salt`, `+salt` and `salt=2`).
+ pub fn tag_name(tag: &str) -> &str {
let t = tag.strip_prefix(['-', '+']).unwrap_or(tag);
- matches!(t, "liga" | "calt" | "dlig" | "smcp" | "frac" | "onum" | "tnum" | "ordn" | "swsh") || EXTRA_FEATURES.contains(&t)
+ t.split_once('=').map_or(t, |(name, _)| name)
+ }
+
+ /// The value feature `tag` of [`EXTRA_FEATURES`] or `swsh` is on with: `Some(1)` for a plain
+ /// tag, `Some(N)` for `tag=N`, `None` when it is off or is another tag.
+ pub fn feature_value(&self, tag: &str) -> Option {
+ if tag == "swsh" {
+ return self.swash.then_some(self.swash_value.max(1));
+ }
+ let i = EXTRA_FEATURES.iter().position(|e| *e == tag)?;
+ (self.extra & (1 << i) != 0).then(|| self.extra_values.get(i).copied().unwrap_or(0).max(1))
+ }
+
+ /// Turn the features of [`EXTRA_FEATURES`] in `mask` (bit `i` for the feature `i`) on, with
+ /// value 1 (a plain tag), or off.
+ pub fn set_extra(&mut self, mask: u32, on: bool) {
+ for (i, value) in self.extra_values.iter_mut().enumerate() {
+ if mask & (1 << i) != 0 {
+ *value = 0;
+ }
+ }
+ if on {
+ self.extra |= mask;
+ } else {
+ self.extra &= !mask;
+ }
}
/// This set with `tags` applied on top.
pub fn with_tags<'a>(mut self, tags: impl IntoIterator- ) -> Self {
let o = &mut self;
for t in tags {
- let (on, t) = match t.strip_prefix('-') {
- Some(r) => (false, r),
- None => (true, t.strip_prefix('+').unwrap_or(t)),
- };
- match t {
+ let Some(ParsedTag { name, on, value }) = parse_tag(t) else { continue };
+ // 1 is a plain tag: only a value from 2 is kept.
+ let kept = if on && value > 1 { value } else { 0 };
+ match name {
"liga" => o.ligatures = on,
"calt" => o.contextual = on,
"dlig" => o.discretionary_ligatures = on,
@@ -167,13 +262,15 @@ impl OtFeatures {
"onum" => o.oldstyle_figures = on,
"tnum" => o.tabular_figures = on,
"ordn" => o.ordinals = on,
- "swsh" => o.swash = on,
+ "swsh" => {
+ o.swash = on;
+ o.swash_value = kept;
+ }
_ => {
- if let Some(i) = EXTRA_FEATURES.iter().position(|e| *e == t) {
- if on {
- o.extra |= 1 << i;
- } else {
- o.extra &= !(1 << i);
+ if let Some(i) = EXTRA_FEATURES.iter().position(|e| *e == name) {
+ o.set_extra(1 << i, on);
+ if let Some(slot) = o.extra_values.get_mut(i) {
+ *slot = kept;
}
}
}
@@ -214,12 +311,12 @@ impl OtFeatures {
(s.swash, b"swsh"),
] {
if on {
- v.push(f(tag, true));
+ v.push(f(tag, if tag == b"swsh" { u32::from(s.swash_value.max(1)) } else { 1 }));
}
}
for (i, tag) in EXTRA_FEATURES.iter().enumerate() {
if let (true, Ok(t)) = (s.extra & (1 << i) != 0, <[u8; 4]>::try_from(tag.as_bytes())) {
- v.push(f(&t, true));
+ v.push(f(&t, u32::from(s.extra_values.get(i).copied().unwrap_or(0).max(1))));
}
}
if self.vertical {
@@ -354,6 +451,69 @@ mod tests {
assert_eq!(OtFeatures::from_tags(start.iter().copied()).with_tags(tags.iter().map(String::as_str)), want, "{start:?}");
}
}
+
+ /// `tag=N` gives a feature a value: parsed, validated, kept in the style's list as written
+ /// (1 is a plain tag, 0 is off), replaced by the next value or by `-tag`, and given to the
+ /// shaper as that value.
+ #[test]
+ fn feature_values_are_parsed_validated_and_shaped() {
+ for t in ["salt=2", "+salt=2", "ss03=12", "aalt=65535", "swsh=3", "salt=0", "salt=01", "titl=2"] {
+ assert!(OtFeatures::known_tag(t), "{t}");
+ }
+ for t in [
+ "salt=",
+ "salt=-1",
+ "salt=65536",
+ "salt=1.5",
+ "salt=x",
+ "-salt=2",
+ "liga=2",
+ "dlig=1",
+ "zzzz=2",
+ "salt==2",
+ "=2",
+ "salt=2=3",
+ "salt=123456",
+ "salt= 2",
+ ] {
+ assert!(!OtFeatures::known_tag(t), "{t}");
+ }
+ assert_eq!(OtFeatures::tag_name("salt=2"), "salt");
+ assert_eq!((OtFeatures::tag_name("-ss01"), OtFeatures::tag_name("+salt=3"), OtFeatures::tag_name("liga")), ("ss01", "salt", "liga"));
+ let o = OtFeatures::from_tags(["salt=2", "ss01", "-liga", "swsh=4"]);
+ assert_eq!(o.to_tags(), ["-liga", "swsh=4", "ss01", "salt=2"]);
+ assert_eq!(
+ (o.feature_value("salt"), o.feature_value("ss01"), o.feature_value("swsh"), o.feature_value("ss02")),
+ (Some(2), Some(1), Some(4), None)
+ );
+ // The next value replaces the last, `salt=1` is a plain `salt`, `salt=0` and `-salt` are off.
+ assert_eq!(OtFeatures::from_tags(["salt=2", "salt=3"]).to_tags(), ["salt=3"]);
+ assert_eq!(OtFeatures::from_tags(["salt=3", "salt"]).to_tags(), ["salt"]);
+ assert_eq!(OtFeatures::from_tags(["salt=1"]).to_tags(), ["salt"]);
+ assert_eq!(OtFeatures::from_tags(["salt=2", "-salt"]).to_tags(), Vec::
::new());
+ assert_eq!(OtFeatures::from_tags(["salt=2", "salt=0"]).to_tags(), Vec::::new());
+ assert_eq!(OtFeatures::from_tags(["salt=2", "-salt", "salt"]).to_tags(), ["salt"], "a value does not outlive being turned off");
+ // A tag with a value this set does not know is ignored.
+ assert_eq!(OtFeatures::from_tags(["salt=x", "liga=2"]), OtFeatures::default());
+ // The panel's own switches turn a feature on at value 1.
+ let mut f = OtFeatures::from_tags(["salt=3"]);
+ f.set_extra(1 << 20, false);
+ f.set_extra(1 << 20, true);
+ assert_eq!(f.to_tags(), ["salt"]);
+ let values = |tags: &[&str]| -> Vec<(String, u32)> {
+ let st = CharStyle { features: tags.iter().map(|t| t.to_string()).collect(), ..CharStyle::default() };
+ OtFeatures::default()
+ .resolve(&st)
+ .iter()
+ .filter(|f| f.value > 0 && f.tag != harfrust::Tag::new(b"kern"))
+ .map(|f| (f.tag.to_string(), f.value))
+ .collect()
+ };
+ assert_eq!(values(&["salt=2"]), [("salt".to_string(), 2)]);
+ assert_eq!(values(&["salt"]), [("salt".to_string(), 1)]);
+ assert_eq!(values(&["swsh=3", "ss01"]), [("swsh".to_string(), 3), ("ss01".to_string(), 1)]);
+ assert!(values(&["-salt", "salt=0"]).is_empty());
+ }
use crate::{FontDb, layout};
use vectorcraft_doc::TextObject;
use vectorcraft_geom::Point;
diff --git a/crates/text/src/fontdb.rs b/crates/text/src/fontdb.rs
index d42e8f3516..c7d116a29d 100644
--- a/crates/text/src/fontdb.rs
+++ b/crates/text/src/fontdb.rs
@@ -24,6 +24,7 @@ mod db;
mod dirs;
mod face;
mod glyph_pen;
+mod gsub;
mod lookup;
mod names;
mod scan;
@@ -32,6 +33,7 @@ mod traits;
mod wanted;
pub use alternates::GlyphAlternate;
+pub(crate) use alternates::listed;
pub use build::style_weight;
#[cfg(test)]
use cache::GlyphCache;
@@ -49,6 +51,7 @@ use face::Instances;
pub(crate) use face::MAX_LIVE_INSTANCES;
pub use face::{FALLBACK_FAMILY, FontFace, IcfMargins};
pub(crate) use glyph_pen::FlipPen;
+pub use gsub::{Substitution, SubstitutionKind};
#[cfg(not(target_arch = "wasm32"))]
use names::SysFallback;
#[cfg(test)]
diff --git a/crates/text/src/fontdb/alternates.rs b/crates/text/src/fontdb/alternates.rs
index 2a582bbe71..d74a55ff64 100644
--- a/crates/text/src/fontdb/alternates.rs
+++ b/crates/text/src/fontdb/alternates.rs
@@ -1,16 +1,16 @@
//! The alternate glyphs a face's `GSUB` table gives one character: the Glyphs panel's Show ›
//! Alternates for Current Selection. The application finds the character's default glyph in the
-//! `cmap` and looks it up in the coverage of each lookup of the features that substitute one glyph
-//! for another, as the OpenType registry describes `aalt`.
+//! `cmap` and lists what the features that substitute one glyph for another give it, as the
+//! OpenType registry describes `aalt` ([`FontFace::substitutions`] reads them).
//!
//!
use skrifa::MetadataProvider;
use skrifa::raw::TableProvider;
-use skrifa::raw::tables::gsub::{AlternateSubstFormat1, SingleSubst, SubstitutionSubtables};
use vectorcraft_doc::GlyphChoice;
use super::FontFace;
+use super::gsub::Substitution;
/// One glyph [`FontFace::alternates`] lists: its id and the feature it was found in (`aalt`).
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
@@ -19,108 +19,52 @@ pub struct GlyphAlternate {
pub feature: [u8; 4],
}
-/// Caps on what a damaged or very large `GSUB` table can make [`FontFace::alternates`] read:
-/// feature records, lookups per feature and in all, subtables per lookup and in all, glyphs per
-/// alternate set and glyphs listed in all.
-const MAX_FEATURES: usize = 4096;
-const MAX_FEATURE_LOOKUPS: usize = 256;
-const MAX_LOOKUPS: usize = 4096;
-const MAX_SUBTABLES: usize = 256;
-const MAX_SUBTABLE_READS: usize = 16384;
-const MAX_SET: usize = 512;
+/// The most glyphs [`FontFace::alternates`] lists.
const MAX_ALTERNATES: usize = 1024;
-/// The other discretionary features that substitute one glyph for another (historical forms,
-/// justification alternates, alternate annotation forms, ornaments, titling), after `cv99`, in
-/// tag order.
-const OTHER_FEATURES: [&[u8; 4]; 5] = [b"hist", b"jalt", b"nalt", b"ornm", b"titl"];
-
-/// Where the glyphs of feature `tag` come in the list: `aalt`, `salt`, `ss01` to `ss20`, `swsh`,
-/// `cv01` to `cv99`, then [`OTHER_FEATURES`]. `None` for a feature that is not listed.
-fn feature_rank(tag: [u8; 4]) -> Option<(u8, u8)> {
- let number = |t: &[u8]| std::str::from_utf8(t).ok().filter(|s| s.bytes().all(|b| b.is_ascii_digit())).and_then(|s| s.parse::().ok());
- match &tag {
- b"aalt" => Some((0, 0)),
- b"salt" => Some((1, 0)),
- [b's', b's', ..] => number(&tag[2..]).filter(|n| (1..=20).contains(n)).map(|n| (2, n)),
- b"swsh" => Some((3, 0)),
- [b'c', b'v', ..] => number(&tag[2..]).filter(|n| (1..=99).contains(n)).map(|n| (4, n)),
- _ => OTHER_FEATURES.iter().position(|t| **t == tag).map(|i| (5, i as u8)),
+/// The alternates of glyph `default` that its substitutions `subs` ([`FontFace::substitutions`])
+/// list, in their order, each glyph once with the first feature that gives it: the default glyph
+/// itself only when `aalt` gives it.
+pub(crate) fn listed(subs: &[Substitution], default: u32) -> Vec {
+ let mut out: Vec = vec![];
+ for substitution in subs {
+ for &gid in &substitution.glyphs {
+ let wanted = gid != default || substitution.feature == *b"aalt";
+ if wanted && out.len() < MAX_ALTERNATES && !out.iter().any(|a| a.gid == gid) {
+ out.push(GlyphAlternate { gid, feature: substitution.feature });
+ }
+ }
}
+ out
}
impl FontFace {
/// The face's alternate glyphs for `c`, from its `GSUB` table: the glyphs single substitutions
/// (lookup type 1) and alternate substitutions (type 3, the whole set in the font's order), in
- /// extension lookups (type 7) too, put in place of `c`'s default glyph. `aalt` comes first,
- /// then `salt`, `ss01` to `ss20`, `swsh`, `cv01` to `cv99`, `hist`, `jalt`, `nalt`, `ornm` and
- /// `titl`; within a feature its lookups go in lookup order. A glyph found again is left out.
- /// The default glyph is listed only when `aalt` lists it. Ligatures and contextual
- /// substitutions are not read. Empty when the face does not map `c`, has no `GSUB` table, or
- /// the table cannot be read; glyph ids past the face's glyph count are left out.
+ /// extension lookups (type 7) too, put in place of `c`'s default glyph
+ /// ([`FontFace::substitutions`]). `aalt` comes first, then `salt`, `ss01` to `ss20`, `swsh`,
+ /// `cv01` to `cv99`, `hist`, `jalt`, `nalt`, `ornm` and `titl`; within a feature its lookups go
+ /// in lookup order. A glyph found again is left out. The default glyph is listed only when
+ /// `aalt` lists it. Ligatures and contextual substitutions are not read. Empty when the face
+ /// does not map `c`, has no `GSUB` table, or the table cannot be read; glyph ids past the face's
+ /// glyph count are left out.
pub fn alternates(&self, c: char) -> Vec {
- let mut out: Vec = vec![];
- let Some(f) = self.skrifa() else { return out };
- let Some(default) = f.charmap().map(c) else { return out };
- let Ok(gsub) = f.gsub() else { return out };
- let (Ok(features), Ok(lookups)) = (gsub.feature_list(), gsub.lookup_list()) else { return out };
- let glyphs = f.maxp().map_or(0, |m| u32::from(m.num_glyphs()));
- // The lookups of the listed features, by rank and lookup index.
- // The listed feature records are read in rank order, so the cap on lookups in all leaves
- // out the lowest ranked ones.
- let records = features.feature_records();
- let mut ranked: Vec<((u8, u8), usize, [u8; 4])> = records
- .iter()
- .take(MAX_FEATURES)
- .enumerate()
- .filter_map(|(i, r)| {
- let tag = r.feature_tag().to_be_bytes();
- feature_rank(tag).map(|rank| (rank, i, tag))
+ let Some(default) = self.skrifa().and_then(|f| f.charmap().map(c)).map(|g| g.to_u32()) else { return vec![] };
+ listed(&self.substitutions(default), default)
+ }
+
+ /// The glyphs no character maps (its `cmap` gives no character for them), by glyph id, without
+ /// `.notdef`: the positional forms, alternates and ligatures the `GSUB` table reaches and any
+ /// other glyph of the font. Read once for the face.
+ pub fn unencoded_glyphs(&self) -> std::sync::Arc<[u32]> {
+ self.unencoded
+ .get_or_init(|| {
+ let Some(font) = self.skrifa() else { return std::sync::Arc::from([]) };
+ let mapped: std::collections::HashSet = font.charmap().mappings().map(|(_, g)| g.to_u32()).collect();
+ let count = font.maxp().map_or(0, |m| u32::from(m.num_glyphs()));
+ (1..count).filter(|g| !mapped.contains(g)).collect()
})
- .collect();
- ranked.sort_unstable_by_key(|r| (r.0, r.1));
- let mut wanted: Vec<((u8, u8), u16, [u8; 4])> = vec![];
- for (rank, i, tag) in ranked {
- let room = MAX_LOOKUPS.saturating_sub(wanted.len());
- if room == 0 {
- break;
- }
- let Some(record) = records.get(i) else { continue };
- let Ok(feature) = record.feature(features.offset_data()) else { continue };
- wanted.extend(feature.lookup_list_indices().iter().take(MAX_FEATURE_LOOKUPS.min(room)).map(|i| (rank, i.get(), tag)));
- }
- wanted.sort_unstable_by_key(|w| (w.0, w.1));
- wanted.dedup_by_key(|w| (w.0, w.1));
- let mut push = |gid: u32, feature: [u8; 4]| {
- let listed = gid != default.to_u32() || feature == *b"aalt";
- if listed && gid < glyphs && out.len() < MAX_ALTERNATES && !out.iter().any(|a| a.gid == gid) {
- out.push(GlyphAlternate { gid, feature });
- }
- };
- let all = lookups.lookups();
- let mut reads = MAX_SUBTABLE_READS;
- for (_, index, tag) in wanted {
- if reads == 0 {
- break;
- }
- let Ok(subtables) = all.get(usize::from(index)).and_then(|l| l.subtables()) else { continue };
- let n = MAX_SUBTABLES.min(reads);
- // A glyph takes the first subtable of a lookup whose coverage holds it.
- match subtables {
- SubstitutionSubtables::Single(s) => {
- if let Some(gid) = s.iter().take(n).inspect(|_| reads -= 1).flatten().find_map(|t| single(&t, default.to_u32())) {
- push(gid, tag);
- }
- }
- SubstitutionSubtables::Alternate(s) => {
- if let Some(set) = s.iter().take(n).inspect(|_| reads -= 1).flatten().find_map(|t| alternate_set(&t, default.to_u32())) {
- set.into_iter().for_each(|gid| push(gid, tag));
- }
- }
- _ => {}
- }
- }
- out
+ .clone()
}
/// How many glyphs the face has (its `maxp` table; 0 when it can't be read).
@@ -141,25 +85,3 @@ impl FontFace {
GlyphChoice { ch: c, gid, font: self.postscript_name(), version: self.version.clone() }
}
}
-
-/// The glyph single substitution `t` puts in place of `gid`, when its coverage holds `gid`.
-fn single(t: &SingleSubst<'_>, gid: u32) -> Option {
- match t {
- SingleSubst::Format1(t) => {
- t.coverage().ok()?.get(skrifa::GlyphId::new(gid))?;
- // The delta is added modulo 65536.
- Some((gid as i32 + i32::from(t.delta_glyph_id())).rem_euclid(0x1_0000) as u32)
- }
- SingleSubst::Format2(t) => {
- let i = t.coverage().ok()?.get(skrifa::GlyphId::new(gid))?;
- t.substitute_glyph_ids().get(usize::from(i)).map(|g| u32::from(g.get().to_u16()))
- }
- }
-}
-
-/// The alternate set of alternate substitution `t` for `gid`, when its coverage holds `gid`.
-fn alternate_set(t: &AlternateSubstFormat1<'_>, gid: u32) -> Option> {
- let i = t.coverage().ok()?.get(skrifa::GlyphId::new(gid))?;
- let set = t.alternate_sets().get(usize::from(i)).ok()?;
- Some(set.alternate_glyph_ids().iter().take(MAX_SET).map(|g| u32::from(g.get().to_u16())).collect())
-}
diff --git a/crates/text/src/fontdb/build.rs b/crates/text/src/fontdb/build.rs
index 40bcb1df1b..261e1b2877 100644
--- a/crates/text/src/fontdb/build.rs
+++ b/crates/text/src/fontdb/build.rs
@@ -78,6 +78,8 @@ pub(super) fn make_face(bytes: FontBytes, index: u32, spec: FaceStyle, path: Opt
colour_bitmaps,
colour_layers,
svg_glyphs,
+ gsub: std::sync::OnceLock::new(),
+ unencoded: std::sync::OnceLock::new(),
})
}
diff --git a/crates/text/src/fontdb/face.rs b/crates/text/src/fontdb/face.rs
index 5ab31eeae0..3e03e5c1da 100644
--- a/crates/text/src/fontdb/face.rs
+++ b/crates/text/src/fontdb/face.rs
@@ -199,6 +199,11 @@ pub struct FontFace {
pub(super) colour_layers: bool,
/// Has an `SVG ` table: OpenType SVG glyphs ([`FontDb::glyph_colour`]).
pub(super) svg_glyphs: bool,
+ /// What the `GSUB` table substitutes ([`Self::substitutions`]), read the first time it is asked
+ /// for.
+ pub(super) gsub: std::sync::OnceLock,
+ /// [`Self::unencoded_glyphs`], read once.
+ pub(super) unencoded: std::sync::OnceLock>,
}
/// Where the ideographic character face (ICF) lies inside the ideographic em box: its distance
diff --git a/crates/text/src/fontdb/gsub.rs b/crates/text/src/fontdb/gsub.rs
new file mode 100644
index 0000000000..3b07aad668
--- /dev/null
+++ b/crates/text/src/fontdb/gsub.rs
@@ -0,0 +1,298 @@
+//! What a face's `GSUB` table substitutes for a glyph: the alternates of one glyph (the Glyphs
+//! panel's Show › Alternates for Current Selection), the feature that gives a glyph no character
+//! maps to (Show › Entire Font) and glyph names. Read once for a face, the first time one is asked
+//! for, and kept with it ([`FontFace`]).
+//!
+//! A feature of alternates (an alternate substitution, lookup type 3; `salt`, `aalt`, `swsh`)
+//! takes the alternate its value says: with the feature at value `N`, a glyph in its coverage is
+//! replaced by the `N`th glyph of its set (the first at value 1, none at 0 or past the set), which
+//! is how the shaper reads it. A feature of single substitutions (type 1; `ss01`, `smcp`) replaces
+//! the glyph at any value from 1.
+//!
+//!
+
+use std::collections::{HashMap, HashSet};
+
+use skrifa::MetadataProvider;
+use skrifa::raw::TableProvider;
+use skrifa::raw::tables::gsub::{AlternateSubstFormat1, LigatureSubstFormat1, MultipleSubstFormat1, SingleSubst, SubstitutionSubtables};
+
+use super::FontFace;
+
+/// Caps on what a damaged or very large `GSUB` table can make a face read: feature records,
+/// lookups per feature and in all, subtables per lookup and in all, glyphs per alternate set, and
+/// glyphs and substitutions listed in all.
+pub(super) const MAX_FEATURES: usize = 4096;
+const MAX_FEATURE_LOOKUPS: usize = 256;
+const MAX_LOOKUPS: usize = 4096;
+const MAX_SUBTABLES: usize = 256;
+const MAX_SUBTABLE_READS: usize = 16384;
+pub(super) const MAX_SET: usize = 512;
+const MAX_ENTRIES: usize = 1 << 18;
+
+/// The other discretionary features that substitute one glyph for another (historical forms,
+/// justification alternates, alternate annotation forms, ornaments, titling), after `cv99`, in
+/// tag order.
+const OTHER_FEATURES: [&[u8; 4]; 5] = [b"hist", b"jalt", b"nalt", b"ornm", b"titl"];
+
+/// Where the substitutions of feature `tag` come in a glyph's list: `aalt`, `salt`, `ss01` to
+/// `ss20`, `swsh`, `cv01` to `cv99`, then [`OTHER_FEATURES`]. `None` for a feature that is not
+/// listed.
+fn feature_rank(tag: [u8; 4]) -> Option<(u8, u8)> {
+ let number = |t: &[u8]| std::str::from_utf8(t).ok().filter(|s| s.bytes().all(|b| b.is_ascii_digit())).and_then(|s| s.parse::().ok());
+ match &tag {
+ b"aalt" => Some((0, 0)),
+ b"salt" => Some((1, 0)),
+ [b's', b's', ..] => number(&tag[2..]).filter(|n| (1..=20).contains(n)).map(|n| (2, n)),
+ b"swsh" => Some((3, 0)),
+ [b'c', b'v', ..] => number(&tag[2..]).filter(|n| (1..=99).contains(n)).map(|n| (4, n)),
+ _ => OTHER_FEATURES.iter().position(|t| **t == tag).map(|i| (5, i as u8)),
+ }
+}
+
+/// The rank of the features that give no listed alternates but reach glyphs ([`FontFace::feature_reaching`]).
+const UNLISTED: (u8, u8) = (9, 0);
+
+/// How a feature gives its glyphs.
+#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
+pub enum SubstitutionKind {
+ /// One glyph, whatever the feature's value is from 1 (a single substitution, type 1).
+ Single,
+ /// A set of glyphs, the one at the feature's value (an alternate substitution, type 3).
+ Alternate,
+}
+
+/// What one feature substitutes for a glyph ([`FontFace::substitutions`]).
+#[derive(Clone, Debug, PartialEq, Eq, Hash)]
+pub struct Substitution {
+ /// The feature's tag (`salt`).
+ pub feature: [u8; 4],
+ pub kind: SubstitutionKind,
+ /// The glyphs, in the font's order: the one glyph of a single substitution, or the alternate
+ /// set (the feature at value `N` gives the `N`th).
+ pub glyphs: Vec,
+}
+
+impl Substitution {
+ /// The value the feature takes to give `glyph` (`salt=2` for 2), `None` when it never gives it.
+ pub fn value_for(&self, glyph: u32) -> Option {
+ match self.kind {
+ SubstitutionKind::Single => (self.glyphs.first() == Some(&glyph)).then_some(1),
+ SubstitutionKind::Alternate => self.glyphs.iter().position(|g| *g == glyph).and_then(|i| u16::try_from(i + 1).ok()),
+ }
+ }
+}
+
+/// A face's substitutions, by the glyph they replace and by the glyph they give.
+#[derive(Default)]
+pub(super) struct GsubIndex {
+ /// The listed features' ([`feature_rank`]) substitutions of each glyph, by feature rank and
+ /// lookup.
+ by_input: HashMap>,
+ /// The first feature, listed ones first, that gives each glyph: by a single, multiple,
+ /// alternate or ligature substitution.
+ reached: HashMap,
+}
+
+/// The glyph single substitution `t` puts in place of each glyph its coverage holds.
+fn single_pairs(t: &SingleSubst<'_>, room: usize) -> Vec<(u32, u32)> {
+ match t {
+ SingleSubst::Format1(t) => {
+ let Ok(coverage) = t.coverage() else { return vec![] };
+ // The delta is added modulo 65536.
+ let delta = i32::from(t.delta_glyph_id());
+ coverage.iter().take(room).map(|g| (g.to_u32(), (g.to_u32() as i32 + delta).rem_euclid(0x1_0000) as u32)).collect()
+ }
+ SingleSubst::Format2(t) => {
+ let Ok(coverage) = t.coverage() else { return vec![] };
+ let out = t.substitute_glyph_ids();
+ coverage.iter().zip(out).take(room).map(|(g, to)| (g.to_u32(), u32::from(to.get().to_u16()))).collect()
+ }
+ }
+}
+
+/// The alternate set of each glyph alternate substitution `t`'s coverage holds.
+fn alternate_sets(t: &AlternateSubstFormat1<'_>, room: usize) -> Vec<(u32, Vec)> {
+ let Ok(coverage) = t.coverage() else { return vec![] };
+ let sets = t.alternate_sets();
+ coverage
+ .iter()
+ .enumerate()
+ .take(room)
+ .filter_map(|(i, g)| {
+ let set = sets.get(i).ok()?;
+ Some((g.to_u32(), set.alternate_glyph_ids().iter().take(MAX_SET).map(|g| u32::from(g.get().to_u16())).collect()))
+ })
+ .collect()
+}
+
+/// The glyphs multiple substitution `t` puts in place of its covered glyphs, and the ligature
+/// glyphs of ligature substitution `l`, as (glyph, glyphs it becomes).
+fn multiple_pairs(t: &MultipleSubstFormat1<'_>, room: usize) -> Vec<(u32, Vec)> {
+ let Ok(coverage) = t.coverage() else { return vec![] };
+ let sequences = t.sequences();
+ coverage
+ .iter()
+ .enumerate()
+ .take(room)
+ .filter_map(|(i, g)| {
+ let sequence = sequences.get(i).ok()?;
+ Some((g.to_u32(), sequence.substitute_glyph_ids().iter().map(|g| u32::from(g.get().to_u16())).collect()))
+ })
+ .collect()
+}
+
+fn ligature_glyphs(t: &LigatureSubstFormat1<'_>, room: usize) -> Vec {
+ let Ok(coverage) = t.coverage() else { return vec![] };
+ let sets = t.ligature_sets();
+ let mut out = vec![];
+ for (i, _) in coverage.iter().enumerate().take(room) {
+ let Ok(set) = sets.get(i) else { continue };
+ out.extend(set.ligatures().iter().flatten().take(room).map(|l| u32::from(l.ligature_glyph().to_u16())));
+ if out.len() >= room {
+ break;
+ }
+ }
+ out
+}
+
+impl GsubIndex {
+ /// Read `face`'s `GSUB` table. Empty when the face has none or it cannot be read; glyph ids past
+ /// the face's glyph count are left out; the caps above bound what a damaged table makes it read.
+ fn read(face: &FontFace) -> Self {
+ let mut index = Self::default();
+ let Some(font) = face.skrifa() else { return index };
+ let Ok(gsub) = font.gsub() else { return index };
+ let (Ok(features), Ok(lookups)) = (gsub.feature_list(), gsub.lookup_list()) else { return index };
+ let glyphs = font.maxp().map_or(0, |m| u32::from(m.num_glyphs()));
+ // The lookups of the features, by rank and lookup index. The feature records are read in
+ // rank order, so the cap on lookups in all leaves out the lowest ranked ones.
+ let records = features.feature_records();
+ let mut ranked: Vec<((u8, u8), usize, [u8; 4])> = records
+ .iter()
+ .take(MAX_FEATURES)
+ .enumerate()
+ .map(|(i, r)| {
+ let tag = r.feature_tag().to_be_bytes();
+ (feature_rank(tag).unwrap_or(UNLISTED), i, tag)
+ })
+ .collect();
+ ranked.sort_unstable_by_key(|r| (r.0, r.1));
+ let mut wanted: Vec<((u8, u8), u16, [u8; 4])> = vec![];
+ for (rank, i, tag) in ranked {
+ let room = MAX_LOOKUPS.saturating_sub(wanted.len());
+ if room == 0 {
+ break;
+ }
+ let Some(record) = records.get(i) else { continue };
+ let Ok(feature) = record.feature(features.offset_data()) else { continue };
+ wanted.extend(feature.lookup_list_indices().iter().take(MAX_FEATURE_LOOKUPS.min(room)).map(|i| (rank, i.get(), tag)));
+ }
+ wanted.sort_by_key(|w| (w.0, w.1));
+ wanted.dedup_by_key(|w| (w.0, w.1));
+ let all = lookups.lookups();
+ let mut reads = MAX_SUBTABLE_READS;
+ let mut entries = 0_usize;
+ for (rank, lookup, tag) in wanted {
+ if reads == 0 || entries >= MAX_ENTRIES {
+ break;
+ }
+ let Ok(subtables) = all.get(usize::from(lookup)).and_then(|l| l.subtables()) else { continue };
+ let n = MAX_SUBTABLES.min(reads);
+ let listed = rank != UNLISTED;
+ // A glyph takes the first subtable of a lookup whose coverage holds it.
+ let mut seen: HashSet = HashSet::new();
+ let mut note = |index: &mut Self, input: u32, kind: SubstitutionKind, out: Vec| {
+ if !seen.insert(input) {
+ return;
+ }
+ let out: Vec = out.into_iter().filter(|g| *g < glyphs).collect();
+ for g in &out {
+ if *g != input && index.reached.len() < MAX_ENTRIES {
+ index.reached.entry(*g).or_insert(tag);
+ }
+ }
+ if listed && !out.is_empty() && entries < MAX_ENTRIES {
+ entries += 1;
+ index.by_input.entry(input).or_default().push(Substitution { feature: tag, kind, glyphs: out });
+ }
+ };
+ match subtables {
+ SubstitutionSubtables::Single(s) => {
+ for t in s.iter().take(n).inspect(|_| reads -= 1).flatten() {
+ for (input, to) in single_pairs(&t, MAX_ENTRIES) {
+ note(&mut index, input, SubstitutionKind::Single, vec![to]);
+ }
+ }
+ }
+ SubstitutionSubtables::Alternate(s) => {
+ for t in s.iter().take(n).inspect(|_| reads -= 1).flatten() {
+ for (input, set) in alternate_sets(&t, MAX_ENTRIES) {
+ note(&mut index, input, SubstitutionKind::Alternate, set);
+ }
+ }
+ }
+ SubstitutionSubtables::Multiple(s) => {
+ let mut covered: HashSet = HashSet::new();
+ for t in s.iter().take(n).inspect(|_| reads -= 1).flatten() {
+ for (input, to) in multiple_pairs(&t, MAX_ENTRIES) {
+ // Not listed as alternates, only reached.
+ if covered.insert(input) {
+ for g in to.into_iter().filter(|g| *g < glyphs && *g != input) {
+ if index.reached.len() < MAX_ENTRIES {
+ index.reached.entry(g).or_insert(tag);
+ }
+ }
+ }
+ }
+ }
+ }
+ SubstitutionSubtables::Ligature(s) => {
+ for t in s.iter().take(n).inspect(|_| reads -= 1).flatten() {
+ for g in ligature_glyphs(&t, MAX_ENTRIES).into_iter().filter(|g| *g < glyphs) {
+ if index.reached.len() < MAX_ENTRIES {
+ index.reached.entry(g).or_insert(tag);
+ }
+ }
+ }
+ }
+ _ => {}
+ }
+ }
+ index
+ }
+}
+
+impl FontFace {
+ fn gsub_index(&self) -> &GsubIndex {
+ self.gsub.get_or_init(|| GsubIndex::read(self))
+ }
+
+ /// What the features `aalt`, `salt`, `ss01` to `ss20`, `swsh`, `cv01` to `cv99`, `hist`,
+ /// `jalt`, `nalt`, `ornm` and `titl` of the face's `GSUB` table substitute for glyph `gid`, in
+ /// that order of features and within a feature in lookup order: the glyph of each single
+ /// substitution (lookup type 1) and the whole set of each alternate substitution (type 3), in
+ /// extension lookups (type 7) too, as the shaper reads them. A feature gives the glyph its
+ /// first substitution (the first subtable of the lookup whose coverage holds it). Ligatures and
+ /// contextual substitutions are not read, nor is the choice of a variable font's instance of
+ /// `FeatureVariations`. Empty when the face has no `GSUB` table or it cannot be read; glyph ids
+ /// past the face's glyph count are left out.
+ pub fn substitutions(&self, gid: u32) -> Vec {
+ self.gsub_index().by_input.get(&gid).cloned().unwrap_or_default()
+ }
+
+ /// The feature of the `GSUB` table that gives glyph `gid` as a substitute (by a single,
+ /// multiple, alternate or ligature substitution), a listed feature ([`Self::substitutions`])
+ /// before another (`init`, `liga`), if any does. Not read through a contextual lookup.
+ pub fn feature_reaching(&self, gid: u32) -> Option<[u8; 4]> {
+ self.gsub_index().reached.get(&gid).copied()
+ }
+
+ /// The name of glyph `gid` from the font's `post` or `CFF` table: `None` for a glyph past the
+ /// face's glyphs, or when the font has no names (the ids are then `gid12`).
+ pub fn glyph_name(&self, gid: u32) -> Option {
+ let font = self.skrifa()?;
+ let name = font.glyph_names().get(skrifa::GlyphId::new(gid))?;
+ (!name.is_synthesized()).then(|| name.as_str().to_string())
+ }
+}
diff --git a/crates/text/src/lib.rs b/crates/text/src/lib.rs
index 9763ea4b6d..b593594fbf 100644
--- a/crates/text/src/lib.rs
+++ b/crates/text/src/lib.rs
@@ -13,6 +13,7 @@
mod bitmap;
mod colr;
mod composer;
+mod context_alternates;
pub mod craft_fonts;
pub mod edit;
pub mod embed;
@@ -32,12 +33,15 @@ pub mod test_fonts;
pub mod thread;
pub use bitmap::GlyphImage;
+pub use context_alternates::{ContextAlternate, ContextAlternates, alternate_changes, alternates_in_context, is_alternate_feature};
pub use craft_fonts::{CRAFT_FONTS, CraftFont, WEB_FONTS, WebFont};
-pub use features::{EXTRA_FEATURES, LIGATURE_TRACKING_LIMITS, MAX_STYLE_FEATURES, OtFeatures, explicit_ligatures, ligatures_suppressed_by};
+pub use features::{
+ EXTRA_FEATURES, LIGATURE_TRACKING_LIMITS, MAX_FEATURE_VALUE, MAX_STYLE_FEATURES, OtFeatures, explicit_ligatures, ligatures_suppressed_by,
+};
pub use fontdb::{
- FALLBACK_FAMILY, FontClass, FontDb, FontFace, FontMatch, FontTraits, GlyphAlternate, IcfMargins, PlatformFontFiles, StylisticSet, WantedFont,
- WantedFonts, app_font_dir, is_font_file, is_suitcase, set_app_font_dir, set_platform_font_files, set_user_font_dirs, set_user_font_dirs_confined,
- style_weight, system_font_dirs, user_font_dirs,
+ FALLBACK_FAMILY, FontClass, FontDb, FontFace, FontMatch, FontTraits, GlyphAlternate, IcfMargins, PlatformFontFiles, StylisticSet, Substitution,
+ SubstitutionKind, WantedFont, WantedFonts, app_font_dir, is_font_file, is_suitcase, set_app_font_dir, set_platform_font_files,
+ set_user_font_dirs, set_user_font_dirs_confined, style_weight, system_font_dirs, user_font_dirs,
};
pub use hyphen::{hyphenation_exceptions, set_hyphenation_exceptions};
use std::sync::Arc;
diff --git a/crates/text/src/shape.rs b/crates/text/src/shape.rs
index 1a6716c5f2..60661795c5 100644
--- a/crates/text/src/shape.rs
+++ b/crates/text/src/shape.rs
@@ -769,8 +769,9 @@ fn source_run_at(runs: &[(Range, &CharStyle)], span: &Range, byte:
/// They do when they differ only in paint and stroke, underline and strikethrough, overprint, the
/// character style's name, leading, baseline shift, rotation, Character Alignment, Character
/// Direction (text is shaped apart where its bidi level changes), Kashidas, the kerning (each glyph takes its
-/// run's, see [`shape_segment`]) and the tracking, as long as the tracking keeps or drops standard
-/// ligatures alike ([`crate::features::ligatures_suppressed_by`]).
+/// run's, see [`shape_segment`]), the OpenType features (each run's apply to its own characters,
+/// as features over a range of the one buffer) and the tracking, as long as the tracking keeps or
+/// drops standard ligatures alike ([`crate::features::ligatures_suppressed_by`]).
fn shapes_alike(a: &CharStyle, b: &CharStyle) -> bool {
// Every field is named, so that a new attribute has to be sorted into one group or the other.
let CharStyle {
@@ -793,7 +794,9 @@ fn shapes_alike(a: &CharStyle, b: &CharStyle) -> bool {
underline: _,
strikethrough: _,
all_caps,
- features,
+ // Runs with different feature lists shape as one buffer: each run's features apply to its
+ // own characters only ([`shape_segment`]).
+ features: _,
style_name: _,
overprint_fill: _,
overprint_stroke: _,
@@ -826,7 +829,6 @@ fn shapes_alike(a: &CharStyle, b: &CharStyle) -> bool {
&& *h_scale == b.h_scale
&& *v_scale == b.v_scale
&& *all_caps == b.all_caps
- && *features == b.features
&& *position == b.position
&& *small_caps == b.small_caps
&& *proportional_metrics == b.proportional_metrics
@@ -978,6 +980,23 @@ struct Shaped {
vpal: Vec>,
}
+/// The features that turn the whole-text features `base` into `own` over the clusters `range`, put
+/// after `base` so that they win there: each feature of `own` that `base` lacks or has with
+/// another value, and each of `base` that `own` leaves out, back at the value the shaper has
+/// without it (on for the default features, off for the rest). The range is given with an
+/// inclusive end because harfrust's `Feature::new` takes an exclusive end one cluster short, but
+/// applies it as exclusive: a one-byte character would get an empty range.
+fn ranged_features(base: &[Feature], own: &[Feature], range: Range, out: &mut Vec) {
+ const ON_BY_DEFAULT: [&[u8; 4]; 7] = [b"kern", b"liga", b"clig", b"calt", b"apkn", b"vkrn", b"vapk"];
+ for f in own.iter().filter(|f| !base.iter().any(|b| b.tag == f.tag && b.value == f.value)) {
+ out.push(Feature::new(f.tag, f.value, range.start..=range.end));
+ }
+ for b in base.iter().filter(|b| !own.iter().any(|f| f.tag == b.tag)) {
+ let default = ON_BY_DEFAULT.iter().any(|t| Tag::new(t) == b.tag);
+ out.push(Feature::new(b.tag, u32::from(default), range.start..=range.end));
+ }
+}
+
fn is_mark(c: char) -> bool {
use unicode_general_category::{GeneralCategory, get_general_category};
matches!(get_general_category(c), GeneralCategory::NonspacingMark | GeneralCategory::SpacingMark | GeneralCategory::EnclosingMark)
@@ -1043,6 +1062,23 @@ fn shape_segment(text: &str, context: &Range, seg: &Segment, feats: &OtFe
return shape_apart(manual_ends, out);
}
+ // Runs that differ only in their OpenType feature lists are shaped as one buffer: the features
+ // of each run apply to its own characters only (a feature with a range of clusters, which here
+ // are source bytes), over those of `base_style`, whose features apply to the whole segment. So
+ // a feature or an alternate (`salt=2`) on one letter of an Arabic word leaves the joining,
+ // ligatures and kerning of the whole word to the shaper, as the same text without it.
+ // `variant` makes a style's features and `base` is what it makes for `base_style`.
+ let run_features = |variant: &dyn Fn(&CharStyle) -> Vec, base_style: &CharStyle, base: &[Feature]| {
+ let mut ranged = Vec::new();
+ for (r, run_style) in runs.get(source_runs.clone()).unwrap_or_default() {
+ let (a, b) = (r.start.max(range.start), r.end.min(range.end));
+ if run_style.features != base_style.features && a < b {
+ let own = variant(&CharStyle { features: run_style.features.clone(), ..base_style.clone() });
+ ranged_features(base, &own, a..b, &mut ranged);
+ }
+ }
+ ranged
+ };
// Shape the segment as if all of it were in `st`, with the features `more` on top.
let shape_in = |st: &CharStyle, more: &[Feature]| {
// A BCP 47 tag is short ASCII; anything else (a file may hold anything) is shaped without one.
@@ -1056,8 +1092,10 @@ fn shape_segment(text: &str, context: &Range, seg: &Segment, feats: &OtFe
let hb_shaped = face.hb().map(|hb| {
let shaper = face.shaper.shaper(&hb).instance(face.instance.as_ref()).build();
// gid, cluster, x and y advance, x and y offset.
- let shape = |feats: &[Feature], direction: Direction| {
- let feats: Vec = feats.iter().chain(more).cloned().collect();
+ let shape = |variant: &dyn Fn(&CharStyle) -> Vec, direction: Direction| {
+ let mut feats = variant(st);
+ feats.extend(run_features(variant, st, &feats));
+ feats.extend(more.iter().cloned());
let mut buf = UnicodeBuffer::new();
for (i, c) in text_seg.char_indices() {
let cl = (range.start + i) as u32;
@@ -1097,13 +1135,13 @@ fn shape_segment(text: &str, context: &Range, seg: &Segment, feats: &OtFe
.collect::>()
};
let along = if level.is_rtl() { Direction::RightToLeft } else { Direction::LeftToRight };
- *raw = shape(&feats.resolve(st), along).into_iter().map(|(g, c, xa, _, xo, yo)| (g, c, xa, xo, yo)).collect();
+ *raw = shape(&|s| feats.resolve(s), along).into_iter().map(|(g, c, xa, _, xo, yo)| (g, c, xa, xo, yo)).collect();
let raw = &*raw;
let same_glyphs = |v: &[(u32, u32, i32, i32, i32, i32)]| v.len() == raw.len() && v.iter().zip(raw).all(|(a, b)| a.0 == b.0);
if feats.proportional(st) {
// `palt` is positioning only: the same glyphs set on their full widths tell which
// advances it changed (kerning and the rest change both alike).
- let full = shape(&feats.resolve_fixed_width(st), along);
+ let full = shape(&|s| feats.resolve_fixed_width(s), along);
if same_glyphs(&full) {
*proportional = full.iter().zip(raw).map(|(a, b)| a.2 != b.2).collect();
}
@@ -1114,7 +1152,7 @@ fn shape_segment(text: &str, context: &Range, seg: &Segment, feats: &OtFe
// and without either tell how it moves each and changes its advance down the column
// (y up, advances negative). Which advances `vpal` itself changed comes from setting
// them with it alone.
- let ttb = |proportional, kerning| shape(&feats.resolve_vertical(st, proportional, kerning), Direction::TopToBottom);
+ let ttb = |proportional, kerning| shape(&|s| feats.resolve_vertical(s, proportional, kerning), Direction::TopToBottom);
let (with, without, alone) = (ttb(true, true), ttb(false, false), ttb(true, false));
if same_glyphs(&with) && same_glyphs(&without) && same_glyphs(&alone) {
*vpal = with
diff --git a/crates/text/src/test_fonts.rs b/crates/text/src/test_fonts.rs
index 69ccbfe700..77929bbee2 100644
--- a/crates/text/src/test_fonts.rs
+++ b/crates/text/src/test_fonts.rs
@@ -735,6 +735,90 @@ pub fn alternates_font(aalt: bool) -> Option> {
Some(builder.build())
}
+/// The family of [`joining_alternates_font`].
+pub const JOINING_ALTERNATES_FAMILY: &str = "Jointest Sans";
+
+/// Beh, teh, noon and alef: the letters [`joining_alternates_font`] maps.
+pub const JOINING_LETTERS: [char; 4] = ['\u{0628}', '\u{062A}', '\u{0646}', '\u{0627}'];
+
+/// A test-only Arabic font over the bundled Source Sans 3, whose glyphs stand for the positional
+/// forms of beh, teh, noon and alef ([`JOINING_LETTERS`]), each a Latin letter's outline: isolated
+/// b t n I, initial B T N, medial v u m, final V U M A (alef joins to the right only). Its `arab`
+/// `GSUB` has `init`, `medi` and `fina` single substitutions from the isolated glyphs, `salt`
+/// alternate substitutions of the medial teh u (w, x, y: three alternates), of the final teh U
+/// (W, X), of the isolated teh t (E) and of the medial beh v (z), and `ss01` single substitutions
+/// u to w, U to W, t to E and v to z. A set differs by position, as in a font that draws each
+/// letter's alternates for the position they join in. Family [`JOINING_ALTERNATES_FAMILY`]. `None`
+/// when it can't be built.
+pub fn joining_alternates_font() -> Option> {
+ use write_fonts::tables::cmap::Cmap;
+ use write_fonts::tables::gsub::{
+ AlternateSet, AlternateSubstFormat1, Gsub, SingleSubst, SingleSubstFormat2, SubstitutionLookup, SubstitutionLookupList,
+ };
+ use write_fonts::tables::layout::{CoverageFormat1, Feature, FeatureList, FeatureRecord, LangSys, Lookup, Script, ScriptList, ScriptRecord};
+ use write_fonts::types::GlyphId16;
+
+ let font = skrifa::FontRef::new(SOURCE_SANS).ok()?;
+ let charmap = font.charmap();
+ let gid = |c: char| Some(GlyphId16::new(u16::try_from(charmap.map(c)?.to_u32()).ok()?));
+ let family = JOINING_ALTERNATES_FAMILY;
+ let full = format!("{family} Regular");
+ let postscript = format!("{}-Regular", family.replace(' ', ""));
+ let name = name_table(&[(1, family), (2, "Regular"), (4, &full), (5, "Version 1.000"), (6, &postscript)])?;
+ let mapping = [(JOINING_LETTERS[0], 'b'), (JOINING_LETTERS[1], 't'), (JOINING_LETTERS[2], 'n'), (JOINING_LETTERS[3], 'I'), (' ', ' ')];
+ let mappings = mapping.into_iter().map(|(c, source)| Some((c, write_fonts::types::GlyphId::new(charmap.map(source)?.to_u32()))));
+ let cmap = Cmap::from_mappings(mappings.collect::>>()?).ok()?;
+ let mut builder = FontBuilder::new();
+ builder.add_table(&cmap).ok()?;
+ builder.add_raw(Tag::new(b"name"), name);
+ // A single substitution by (glyph, replacement) pairs, sorted by glyph id as coverage needs.
+ let single = |pairs: &[(char, char)]| {
+ let mut ids = pairs.iter().map(|(from, to)| Some((gid(*from)?, gid(*to)?))).collect:: >>()?;
+ ids.sort_by_key(|p| p.0.to_u16());
+ let from = ids.iter().map(|p| p.0).collect();
+ let to = ids.iter().map(|p| p.1).collect();
+ Some(SubstitutionLookup::Single(Lookup::new(
+ Default::default(),
+ vec![SingleSubst::from(SingleSubstFormat2::new(CoverageFormat1::new(from).into(), to))],
+ )))
+ };
+ let alternates = |sets: &[(char, &[char])]| {
+ let mut ids = sets
+ .iter()
+ .map(|(from, set)| Some((gid(*from)?, set.iter().map(|c| gid(*c)).collect:: >>()?)))
+ .collect:: >>()?;
+ ids.sort_by_key(|p| p.0.to_u16());
+ let from = ids.iter().map(|p| p.0).collect();
+ let sets = ids.into_iter().map(|p| AlternateSet::new(p.1)).collect();
+ Some(SubstitutionLookup::Alternate(Lookup::new(
+ Default::default(),
+ vec![AlternateSubstFormat1::new(CoverageFormat1::new(from).into(), sets)],
+ )))
+ };
+ // Lookups: 0 init, 1 medi, 2 fina, 3 salt, 4 ss01.
+ let lookups = SubstitutionLookupList::new(vec![
+ single(&[('b', 'B'), ('t', 'T'), ('n', 'N')])?,
+ single(&[('b', 'v'), ('t', 'u'), ('n', 'm')])?,
+ single(&[('b', 'V'), ('t', 'U'), ('n', 'M'), ('I', 'A')])?,
+ alternates(&[('u', &['w', 'x', 'y']), ('U', &['W', 'X']), ('t', &['E']), ('v', &['z'])])?,
+ single(&[('u', 'w'), ('U', 'W'), ('t', 'E'), ('v', 'z')])?,
+ ]);
+ // Features sorted by tag, as OpenType requires: fina, init, medi, salt, ss01.
+ let records: Vec = [(b"fina", 2), (b"init", 0), (b"medi", 1), (b"salt", 3), (b"ss01", 4)]
+ .into_iter()
+ .map(|(tag, lookup)| FeatureRecord::new(Tag::new(tag), Feature::new(None, vec![lookup])))
+ .collect();
+ let scripts = ScriptList::new(vec![ScriptRecord::new(Tag::new(b"arab"), Script::new(Some(LangSys::new(vec![0, 1, 2, 3, 4])), vec![]))]);
+ builder.add_table(&Gsub::new(scripts, FeatureList::new(records), lookups)).ok()?;
+ for r in font.table_directory.table_records() {
+ let tag = Tag::new(&r.tag().to_be_bytes());
+ if !builder.contains(tag) {
+ builder.add_raw(tag, font.table_data(r.tag())?.as_bytes());
+ }
+ }
+ Some(builder.build())
+}
+
/// The family of [`ligature_caret_font`].
pub const LIGATURE_CARET_FAMILY: &str = "Carettest Sans";
diff --git a/crates/text/src/tests.rs b/crates/text/src/tests.rs
index 031d35bdce..b9504b6ac9 100644
--- a/crates/text/src/tests.rs
+++ b/crates/text/src/tests.rs
@@ -12,6 +12,7 @@ mod combos;
mod devanagari;
mod embed;
mod fit;
+mod glyph_alternates;
mod hang;
mod inline;
mod joiners;
diff --git a/crates/text/src/tests/glyph_alternates.rs b/crates/text/src/tests/glyph_alternates.rs
new file mode 100644
index 0000000000..edec6de6f0
--- /dev/null
+++ b/crates/text/src/tests/glyph_alternates.rs
@@ -0,0 +1,293 @@
+//! A feature with a value (`salt=2`) on some characters of an Arabic word: the font's alternate
+//! for them, with the word shaped once so that every other letter keeps its joining form and the
+//! letters still touch. Read from a font built in code (and, when it is installed, the owner's
+//! Kufi Murabba PIIIX).
+
+use kurbo::Point;
+use vectorcraft_doc::{CharStyle, TextRun};
+
+use crate::test_fonts::{JOINING_ALTERNATES_FAMILY, JOINING_LETTERS, joining_alternates_font, source_sans_gid};
+use crate::*;
+
+const BEH: &str = "\u{0628}";
+const TEH: &str = "\u{062A}";
+const NOON: &str = "\u{0646}";
+
+fn db() -> FontDb {
+ let db = FontDb::with_font_dirs(vec![]);
+ db.add_font(joining_alternates_font().expect("the test font builds"));
+ db
+}
+
+fn style(family: &str, features: &[&str]) -> CharStyle {
+ CharStyle { font_family: family.into(), size: 100.0, features: features.iter().map(|f| f.to_string()).collect(), ..CharStyle::default() }
+}
+
+/// `text` as one text object in `family`, each piece with its own features.
+fn set(db: &FontDb, family: &str, pieces: &[(&str, &[&str])]) -> Vec {
+ let mut text = TextObject::point(Point::ZERO, "", style(family, &[]));
+ text.runs = pieces.iter().map(|(t, features)| TextRun { text: t.to_string(), style: style(family, features), inline: None }).collect();
+ // In reading order of the characters, whichever way the line runs.
+ let mut glyphs = layout(db, &text).glyphs;
+ glyphs.sort_by_key(|g| g.byte);
+ glyphs
+}
+
+fn joined(db: &FontDb, pieces: &[(&str, &[&str])]) -> Vec {
+ set(db, JOINING_ALTERNATES_FAMILY, pieces)
+}
+
+fn gids(glyphs: &[PositionedGlyph]) -> Vec {
+ glyphs.iter().map(|g| g.gid).collect()
+}
+
+fn g(c: char) -> u32 {
+ u32::from(source_sans_gid(c).expect("a Source Sans glyph"))
+}
+
+/// The gap between each glyph and the next along the line, whichever way the line runs: 0 when the
+/// letters touch.
+fn gaps(glyphs: &[PositionedGlyph]) -> Vec {
+ // `origin` is the pen position at the glyph's left edge, also in right-to-left text.
+ let mut spans: Vec<(f64, f64)> = glyphs.iter().map(|g| (g.origin.x, g.origin.x + g.advance)).collect();
+ spans.sort_by(|a, b| a.0.total_cmp(&b.0));
+ spans.windows(2).map(|w| w[1].0 - w[0].1).collect()
+}
+
+/// Beh, teh, noon: initial, medial and final in one word, teh drawn with `features`.
+fn word(db: &FontDb, teh_features: &[&str]) -> Vec {
+ joined(db, &[(BEH, &[]), (TEH, teh_features), (NOON, &[])])
+}
+
+/// Without a feature the word is beh initial, teh medial, noon final.
+#[test]
+fn the_fixture_word_takes_its_joining_forms() {
+ let db = db();
+ assert_eq!(gids(&joined(&db, &[(&format!("{BEH}{TEH}{NOON}"), &[])])), [g('B'), g('u'), g('M')]);
+ assert_eq!(JOINING_LETTERS.len(), 4);
+}
+
+/// `salt=N` on the middle letter draws the font's Nth alternate for it, its neighbours keep their
+/// initial and final forms (they are not shaped as lone letters), and the letters still touch.
+#[test]
+fn a_salt_value_on_one_letter_picks_its_alternate_and_keeps_the_word_joined() {
+ let db = db();
+ let plain = word(&db, &[]);
+ assert_eq!(gids(&plain), [g('B'), g('u'), g('M')]);
+ let plain_gaps = gaps(&plain);
+ assert!(plain_gaps.iter().all(|gap| gap.abs() < 1e-6), "the word's glyphs touch: {plain_gaps:?}");
+ for (features, middle) in [(&["salt"][..], 'w'), (&["salt=1"][..], 'w'), (&["salt=2"][..], 'x'), (&["salt=3"][..], 'y')] {
+ let glyphs = word(&db, features);
+ assert_eq!(gids(&glyphs), [g('B'), g(middle), g('M')], "{features:?}: the alternate between the word's own initial and final forms");
+ let gap = gaps(&glyphs);
+ assert!(gap.iter().all(|gap| gap.abs() < 1e-6), "{features:?}: the letters touch: {gap:?}");
+ }
+ // An alternate number the font has no glyph for, and the feature off, draw the default glyph.
+ for features in [&["salt=4"][..], &["-salt"][..], &["salt=0"][..], &["salt=2", "-salt"][..], &["ss03"][..]] {
+ assert_eq!(gids(&word(&db, features)), [g('B'), g('u'), g('M')], "{features:?}");
+ }
+ // The last value of a feature wins.
+ assert_eq!(gids(&word(&db, &["salt=3", "salt=2"]))[1], g('x'));
+ // The font's salt lookup comes before its ss01 lookup, so salt takes the glyph first.
+ assert_eq!(gids(&word(&db, &["salt=2", "ss01"]))[1], g('x'));
+}
+
+/// A feature on the first or the last letter of the word does not split it either.
+#[test]
+fn a_feature_on_an_end_letter_does_not_break_the_joining() {
+ let db = db();
+ for (pieces, expected) in [
+ (vec![(BEH, &["salt=2"][..]), (TEH, &[][..]), (NOON, &[][..])], [g('B'), g('u'), g('M')]),
+ (vec![(BEH, &[][..]), (TEH, &[][..]), (NOON, &["salt=2"][..])], [g('B'), g('u'), g('M')]),
+ (vec![(BEH, &["ss01"][..]), (TEH, &["ss01"][..]), (NOON, &["ss01"][..])], [g('B'), g('w'), g('M')]),
+ ] {
+ let glyphs = joined(&db, &pieces);
+ assert_eq!(gids(&glyphs), expected, "{pieces:?}");
+ assert!(gaps(&glyphs).iter().all(|gap| gap.abs() < 1e-6), "{pieces:?}");
+ }
+}
+
+/// The alternates of a letter depend on the form it takes where it stands: the final teh has two,
+/// the medial teh three, so `salt=3` is an alternate of one and nothing for the other.
+#[test]
+fn the_alternate_a_value_picks_depends_on_the_letters_position() {
+ let db = db();
+ let medial = |features: &[&str]| gids(&word(&db, features))[1];
+ let final_form = |features: &[&str]| gids(&joined(&db, &[(BEH, &[]), (TEH, features)]))[1];
+ assert_eq!((medial(&["salt=3"]), final_form(&["salt=3"])), (g('y'), g('U')), "no third alternate for the final form");
+ assert_eq!((medial(&["salt=2"]), final_form(&["salt=2"])), (g('x'), g('X')));
+ // A letter alone takes the isolated form's alternate.
+ assert_eq!(gids(&joined(&db, &[(TEH, &["salt"])])), [g('E')]);
+ assert_eq!(gids(&joined(&db, &[(TEH, &[])])), [g('t')]);
+}
+
+/// A run with its own feature next to runs of another style in the word shapes with them: the
+/// same glyph ids and places as a word typed in one run, when the feature changes nothing.
+#[test]
+fn runs_that_differ_only_in_features_shape_as_one_word() {
+ let db = db();
+ let one_run = joined(&db, &[(&format!("{BEH}{TEH}{NOON}"), &[])]);
+ let three_runs = joined(&db, &[(BEH, &[]), (TEH, &["salt=9"]), (NOON, &["-liga"])]);
+ assert_eq!(gids(&three_runs), gids(&one_run));
+ let (a, b): (Vec, Vec) = (one_run.iter().map(|g| g.origin.x).collect(), three_runs.iter().map(|g| g.origin.x).collect());
+ assert_eq!(a, b, "the same places");
+}
+
+/// The Kufi Murabba PIIIX of the font's author, when it is installed: the middle letter of a word
+/// takes each of its `salt` alternates and every other letter keeps the glyph it has without one.
+#[test]
+fn kufi_murabba_alternates_keep_the_word_joined() {
+ let Some(path) =
+ std::env::var_os("HOME").map(|h| std::path::Path::new(&h).join("Library/Fonts/KufiMurabbaPIIIX-Regular.ttf")).filter(|p| p.exists())
+ else {
+ return;
+ };
+ let Ok(bytes) = std::fs::read(path) else { return };
+ let db = FontDb::with_font_dirs(vec![]);
+ db.add_font(bytes);
+ let Some(face) = db.face("Kufi Murabba PIIIX", "Regular") else { return };
+ let family = face.family.clone();
+ let letters: Vec = "الخوارزمی".chars().collect();
+ let text = |pieces: &[(String, Vec<&str>)]| {
+ let pieces: Vec<(&str, &[&str])> = pieces.iter().map(|(t, f)| (t.as_str(), f.as_slice())).collect();
+ set(&db, &family, &pieces)
+ };
+ let plain = text(&[(letters.iter().collect(), vec![])]);
+ assert_eq!(plain.len(), letters.len(), "one glyph for each letter");
+ assert!(gaps(&plain).iter().all(|gap| gap.abs() < 1e-3), "the word joins without a gap: {:?}", gaps(&plain));
+ // The medial khah.
+ let at = 2;
+ let default = plain[at].gid;
+ let salt =
+ face.substitutions(default).into_iter().find(|s| &s.feature == b"salt" && s.glyphs.len() >= 2).expect("the medial khah has salt alternates");
+ for (i, alternate) in salt.glyphs.iter().enumerate() {
+ let value = format!("salt={}", i + 1);
+ let before: String = letters[..at].iter().collect();
+ let after: String = letters[at + 1..].iter().collect();
+ let glyphs = text(&[(before, vec![]), (letters[at].to_string(), vec![value.as_str()]), (after, vec![])]);
+ assert_eq!(glyphs.len(), plain.len(), "{value}");
+ assert_eq!(glyphs[at].gid, *alternate, "{value}: the alternate");
+ for (n, (a, b)) in glyphs.iter().zip(&plain).enumerate().filter(|(n, _)| *n != at) {
+ assert_eq!(a.gid, b.gid, "{value}: the letter {n} keeps its form");
+ }
+ assert!(gaps(&glyphs).iter().all(|gap| gap.abs() < 1e-3), "{value}: no gap: {:?}", gaps(&glyphs));
+ }
+}
+
+/// The `GSUB` reader gives the alternate set of each glyph (position in the set = the feature's
+/// value), single substitutions, the feature that reaches a glyph, names and the glyph count.
+#[test]
+fn the_gsub_reader_lists_alternate_sets_singles_and_the_feature_reaching_a_glyph() {
+ use crate::{Substitution, SubstitutionKind};
+ let db = db();
+ let face = db.face(JOINING_ALTERNATES_FAMILY, "Regular").expect("the test face");
+ let salt = Substitution { feature: *b"salt", kind: SubstitutionKind::Alternate, glyphs: vec![g('w'), g('x'), g('y')] };
+ let ss01 = Substitution { feature: *b"ss01", kind: SubstitutionKind::Single, glyphs: vec![g('w')] };
+ assert_eq!(face.substitutions(g('u')), [salt.clone(), ss01.clone()], "salt (an alternate set) before ss01 (a single)");
+ assert_eq!(salt.value_for(g('x')), Some(2));
+ assert_eq!(salt.value_for(g('u')), None);
+ assert_eq!((ss01.value_for(g('w')), ss01.value_for(g('x'))), (Some(1), None));
+ // Each form of a letter has its own alternates.
+ let sets = |c| face.substitutions(g(c)).iter().map(|s| (s.feature, s.glyphs.len())).collect::>();
+ assert_eq!(sets('U'), [(*b"salt", 2), (*b"ss01", 1)]);
+ assert_eq!(sets('t'), [(*b"salt", 1), (*b"ss01", 1)]);
+ assert!(sets('B').is_empty() && sets('M').is_empty(), "the initial and final forms of the others have none");
+ assert!(face.substitutions(face.glyph_count()).is_empty(), "past the glyphs");
+ // The feature that reaches a glyph no character maps: the listed ones first, then positional forms.
+ assert_eq!(face.feature_reaching(g('x')), Some(*b"salt"));
+ assert_eq!(face.feature_reaching(g('w')), Some(*b"salt"), "salt ranks before ss01");
+ assert_eq!(face.feature_reaching(g('B')), Some(*b"init"));
+ assert_eq!(face.feature_reaching(g('m')), Some(*b"medi"));
+ assert_eq!(face.feature_reaching(g('A')), Some(*b"fina"));
+ assert_eq!(face.feature_reaching(g('b')), None, "the isolated form is reached by no feature");
+ // Names and the glyph count.
+ let bundled = db.face(FALLBACK_FAMILY, "Regular").expect("the bundled face");
+ assert_eq!(face.glyph_count(), bundled.glyph_count());
+ assert_eq!(face.glyph_name(g('B')).as_deref(), Some("B"));
+ assert_eq!(face.glyph_name(face.glyph_count()), None);
+ // `alternates` is the same reading, for a character.
+ let listed: Vec<([u8; 4], u32)> = face.alternates('\u{062A}').into_iter().map(|a| (a.feature, a.gid)).collect();
+ assert_eq!(listed, [(*b"salt", g('E'))], "the isolated teh");
+}
+
+/// The alternates of a letter as it stands in its word: the glyph the shaper makes for it there,
+/// its alternates, and the feature value that draws each on that letter alone.
+#[test]
+fn alternates_in_context_list_the_letters_positional_form_and_the_feature_that_draws_each() {
+ let db = db();
+ let text = |teh: &[&str]| {
+ let mut t = TextObject::point(Point::ZERO, "", style(JOINING_ALTERNATES_FAMILY, &[]));
+ t.runs = [(BEH, &[][..]), (TEH, teh), (NOON, &[][..])]
+ .into_iter()
+ .map(|(s, f)| TextRun { text: s.to_string(), style: style(JOINING_ALTERNATES_FAMILY, f), inline: None })
+ .collect();
+ t
+ };
+ let teh = BEH.len();
+ let found = alternates_in_context(&db, &text(&[]), teh).expect("the medial teh");
+ assert_eq!((found.default, found.current), (g('u'), g('u')));
+ let listed: Vec<(u32, Option)> = found.alternates.iter().map(|a| (a.gid, a.apply.clone())).collect();
+ assert_eq!(
+ listed,
+ [(g('w'), Some("salt".to_string())), (g('x'), Some("salt=2".to_string())), (g('y'), Some("salt=3".to_string()))],
+ "the medial form's three alternates, not the isolated form's one"
+ );
+ // Drawn with salt=2 now: the default is still the form without it.
+ let now = alternates_in_context(&db, &text(&["salt=2"]), teh).expect("the medial teh");
+ assert_eq!((now.default, now.current, now.alternates.len()), (g('u'), g('x'), 3));
+ // The first and last letters take their own forms.
+ let beh = alternates_in_context(&db, &text(&[]), 0).expect("the initial beh");
+ assert_eq!((beh.default, beh.alternates.len()), (g('B'), 0));
+ let noon = alternates_in_context(&db, &text(&[]), teh + TEH.len()).expect("the final noon");
+ assert_eq!((noon.default, noon.alternates.len()), (g('M'), 0));
+ assert_eq!(alternates_in_context(&db, &text(&[]), 1), None, "not a character boundary");
+ // alternate_changes: the character keeps only the alternate feature it is given.
+ let own = |f: &[&str]| f.iter().map(|s| s.to_string()).collect::>();
+ assert_eq!(alternate_changes(&own(&["ss01", "salt=2", "dlig", "-ss02"]), Some("salt=3")), ["-ss01", "salt=3"]);
+ assert_eq!(alternate_changes(&own(&["ss01", "salt=2", "dlig"]), None), ["-ss01", "-salt"]);
+ assert!(alternate_changes(&own(&["salt=2", "dlig"]), Some("salt=2")).is_empty());
+ assert_eq!(alternate_changes(&own(&["salt"]), Some("salt=2")), ["salt=2"]);
+ assert!(alternate_changes(&own(&["dlig"]), None).is_empty());
+ assert!(is_alternate_feature("-ss03") && is_alternate_feature("salt=2") && !is_alternate_feature("dlig") && !is_alternate_feature("titl"));
+}
+
+/// A glyph no feature of the style draws is listed without one, and a feature's glyph is found
+/// with the feature that draws it.
+#[test]
+fn a_variant_no_feature_draws_has_no_feature_to_apply() {
+ use crate::test_fonts::{alternates_family, alternates_font, unencoded_glyphs};
+ let db = FontDb::with_font_dirs(vec![]);
+ db.add_font(alternates_font(true).expect("the test font builds"));
+ let [u1, u2, u3, u4] = unencoded_glyphs().expect("unencoded glyphs");
+ let face = db.face(alternates_family(true), "Regular").expect("the test face");
+ let st = CharStyle { font_family: alternates_family(true).into(), size: 100.0, ..CharStyle::default() };
+ let found = alternates_in_context(&db, &TextObject::point(Point::ZERO, "xay", st), 1).expect("the a");
+ let apply = |gid: u32| found.alternates.iter().find(|a| a.gid == gid).map(|a| a.apply.clone());
+ assert_eq!(found.default, face.glyph_for('a'));
+ assert_eq!(apply(u1), Some(Some("swsh=2".to_string())), "swsh before aalt");
+ assert_eq!(apply(u2), Some(Some("ss01".to_string())));
+ assert_eq!(apply(u3), Some(Some("swsh".to_string())));
+ assert_eq!(apply(u4), Some(Some("ss02".to_string())));
+ assert_eq!(apply(face.glyph_for('c')), Some(None), "cv01 has no style tag");
+ assert!(face.unencoded_glyphs().contains(&u1) && !face.unencoded_glyphs().contains(&face.glyph_for('a')));
+}
+
+/// A feature on a one-byte character (Latin) reaches that character: the range of a feature is
+/// its source bytes, and harfrust applies a range `a..b` to clusters `a..b - 1` unless the end is
+/// inclusive, which would leave a one-byte character out.
+#[test]
+fn a_feature_on_a_one_byte_character_reaches_it() {
+ use crate::test_fonts::{alternates_family, alternates_font};
+ let db = FontDb::with_font_dirs(vec![]);
+ db.add_font(alternates_font(true).expect("the test font builds"));
+ let face = db.face(alternates_family(true), "Regular").expect("the test face");
+ let st = |features: &[&str]| style(alternates_family(true), features);
+ let mut text = TextObject::point(Point::ZERO, "", st(&[]));
+ text.runs = vec![
+ TextRun { text: "x".into(), style: st(&[]), inline: None },
+ TextRun { text: "a".into(), style: st(&["salt"]), inline: None },
+ TextRun { text: "y".into(), style: st(&[]), inline: None },
+ ];
+ let gids: Vec = layout(&db, &text).glyphs.iter().map(|g| g.gid).collect();
+ assert_eq!(gids, [face.glyph_for('x'), face.glyph_for('b'), face.glyph_for('y')], "salt draws b for the a alone");
+}
diff --git a/crates/ui-egui/src/dialogs/char_style_options.rs b/crates/ui-egui/src/dialogs/char_style_options.rs
index 41530de0e9..32333848c7 100644
--- a/crates/ui-egui/src/dialogs/char_style_options.rs
+++ b/crates/ui-egui/src/dialogs/char_style_options.rs
@@ -183,9 +183,10 @@ pub(crate) fn swatch_choices(doc: &vectorcraft_doc::Document) -> Value {
Value::Array(choices.collect())
}
-/// A feature tag without its `-` or `+`.
+/// A feature tag without its `-` or `+` prefix and its `=N` value.
fn bare(tag: &str) -> &str {
- tag.trim_start_matches(['-', '+'])
+ let tag = tag.trim_start_matches(['-', '+']);
+ tag.split_once('=').map_or(tag, |(name, _)| name)
}
/// The OpenType Features fields for a style's `features`: each control is unset unless the list
@@ -486,9 +487,14 @@ fn opentype_attrs(d: &Dialog, original: &Map, original_fields: &M
}
if !unchanged("ot_sets") {
let sets = &vectorcraft_text::EXTRA_FEATURES[..20];
+ // A set still on keeps the value its tag gives (`ss01=2`).
+ let before: Vec = tags.iter().filter(|t| sets.contains(&bare(t)) && !t.starts_with('-')).cloned().collect();
tags.retain(|t| !sets.contains(&bare(t)));
let listed = d.fields.get("ot_sets").and_then(Value::as_array).into_iter().flatten().filter_map(Value::as_str);
- tags.extend(listed.filter(|t| sets.contains(&bare(t))).map(str::to_string));
+ tags.extend(listed.filter(|t| sets.contains(&bare(t))).map(|t| {
+ let kept = before.iter().rev().find(|b| !t.starts_with('-') && bare(b) == bare(t));
+ kept.map_or_else(|| t.to_string(), Clone::clone)
+ }));
}
if !tags.is_empty() {
attrs.insert("features".into(), json!(tags));
diff --git a/crates/ui-egui/src/panels/opentype.rs b/crates/ui-egui/src/panels/opentype.rs
index 1f98c74cdf..591524b794 100644
--- a/crates/ui-egui/src/panels/opentype.rs
+++ b/crates/ui-egui/src/panels/opentype.rs
@@ -44,8 +44,7 @@ impl Flag {
fn put(self, f: &mut OtFeatures, on: bool) {
match self {
Flag::Field(field) => *field(f) = on,
- Flag::Extra(tag) if on => f.extra |= extra_bit(tag),
- Flag::Extra(tag) => f.extra &= !extra_bit(tag),
+ Flag::Extra(tag) => f.set_extra(extra_bit(tag), on),
}
}
}
@@ -69,13 +68,16 @@ pub(crate) fn figure(f: &OtFeatures) -> usize {
fn set_figure(f: &mut OtFeatures, i: usize) {
f.tabular_figures = i == 1 || i == 4;
f.oldstyle_figures = i >= 3;
- f.extra &= !match i {
- 1 => extra_bit("pnum"),
- 3 => extra_bit("lnum"),
- _ => extra_bit("pnum") | extra_bit("lnum"),
- };
+ f.set_extra(
+ match i {
+ 1 => extra_bit("pnum"),
+ 3 => extra_bit("lnum"),
+ _ => extra_bit("pnum") | extra_bit("lnum"),
+ },
+ false,
+ );
if i == 2 {
- f.extra |= extra_bit("pnum") | extra_bit("lnum");
+ f.set_extra(extra_bit("pnum") | extra_bit("lnum"), true);
}
}
@@ -95,11 +97,7 @@ pub(crate) fn position(f: &OtFeatures) -> Option {
/// Default Position turns all four off.
fn set_position(f: &mut OtFeatures, i: usize) {
for (k, tag) in POSITION_TAGS.iter().enumerate() {
- if k + 1 == i {
- f.extra |= extra_bit(tag);
- } else {
- f.extra &= !extra_bit(tag);
- }
+ f.set_extra(extra_bit(tag), k + 1 == i);
}
}
diff --git a/docs/control-protocol.md b/docs/control-protocol.md
index 0a9bfd9f09..88b80aecde 100644
--- a/docs/control-protocol.md
+++ b/docs/control-protocol.md
@@ -1,6 +1,6 @@
# Control protocol
-> **Last updated:** 2026-10-31 · **Change:** Confining file access refuses a `:` in a name after the drive on Windows, an NTFS alternate data stream such as `x.svg:evil` (#3604); previously: Object → Expand… dimmed while the selection has an appearance Expand Appearance expands (`object.expand.info`'s `appearance`, #3089); previously: brush library panels (`window.brushLibrary`, `window.brushLibrary.other`, `ui.saveBrushLibrary`); previously: Transform Each opens on the values it was last confirmed with; its Copy button confirms with `copy: true` (#1732); previously: panel docking on craft-ui's layout: `ui.docking`, the parameters of `window.panel.float` and `window.panel.dock`; previously: `ui.screenshot`'s 64 MiB inline-PNG cap and its path-plus-data downgrade; symbol library panels (`window.symbolLibrary`, `window.symbolLibrary.other`, `ui.saveSymbolLibrary`); synthetic input budgets (#1709); bounded transport waits (#1726).
+> **Last updated:** 2026-11-07 · **Change:** text style commands take a feature with a value (`"features": ["salt=2"]`), the font's alternate number, and shape runs that differ only in features as one piece of text; previously: Confining file access refuses a `:` in a name after the drive on Windows, an NTFS alternate data stream such as `x.svg:evil` (#3604); previously: Object → Expand… dimmed while the selection has an appearance Expand Appearance expands (`object.expand.info`'s `appearance`, #3089); previously: brush library panels (`window.brushLibrary`, `window.brushLibrary.other`, `ui.saveBrushLibrary`); previously: Transform Each opens on the values it was last confirmed with; its Copy button confirms with `copy: true` (#1732); previously: panel docking on craft-ui's layout: `ui.docking`, the parameters of `window.panel.float` and `window.panel.dock`; previously: `ui.screenshot`'s 64 MiB inline-PNG cap and its path-plus-data downgrade; symbol library panels (`window.symbolLibrary`, `window.symbolLibrary.other`, `ui.saveSymbolLibrary`); synthetic input budgets (#1709); bounded transport waits (#1726).
`vectorcraft --control ` listens on `127.0.0.1:` (loopback only). The port must be a number
from 1 to 65535 (`--control=` works too), given once; otherwise the app prints an error and exits
@@ -922,6 +922,23 @@ handle); a Mesh tool click inside a mesh envelope adds a row and a column. While
selected the Control bar shows its controls (Edit Envelope / Edit Contents, warp style, orientation, bend and
distortions or mesh rows and columns, Reset, Envelope Options); each runs an `object.envelope.*` command.
+## OpenType feature values in text commands
+
+`text.setRangeStyle`, `text.setStyle` and the character style commands take OpenType features as a list of tags in
+`features` (the whole list) or `featureChanges` (changes on top of each character's own): `"dlig"` on, `"-liga"` off.
+A feature with alternates (`swsh`, `ss01`–`ss20`, `salt`, `titl`, `pnum`, `lnum`, `sups`, `subs`, `numr`, `dnom`,
+`aalt`) also takes `tag=N`, `N` from 0 to 65535, the font's alternate number: `"salt=2"` draws the second alternate of
+each glyph in the range that has one. It is the value the shaper gets (an OpenType alternate substitution picks the
+glyph at that position of its set), so `"salt"` is `"salt=1"`, and `"-salt"`, `"salt=0"` and a missing tag are off. A
+later value replaces an earlier one; `salt=x`, `salt=65536`, `-salt=2` and `liga=2` are refused. The style's
+`features` list and the saved file keep the string as typed (`["salt=2"]`), and SVG export writes CSS
+`font-feature-settings: "salt" 2`. Runs that differ only in `features` are shaped as one piece of text with each run's
+features over its own characters, so one letter of an Arabic word with `salt=2` keeps its neighbours' joining forms.
+
+```json
+{"id":1,"method":"engine.execute","params":{"command":"text.setRangeStyle","params":{"id":7,"start":4,"end":6,"features":["salt=2"]}}}
+```
+
## Shared panel docking
The canvas and the panels form one craft-ui docking layout: the protected `canvas` panel beside a dock at the
@@ -1017,6 +1034,7 @@ hidden tab (whose frames the browser pauses) answers when it is shown again.
| Date | Change | Summary |
|---|---|---|
+| 2026-11-07 | minor | Documented OpenType feature values in text commands (`"salt=2"`, the font's alternate number) and that runs differing only in features shape as one piece of text. |
| 2026-10-31 | minor | Confining file access: on Windows a `:` in a name after the drive (an NTFS alternate data stream) is refused with the device names (#3604). |
| 2026-10-25 | minor | Panel docking on craft-ui's layout: `ui.docking`, the floating and dock parameters, native floating windows. |
| 2026-10-24 | minor | Documented the brush library panel commands (`window.brushLibrary`, `.other`, `ui.saveBrushLibrary`, `window.userBrushLibrary1`…`10`). |
diff --git a/docs/mcp.md b/docs/mcp.md
index 1adcf32b1b..238bd07ce8 100644
--- a/docs/mcp.md
+++ b/docs/mcp.md
@@ -5726,8 +5726,10 @@ it, as with any style change to one character (`text.setRangeStyle`). The type s
## Alternate glyphs
The Glyphs panel's Show › Alternates for Current Selection lists the alternate glyphs of the character selected with
-the Type tool (a range's first character, else the one before the caret), with the default glyph first when the font
-does not list it, and a double-click on one draws that character with it. `text.glyphAlternates {id, start}` lists them for the character at byte `start`: `{char, font, style, default,
+the Type tool (a range's first character, else the one before the caret) as it stands in its word, with the glyph the
+shaper makes for it first, and a double-click on one applies the OpenType feature that draws it to that character alone
+(see [Alternates as OpenType feature values](#alternates-as-opentype-feature-values)); a glyph no feature draws there is
+drawn by `text.setGlyphAlternate`. `text.glyphAlternates {id, start}` lists them for the character at byte `start`: `{char, font, style, default,
chosen, alternates: [{gid, feature}]}`, where `default` is the glyph the font's character map gives, `chosen` the glyph
drawn now, and `alternates` the glyphs the font's `GSUB` single and alternate substitutions put in place of the default
glyph: `aalt` first, then `salt`, `ss01` to `ss20`, `swsh`, `cv01` to `cv99`, `hist`, `jalt`, `nalt`, `ornm` and
@@ -5748,6 +5750,36 @@ system). Choosing the glyph a character already draws is not an edit. Character
{"name":"run_command","arguments":{"command":"text.setGlyphAlternate","params":{"id":7,"start":2,"gid":1043}}}
```
+### Alternates as OpenType feature values
+
+`text.setGlyphAlternate`'s choice swaps a glyph id into one character and shapes that character apart from its neighbours.
+A feature with a value keeps the text real and joined: `features`, `featureChanges` (`text.setRangeStyle`,
+`text.setStyle`, `charStyle.*`) take `tag=N` for `swsh`, `ss01` to `ss20`, `salt`, `titl`, `pnum`, `lnum`, `sups`,
+`subs`, `numr`, `dnom` and `aalt`, `N` from 0 to 65535, and the style's `features` list keeps the string:
+`"salt=2"` draws the font's second alternate of every glyph in the run that has one (the OpenType alternate
+substitution picks the glyph at that position of its set; a glyph without one, or with fewer alternates, keeps the
+glyph it has). A plain `"salt"` is `"salt=1"` and is written so; `"salt=0"`, `"-salt"` and leaving it out are off. The
+next value for a feature replaces the last one, in `features` and in `featureChanges`, and a tag the build does not know
+(`salt=x`, `salt=65536`, `-salt=2`, `liga=2`) is refused with the unknown-feature error. SVG export writes the value as
+CSS `font-feature-settings: "salt" 2`.
+
+Runs that differ only in their `features` are shaped together as one piece of text, each run's features over its own
+characters: styling the middle letter of an Arabic word with `salt=2` leaves every other letter its initial, medial or
+final form and the letters still join, as the same word typed with no style change. Runs that differ in font, size,
+scale, case, language, script or direction are still shaped apart.
+
+`text.glyphAlternates` lists a character's alternates by the glyph the font's character map gives it; the Glyphs panel
+instead lists those of the glyph the shaper makes for the character in its word (a medial letter's medial form), finds
+for each the feature value that draws it there, and on a double-click runs one `text.setRangeStyle` on exactly that
+character: `featureChanges` turns its other alternate features (`salt`, `aalt`, `swsh`, `ss01`–`ss20`) off and sets the
+value (`["-ss01", "salt=2"]`), and `glyphAlternate: null` takes away a glyph choice that would otherwise keep drawing
+its own glyph (`text.setRangeStyle` takes it; any other value is refused). It is one undo step.
+
+```json
+{"name":"run_command","arguments":{"command":"text.setRangeStyle","params":{"id":7,"start":4,"end":6,"features":["salt=2"]}}}
+{"name":"run_command","arguments":{"command":"text.setRangeStyle","params":{"id":7,"start":4,"end":6,"featureChanges":["-salt"]}}}
+```
+
## New type in a Japanese interface
While the interface is in Japanese (Preferences › User Interface › Language, or `auto` on a Japanese system), new type starts with em
From 7e4594739910cdaf912e902d97eadae598b75e17 Mon Sep 17 00:00:00 2001
From: Taha-Mahmoodi <85902429+Taha-Mahmoodi@users.noreply.github.com>
Date: Sat, 7 Nov 2026 14:19:24 +0430
Subject: [PATCH 06/86] Glyphs: Alternates for Current Selection applies a
feature value to one letter; Entire Font lists unencoded glyphs
The grid lists the alternates of the glyph the shaper makes for the selected character
in its word, and a double-click applies the feature that draws it (salt=2) to that
character alone in one undo step, so an Arabic word stays joined. A variant no feature
draws keeps the replacement-glyph fallback, with a tooltip. Entire Font adds glyphs no
character maps, with name or gid and the feature reaching them. i18n rows in every
catalog; parity docs updated (#5514).
---
crates/ui-egui/src/i18n/bg.tsv | 2 +
crates/ui-egui/src/i18n/bs.tsv | 2 +
crates/ui-egui/src/i18n/ca.tsv | 2 +
crates/ui-egui/src/i18n/cs.tsv | 2 +
crates/ui-egui/src/i18n/da.tsv | 2 +
crates/ui-egui/src/i18n/de.tsv | 2 +
crates/ui-egui/src/i18n/el.tsv | 2 +
crates/ui-egui/src/i18n/es.tsv | 2 +
crates/ui-egui/src/i18n/fi.tsv | 2 +
crates/ui-egui/src/i18n/fr.tsv | 2 +
crates/ui-egui/src/i18n/hi.tsv | 2 +
crates/ui-egui/src/i18n/hu.tsv | 2 +
crates/ui-egui/src/i18n/id.tsv | 2 +
crates/ui-egui/src/i18n/it.tsv | 2 +
crates/ui-egui/src/i18n/ja.tsv | 2 +
crates/ui-egui/src/i18n/ka.tsv | 2 +
crates/ui-egui/src/i18n/ko.tsv | 2 +
crates/ui-egui/src/i18n/nl.tsv | 2 +
crates/ui-egui/src/i18n/pl.tsv | 2 +
crates/ui-egui/src/i18n/pt-br.tsv | 2 +
crates/ui-egui/src/i18n/ru.tsv | 2 +
crates/ui-egui/src/i18n/sv.tsv | 2 +
crates/ui-egui/src/i18n/tr.tsv | 2 +
crates/ui-egui/src/i18n/uk.tsv | 2 +
crates/ui-egui/src/i18n/vi.tsv | 2 +
crates/ui-egui/src/i18n/zh-hans.tsv | 2 +
crates/ui-egui/src/i18n/zh-hant.tsv | 2 +
crates/ui-egui/src/panels/glyphs.rs | 330 +++++++++++++++++++++++-----
docs/gaps.md | 5 +-
docs/target-app-parity.md | 4 +-
docs/type-parity.md | 4 +-
31 files changed, 335 insertions(+), 62 deletions(-)
diff --git a/crates/ui-egui/src/i18n/bg.tsv b/crates/ui-egui/src/i18n/bg.tsv
index f6a8debdc7..dc53b5b246 100644
--- a/crates/ui-egui/src/i18n/bg.tsv
+++ b/crates/ui-egui/src/i18n/bg.tsv
@@ -846,6 +846,8 @@ font-technology Variable Променливи
The selected character's font is not available. Шрифтът на избрания знак не е наличен.
No alternates for the selected character. Няма алтернативи за избрания знак.
Double-click to replace Двойно щракване за замяна
+ No OpenType feature draws this glyph here, so it replaces the character's glyph and may not join its neighbors. Нито една OpenType функция не рисува този глиф тук, затова той заменя глифа на знака и може да не се свързва със съседните знаци.
+ This glyph has no character, so it can't be inserted. Use Alternates for Current Selection to apply it. Този глиф няма знак, затова не може да се вмъкне. Използвайте „Алтернативи за текущата селекция“, за да го приложите.
Double-click to insert Двойно щракване за вмъкване
Double-click to rename Двойно щракване за преименуване
Double-click to target the contents Двойно щракване за насочване към съдържанието
diff --git a/crates/ui-egui/src/i18n/bs.tsv b/crates/ui-egui/src/i18n/bs.tsv
index a8db1ebeaf..a4c34938b5 100644
--- a/crates/ui-egui/src/i18n/bs.tsv
+++ b/crates/ui-egui/src/i18n/bs.tsv
@@ -1554,6 +1554,8 @@ digits Malayalam Malajalam
The selected character's font is not available. Font odabranog znaka nije dostupan.
No alternates for the selected character. Nema alternativa za odabrani znak.
Double-click to replace Dvostruki klik za zamjenu
+ No OpenType feature draws this glyph here, so it replaces the character's glyph and may not join its neighbors. Nijedna OpenType funkcija ne crta ovaj glif ovdje, pa on zamjenjuje glif znaka i možda se neće spajati sa susjednim znakovima.
+ This glyph has no character, so it can't be inserted. Use Alternates for Current Selection to apply it. Ovaj glif nema znak, pa se ne može umetnuti. Koristite „Alternative za trenutni odabir“ da ga primijenite.
Double-click to insert Dvostruki klik za umetanje
Entire Font Cijeli font
glyph {id} glif {id}
diff --git a/crates/ui-egui/src/i18n/ca.tsv b/crates/ui-egui/src/i18n/ca.tsv
index 3898a677f4..7af14f8aab 100644
--- a/crates/ui-egui/src/i18n/ca.tsv
+++ b/crates/ui-egui/src/i18n/ca.tsv
@@ -1208,6 +1208,8 @@
The selected character's font is not available. El tipus de lletra del caràcter seleccionat no està disponible.
No alternates for the selected character. No hi ha alternatives per al caràcter seleccionat.
Double-click to replace Feu doble clic per substituir
+ No OpenType feature draws this glyph here, so it replaces the character's glyph and may not join its neighbors. Cap funció OpenType dibuixa aquest glif aquí, de manera que substitueix el glif del caràcter i pot no unir-se als caràcters veïns.
+ This glyph has no character, so it can't be inserted. Use Alternates for Current Selection to apply it. Aquest glif no té cap caràcter, de manera que no es pot inserir. Feu servir «Alternatives per a la selecció actual» per aplicar-lo.
Double-click to insert Feu doble clic per inserir
Double-click to rename Feu doble clic per canviar el nom
Double-click to target the contents Feu doble clic per apuntar al contingut
diff --git a/crates/ui-egui/src/i18n/cs.tsv b/crates/ui-egui/src/i18n/cs.tsv
index eff1d2d343..93c816d1c6 100644
--- a/crates/ui-egui/src/i18n/cs.tsv
+++ b/crates/ui-egui/src/i18n/cs.tsv
@@ -1578,6 +1578,8 @@ digits Malayalam Malajálamské
The selected character's font is not available. Písmo vybraného znaku není k dispozici.
No alternates for the selected character. Vybraný znak nemá žádné alternativy.
Double-click to replace Dvojklikem nahradíte
+ No OpenType feature draws this glyph here, so it replaces the character's glyph and may not join its neighbors. Žádná funkce OpenType tento glyf zde nekreslí, proto nahradí glyf znaku a nemusí se spojit se sousedními znaky.
+ This glyph has no character, so it can't be inserted. Use Alternates for Current Selection to apply it. Tento glyf nemá žádný znak, takže ho nelze vložit. Použijte „Alternativy pro aktuální výběr“.
Double-click to insert Dvojklikem vložíte
Entire Font Celé písmo
glyph {id} glyf {id}
diff --git a/crates/ui-egui/src/i18n/da.tsv b/crates/ui-egui/src/i18n/da.tsv
index abfe581e70..4917bd8168 100644
--- a/crates/ui-egui/src/i18n/da.tsv
+++ b/crates/ui-egui/src/i18n/da.tsv
@@ -1488,6 +1488,8 @@ digits Malayalam Malayalam
The selected character's font is not available. Skrifttypen for det markerede tegn er ikke tilgængelig.
No alternates for the selected character. Ingen alternativer til det markerede tegn.
Double-click to replace Dobbeltklik for at erstatte
+ No OpenType feature draws this glyph here, so it replaces the character's glyph and may not join its neighbors. Ingen OpenType-funktion tegner dette glyf her, så det erstatter tegnets glyf og forbindes måske ikke med nabotegnene.
+ This glyph has no character, so it can't be inserted. Use Alternates for Current Selection to apply it. Dette glyf har intet tegn og kan derfor ikke indsættes. Brug „Alternativer til den aktuelle markering“ for at anvende det.
Double-click to insert Dobbeltklik for at indsætte
Entire Font Hele skrifttypen
glyph {id} glyf {id}
diff --git a/crates/ui-egui/src/i18n/de.tsv b/crates/ui-egui/src/i18n/de.tsv
index 4a4142d883..678162e111 100644
--- a/crates/ui-egui/src/i18n/de.tsv
+++ b/crates/ui-egui/src/i18n/de.tsv
@@ -840,6 +840,8 @@ font-technology Variable Variabel
The selected character's font is not available. Die Schrift des ausgewählten Zeichens ist nicht verfügbar.
No alternates for the selected character. Keine Alternativen für das ausgewählte Zeichen.
Double-click to replace Doppelklicken zum Ersetzen
+ No OpenType feature draws this glyph here, so it replaces the character's glyph and may not join its neighbors. Keine OpenType-Funktion zeichnet diese Glyphe hier, daher ersetzt sie die Glyphe des Zeichens und verbindet sich möglicherweise nicht mit den Nachbarzeichen.
+ This glyph has no character, so it can't be inserted. Use Alternates for Current Selection to apply it. Diese Glyphe hat kein Zeichen und lässt sich daher nicht einfügen. Verwenden Sie „Alternativen für aktuelle Auswahl“, um sie anzuwenden.
Double-click to insert Doppelklicken zum Einfügen
Double-click to rename Doppelklicken zum Umbenennen
Double-click to target the contents Doppelklicken, um den Inhalt als Ziel auszuwählen
diff --git a/crates/ui-egui/src/i18n/el.tsv b/crates/ui-egui/src/i18n/el.tsv
index 6b2a022d58..3e5d56de29 100644
--- a/crates/ui-egui/src/i18n/el.tsv
+++ b/crates/ui-egui/src/i18n/el.tsv
@@ -819,6 +819,8 @@ font-technology Variable Μεταβλητές
The selected character's font is not available. Η γραμματοσειρά του επιλεγμένου χαρακτήρα δεν είναι διαθέσιμη.
No alternates for the selected character. Δεν υπάρχουν εναλλακτικοί για τον επιλεγμένο χαρακτήρα.
Double-click to replace Διπλό κλικ για αντικατάσταση
+ No OpenType feature draws this glyph here, so it replaces the character's glyph and may not join its neighbors. Καμία λειτουργία OpenType δεν σχεδιάζει αυτόν τον γλύφο εδώ, οπότε αντικαθιστά τον γλύφο του χαρακτήρα και μπορεί να μην συνδέεται με τους γειτονικούς χαρακτήρες.
+ This glyph has no character, so it can't be inserted. Use Alternates for Current Selection to apply it. Αυτός ο γλύφος δεν έχει χαρακτήρα και δεν μπορεί να εισαχθεί. Χρησιμοποιήστε τους «Εναλλακτικούς για την τρέχουσα επιλογή» για να τον εφαρμόσετε.
Double-click to insert Διπλό κλικ για εισαγωγή
Double-click to rename Διπλό κλικ για μετονομασία
Double-click to target the contents Διπλό κλικ για επιλογή του περιεχομένου ως στόχου
diff --git a/crates/ui-egui/src/i18n/es.tsv b/crates/ui-egui/src/i18n/es.tsv
index 6ccd50b466..5a262f20d5 100644
--- a/crates/ui-egui/src/i18n/es.tsv
+++ b/crates/ui-egui/src/i18n/es.tsv
@@ -861,6 +861,8 @@ font-technology Variable Variables
The selected character's font is not available. La fuente del carácter seleccionado no está disponible.
No alternates for the selected character. No hay alternativas para el carácter seleccionado.
Double-click to replace Doble clic para reemplazar
+ No OpenType feature draws this glyph here, so it replaces the character's glyph and may not join its neighbors. Ninguna función OpenType dibuja este glifo aquí, así que sustituye el glifo del carácter y puede que no se una a los caracteres vecinos.
+ This glyph has no character, so it can't be inserted. Use Alternates for Current Selection to apply it. Este glifo no tiene carácter, así que no se puede insertar. Usa «Alternativas para la selección actual» para aplicarlo.
Double-click to insert Doble clic para insertar
Double-click to rename Doble clic para cambiar el nombre
Double-click to target the contents Doble clic para seleccionar el contenido como destino
diff --git a/crates/ui-egui/src/i18n/fi.tsv b/crates/ui-egui/src/i18n/fi.tsv
index 3cf8607c0b..202e008e12 100644
--- a/crates/ui-egui/src/i18n/fi.tsv
+++ b/crates/ui-egui/src/i18n/fi.tsv
@@ -1551,6 +1551,8 @@ digits Malayalam Malajalam
The selected character's font is not available. Valitun merkin fontti ei ole saatavilla.
No alternates for the selected character. Valitulla merkillä ei ole vaihtoehtoja.
Double-click to replace Korvaa kaksoisnapsauttamalla
+ No OpenType feature draws this glyph here, so it replaces the character's glyph and may not join its neighbors. Mikään OpenType-ominaisuus ei piirrä tätä glyfiä tässä, joten se korvaa merkin glyfin eikä välttämättä liity viereisiin merkkeihin.
+ This glyph has no character, so it can't be inserted. Use Alternates for Current Selection to apply it. Tällä glyfillä ei ole merkkiä, joten sitä ei voi lisätä. Käytä toimintoa ”Nykyisen valinnan vaihtoehdot” sen käyttöön.
Double-click to insert Lisää kaksoisnapsauttamalla
Entire Font Koko fontti
glyph {id} merkki {id}
diff --git a/crates/ui-egui/src/i18n/fr.tsv b/crates/ui-egui/src/i18n/fr.tsv
index 67a7cb29c8..f93b915232 100644
--- a/crates/ui-egui/src/i18n/fr.tsv
+++ b/crates/ui-egui/src/i18n/fr.tsv
@@ -864,6 +864,8 @@ font-technology Variable Variables
The selected character's font is not available. La police du caractère sélectionné n’est pas disponible.
No alternates for the selected character. Aucune variante pour le caractère sélectionné.
Double-click to replace Double-cliquez pour remplacer
+ No OpenType feature draws this glyph here, so it replaces the character's glyph and may not join its neighbors. Aucune fonction OpenType ne dessine ce glyphe ici : il remplace le glyphe du caractère et peut ne pas se lier aux caractères voisins.
+ This glyph has no character, so it can't be inserted. Use Alternates for Current Selection to apply it. Ce glyphe n’a pas de caractère et ne peut donc pas être inséré. Utilisez « Variantes de la sélection » pour l’appliquer.
Double-click to insert Double-cliquez pour insérer
Double-click to rename Double-cliquez pour renommer
Double-click to target the contents Double-cliquez pour cibler le contenu
diff --git a/crates/ui-egui/src/i18n/hi.tsv b/crates/ui-egui/src/i18n/hi.tsv
index e1af82a3a9..5b8b11cc50 100644
--- a/crates/ui-egui/src/i18n/hi.tsv
+++ b/crates/ui-egui/src/i18n/hi.tsv
@@ -1344,6 +1344,8 @@
Double-click to insert सम्मिलित करने के लिए डबल क्लिक करें
Double-click to rename नाम बदलने के लिए डबल क्लिक करें
Double-click to replace प्रतिस्थापित करने के लिए डबल क्लिक करें
+ No OpenType feature draws this glyph here, so it replaces the character's glyph and may not join its neighbors. यहाँ कोई OpenType फ़ीचर इस ग्लिफ़ को नहीं बनाता, इसलिए यह कैरेक्टर के ग्लिफ़ की जगह लेता है और पड़ोसी कैरेक्टरों से जुड़ नहीं भी सकता।
+ This glyph has no character, so it can't be inserted. Use Alternates for Current Selection to apply it. इस ग्लिफ़ का कोई कैरेक्टर नहीं है, इसलिए इसे सम्मिलित नहीं किया जा सकता। इसे लागू करने के लिए “वर्तमान सिलेक्शन के लिए ऑप्शन” का उपयोग करें।
Double-click to target the contents सामग्री को लक्षित करने के लिए डबल क्लिक करें
Down (right reading) नीचे (दाएं पढ़ने)
Download डाउनलोड
diff --git a/crates/ui-egui/src/i18n/hu.tsv b/crates/ui-egui/src/i18n/hu.tsv
index 82f75da721..516ccf02de 100644
--- a/crates/ui-egui/src/i18n/hu.tsv
+++ b/crates/ui-egui/src/i18n/hu.tsv
@@ -1114,6 +1114,8 @@ font-technology Variable Változó
The selected character's font is not available. A kijelölt karakter betűtípusa nem érhető el.
No alternates for the selected character. A kijelölt karakternek nincsenek változatai.
Double-click to replace Csere dupla kattintással
+ No OpenType feature draws this glyph here, so it replaces the character's glyph and may not join its neighbors. Itt egyetlen OpenType-funkció sem rajzolja meg ezt a glifát, ezért a karakter glifáját váltja fel, és előfordulhat, hogy nem kapcsolódik a szomszédos karakterekhez.
+ This glyph has no character, so it can't be inserted. Use Alternates for Current Selection to apply it. Ennek a glifának nincs karaktere, ezért nem szúrható be. Az alkalmazásához használd az „Az aktuális kijelölés változatai” nézetet.
Double-click to insert Beszúrás dupla kattintással
Double-click to rename Dupla kattintással átnevezhető
Double-click to target the contents Dupla kattintással a tartalom lesz a cél
diff --git a/crates/ui-egui/src/i18n/id.tsv b/crates/ui-egui/src/i18n/id.tsv
index 7230b6bfa5..6b488a850e 100644
--- a/crates/ui-egui/src/i18n/id.tsv
+++ b/crates/ui-egui/src/i18n/id.tsv
@@ -1488,6 +1488,8 @@ digits Malayalam Malayalam
The selected character's font is not available. Font karakter yang dipilih tidak tersedia.
No alternates for the selected character. Tidak ada alternatif untuk karakter yang dipilih.
Double-click to replace Klik ganda untuk mengganti
+ No OpenType feature draws this glyph here, so it replaces the character's glyph and may not join its neighbors. Tidak ada fitur OpenType yang menggambar glif ini di sini, jadi glif ini menggantikan glif karakter dan mungkin tidak menyambung dengan karakter di sebelahnya.
+ This glyph has no character, so it can't be inserted. Use Alternates for Current Selection to apply it. Glif ini tidak punya karakter, sehingga tidak bisa disisipkan. Gunakan “Alternatif untuk Pilihan Saat Ini” untuk menerapkannya.
Double-click to insert Klik ganda untuk menyisipkan
Entire Font Seluruh font
glyph {id} glif {id}
diff --git a/crates/ui-egui/src/i18n/it.tsv b/crates/ui-egui/src/i18n/it.tsv
index 723fa4ab08..433847dbf1 100644
--- a/crates/ui-egui/src/i18n/it.tsv
+++ b/crates/ui-egui/src/i18n/it.tsv
@@ -861,6 +861,8 @@ font-technology Variable Variabili
The selected character's font is not available. Il font del carattere selezionato non è disponibile.
No alternates for the selected character. Nessuna alternativa per il carattere selezionato.
Double-click to replace Fai doppio clic per sostituire
+ No OpenType feature draws this glyph here, so it replaces the character's glyph and may not join its neighbors. Nessuna funzione OpenType disegna questo glifo qui: sostituisce il glifo del carattere e potrebbe non unirsi ai caratteri vicini.
+ This glyph has no character, so it can't be inserted. Use Alternates for Current Selection to apply it. Questo glifo non ha un carattere e non può essere inserito. Usa «Alternative per la selezione corrente» per applicarlo.
Double-click to insert Fai doppio clic per inserire
Double-click to rename Fai doppio clic per rinominare
Double-click to target the contents Fai doppio clic per impostare il contenuto come destinazione
diff --git a/crates/ui-egui/src/i18n/ja.tsv b/crates/ui-egui/src/i18n/ja.tsv
index 8bb1012693..e0d8f5c2e8 100644
--- a/crates/ui-egui/src/i18n/ja.tsv
+++ b/crates/ui-egui/src/i18n/ja.tsv
@@ -1424,6 +1424,8 @@ digits Malayalam マラヤーラム数字
The selected character's font is not available. 選択した文字のフォントを使用できません。
No alternates for the selected character. 選択した文字には異体字がありません。
Double-click to replace ダブルクリックで置換
+ No OpenType feature draws this glyph here, so it replaces the character's glyph and may not join its neighbors. このグリフを表示する OpenType フィーチャーがないため、文字のグリフが置き換わり、前後の文字とつながらないことがあります。
+ This glyph has no character, so it can't be inserted. Use Alternates for Current Selection to apply it. このグリフには対応する文字がないため、挿入できません。適用するには「選択文字の異体字」を使用してください。
Double-click to insert ダブルクリックで挿入
Double-click to rename ダブルクリックで名前を変更
Double-click to target the contents ダブルクリックで内容をターゲット
diff --git a/crates/ui-egui/src/i18n/ka.tsv b/crates/ui-egui/src/i18n/ka.tsv
index 343b415a48..9e473e2f3d 100644
--- a/crates/ui-egui/src/i18n/ka.tsv
+++ b/crates/ui-egui/src/i18n/ka.tsv
@@ -1414,6 +1414,8 @@ digits Malayalam მალაიალამური
The selected character's font is not available. მონიშნული სიმბოლოს შრიფტი მიუწვდომელია.
No alternates for the selected character. მონიშნულ სიმბოლოს ალტერნატივები არ აქვს.
Double-click to replace ორჯერ დაწკაპეთ ჩასანაცვლებლად
+ No OpenType feature draws this glyph here, so it replaces the character's glyph and may not join its neighbors. აქ არც ერთი OpenType ფუნქცია არ ხატავს ამ გლიფს, ამიტომ ის ანაცვლებს სიმბოლოს გლიფს და შეიძლება არ დაუკავშირდეს მეზობელ სიმბოლოებს.
+ This glyph has no character, so it can't be inserted. Use Alternates for Current Selection to apply it. ამ გლიფს სიმბოლო არ აქვს, ამიტომ მისი ჩასმა შეუძლებელია. გამოსაყენებლად გამოიყენეთ „მიმდინარე მონიშვნის ალტერნატივები“.
Double-click to insert ორჯერ დაწკაპეთ ჩასასმელად
Entire Font მთელი შრიფტი
glyph {id} გლიფი {id}
diff --git a/crates/ui-egui/src/i18n/ko.tsv b/crates/ui-egui/src/i18n/ko.tsv
index f4cc45b827..8333201b82 100644
--- a/crates/ui-egui/src/i18n/ko.tsv
+++ b/crates/ui-egui/src/i18n/ko.tsv
@@ -1414,6 +1414,8 @@ digits Malayalam 말라얄람어
The selected character's font is not available. 선택한 문자의 글꼴을 사용할 수 없습니다.
No alternates for the selected character. 선택한 문자의 대체 글리프가 없습니다.
Double-click to replace 더블클릭하여 바꾸기
+ No OpenType feature draws this glyph here, so it replaces the character's glyph and may not join its neighbors. 이 글리프를 그리는 OpenType 기능이 없으므로 문자의 글리프를 대체하며 이웃 문자와 연결되지 않을 수 있습니다.
+ This glyph has no character, so it can't be inserted. Use Alternates for Current Selection to apply it. 이 글리프에는 문자가 없어 삽입할 수 없습니다. 적용하려면 ‘현재 선택 항목의 대체 글리프’를 사용하세요.
Double-click to insert 더블클릭하여 삽입
Double-click to rename 더블클릭하여 이름 변경
Double-click to target the contents 더블클릭하여 내용 선택
diff --git a/crates/ui-egui/src/i18n/nl.tsv b/crates/ui-egui/src/i18n/nl.tsv
index 2e8d6f34c3..8fb204e2d6 100644
--- a/crates/ui-egui/src/i18n/nl.tsv
+++ b/crates/ui-egui/src/i18n/nl.tsv
@@ -1502,6 +1502,8 @@ digits Malayalam Malayalam
The selected character's font is not available. Het lettertype van het geselecteerde teken is niet beschikbaar.
No alternates for the selected character. Geen alternatieven voor het geselecteerde teken.
Double-click to replace Dubbelklik om te vervangen
+ No OpenType feature draws this glyph here, so it replaces the character's glyph and may not join its neighbors. Geen enkele OpenType-functie tekent deze glyph hier, dus hij vervangt de glyph van het teken en sluit mogelijk niet aan op de buurtekens.
+ This glyph has no character, so it can't be inserted. Use Alternates for Current Selection to apply it. Deze glyph heeft geen teken en kan dus niet worden ingevoegd. Gebruik ‘Alternatieven voor huidige selectie’ om hem toe te passen.
Double-click to insert Dubbelklik om in te voegen
Entire Font Volledig lettertype
glyph {id} glyph {id}
diff --git a/crates/ui-egui/src/i18n/pl.tsv b/crates/ui-egui/src/i18n/pl.tsv
index c9e1256c3e..86af456b81 100644
--- a/crates/ui-egui/src/i18n/pl.tsv
+++ b/crates/ui-egui/src/i18n/pl.tsv
@@ -1491,6 +1491,8 @@ digits Malayalam Malajalam
The selected character's font is not available. Czcionka zaznaczonego znaku jest niedostępna.
No alternates for the selected character. Brak alternatyw dla zaznaczonego znaku.
Double-click to replace Kliknij dwukrotnie, aby zastąpić
+ No OpenType feature draws this glyph here, so it replaces the character's glyph and may not join its neighbors. Żadna funkcja OpenType nie rysuje tutaj tego glifu, więc zastępuje on glif znaku i może nie łączyć się z sąsiednimi znakami.
+ This glyph has no character, so it can't be inserted. Use Alternates for Current Selection to apply it. Ten glif nie ma znaku, więc nie można go wstawić. Użyj opcji „Alternatywy bieżącego zaznaczenia”, aby go zastosować.
Double-click to insert Kliknij dwukrotnie, aby wstawić
Entire Font Cała czcionka
glyph {id} glif {id}
diff --git a/crates/ui-egui/src/i18n/pt-br.tsv b/crates/ui-egui/src/i18n/pt-br.tsv
index 062b7d88b4..5e5a4396cf 100644
--- a/crates/ui-egui/src/i18n/pt-br.tsv
+++ b/crates/ui-egui/src/i18n/pt-br.tsv
@@ -771,6 +771,8 @@
The selected character's font is not available. A fonte do caractere selecionado não está disponível.
No alternates for the selected character. Nenhuma alternativa para o caractere selecionado.
Double-click to replace Clique duplo para substituir
+ No OpenType feature draws this glyph here, so it replaces the character's glyph and may not join its neighbors. Nenhum recurso OpenType desenha este glifo aqui, então ele substitui o glifo do caractere e pode não se unir aos caracteres vizinhos.
+ This glyph has no character, so it can't be inserted. Use Alternates for Current Selection to apply it. Este glifo não tem caractere e não pode ser inserido. Use “Alternativas para a seleção atual” para aplicá-lo.
Double-click to insert Clique duplo para inserir
Double-click to rename Clique duplo para renomear
Double-click to target the contents Clique duplo para direcionar o conteúdo
diff --git a/crates/ui-egui/src/i18n/ru.tsv b/crates/ui-egui/src/i18n/ru.tsv
index a83d9edf36..6de9bb6713 100644
--- a/crates/ui-egui/src/i18n/ru.tsv
+++ b/crates/ui-egui/src/i18n/ru.tsv
@@ -859,6 +859,8 @@ font-technology Variable Вариативные
The selected character's font is not available. Шрифт выделенного символа недоступен.
No alternates for the selected character. У выделенного символа нет альтернатив.
Double-click to replace Дважды щёлкните для замены
+ No OpenType feature draws this glyph here, so it replaces the character's glyph and may not join its neighbors. Ни одна функция OpenType не рисует здесь этот глиф, поэтому он заменяет глиф символа и может не соединяться с соседними символами.
+ This glyph has no character, so it can't be inserted. Use Alternates for Current Selection to apply it. У этого глифа нет символа, поэтому его нельзя вставить. Чтобы применить его, используйте «Альтернативы для текущего выделения».
Double-click to insert Дважды щёлкните для вставки
Double-click to rename Дважды щёлкните для переименования
Double-click to target the contents Дважды щёлкните, чтобы выбрать содержимое как объект
diff --git a/crates/ui-egui/src/i18n/sv.tsv b/crates/ui-egui/src/i18n/sv.tsv
index 780d23beae..87735c99d9 100644
--- a/crates/ui-egui/src/i18n/sv.tsv
+++ b/crates/ui-egui/src/i18n/sv.tsv
@@ -848,6 +848,8 @@ font-technology Variable Variabla
The selected character's font is not available. Typsnittet för det markerade tecknet är inte tillgängligt.
No alternates for the selected character. Inga alternativ för det markerade tecknet.
Double-click to replace Dubbelklicka för att ersätta
+ No OpenType feature draws this glyph here, so it replaces the character's glyph and may not join its neighbors. Ingen OpenType-funktion ritar den här glyfen här, så den ersätter tecknets glyf och ansluter kanske inte till grannteckenen.
+ This glyph has no character, so it can't be inserted. Use Alternates for Current Selection to apply it. Den här glyfen har inget tecken och kan därför inte infogas. Använd ”Alternativ för aktuell markering” för att tillämpa den.
Double-click to insert Dubbelklicka för att infoga
Double-click to rename Dubbelklicka för att byta namn
Double-click to target the contents Dubbelklicka för att välja innehållet som mål
diff --git a/crates/ui-egui/src/i18n/tr.tsv b/crates/ui-egui/src/i18n/tr.tsv
index 55a9cdc737..13c68896dd 100644
--- a/crates/ui-egui/src/i18n/tr.tsv
+++ b/crates/ui-egui/src/i18n/tr.tsv
@@ -840,6 +840,8 @@ font-technology Variable Değişken
The selected character's font is not available. Seçili karakterin yazı tipi kullanılamıyor.
No alternates for the selected character. Seçili karakter için alternatif yok.
Double-click to replace Değiştirmek için çift tıklayın
+ No OpenType feature draws this glyph here, so it replaces the character's glyph and may not join its neighbors. Hiçbir OpenType özelliği bu glifi burada çizmez; bu yüzden karakterin glifinin yerine geçer ve komşu karakterlere bağlanmayabilir.
+ This glyph has no character, so it can't be inserted. Use Alternates for Current Selection to apply it. Bu glifin karakteri yok, bu yüzden eklenemez. Uygulamak için “Geçerli Seçim için Alternatifler”i kullanın.
Double-click to insert Eklemek için çift tıklayın
Double-click to rename Yeniden adlandırmak için çift tıklayın
Double-click to target the contents İçeriği hedeflemek için çift tıklayın
diff --git a/crates/ui-egui/src/i18n/uk.tsv b/crates/ui-egui/src/i18n/uk.tsv
index bbfc332f91..6a8756e775 100644
--- a/crates/ui-egui/src/i18n/uk.tsv
+++ b/crates/ui-egui/src/i18n/uk.tsv
@@ -855,6 +855,8 @@ font-technology Variable Варіативні
The selected character's font is not available. Шрифт виділеного символу недоступний.
No alternates for the selected character. Виділений символ не має альтернатив.
Double-click to replace Двічі клацніть для заміни
+ No OpenType feature draws this glyph here, so it replaces the character's glyph and may not join its neighbors. Жодна функція OpenType не малює тут цей гліф, тому він замінює гліф символу й може не з’єднуватися із сусідніми символами.
+ This glyph has no character, so it can't be inserted. Use Alternates for Current Selection to apply it. У цього гліфа немає символу, тому його не можна вставити. Щоб застосувати його, скористайтеся «Альтернативи для поточного виділення».
Double-click to insert Двічі клацніть для вставки
Double-click to rename Двічі клацніть для перейменування
Double-click to target the contents Двічі клацніть, щоб вибрати вміст як об’єкт
diff --git a/crates/ui-egui/src/i18n/vi.tsv b/crates/ui-egui/src/i18n/vi.tsv
index 47915fb171..1e1061a79b 100644
--- a/crates/ui-egui/src/i18n/vi.tsv
+++ b/crates/ui-egui/src/i18n/vi.tsv
@@ -999,6 +999,8 @@ font-technology Variable Biến thiên
The selected character's font is not available. Phông chữ của ký tự đã chọn không khả dụng.
No alternates for the selected character. Ký tự đã chọn không có glyph thay thế.
Double-click to replace Bấm đúp để thay thế
+ No OpenType feature draws this glyph here, so it replaces the character's glyph and may not join its neighbors. Không có tính năng OpenType nào vẽ glyph này ở đây, nên nó thay glyph của ký tự và có thể không nối với các ký tự bên cạnh.
+ This glyph has no character, so it can't be inserted. Use Alternates for Current Selection to apply it. Glyph này không có ký tự nên không thể chèn. Hãy dùng “Glyph thay thế cho vùng chọn hiện tại” để áp dụng.
Double-click to insert Bấm đúp để chèn
Double-click to rename Bấm đúp để đổi tên
Double-click to target the contents Bấm đúp để chọn nội dung
diff --git a/crates/ui-egui/src/i18n/zh-hans.tsv b/crates/ui-egui/src/i18n/zh-hans.tsv
index e646c956bb..ae82c23144 100644
--- a/crates/ui-egui/src/i18n/zh-hans.tsv
+++ b/crates/ui-egui/src/i18n/zh-hans.tsv
@@ -803,6 +803,8 @@
The selected character's font is not available. 所选字符的字体不可用。
No alternates for the selected character. 所选字符没有替代字。
Double-click to replace 双击以替换
+ No OpenType feature draws this glyph here, so it replaces the character's glyph and may not join its neighbors. 没有任何 OpenType 功能会在此处绘制该字形,因此它会替换该字符的字形,并且可能无法与相邻字符连接。
+ This glyph has no character, so it can't be inserted. Use Alternates for Current Selection to apply it. 此字形没有对应的字符,无法插入。请使用“当前所选字符的替代字”来应用它。
Double-click to insert 双击以插入
Double-click to rename 双击以重命名
Double-click to target the contents 双击以指定内容
diff --git a/crates/ui-egui/src/i18n/zh-hant.tsv b/crates/ui-egui/src/i18n/zh-hant.tsv
index 468ad851aa..841cff8558 100644
--- a/crates/ui-egui/src/i18n/zh-hant.tsv
+++ b/crates/ui-egui/src/i18n/zh-hant.tsv
@@ -823,6 +823,8 @@ font-technology Variable 可變
The selected character's font is not available. 選取字元的字體無法使用。
No alternates for the selected character. 選取的字元沒有替代字。
Double-click to replace 按兩下以取代
+ No OpenType feature draws this glyph here, so it replaces the character's glyph and may not join its neighbors. 沒有任何 OpenType 功能會在此處繪製該字形,因此它會取代該字元的字形,且可能無法與相鄰字元連接。
+ This glyph has no character, so it can't be inserted. Use Alternates for Current Selection to apply it. 此字形沒有對應的字元,無法插入。請使用「目前選取字元的替代字」來套用。
Double-click to insert 按兩下以插入
Double-click to rename 按兩下以重新命名
Double-click to target the contents 按兩下以指定內容
diff --git a/crates/ui-egui/src/panels/glyphs.rs b/crates/ui-egui/src/panels/glyphs.rs
index 5c07441bee..b4d0ab6904 100644
--- a/crates/ui-egui/src/panels/glyphs.rs
+++ b/crates/ui-egui/src/panels/glyphs.rs
@@ -1,8 +1,10 @@
//! Glyphs panel: every character the current font maps (its cmap), drawn from the font's own
-//! outlines. Double-click inserts the character at the Type tool's caret (or appends it to the
-//! selected text objects). The character selected with the Type tool is highlighted in the grid.
-//! Show › Alternates for Current Selection lists that character's alternate glyphs instead, and a
-//! double-click draws the character with one of them.
+//! outlines; Show › Entire Font adds the glyphs no character maps, by glyph id. Double-click
+//! inserts the character at the Type tool's caret (or appends it to the selected text objects).
+//! The character selected with the Type tool is highlighted in the grid.
+//! Show › Alternates for Current Selection lists that character's alternate glyphs instead (as it
+//! stands in its word), and a double-click applies the OpenType feature that draws one to that
+//! character alone, so the text stays real text that joins its neighbors.
use std::collections::HashMap;
@@ -240,9 +242,13 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) {
};
let chars: Vec<(char, u32)> =
face.chars().into_iter().filter(|(c, _)| (lo..=hi).contains(&(*c as u32)) && !c.is_control() && !c.is_whitespace()).collect();
+ // Entire Font also lists the glyphs no character maps (positional forms, alternates,
+ // ligatures) by glyph id, after the characters.
+ let unencoded = if subset == 0 { face.unencoded_glyphs() } else { Default::default() };
+ let total = chars.len() + unencoded.len();
let cell = CELL_STEPS[cell_index(app)];
let cols = ((w / cell).floor() as usize).max(1);
- let rows = chars.len().div_ceil(cols);
+ let rows = total.div_ceil(cols);
let px = (cell * ui.ctx().pixels_per_point()).round().clamp(8.0, 256.0) as u32;
let list_h = (ui.available_height() - 64.0).max(cell * 3.0);
let hover_id = egui::Id::unique("gl-hover");
@@ -264,9 +270,13 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) {
for r in range.clone() {
ui.horizontal(|ui| {
ui.spacing_mut().item_spacing = vec2(0.0, 0.0);
- for &(c, gid) in chars.iter().skip(r * cols).take(cols) {
+ for k in r * cols..((r + 1) * cols).min(total) {
+ let (c, gid) = match chars.get(k) {
+ Some(&(c, gid)) => (Some(c), gid),
+ None => (None, unencoded.get(k - chars.len()).copied().unwrap_or_default()),
+ };
let (rect, resp) = ui.allocate_exact_size(vec2(cell, cell), Sense::click());
- let chosen = selected == Some(c);
+ let chosen = c.is_some() && selected == c;
if resp.hovered() {
ui.painter().rect_filled(rect, 2, t.hover);
ui.ctx().data_mut(|d| d.insert_temp(hover_id, (c, gid)));
@@ -277,9 +287,18 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) {
ui.painter().rect_stroke(rect.shrink(0.5), 0, edge, StrokeKind::Inside);
let tex = glyph_texture(ui.ctx(), &face, gid, px);
ui.painter().image(tex.id(), rect, egui::Rect::from_min_max(egui::pos2(0.0, 0.0), egui::pos2(1.0, 1.0)), t.text_strong);
- let resp = resp.on_hover_text(format!("U+{:04X} {c} (glyph {gid})\n{}", c as u32, tl!("Double-click to insert")));
+ let tip = match c {
+ Some(c) => format!("U+{:04X} {c} (glyph {gid})\n{}", c as u32, tl!("Double-click to insert")),
+ None => unencoded_tip(&face, gid),
+ };
+ let resp = resp.on_hover_text(tip);
if resp.double_clicked() {
- insert(app, ui.ctx(), c);
+ match c {
+ Some(c) => insert(app, ui.ctx(), c),
+ None => app.status(tl!(
+ "This glyph has no character, so it can't be inserted. Use Alternates for Current Selection to apply it."
+ )),
+ }
}
}
});
@@ -288,16 +307,18 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) {
});
ui.ctx().data_mut(|d| d.insert_temp(follow_id, (selected, shown.inner)));
ui.add_space(4.0);
- let info = ui.ctx().data(|d| d.get_temp::<(char, u32)>(hover_id));
+ let info = ui.ctx().data(|d| d.get_temp::<(Option, u32)>(hover_id));
+ let count = crate::i18n::tn(total as u64, "{n} glyph", "{n} glyphs");
ui.label(
egui::RichText::new(match info {
- Some((c, g)) => format!(
- "U+{:04X} {} · {}",
- c as u32,
- crate::i18n::fmt(tl!("glyph {id}"), &[("id", &g.to_string())]),
- crate::i18n::tn(chars.len() as u64, "{n} glyph", "{n} glyphs")
- ),
- None => crate::i18n::tn(chars.len() as u64, "{n} glyph", "{n} glyphs"),
+ Some((c, g)) => {
+ let glyph = crate::i18n::fmt(tl!("glyph {id}"), &[("id", &g.to_string())]);
+ match c {
+ Some(c) => format!("U+{:04X} {glyph} · {count}", c as u32),
+ None => format!("{glyph} · {count}"),
+ }
+ }
+ None => count,
})
.size(11.0)
.color(t.text_dim),
@@ -340,11 +361,36 @@ fn font_row(app: &mut VectorcraftApp, ui: &mut Ui, family: &str, style: &str, w:
});
}
+/// One cell of Show › Alternates for Current Selection: a glyph, the feature that lists it, and the
+/// feature tag that draws it on the selected character in its word (`salt=2`), when one does.
+struct Variant {
+ gid: u32,
+ feature: [u8; 4],
+ apply: Option,
+ /// The glyph the shaper makes for the character with none of its own alternate features.
+ default: bool,
+}
+
+/// What a double-click on a variant does to the selected character.
+enum Pick {
+ /// Back to the default glyph: its alternate features off, a glyph choice taken away.
+ Default,
+ /// A feature that draws the variant: the text stays real Unicode and its word stays joined.
+ Feature(String),
+ /// No feature draws it: it replaces the character's glyph (`text.setGlyphAlternate`), which is
+ /// shaped apart from its neighbors.
+ Glyph(u32),
+}
+
/// The grid of Show › Alternates for Current Selection: the alternate glyphs of the Type tool's
-/// selected character in the face its text is set in, whatever font the panel browses
-/// ([`vectorcraft_text::FontFace::alternates`]), with that face's name under the grid. The default
-/// glyph comes first when the font does not list it. The glyph the character is drawn with is
-/// highlighted. A double-click draws the character with the glyph (`text.setGlyphAlternate`).
+/// selected character as it stands in its word ([`vectorcraft_text::alternates_in_context`]: a
+/// medial letter lists its medial form's alternates), in the face the text is set in, whatever font
+/// the panel browses, with that face's name under the grid. The glyph the shaper makes for the
+/// character with none of its own alternate features comes first, and the glyph the character is
+/// drawn with is highlighted. A double-click applies the OpenType feature that draws the glyph to
+/// that character alone (`text.setRangeStyle`, `salt=2`), as one undo step, so the text stays real
+/// text that joins its neighbors. A glyph no feature draws there replaces the character's glyph
+/// instead (`text.setGlyphAlternate`), and its tooltip says that it may not join.
fn alternates(app: &mut VectorcraftApp, ui: &mut Ui, w: f32) {
let t = Tokens::get(ui.ctx());
let db = FontDb::global();
@@ -360,13 +406,25 @@ fn alternates(app: &mut VectorcraftApp, ui: &mut Ui, w: f32) {
return note(ui, tl!("The selected character's font is not available."));
};
let (id, byte, ch) = (target.id, target.byte, target.ch);
- let (list, drawn) = alternates_and_drawn(app, ui, &face, id, byte, ch);
+ let (features, has_choice) = (target.style.features.clone(), target.style.glyph_alternate.is_some());
+ let found = alternates_found(app, ui, id, byte, face.id());
let font = format!("{} {}", face.family, face.style);
- if list.is_empty() {
+ let Some(found) = found.filter(|f| !f.alternates.is_empty()) else {
note(ui, tl!("No alternates for the selected character."));
ui.label(egui::RichText::new(font).size(11.0).color(t.text_dim));
return;
- }
+ };
+ // The glyphs are drawn from the face that supplied the character's glyph (a fallback face for
+ // a character the text's font lacks).
+ let face = db.face_by_id(found.font_id).unwrap_or(face);
+ let drawn = Some(found.current);
+ let mut list = vec![Variant { gid: found.default, feature: [0; 4], apply: None, default: true }];
+ list.extend(found.alternates.iter().filter(|a| a.gid != found.default).map(|a| Variant {
+ gid: a.gid,
+ feature: a.feature,
+ apply: a.apply.clone(),
+ default: false,
+ }));
let cell = CELL_STEPS[cell_index(app)];
let cols = ((w / cell).floor() as usize).max(1);
let rows = list.len().div_ceil(cols);
@@ -381,7 +439,7 @@ fn alternates(app: &mut VectorcraftApp, ui: &mut Ui, w: f32) {
ui.spacing_mut().item_spacing = vec2(0.0, 0.0);
for a in list.iter().skip(r * cols).take(cols) {
let (rect, resp) = ui.allocate_exact_size(vec2(cell, cell), Sense::click());
- let current = drawn.is_some_and(|(font_id, gid)| font_id == face.id() && a.gid == gid);
+ let current = drawn == Some(a.gid);
if resp.hovered() {
ui.painter().rect_filled(rect, 2, t.hover);
} else if current {
@@ -392,10 +450,25 @@ fn alternates(app: &mut VectorcraftApp, ui: &mut Ui, w: f32) {
let tex = glyph_texture(ui.ctx(), &face, a.gid, px);
ui.painter().image(tex.id(), rect, egui::Rect::from_min_max(egui::pos2(0.0, 0.0), egui::pos2(1.0, 1.0)), t.text_strong);
let glyph = crate::i18n::fmt(tl!("glyph {id}"), &[("id", &a.gid.to_string())]);
- let glyph = if a.feature == [0; 4] { glyph } else { format!("{glyph} ({})", String::from_utf8_lossy(&a.feature)) };
- let resp = resp.on_hover_text(format!("{glyph}\n{}", tl!("Double-click to replace")));
+ let glyph = match (&a.apply, a.default) {
+ (_, true) => glyph,
+ (Some(tag), _) => format!("{glyph} ({tag})"),
+ (None, _) => format!("{glyph} ({})", String::from_utf8_lossy(&a.feature)),
+ };
+ let mut tip = format!("{glyph}\n{}", tl!("Double-click to replace"));
+ if !a.default && a.apply.is_none() {
+ tip.push('\n');
+ tip.push_str(tl!(
+ "No OpenType feature draws this glyph here, so it replaces the character's glyph and may not join its neighbors."
+ ));
+ }
+ let resp = resp.on_hover_text(tip);
if resp.double_clicked() {
- pick = Some(a.gid);
+ pick = Some(match (&a.apply, a.default) {
+ (_, true) => Pick::Default,
+ (Some(tag), _) => Pick::Feature(tag.clone()),
+ (None, _) => Pick::Glyph(a.gid),
+ });
}
}
});
@@ -404,28 +477,61 @@ fn alternates(app: &mut VectorcraftApp, ui: &mut Ui, w: f32) {
ui.add_space(4.0);
let count = crate::i18n::tn(list.len() as u64, "{n} glyph", "{n} glyphs");
ui.label(egui::RichText::new(format!("{font} · {count}")).size(11.0).color(t.text_dim));
- if let Some(gid) = pick {
+ if let Some(pick) = pick {
super::character::end_typing(app);
- if let Err(e) = app.run("text.setGlyphAlternate", json!({"id": id.0, "start": byte, "gid": gid})) {
- app.status(e.to_string());
- }
+ apply_alternate(app, id, (byte, byte + ch.len_utf8()), &features, has_choice, pick);
}
}
-/// The alternates of `ch` in `face` and the face id and glyph the text object `id` draws the
-/// character at `byte` with. Both are kept in egui's temporary data until the document, the
-/// character or the face changes, as [`super::character`] keeps the kerning at the caret.
-fn alternates_and_drawn(
- app: &VectorcraftApp,
- ui: &Ui,
- face: &vectorcraft_text::FontFace,
- id: NodeId,
- byte: usize,
- ch: char,
-) -> (Vec, Option<(u32, u32)>) {
- type Found = (Vec, Option<(u32, u32)>);
+/// Do what a double-click on a variant says to the character at `bytes` of text `id`, whose style
+/// has `features` and, when `has_choice`, a glyph choice: one command, so one undo step. A feature
+/// goes through `text.setRangeStyle` on exactly the character (its other alternate features are
+/// turned off, a glyph choice is taken away); a glyph no feature draws through
+/// `text.setGlyphAlternate`. The default glyph, or a variant the character already has, changes
+/// nothing.
+fn apply_alternate(app: &mut VectorcraftApp, id: NodeId, bytes: (usize, usize), features: &[String], has_choice: bool, pick: Pick) {
+ let (cmd, params) = match pick {
+ Pick::Glyph(gid) => ("text.setGlyphAlternate", json!({"id": id.0, "start": bytes.0, "gid": gid})),
+ Pick::Default | Pick::Feature(_) => {
+ let tag = if let Pick::Feature(tag) = &pick { Some(tag.as_str()) } else { None };
+ let changes = vectorcraft_text::alternate_changes(features, tag);
+ if changes.is_empty() && !has_choice {
+ return;
+ }
+ let mut params = json!({"id": id.0, "start": bytes.0, "end": bytes.1});
+ if !changes.is_empty() {
+ params["featureChanges"] = json!(changes);
+ }
+ if has_choice {
+ params["glyphAlternate"] = serde_json::Value::Null;
+ }
+ ("text.setRangeStyle", params)
+ }
+ };
+ if let Err(e) = app.run(cmd, params) {
+ app.status(e.to_string());
+ }
+}
+
+/// The tooltip of a glyph no character maps (Show › Entire Font): its name from the font, `gid N`
+/// when it has none, its id, and the feature that gives it, if one does.
+fn unencoded_tip(face: &vectorcraft_text::FontFace, gid: u32) -> String {
+ let name = face.glyph_name(gid).unwrap_or_else(|| format!("gid {gid}"));
+ let glyph = crate::i18n::fmt(tl!("glyph {id}"), &[("id", &gid.to_string())]);
+ match face.feature_reaching(gid) {
+ Some(feature) => format!("{name} ({glyph}) · {}", String::from_utf8_lossy(&feature)),
+ None => format!("{name} ({glyph})"),
+ }
+}
+
+/// The alternates of the character at `byte` of text `id` in its word, and the glyph it is drawn
+/// with ([`vectorcraft_text::alternates_in_context`]). Kept in egui's temporary data until the
+/// document, the character or the face changes, as [`super::character`] keeps the kerning at the
+/// caret.
+fn alternates_found(app: &VectorcraftApp, ui: &Ui, id: NodeId, byte: usize, face_id: u32) -> Option {
+ type Found = Option;
let state = app.session.active();
- let key = state.map(|s| (s.uid, s.revision, id, byte, face.id()));
+ let key = state.map(|s| (s.uid, s.revision, id, byte, face_id));
let slot = ui.scope_id().with("gl-alternates-found");
if let Some(key) = key
&& let Some((cached_key, found)) = ui.ctx().data(|d| d.get_temp::<((u64, u64, NodeId, usize, u32), Found)>(slot))
@@ -433,20 +539,10 @@ fn alternates_and_drawn(
{
return found;
}
- let drawn = state
- .and_then(|s| s.doc.node(id))
- .and_then(|node| match &node.kind {
- NodeKind::Text(text) => Some(vectorcraft_text::layout(FontDb::global(), text)),
- _ => None,
- })
- .and_then(|layout| layout.glyphs.into_iter().find(|g| g.byte <= byte && byte < g.byte.saturating_add(g.len)))
- .map(|g| (g.font_id, g.gid));
- let mut list = face.alternates(ch);
- let default = face.glyph_for(ch);
- if !list.is_empty() && default != 0 && !list.iter().any(|a| a.gid == default) {
- list.insert(0, vectorcraft_text::GlyphAlternate { gid: default, feature: [0; 4] });
- }
- let found = (list, drawn);
+ let found = state.and_then(|s| s.doc.node(id)).and_then(|node| match &node.kind {
+ NodeKind::Text(text) => vectorcraft_text::alternates_in_context(FontDb::global(), text, byte),
+ _ => None,
+ });
if let Some(key) = key {
ui.ctx().data_mut(|d| d.insert_temp(slot, (key, found.clone())));
}
@@ -800,6 +896,122 @@ mod tests {
assert_eq!(highlighted(&found, &t), [squares(&found)[5].center()], "the chosen glyph, after b, u2, u4 and u3");
}
+ /// Double-click the glyph cell at `at`.
+ fn double_click(ctx: &egui::Context, app: &mut VectorcraftApp, at: Pos2) {
+ let button = |pressed| egui::Event::PointerButton { pos: at, button: egui::PointerButton::Primary, pressed, modifiers: Default::default() };
+ // Time only moves forward in one egui context.
+ let mut time = ctx.input(|i| i.time) + 10.0;
+ let mut timed = |app: &mut VectorcraftApp, events: Vec| {
+ time += 0.05;
+ let screen = Some(Rect::from_min_size(Pos2::ZERO, vec2(280.0, 420.0)));
+ let mut out = ctx.run_ui(egui::RawInput { screen_rect: screen, time: Some(time), events, ..Default::default() }, |ui| show(app, ui));
+ out.textures_delta.clear();
+ };
+ timed(app, vec![egui::Event::PointerMoved(at)]);
+ for _ in 0..2 {
+ timed(app, vec![button(true)]);
+ timed(app, vec![button(false)]);
+ }
+ }
+
+ /// The glyph ids of the text `id`, in the order of its characters.
+ fn glyph_ids(app: &VectorcraftApp, id: &serde_json::Value) -> Vec {
+ let NodeKind::Text(text) = &app.session.active().unwrap().doc.node(NodeId(id.as_u64().unwrap())).unwrap().kind else { panic!("text") };
+ let mut glyphs = vectorcraft_text::layout(FontDb::global(), text).glyphs;
+ glyphs.sort_by_key(|g| g.byte);
+ glyphs.iter().map(|g| g.gid).collect()
+ }
+
+ /// For the selected medial letter of an Arabic word the grid lists that form's alternates, and
+ /// a double-click applies the feature that draws one to that letter alone, as one undo step:
+ /// the word keeps its joining forms and the text its characters; undo takes it back.
+ #[test]
+ fn a_double_click_applies_a_feature_value_to_one_letter_of_a_joined_word() {
+ use vectorcraft_text::test_fonts::{JOINING_ALTERNATES_FAMILY, joining_alternates_font, source_sans_gid};
+ FontDb::global().add_font(joining_alternates_font().expect("the test font builds"));
+ let g = |c| u32::from(source_sans_gid(c).expect("a glyph"));
+ let (mut app, id) = editing("\u{628}\u{62A}\u{646}");
+ app.run("text.setRangeStyle", json!({"id": id, "font": JOINING_ALTERNATES_FAMILY})).unwrap();
+ assert_eq!(glyph_ids(&app, &id), [g('B'), g('u'), g('M')]);
+ select(&mut app, 2, 4);
+ let ctx = egui::Context::default();
+ let t = Tokens::get(&ctx);
+ choose(&ctx, &mut app, "Alternates for Current Selection");
+ let found = shapes(&ctx, &mut app, vec![]);
+ let cells = squares(&found);
+ assert_eq!(cells.len(), 4, "the medial teh and its three alternates, not the isolated form's");
+ assert_eq!(highlighted(&found, &t), [cells[0].center()], "the form the letter has is first and drawn");
+ let undo = app.session.active().unwrap().history.undo.len();
+ double_click(&ctx, &mut app, cells[2].center());
+ assert_eq!(app.session.active().unwrap().history.undo.len(), undo + 1, "one undo step; status: {}", app.ui.status);
+ let NodeKind::Text(text) = &app.session.active().unwrap().doc.node(NodeId(id.as_u64().unwrap())).unwrap().kind else { panic!("text") };
+ assert_eq!(text.plain_text(), "\u{628}\u{62A}\u{646}", "the text keeps its characters");
+ let features: Vec<(String, Vec)> = text.runs.iter().map(|r| (r.text.clone(), r.style.features.clone())).collect();
+ assert_eq!(
+ features,
+ [("\u{628}".to_string(), vec![]), ("\u{62A}".to_string(), vec!["salt=2".to_string()]), ("\u{646}".to_string(), vec![])],
+ "the feature is on exactly that letter"
+ );
+ assert!(text.runs.iter().all(|r| r.style.glyph_alternate.is_none()));
+ assert_eq!(glyph_ids(&app, &id), [g('B'), g('x'), g('M')], "the neighbours keep their initial and final forms");
+ // The pointer leaves the cell: a hovered cell is painted as hovered, not as the drawn one.
+ shapes(&ctx, &mut app, vec![egui::Event::PointerMoved(Pos2::ZERO)]);
+ let found = shapes(&ctx, &mut app, vec![]);
+ assert_eq!(highlighted(&found, &t), [squares(&found)[2].center()], "the applied variant is the one drawn");
+ // The default glyph takes the feature off again, and undo takes each step back.
+ double_click(&ctx, &mut app, squares(&found)[0].center());
+ assert_eq!(glyph_ids(&app, &id), [g('B'), g('u'), g('M')]);
+ app.run("edit.undo", json!({})).unwrap();
+ assert_eq!(glyph_ids(&app, &id), [g('B'), g('x'), g('M')]);
+ app.run("edit.undo", json!({})).unwrap();
+ assert_eq!(glyph_ids(&app, &id), [g('B'), g('u'), g('M')]);
+ let NodeKind::Text(text) = &app.session.active().unwrap().doc.node(NodeId(id.as_u64().unwrap())).unwrap().kind else { panic!("text") };
+ assert!(text.runs.iter().all(|r| r.style.features.is_empty()), "undo restores the features");
+ }
+
+ /// A variant no feature draws is still enabled: it replaces the glyph (`text.setGlyphAlternate`).
+ #[test]
+ fn a_variant_no_feature_draws_replaces_the_glyph() {
+ use vectorcraft_text::test_fonts::{alternates_family, alternates_font};
+ FontDb::global().add_font(alternates_font(true).expect("the test font builds"));
+ let (mut app, id) = editing("xay");
+ app.run("text.setRangeStyle", json!({"id": id, "font": alternates_family(true)})).unwrap();
+ select(&mut app, 2, 2);
+ let ctx = egui::Context::default();
+ choose(&ctx, &mut app, "Alternates for Current Selection");
+ let cells = squares(&shapes(&ctx, &mut app, vec![]));
+ let face = FontDb::global().face(alternates_family(true), "Regular").expect("the test face");
+ // The last cell is cv01's glyph c, which the style has no tag for.
+ double_click(&ctx, &mut app, cells[cells.len() - 1].center());
+ assert_eq!(glyph_ids(&app, &id)[1], face.glyph_for('c'));
+ let NodeKind::Text(text) = &app.session.active().unwrap().doc.node(NodeId(id.as_u64().unwrap())).unwrap().kind else { panic!("text") };
+ assert!(text.runs.iter().any(|r| r.style.glyph_alternate.is_some()));
+ // A feature variant afterwards takes the choice away in the same step.
+ let found = shapes(&ctx, &mut app, vec![]);
+ let undo = app.session.active().unwrap().history.undo.len();
+ double_click(&ctx, &mut app, squares(&found)[2].center());
+ assert_eq!(app.session.active().unwrap().history.undo.len(), undo + 1);
+ let NodeKind::Text(text) = &app.session.active().unwrap().doc.node(NodeId(id.as_u64().unwrap())).unwrap().kind else { panic!("text") };
+ assert!(text.runs.iter().all(|r| r.style.glyph_alternate.is_none()), "the choice is gone");
+ }
+
+ /// Show › Entire Font also lists the glyphs no character maps.
+ #[test]
+ fn entire_font_lists_the_unencoded_glyphs() {
+ use vectorcraft_text::test_fonts::{JOINING_ALTERNATES_FAMILY, joining_alternates_font};
+ FontDb::global().add_font(joining_alternates_font().expect("the test font builds"));
+ let (mut app, id) = editing("\u{628}");
+ app.run("text.setRangeStyle", json!({"id": id, "font": JOINING_ALTERNATES_FAMILY})).unwrap();
+ let face = FontDb::global().face(JOINING_ALTERNATES_FAMILY, "Regular").expect("the test face");
+ let encoded = face.chars().into_iter().filter(|(c, _)| !c.is_control() && !c.is_whitespace()).count();
+ let unencoded = face.unencoded_glyphs();
+ assert!(unencoded.len() > 1000 && encoded == 4, "{} unencoded, {encoded} encoded", unencoded.len());
+ let ctx = egui::Context::default();
+ let all = texts(&ctx, &mut app);
+ assert!(all.iter().any(|s| s == &format!("{} glyphs", encoded + unencoded.len())), "{all:?}");
+ assert!(face.glyph_name(unencoded[0]).is_some(), "the font names its glyphs");
+ }
+
#[test]
fn zoom_stops_at_the_ends() {
assert_eq!(nudge_cell(CELL_DEFAULT, 1), CELL_DEFAULT + 1);
diff --git a/docs/gaps.md b/docs/gaps.md
index 4dac9431ee..cb915bd80a 100644
--- a/docs/gaps.md
+++ b/docs/gaps.md
@@ -1,6 +1,6 @@
# Gaps
-> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-07 · **Change:** minor (Indian-language hyphenation review and license allowlist, #4947; opening a slow file shows a progress window, part of #5930; Hindi catalog and contributor loanword preferences; agent wording pass complete; wider native proofreading welcome; an image zoomed in on is separated, colorized or proofed only where it shows (#4155); scores and estimates unchanged; a document's first frame renders on the render worker, so opening a heavy file no longer freezes the window; scores and estimates unchanged; large images in CMYK documents are shrunk before they are separated into ink planes (#4155); scores and estimates unchanged; `.ai` paths with a Calligraphic brush open brushed, with the 6D Art Pen's channels, and Windows reads the pen's tilt and barrel rotation, #372; scores and estimates unchanged; Compound Shapes add-on, beyond Illustrator; scores and estimates unchanged; Contour add-on, beyond Illustrator; scores and estimates unchanged; a slow first frame is waited for instead of restarting without the graphics adapter, and the menu bar no longer waits for the installed fonts' scan; scores and estimates unchanged) · **Target:** Adobe Illustrator 2026 (30.x)
+> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-07 · **Change:** minor (Glyphs panel: Alternates for Current Selection applies an OpenType feature value (`salt=2`) to the selected letter alone, so an Arabic word stays joined, and Entire Font lists the unencoded glyphs, #5514; Indian-language hyphenation review and license allowlist, #4947; opening a slow file shows a progress window, part of #5930; Hindi catalog and contributor loanword preferences; agent wording pass complete; wider native proofreading welcome; an image zoomed in on is separated, colorized or proofed only where it shows (#4155); scores and estimates unchanged; a document's first frame renders on the render worker, so opening a heavy file no longer freezes the window; scores and estimates unchanged; large images in CMYK documents are shrunk before they are separated into ink planes (#4155); scores and estimates unchanged; `.ai` paths with a Calligraphic brush open brushed, with the 6D Art Pen's channels, and Windows reads the pen's tilt and barrel rotation, #372; scores and estimates unchanged; Compound Shapes add-on, beyond Illustrator; scores and estimates unchanged; Contour add-on, beyond Illustrator; scores and estimates unchanged; a slow first frame is waited for instead of restarting without the graphics adapter, and the menu bar no longer waits for the installed fonts' scan; scores and estimates unchanged) · **Target:** Adobe Illustrator 2026 (30.x)
## Feature submission evidence
@@ -372,6 +372,8 @@ from G14 and the budget run of G15: **~170–270 h** one agent, ~50–80 h wall
## G4 Advanced type
+- **Done (#5514):** the Glyphs panel's Alternates for Current Selection lists the alternates of the glyph the shaper makes for the selected character in its word, and a double-click applies the OpenType feature value that draws one (`salt=2`, `ss03`) to that character alone, in one undo step, so the text stays real Unicode and an Arabic word keeps its joining forms: runs that differ only in their features are shaped as one piece of text. A variant no feature draws there is still drawn as a replacement glyph, which may not join. Show › Entire Font lists the unencoded glyphs by id, with their name and the feature that gives them. Left: alternates reached through contextual or ligature lookups, and `cv01`–`cv99` and `hist`, which have no style tag; estimate unchanged.
+
- **Fixed:** fullwidth `!` / `?` stay upright in vertical point and area type when a font has no vertical alternate. Existing `vert` substitutions and the rotated fallback for brackets and long vowel marks are preserved; the broader CJK gap and estimate are unchanged.
- **Done:** character-style Auto-leading resets propagate to style users while retaining local leading overrides. Sparse definitions remain sparse, native serialization retains explicit Auto, and the Character Styles override marker stays consistent.
@@ -817,6 +819,7 @@ trap, and the defaults (0.25 pt, 100%, 40%) are VectorCraft's own, the pages giv
| Date | Change | Summary |
|---|---|---|
+| 2026-11-07 | minor | G4: the Glyphs panel's Alternates for Current Selection applies the OpenType feature that draws a variant to that letter alone (`salt=2`; the style commands take `tag=N`; runs that differ only in their features shape as one piece of text), so an Arabic word stays joined, and Show › Entire Font lists the unencoded glyphs (#5514). Estimate unchanged. |
| 2026-11-06 | minor | Hindi (`hi`): 7,877-row machine-assisted catalog, zero/one singular plurals, desktop font fallback and optional Devanagari craft-fonts fallback; all 55 translation tests pass; native review and a web font remain; scores and estimates unchanged |
| 2026-11-05 | minor | G15: nearby hits on plain long strokes and outlines check their candidate segments directly; a 200,000-anchor middle hit took about 11–13 ms before and 1.0–1.4 ms after in paired runs. An inside-bounds miss fell from 18.7 ms to 1.6 ms. Estimate unchanged; idle budget run still open. |
| 2026-11-05 | minor | G15: an image zoomed in on is separated, colorized or proofed only for the tiles in view (#4155); 100% zoom on that file 8.6 s → 0.05 s. Estimate unchanged. |
diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md
index 0af2f06708..ca40e4c817 100644
--- a/docs/target-app-parity.md
+++ b/docs/target-app-parity.md
@@ -1,6 +1,6 @@
# Parity with Adobe Illustrator
-> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-07 · **Change:** minor (the canvas keeps selection outlines and anchors between frames; Indian-language hyphenation review and license allowlist, #4947; Stroke gradients: an arrowhead aligned to the tip is painted to the corners of its base, #5964; Drop Shadow and glow dialogs: spinner arrows, #5367, #5368; Stroke Weight Up and Down shortcuts, #5797; Hindi catalog and contributor loanword preferences; full wording review in progress; Properties in Edit Artboards: a Transform section with the reference point above the Artboards section, #5219; the Control bar's Stroke: and Opacity: links scrub their fields, #4088; an image zoomed in on is separated, colorized or proofed only where it shows (#4155); a document's first frame renders on the render worker, so opening a heavy file no longer freezes the window, #4155; Control bar Paragraph section with type selected, #5372; translating unchanged type reuses its glyph and character-stroke outlines; scores and estimates unchanged) · **Target:** Adobe Illustrator 2026 (30.x; public release notes through 30.7, August 2026)
+> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-07 · **Change:** minor (Glyphs panel: Alternates for Current Selection applies the OpenType feature that draws a variant to that letter alone, so an Arabic word stays joined, with feature values `salt=2` in the style commands, and Entire Font lists the unencoded glyphs, #5514; the canvas keeps selection outlines and anchors between frames; Indian-language hyphenation review and license allowlist, #4947; Stroke gradients: an arrowhead aligned to the tip is painted to the corners of its base, #5964; Drop Shadow and glow dialogs: spinner arrows, #5367, #5368; Stroke Weight Up and Down shortcuts, #5797; Hindi catalog and contributor loanword preferences; full wording review in progress; Properties in Edit Artboards: a Transform section with the reference point above the Artboards section, #5219; the Control bar's Stroke: and Opacity: links scrub their fields, #4088; an image zoomed in on is separated, colorized or proofed only where it shows (#4155); a document's first frame renders on the render worker, so opening a heavy file no longer freezes the window, #4155; Control bar Paragraph section with type selected, #5372; translating unchanged type reuses its glyph and character-stroke outlines; scores and estimates unchanged) · **Target:** Adobe Illustrator 2026 (30.x; public release notes through 30.7, August 2026)
## Feature submission evidence
@@ -986,6 +986,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur
The OpenType panel's Stylistic Alternates and Titling Alternates checkboxes turn `salt` and `titl` on and off for the selected type, and its Figure menu lists Default Figure, Tabular Lining, Proportional Lining, Proportional Oldstyle and Tabular Oldstyle; Proportional Lining sets `pnum` and `lnum` and turns `tnum` and `onum` off, Tabular Lining turns `pnum` off and keeps `lnum`, Proportional Oldstyle turns `lnum` off and keeps `pnum`, and Default Figure and Tabular Oldstyle turn `pnum` and `lnum` off. Behavior reference: [Format text with OpenType features](https://helpx.adobe.com/illustrator/desktop/design-with-text/special-characters-glyphs/format-text-with-opentype-features.html) and [Use swashes, titling alternates, or stylistic alternates](https://helpx.adobe.com/illustrator/desktop/design-with-text/special-characters-glyphs/use-swashes-titling-alternates-or-stylistic-alternates.html) (public documentation). Tests: `panels::opentype::tests`.
The OpenType panel's Position menu lists Default Position, Superscript, Subscript, Numerator and Denominator, as [Format text with OpenType features](https://helpx.adobe.com/illustrator/desktop/design-with-text/special-characters-glyphs/format-text-with-opentype-features.html) describes; Superscript, Subscript, Numerator and Denominator set `sups`, `subs`, `numr` and `dnom` for the selected type and turn the other three off, and Default Position turns all four off. The menu shows Mixed when the characters it acts on differ, and an entry chosen then applies to all of them. These two rules are VectorCraft's own, as is setting the feature on type whose font lacks the glyphs, which then draws unchanged. The panel's menus and checkboxes change only their own features on each selected character, so characters keep their other OpenType features (`text.setStyle` and `text.setRangeStyle` `{featureChanges}`). Tests: `panels::opentype::tests`, `features::tests::numerators_and_denominators_are_kept_and_shaped`.
The Glyphs panel's Show menu and panel menu have Alternates for Current Selection, as [Replace characters with alternate glyphs](https://helpx.adobe.com/illustrator/desktop/design-with-text/special-characters-glyphs/replace-characters-with-alternate-glyphs.html) describes: the grid lists the alternate glyphs of the character selected with the Type tool, and a double-click draws that character with the glyph as one undo step (`text.glyphAlternates`, `text.setGlyphAlternate`). The alternates come from the font's `GSUB` single and alternate substitutions of `aalt`, then `salt`, `ss01` to `ss20`, `swsh`, `cv01` to `cv99` and a few other alternate features, each glyph once. The text keeps the character, native files keep the choice with the face it was made in, and type set in another font or version draws the default glyph. The list uses the font of the character's text even when the panel browses another font, and the panel shows a note when the character has no alternates, its font is missing or no character is selected; these rules are VectorCraft's own. Tests: `panels::glyphs::tests`, `tests::alternates`, `tests::textedit::a_glyph_alternate_replaces_one_character_through_undo_and_reopen`.
+ The Glyphs panel's Alternates for Current Selection (#5514) applies the OpenType feature that draws the glyph to the selected character alone, as [Replace characters with alternate glyphs](https://helpx.adobe.com/illustrator/desktop/design-with-text/special-characters-glyphs/replace-characters-with-alternate-glyphs.html) describes (Alternates For Current Selection in the Show menu; double-click the glyph to replace the selected character). One deliberate difference from a literal reading: the text keeps the Unicode character and gains a character-style feature with a value (`salt=2`, `ss03`; `text.setRangeStyle {featureChanges}`) instead of taking an unencoded glyph in its place. The grid lists the alternates of the glyph the shaper makes for the character in its word (a medial letter shows its medial variants; the glyph it has comes first and is highlighted); a variant no feature draws there stays enabled as a replacement glyph (`text.setGlyphAlternate`), and its tooltip says it may not join its neighbors; the default glyph takes the character's alternate features off again. Runs that differ only in their OpenType features are shaped as one piece of text, each run's features over its own characters, so the letters of an Arabic word keep their joining forms. Show › Entire Font also lists the glyphs no character maps, with the font's name for them (or `gid N`) and the feature that gives them. These rules are VectorCraft's own. Tests: `tests::glyph_alternates` in the text crate (a fixture font; Kufi Murabba PIIIX when it is installed), `panels::glyphs::tests`, `tests::textedit::feature_values_are_set_replaced_and_saved`.
The OpenType panel's Stylistic Sets menu offers `ss01`–`ss20` sets present in the selected text's font, using the font's custom labels when available and numbered labels otherwise. Sets can be combined and deselected individually. With mixed fonts, the menu shows the first selected character's font's sets and applies the feature tag to all selected text; a font without that set draws unchanged. The mixed-font rule is a VectorCraft design choice. Behavior reference: [Add stylistic sets to selected text](https://helpx.adobe.com/illustrator/desktop/design-with-text/special-characters-glyphs/add-stylistic-sets-to-selected-text.html) and the [OpenType specification](https://learn.microsoft.com/en-us/typography/opentype/spec/features_pt).
`text.setStyle {stroke, strokeWidth}` sets the character stroke's color and weight of the selected type or of a range of its characters (the weight in document points, as `stroke.set` sets it), in the same undo step as the command's other changes. Tests: `tests::textedit::set_style_sets_the_character_stroke`.
- **Paragraph alignment:** the shared Paragraph/Properties alignment row leaves every button unselected when the paragraphs it acts on differ. Auto resolves per paragraph; choosing an alignment uses the existing command. Headless paragraph_mixed_alignment_* regressions exercise mixed → Center → Undo, caret/uniform states, Unicode range boundaries, Auto/direction, split runs and grouped text. These regressions and full integration CI pass. Original captures confirm mixed → Center → Undo in the existing alignment-button layout. The [public Paragraph panel guide](https://helpx.adobe.com/illustrator/desktop/design-with-text/edit-format-text/paragraph-panel-overview.html) lists the seven controls, but does not specify mixed-state rendering. Other paragraph fields remain outside this change; scores and hours unchanged.
@@ -1725,6 +1726,7 @@ Edit › Copy and Cut put the selected ruler guides on the clipboard, with the s
| Date | Change | Summary |
|---|---|---|
+| 2026-11-07 | minor | Type core: the Glyphs panel's Alternates for Current Selection applies the OpenType feature that draws a variant to the selected letter alone, so an Arabic word stays joined (`salt=2`; the style commands take `tag=N`), and Show › Entire Font lists the glyphs no character maps (#5514; [Replace characters with alternate glyphs](https://helpx.adobe.com/illustrator/desktop/design-with-text/special-characters-glyphs/replace-characters-with-alternate-glyphs.html)); evidence added; scores unchanged. |
| 2026-11-06 | minor | Direct Selection: with Snap to Grid, a dragged straight segment lands its anchors on the grid (#3835); evidence added; scores unchanged. |
| 2026-11-06 | minor | Snap to Glyph › Glyph Bounds: the corners of each glyph's box are points a drawn point lands on, also on a curved side (`B`), and the box turns with rotated type, its edges then Angular Guides (#5112; [Snap to Glyph options](https://helpx.adobe.com/illustrator/desktop/design-with-text/special-characters-glyphs/snap-to-glyph-options.html)); scores and estimates unchanged. |
| 2026-11-06 | minor | Drop Shadow: a swatch beside Color opens the Color Picker over the dialog, as the other effect colour fields do (#5364); correction: the #5365 entry listed Drop Shadow, whose Color is drawn outside the shared grid and had no swatch; scores unchanged. |
diff --git a/docs/type-parity.md b/docs/type-parity.md
index b36cb48921..26a1fdaa07 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-11-07 · **Change:** minor (Indian-language hyphenation review and license allowlist, #4947; Bulgarian among the languages, with its quote marks and the fonts' Bulgarian forms, #5945; previously minor: Show Snap to Glyph Options scrolls to the options, with a Snap to Glyph checkbox, #5273; scores and estimates unchanged) · **Target:** Adobe Illustrator 2026 (30.x)
+> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-07 · **Change:** minor (the Glyphs panel's Alternates for Current Selection applies the OpenType feature that draws a variant to that letter alone, so an Arabic word stays joined, and Entire Font lists the unencoded glyphs, #5514; Indian-language hyphenation review and license allowlist, #4947; Bulgarian among the languages, with its quote marks and the fonts' Bulgarian forms, #5945; previously minor: Show Snap to Glyph Options scrolls to the options, with a Snap to Glyph checkbox, #5273; scores and estimates unchanged) · **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
@@ -31,6 +31,7 @@ against Illustrator's public documentation; **estimated** unless noted.
| Character size and horizontal scale after object/group scaling | done | #1034: fields and setters compensate the stored affine; point size follows the transformed em-height axis, not the glyph ink bounds. Range edits, keyboard increments, leading and baseline shift use document points; rotation, reflection and shear remain editable. |
| Preserve font sizes while scaling a group | missing | Separate enhancement; scaling still scales text with the other artwork. |
| OpenType panel, Glyphs panel, alternates | done | Highlight Alternate Glyphs preference (#394, off by default) paints pink behind OpenType substitutions on screen when on (required shaping such as Arabic joining forms and the lam-alef ligature, including a lam-alef older fonts file under `liga`, is not highlighted; an optional feature that changes the ligature is); stylistic sets ss01–ss20, stylistic and titling alternates, proportional and lining figures, superscript and subscript are character-style features (`text.setStyle {features}`, #1607); the OpenType panel's Position menu sets superscript, subscript, numerator or denominator glyphs (`sups`, `subs`, `numr`, `dnom`). The OpenType panel's Stylistic Sets menu lists the selected font's `ss01`–`ss20` features, using a font-supplied name where available and `Set N` otherwise; several sets can be active, and selecting an active one removes it. For a mixed-font selection it uses the first selected character's font, a VectorCraft design choice ([public help](https://helpx.adobe.com/illustrator/desktop/design-with-text/special-characters-glyphs/add-stylistic-sets-to-selected-text.html), [OpenType feature specification](https://learn.microsoft.com/en-us/typography/opentype/spec/features_pt)). The Glyphs panel highlights the character selected with the Type tool (a range's first character, else the one before the caret) and scrolls it into view as the selection moves (#4908; `panels::glyphs` tests); its Show menu lists Unicode ranges and Alternates for Current Selection, the selected character's alternate glyphs from the font's `GSUB` table, one of which a double-click draws for that character (`text.setGlyphAlternate`); the glyph-cell pop-up of alternates is not there yet |
+| Glyphs panel: alternates as OpenType feature values, unencoded glyphs | done | #5514, as [Replace characters with alternate glyphs](https://helpx.adobe.com/illustrator/desktop/design-with-text/special-characters-glyphs/replace-characters-with-alternate-glyphs.html) describes (Show › Alternates For Current Selection; replace the selected character by double-clicking the glyph). A character-style feature takes a value (`"salt=2"`, the font's alternate number; `ss01`–`ss20`, `swsh`, `titl` and `aalt` too) in `features` and `featureChanges`, in SVG `font-feature-settings` (`"salt" 2`) and in native files as the plain string. Runs that differ only in their features shape as one piece of text, each run's features over its own characters, so a variant on one letter of an Arabic word keeps its neighbours' initial, medial and final forms and the letters still touch. The panel lists the alternates of the glyph the shaper makes for the selected character in its word (a medial letter shows medial variants), and a double-click applies the feature that draws the variant to that character alone, in one undo step (`text.setRangeStyle`); a variant no feature draws there is drawn as a replacement glyph (`text.setGlyphAlternate`), which its tooltip says may not join. Show › Entire Font also lists the glyphs no character maps, with their name or `gid N` and the feature that gives them. The text crate reads each face's `GSUB` once (alternate sets, single substitutions, the feature that reaches a glyph). Not done: alternates reached through contextual or ligature lookups, and features of the font that the style has no tag for (`cv01`–`cv99`, `hist`). Evidence: `tests::glyph_alternates` in the text crate (a fixture font with a medial teh at `salt=1` to `3`, the neighbours' forms, no gap; Kufi Murabba PIIIX when it is installed), `features::tests::feature_values_are_parsed_validated_and_shaped`, `tests::textedit::feature_values_are_set_replaced_and_saved`, the `panels::glyphs` tests. |
| Font menu: samples, live preview, filters, favourites, recent families; font matching by any name; Find Font | done | #3515: recently applied families appear first, once each; missing families are omitted and the existing Number of Recent Fonts setting applies; #339; script filter: Japanese, Latin, Thai; the font scan on Linux also reads the folders fontconfig's configuration lists, such as TeX fonts under `/usr/share/texmf/fonts` (#1548, fixes #1529; not verified on Linux) Replace With Font lists family and style rows, one click setting both, with a search over them; its list scrolls on its own, apart from the document's font list (#4972). |
| Missing glyphs set in an installed font made for their script (Thai, Lao, Khmer, Myanmar first; then any font that has them) | done | Thai: Leelawadee UI, Tahoma, Thonburi, Sukhumvit Set, Noto Sans Thai, TLWG |
| 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 |
@@ -79,6 +80,7 @@ Character Styles menu New/Redefine and footer New capture the first selected cha
| Date | Change | Summary |
|---|---|---|
+| 2026-11-07 | minor | Glyphs panel (#5514): Alternates for Current Selection lists the alternates of the selected character's glyph in its word and a double-click applies the OpenType feature that draws one to that letter alone (`salt=2`; `features` and `featureChanges` take `tag=N`), so an Arabic word stays joined, and Entire Font lists the unencoded glyphs. Runs that differ only in their features shape as one piece of text. Scores and estimates unchanged. |
| 2026-11-07 | minor | Indian-language hyphenation (#4947): eleven MIT-licensed pattern sets, with grapheme and shaping-cluster protection in both composers and existing paragraph limits. Assamese and Sanskrit are selectable; Sanskrit automatic patterns remain pending an allowlisted source. Scores and estimates unchanged. |
| 2026-11-06 | minor | Alt + numeric-keypad codes (`Alt+0176` → °) type their character at the caret on Windows, and `type.insert` inserts a character by `code` or `unicode` (#5108); scores and estimates unchanged |
| 2026-11-06 | minor | Snap to Glyph › Glyph Bounds: each glyph's box gives four corners a drawn point lands on, also on a curved side (`B`), and its box and edges turn with rotated type (#5112); scores and estimates unchanged |
From 87978f8de2e56abe82e9ffc6c164f1e46c48afa3 Mon Sep 17 00:00:00 2001
From: Taha-Mahmoodi <85902429+Taha-Mahmoodi@users.noreply.github.com>
Date: Sat, 7 Nov 2026 14:39:58 +0430
Subject: [PATCH 07/86] Text: the Kufi joining test checks the drawn ink on the
installed font, never skips when it is there, and covers ss01-ss05
---
crates/text/src/tests/glyph_alternates.rs | 122 +++++++++++++++++-----
1 file changed, 94 insertions(+), 28 deletions(-)
diff --git a/crates/text/src/tests/glyph_alternates.rs b/crates/text/src/tests/glyph_alternates.rs
index edec6de6f0..98dcaf3b3d 100644
--- a/crates/text/src/tests/glyph_alternates.rs
+++ b/crates/text/src/tests/glyph_alternates.rs
@@ -133,47 +133,113 @@ fn runs_that_differ_only_in_features_shape_as_one_word() {
assert_eq!(a, b, "the same places");
}
-/// The Kufi Murabba PIIIX of the font's author, when it is installed: the middle letter of a word
-/// takes each of its `salt` alternates and every other letter keeps the glyph it has without one.
+/// Where the owner's Kufi Murabba PIIIX is installed.
+const KUFI_FILE: &str = "Library/Fonts/KufiMurabbaPIIIX-Regular.ttf";
+
+/// The Kufi Murabba PIIIX of the font's author in a font database of its own, `None` when it is not
+/// installed (the test then has nothing to read). When the file is there, a face that does not
+/// load is a failure, never a skip.
+fn kufi() -> Option<(FontDb, std::sync::Arc)> {
+ let path = std::path::Path::new(&std::env::var_os("HOME")?).join(KUFI_FILE);
+ if !path.exists() {
+ eprintln!("skipped: {} is not installed", path.display());
+ return None;
+ }
+ let bytes = std::fs::read(&path).unwrap_or_else(|e| panic!("{} exists but cannot be read: {e}", path.display()));
+ let db = FontDb::with_font_dirs(vec![]);
+ let added = db.add_font(bytes);
+ assert!(added > 0, "{} exists but no face of it loaded", path.display());
+ let face = db.face("Kufi Murabba PIIIX", "Regular").expect("a face");
+ assert_eq!((face.family.as_str(), face.style.as_str()), ("Kufi Murabba PIIIX", "Regular"), "the installed font's face, not a fallback");
+ Some((db, face))
+}
+
+/// The word al-Khwarizmi: alef, lam, khah, waw, alef, reh, zain, meem, farsi yeh. The lam, khah and
+/// waw join (initial, medial, final); the letters around them do not join forward.
+const KHWARIZMI: &str = "الخوارزمی";
+/// The medial khah, the middle letter of the joined lam-khah-waw.
+const KHAH: usize = 2;
+
+/// The word with `features` on the character `at` only, shaped as one piece (the runs differ only
+/// in features), in reading order of the characters.
+fn khwarizmi(db: &FontDb, family: &str, at: usize, features: &[&str]) -> Vec {
+ let letters: Vec = KHWARIZMI.chars().collect();
+ let part = |r: std::ops::Range| -> String { letters[r].iter().collect() };
+ let (before, middle, after) = (part(0..at), part(at..at + 1), part(at + 1..letters.len()));
+ set(db, family, &[(&before, &[]), (&middle, features), (&after, &[])])
+}
+
+/// The gap between the drawn outlines of the letters that join (the lam, khah and waw), along the
+/// line: 0 when the ink of one reaches the ink of the next. Unlike [`gaps`], which only checks that
+/// the pen advances by each glyph's advance, this reads the glyphs drawn.
+fn ink_gaps(glyphs: &[PositionedGlyph]) -> Vec {
+ use kurbo::Shape;
+ let mut spans: Vec<(f64, f64)> = glyphs[KHAH - 1..=KHAH + 1].iter().map(|g| (g.outline.bounding_box().x0, g.outline.bounding_box().x1)).collect();
+ spans.sort_by(|a, b| a.0.total_cmp(&b.0));
+ spans.windows(2).map(|w| w[1].0 - w[0].1).collect()
+}
+
+/// One line of what the shaper made, for a failure message and `--nocapture`.
+fn describe(glyphs: &[PositionedGlyph]) -> String {
+ glyphs.iter().map(|g| format!("{}@{:.1}+{:.1}", g.gid, g.origin.x, g.advance)).collect::>().join(" ")
+}
+
+/// The Kufi Murabba PIIIX of the font's author, when it is installed: the medial khah of a word
+/// takes each of its `salt` alternates, every other letter keeps the glyph it has without one and
+/// the letters still touch.
#[test]
fn kufi_murabba_alternates_keep_the_word_joined() {
- let Some(path) =
- std::env::var_os("HOME").map(|h| std::path::Path::new(&h).join("Library/Fonts/KufiMurabbaPIIIX-Regular.ttf")).filter(|p| p.exists())
- else {
- return;
- };
- let Ok(bytes) = std::fs::read(path) else { return };
- let db = FontDb::with_font_dirs(vec![]);
- db.add_font(bytes);
- let Some(face) = db.face("Kufi Murabba PIIIX", "Regular") else { return };
+ let Some((db, face)) = kufi() else { return };
let family = face.family.clone();
- let letters: Vec = "الخوارزمی".chars().collect();
- let text = |pieces: &[(String, Vec<&str>)]| {
- let pieces: Vec<(&str, &[&str])> = pieces.iter().map(|(t, f)| (t.as_str(), f.as_slice())).collect();
- set(&db, &family, &pieces)
- };
- let plain = text(&[(letters.iter().collect(), vec![])]);
- assert_eq!(plain.len(), letters.len(), "one glyph for each letter");
+ let plain = khwarizmi(&db, &family, KHAH, &[]);
+ assert_eq!(plain.len(), KHWARIZMI.chars().count(), "one glyph for each letter: {}", describe(&plain));
assert!(gaps(&plain).iter().all(|gap| gap.abs() < 1e-3), "the word joins without a gap: {:?}", gaps(&plain));
- // The medial khah.
- let at = 2;
- let default = plain[at].gid;
+ assert!(ink_gaps(&plain).iter().all(|gap| gap.abs() < 1e-3), "the drawn letters touch: {:?}", ink_gaps(&plain));
+ let default = plain[KHAH].gid;
+ assert_eq!(face.glyph_name(default).as_deref(), Some("khah.medi"), "the khah in the middle of the word takes its medial form");
let salt =
face.substitutions(default).into_iter().find(|s| &s.feature == b"salt" && s.glyphs.len() >= 2).expect("the medial khah has salt alternates");
for (i, alternate) in salt.glyphs.iter().enumerate() {
let value = format!("salt={}", i + 1);
- let before: String = letters[..at].iter().collect();
- let after: String = letters[at + 1..].iter().collect();
- let glyphs = text(&[(before, vec![]), (letters[at].to_string(), vec![value.as_str()]), (after, vec![])]);
- assert_eq!(glyphs.len(), plain.len(), "{value}");
- assert_eq!(glyphs[at].gid, *alternate, "{value}: the alternate");
- for (n, (a, b)) in glyphs.iter().zip(&plain).enumerate().filter(|(n, _)| *n != at) {
- assert_eq!(a.gid, b.gid, "{value}: the letter {n} keeps its form");
+ let glyphs = khwarizmi(&db, &family, KHAH, &[value.as_str()]);
+ assert_eq!(glyphs.len(), plain.len(), "{value}: {}", describe(&glyphs));
+ assert_eq!(glyphs[KHAH].gid, *alternate, "{value}: the alternate");
+ assert_ne!(glyphs[KHAH].gid, default, "{value}: not the default glyph");
+ assert_eq!(face.glyph_name(*alternate), Some(format!("khah.medi.alt{}", i + 1)), "{value}: an alternate of the medial form");
+ for (n, (a, b)) in glyphs.iter().zip(&plain).enumerate().filter(|(n, _)| *n != KHAH) {
+ assert_eq!(a.gid, b.gid, "{value}: the letter {n} keeps its form: {}", describe(&glyphs));
}
assert!(gaps(&glyphs).iter().all(|gap| gap.abs() < 1e-3), "{value}: no gap: {:?}", gaps(&glyphs));
+ assert!(ink_gaps(&glyphs).iter().all(|gap| gap.abs() < 1e-3), "{value}: the drawn letters touch: {:?}", ink_gaps(&glyphs));
}
}
+/// The stylistic sets `ss01` to `ss05` on a letter in the middle of a joined group: the glyph the
+/// font's set gives its medial form (the default when it has none), the same forms for the others,
+/// no gap.
+#[test]
+fn kufi_murabba_stylistic_sets_on_a_middle_letter_keep_the_word_joined() {
+ let Some((db, face)) = kufi() else { return };
+ let family = face.family.clone();
+ let plain = khwarizmi(&db, &family, KHAH, &[]);
+ let default = plain[KHAH].gid;
+ let mut changed = 0;
+ for set_number in 1..=5 {
+ let tag = format!("ss{set_number:02}");
+ let wanted = face.substitutions(default).into_iter().find(|s| s.feature == tag.as_bytes() && !s.glyphs.is_empty()).map(|s| s.glyphs[0]);
+ let glyphs = khwarizmi(&db, &family, KHAH, &[tag.as_str()]);
+ assert_eq!(glyphs.len(), plain.len(), "{tag}: {}", describe(&glyphs));
+ assert_eq!(glyphs[KHAH].gid, wanted.unwrap_or(default), "{tag}: the set's glyph for the medial khah");
+ changed += usize::from(wanted.is_some_and(|w| w != default));
+ for (n, (a, b)) in glyphs.iter().zip(&plain).enumerate().filter(|(n, _)| *n != KHAH) {
+ assert_eq!(a.gid, b.gid, "{tag}: the letter {n} keeps its form: {}", describe(&glyphs));
+ }
+ assert!(gaps(&glyphs).iter().all(|gap| gap.abs() < 1e-3), "{tag}: no gap: {:?}", gaps(&glyphs));
+ assert!(ink_gaps(&glyphs).iter().all(|gap| gap.abs() < 1e-3), "{tag}: the drawn letters touch: {:?}", ink_gaps(&glyphs));
+ }
+ assert!(changed >= 1, "the font has stylistic sets for the medial khah");
+}
+
/// The `GSUB` reader gives the alternate set of each glyph (position in the set = the feature's
/// value), single substitutions, the feature that reaches a glyph, names and the glyph count.
#[test]
From 2b1d657d8a9500d6a2c389c69b9de2d10e90c136 Mon Sep 17 00:00:00 2001
From: Taha-Mahmoodi <85902429+Taha-Mahmoodi@users.noreply.github.com>
Date: Sat, 7 Nov 2026 15:55:00 +0430
Subject: [PATCH 08/86] Glyphs: Entire Font applies an unencoded glyph that is
the selected letter's alternate, dims the others with the reason, and inserts
a presentation form's letter so the word joins
---
crates/text/src/fontdb.rs | 2 +-
crates/text/src/fontdb/gsub.rs | 73 +++-
crates/text/src/lib.rs | 6 +-
crates/text/src/tests/glyph_alternates.rs | 57 ++-
crates/ui-egui/src/i18n/bg.tsv | 12 +-
crates/ui-egui/src/i18n/bs.tsv | 12 +-
crates/ui-egui/src/i18n/ca.tsv | 12 +-
crates/ui-egui/src/i18n/cs.tsv | 12 +-
crates/ui-egui/src/i18n/da.tsv | 12 +-
crates/ui-egui/src/i18n/de.tsv | 12 +-
crates/ui-egui/src/i18n/el.tsv | 12 +-
crates/ui-egui/src/i18n/es.tsv | 12 +-
crates/ui-egui/src/i18n/fi.tsv | 12 +-
crates/ui-egui/src/i18n/fr.tsv | 12 +-
crates/ui-egui/src/i18n/hi.tsv | 12 +-
crates/ui-egui/src/i18n/hu.tsv | 12 +-
crates/ui-egui/src/i18n/id.tsv | 12 +-
crates/ui-egui/src/i18n/it.tsv | 12 +-
crates/ui-egui/src/i18n/ja.tsv | 12 +-
crates/ui-egui/src/i18n/ka.tsv | 12 +-
crates/ui-egui/src/i18n/ko.tsv | 12 +-
crates/ui-egui/src/i18n/nl.tsv | 12 +-
crates/ui-egui/src/i18n/pl.tsv | 12 +-
crates/ui-egui/src/i18n/pt-br.tsv | 12 +-
crates/ui-egui/src/i18n/ru.tsv | 12 +-
crates/ui-egui/src/i18n/sv.tsv | 12 +-
crates/ui-egui/src/i18n/tr.tsv | 12 +-
crates/ui-egui/src/i18n/uk.tsv | 12 +-
crates/ui-egui/src/i18n/vi.tsv | 12 +-
crates/ui-egui/src/i18n/zh-hans.tsv | 12 +-
crates/ui-egui/src/i18n/zh-hant.tsv | 12 +-
crates/ui-egui/src/panels/glyphs.rs | 433 +++++++++++++++++++++-
docs/gaps.md | 2 +-
docs/type-parity.md | 4 +-
34 files changed, 844 insertions(+), 57 deletions(-)
diff --git a/crates/text/src/fontdb.rs b/crates/text/src/fontdb.rs
index c7d116a29d..44be66ae12 100644
--- a/crates/text/src/fontdb.rs
+++ b/crates/text/src/fontdb.rs
@@ -51,7 +51,7 @@ use face::Instances;
pub(crate) use face::MAX_LIVE_INSTANCES;
pub use face::{FALLBACK_FAMILY, FontFace, IcfMargins};
pub(crate) use glyph_pen::FlipPen;
-pub use gsub::{Substitution, SubstitutionKind};
+pub use gsub::{GlyphOrigin, JoiningPosition, Substitution, SubstitutionKind};
#[cfg(not(target_arch = "wasm32"))]
use names::SysFallback;
#[cfg(test)]
diff --git a/crates/text/src/fontdb/gsub.rs b/crates/text/src/fontdb/gsub.rs
index 3b07aad668..888c89ab09 100644
--- a/crates/text/src/fontdb/gsub.rs
+++ b/crates/text/src/fontdb/gsub.rs
@@ -83,6 +83,8 @@ impl Substitution {
}
}
+type Tag = [u8; 4];
+
/// A face's substitutions, by the glyph they replace and by the glyph they give.
#[derive(Default)]
pub(super) struct GsubIndex {
@@ -90,8 +92,8 @@ pub(super) struct GsubIndex {
/// lookup.
by_input: HashMap>,
/// The first feature, listed ones first, that gives each glyph: by a single, multiple,
- /// alternate or ligature substitution.
- reached: HashMap,
+ /// alternate or ligature substitution, and the glyph it replaces (not known for a ligature).
+ reached: HashMap)>,
}
/// The glyph single substitution `t` puts in place of each glyph its coverage holds.
@@ -209,7 +211,7 @@ impl GsubIndex {
let out: Vec = out.into_iter().filter(|g| *g < glyphs).collect();
for g in &out {
if *g != input && index.reached.len() < MAX_ENTRIES {
- index.reached.entry(*g).or_insert(tag);
+ index.reached.entry(*g).or_insert((tag, Some(input)));
}
}
if listed && !out.is_empty() && entries < MAX_ENTRIES {
@@ -240,7 +242,7 @@ impl GsubIndex {
if covered.insert(input) {
for g in to.into_iter().filter(|g| *g < glyphs && *g != input) {
if index.reached.len() < MAX_ENTRIES {
- index.reached.entry(g).or_insert(tag);
+ index.reached.entry(g).or_insert((tag, Some(input)));
}
}
}
@@ -251,7 +253,7 @@ impl GsubIndex {
for t in s.iter().take(n).inspect(|_| reads -= 1).flatten() {
for g in ligature_glyphs(&t, MAX_ENTRIES).into_iter().filter(|g| *g < glyphs) {
if index.reached.len() < MAX_ENTRIES {
- index.reached.entry(g).or_insert(tag);
+ index.reached.entry(g).or_insert((tag, None));
}
}
}
@@ -263,6 +265,40 @@ impl GsubIndex {
}
}
+/// The most substitutions [`FontFace::glyph_origin`] follows back.
+const MAX_ORIGIN_STEPS: usize = 8;
+
+/// The character a glyph comes from ([`FontFace::glyph_origin`]).
+#[derive(Clone, Debug, PartialEq, Eq)]
+pub struct GlyphOrigin {
+ pub ch: char,
+ /// The features from the glyph back to the character's glyph, the glyph's own first.
+ pub features: Vec<[u8; 4]>,
+}
+
+impl GlyphOrigin {
+ /// The place in a word that the glyph's features give it: the first of `init`, `medi`, `fina`
+ /// and `isol` among them, `None` when none is (a letter that does not join).
+ pub fn position(&self) -> Option {
+ self.features.iter().find_map(|f| match f {
+ b"init" => Some(crate::JoiningPosition::Initial),
+ b"medi" => Some(crate::JoiningPosition::Medial),
+ b"fina" => Some(crate::JoiningPosition::Final),
+ b"isol" => Some(crate::JoiningPosition::Isolated),
+ _ => None,
+ })
+ }
+}
+
+/// Where a letter stands in a word, as the `init`, `medi`, `fina` and `isol` features say.
+#[derive(Clone, Copy, Debug, PartialEq, Eq)]
+pub enum JoiningPosition {
+ Initial,
+ Medial,
+ Final,
+ Isolated,
+}
+
impl FontFace {
fn gsub_index(&self) -> &GsubIndex {
self.gsub.get_or_init(|| GsubIndex::read(self))
@@ -285,7 +321,32 @@ impl FontFace {
/// multiple, alternate or ligature substitution), a listed feature ([`Self::substitutions`])
/// before another (`init`, `liga`), if any does. Not read through a contextual lookup.
pub fn feature_reaching(&self, gid: u32) -> Option<[u8; 4]> {
- self.gsub_index().reached.get(&gid).copied()
+ self.gsub_index().reached.get(&gid).map(|r| r.0)
+ }
+
+ /// Where glyph `gid` comes from: the character whose glyph the `GSUB` table's substitutions
+ /// lead to it from, and the features on the way, `gid`'s own first (`aalt`, then `fina` for
+ /// `yehfarsi.fina.alt2`, whose character is U+06CC). The chain follows the first substitution
+ /// that gives each glyph back to a glyph none gives, as far as [`MAX_ORIGIN_STEPS`] steps; a
+ /// presentation-form character that maps a glyph in the chain does not stop it, and the
+ /// character is the lowest one outside the Arabic presentation forms that maps the glyph it
+ /// ends on (the lowest of all when only presentation forms do). `None` when no character maps
+ /// that glyph (a glyph no feature reaches, a ligature).
+ pub fn glyph_origin(&self, gid: u32) -> Option {
+ let index = self.gsub_index();
+ let mut features = vec![];
+ let mut at = gid;
+ for _ in 0..MAX_ORIGIN_STEPS {
+ let Some((tag, from)) = index.reached.get(&at) else { break };
+ features.push(*tag);
+ at = (*from)?;
+ }
+ let font = self.skrifa()?;
+ let presentation = |c: u32| (0xFB50..=0xFDFF).contains(&c) || (0xFE70..=0xFEFF).contains(&c);
+ let mut chars: Vec = font.charmap().mappings().filter(|(_, g)| g.to_u32() == at).map(|(c, _)| c).collect();
+ chars.sort_by_key(|c| (presentation(*c), *c));
+ let ch = chars.first().copied().and_then(char::from_u32)?;
+ Some(GlyphOrigin { ch, features })
}
/// The name of glyph `gid` from the font's `post` or `CFF` table: `None` for a glyph past the
diff --git a/crates/text/src/lib.rs b/crates/text/src/lib.rs
index b593594fbf..095e26f0de 100644
--- a/crates/text/src/lib.rs
+++ b/crates/text/src/lib.rs
@@ -39,9 +39,9 @@ pub use features::{
EXTRA_FEATURES, LIGATURE_TRACKING_LIMITS, MAX_FEATURE_VALUE, MAX_STYLE_FEATURES, OtFeatures, explicit_ligatures, ligatures_suppressed_by,
};
pub use fontdb::{
- FALLBACK_FAMILY, FontClass, FontDb, FontFace, FontMatch, FontTraits, GlyphAlternate, IcfMargins, PlatformFontFiles, StylisticSet, Substitution,
- SubstitutionKind, WantedFont, WantedFonts, app_font_dir, is_font_file, is_suitcase, set_app_font_dir, set_platform_font_files,
- set_user_font_dirs, set_user_font_dirs_confined, style_weight, system_font_dirs, user_font_dirs,
+ FALLBACK_FAMILY, FontClass, FontDb, FontFace, FontMatch, FontTraits, GlyphAlternate, GlyphOrigin, IcfMargins, JoiningPosition, PlatformFontFiles,
+ StylisticSet, Substitution, SubstitutionKind, WantedFont, WantedFonts, app_font_dir, is_font_file, is_suitcase, set_app_font_dir,
+ set_platform_font_files, set_user_font_dirs, set_user_font_dirs_confined, style_weight, system_font_dirs, user_font_dirs,
};
pub use hyphen::{hyphenation_exceptions, set_hyphenation_exceptions};
use std::sync::Arc;
diff --git a/crates/text/src/tests/glyph_alternates.rs b/crates/text/src/tests/glyph_alternates.rs
index 98dcaf3b3d..992dc9f711 100644
--- a/crates/text/src/tests/glyph_alternates.rs
+++ b/crates/text/src/tests/glyph_alternates.rs
@@ -136,11 +136,19 @@ fn runs_that_differ_only_in_features_shape_as_one_word() {
/// Where the owner's Kufi Murabba PIIIX is installed.
const KUFI_FILE: &str = "Library/Fonts/KufiMurabbaPIIIX-Regular.ttf";
+/// The owner's Heavy weight of it, the one a final yeh was reported against.
+const KUFI_HEAVY_FILE: &str = "Library/Fonts/KufiMurabbaPIIIX-Heavy.ttf";
+
/// The Kufi Murabba PIIIX of the font's author in a font database of its own, `None` when it is not
/// installed (the test then has nothing to read). When the file is there, a face that does not
/// load is a failure, never a skip.
fn kufi() -> Option<(FontDb, std::sync::Arc)> {
- let path = std::path::Path::new(&std::env::var_os("HOME")?).join(KUFI_FILE);
+ kufi_weight(KUFI_FILE, "Regular")
+}
+
+/// [`kufi`] for the installed file `file` (under the home folder), whose face has style `style`.
+fn kufi_weight(file: &str, style: &str) -> Option<(FontDb, std::sync::Arc)> {
+ let path = std::path::Path::new(&std::env::var_os("HOME")?).join(file);
if !path.exists() {
eprintln!("skipped: {} is not installed", path.display());
return None;
@@ -149,8 +157,8 @@ fn kufi() -> Option<(FontDb, std::sync::Arc)> {
let db = FontDb::with_font_dirs(vec![]);
let added = db.add_font(bytes);
assert!(added > 0, "{} exists but no face of it loaded", path.display());
- let face = db.face("Kufi Murabba PIIIX", "Regular").expect("a face");
- assert_eq!((face.family.as_str(), face.style.as_str()), ("Kufi Murabba PIIIX", "Regular"), "the installed font's face, not a fallback");
+ let face = db.face("Kufi Murabba PIIIX", style).expect("a face");
+ assert_eq!((face.family.as_str(), face.style.as_str()), ("Kufi Murabba PIIIX", style), "the installed font's face, not a fallback");
Some((db, face))
}
@@ -357,3 +365,46 @@ fn a_feature_on_a_one_byte_character_reaches_it() {
let gids: Vec = layout(&db, &text).glyphs.iter().map(|g| g.gid).collect();
assert_eq!(gids, [face.glyph_for('x'), face.glyph_for('b'), face.glyph_for('y')], "salt draws b for the a alone");
}
+
+/// The owner's Kufi Murabba PIIIX Heavy, with the final letter of al-Khwarizmi as Persian yeh
+/// (U+06CC), Arabic yeh (U+064A) or alef maksura (U+0649): in each the letter takes its own final
+/// form, the byte offset of the last character finds its alternates, `salt` to `salt=6` draw them
+/// on that letter alone with the rest of the word and its joining untouched, and the glyph an
+/// alternate is (`yehfarsi.fina.alt2`) is traced back to its letter and its place in the word.
+#[test]
+fn kufi_murabba_heavy_final_letters_alternates_belong_to_their_letter() {
+ let Some((db, face)) = kufi_weight(KUFI_HEAVY_FILE, "Heavy") else { return };
+ let family = face.family.clone();
+ for (letter, base) in [('\u{06CC}', "yehfarsi"), ('\u{064A}', "yeh"), ('\u{0649}', "alefmaksura")] {
+ let head: String = KHWARIZMI.chars().take(KHWARIZMI.chars().count() - 1).collect();
+ let word = format!("{head}{letter}");
+ let last = word.len() - letter.len_utf8();
+ let letter = letter.to_string();
+ let plain = set(&db, &family, &[(&head, &[]), (&letter, &[])]);
+ assert_eq!(plain.len(), KHWARIZMI.chars().count(), "{base}: one glyph for each letter: {}", describe(&plain));
+ assert_eq!(face.glyph_name(plain[plain.len() - 1].gid), Some(format!("{base}.fina")), "{base}: the letter ends the word in its final form");
+ let mut text = TextObject::point(Point::ZERO, "", style(&family, &[]));
+ text.runs = vec![TextRun { text: word.clone(), style: style(&family, &[]), inline: None }];
+ let found = alternates_in_context(&db, &text, last).expect("the final letter");
+ assert_eq!((found.default, found.current), (plain[plain.len() - 1].gid, plain[plain.len() - 1].gid), "{base}");
+ assert_eq!(found.alternates.len(), 6, "{base}: the six alternates of the final form");
+ for (n, a) in found.alternates.iter().enumerate() {
+ let tag = if n == 0 { "salt".to_string() } else { format!("salt={}", n + 1) };
+ assert_eq!(a.apply.as_deref(), Some(tag.as_str()), "{base}: the feature that draws alternate {}", n + 1);
+ assert_eq!(face.glyph_name(a.gid), Some(format!("{base}.fina.alt{}", n + 1)), "{base}");
+ let glyphs = set(&db, &family, &[(&head, &[]), (&letter, &[tag.as_str()])]);
+ assert_eq!(glyphs.len(), plain.len(), "{base} {tag}: {}", describe(&glyphs));
+ assert_eq!(glyphs[glyphs.len() - 1].gid, a.gid, "{base} {tag}: the alternate");
+ assert!(
+ glyphs.iter().zip(&plain).take(plain.len() - 1).all(|(x, y)| x.gid == y.gid),
+ "{base} {tag}: the rest of the word keeps its forms"
+ );
+ let origin = face.glyph_origin(a.gid).expect("the alternate comes from a letter");
+ assert_eq!(origin.ch.to_string(), letter, "{base}: {} is a form of its own letter", face.glyph_name(a.gid).unwrap_or_default());
+ assert_eq!(origin.position(), Some(JoiningPosition::Final), "{base}: and of its end of a word");
+ }
+ }
+ // Glyph 820 of the owner's file is yehfarsi.fina.alt2: Persian yeh's, at the end of a word.
+ let g820 = face.glyph_origin(820).expect("glyph 820 comes from a letter");
+ assert_eq!((face.glyph_name(820).as_deref(), g820.ch, g820.position()), (Some("yehfarsi.fina.alt2"), '\u{06CC}', Some(JoiningPosition::Final)));
+}
diff --git a/crates/ui-egui/src/i18n/bg.tsv b/crates/ui-egui/src/i18n/bg.tsv
index dc53b5b246..3539c19742 100644
--- a/crates/ui-egui/src/i18n/bg.tsv
+++ b/crates/ui-egui/src/i18n/bg.tsv
@@ -846,8 +846,18 @@ font-technology Variable Променливи
The selected character's font is not available. Шрифтът на избрания знак не е наличен.
No alternates for the selected character. Няма алтернативи за избрания знак.
Double-click to replace Двойно щракване за замяна
+ Select one character with the Type tool first. Първо изберете един знак с инструмента „Текст“.
+ {glyph} is a glyph of {font}, not of the font the selected text is set in ({text_font}). {glyph} е глиф на {font}, а не на шрифта, с който е зададен избраният текст ({text_font}).
+ {glyph} belongs to {base} (U+{base_code}) {place}; your selection is {sel} (U+{code}). {glyph} принадлежи на {base} (U+{base_code}) {place}; избраният от вас знак е {sel} (U+{code}).
+ at the start of a word в началото на дума
+ in the middle of a word в средата на дума
+ at the end of a word в края на дума
+ standing alone когато стои сам
+ {glyph} is not an alternate of {sel} (U+{code}) as it stands in its word. {glyph} не е алтернатива на {sel} (U+{code}) там, където е в думата си.
+ No OpenType feature reaches {glyph}, so it cannot be applied to text. Никоя OpenType функция не извежда {glyph}, затова не може да се приложи към текст.
+ Double-click to insert {base} (U+{code}) instead: a presentation form does not join, the letter does. Двойно щракване за вмъкване на {base} (U+{code}): представителната форма не се свързва, буквата се свързва.
+ Inserted {base} (U+{code}) instead of a presentation form: the font joins it to its neighbors by position. Вмъкнато е {base} (U+{code}) вместо представителна форма: шрифтът я свързва със съседите според позицията.
No OpenType feature draws this glyph here, so it replaces the character's glyph and may not join its neighbors. Нито една OpenType функция не рисува този глиф тук, затова той заменя глифа на знака и може да не се свързва със съседните знаци.
- This glyph has no character, so it can't be inserted. Use Alternates for Current Selection to apply it. Този глиф няма знак, затова не може да се вмъкне. Използвайте „Алтернативи за текущата селекция“, за да го приложите.
Double-click to insert Двойно щракване за вмъкване
Double-click to rename Двойно щракване за преименуване
Double-click to target the contents Двойно щракване за насочване към съдържанието
diff --git a/crates/ui-egui/src/i18n/bs.tsv b/crates/ui-egui/src/i18n/bs.tsv
index a4c34938b5..dd785fc5d5 100644
--- a/crates/ui-egui/src/i18n/bs.tsv
+++ b/crates/ui-egui/src/i18n/bs.tsv
@@ -1554,8 +1554,18 @@ digits Malayalam Malajalam
The selected character's font is not available. Font odabranog znaka nije dostupan.
No alternates for the selected character. Nema alternativa za odabrani znak.
Double-click to replace Dvostruki klik za zamjenu
+ Select one character with the Type tool first. Prvo odaberite jedan znak alatom za tekst.
+ {glyph} is a glyph of {font}, not of the font the selected text is set in ({text_font}). {glyph} je glif fonta {font}, a ne fonta kojim je postavljen odabrani tekst ({text_font}).
+ {glyph} belongs to {base} (U+{base_code}) {place}; your selection is {sel} (U+{code}). {glyph} pripada znaku {base} (U+{base_code}) {place}; vaš odabir je {sel} (U+{code}).
+ at the start of a word na početku riječi
+ in the middle of a word usred riječi
+ at the end of a word na kraju riječi
+ standing alone kad stoji sam
+ {glyph} is not an alternate of {sel} (U+{code}) as it stands in its word. {glyph} nije alternativa znaku {sel} (U+{code}) onako kako stoji u svojoj riječi.
+ No OpenType feature reaches {glyph}, so it cannot be applied to text. Nijedna OpenType funkcija ne daje {glyph}, pa se ne može primijeniti na tekst.
+ Double-click to insert {base} (U+{code}) instead: a presentation form does not join, the letter does. Dvostruki klik za umetanje znaka {base} (U+{code}): oblik za prikaz se ne spaja, slovo se spaja.
+ Inserted {base} (U+{code}) instead of a presentation form: the font joins it to its neighbors by position. Umetnut je znak {base} (U+{code}) umjesto oblika za prikaz: font ga spaja sa susjedima prema položaju.
No OpenType feature draws this glyph here, so it replaces the character's glyph and may not join its neighbors. Nijedna OpenType funkcija ne crta ovaj glif ovdje, pa on zamjenjuje glif znaka i možda se neće spajati sa susjednim znakovima.
- This glyph has no character, so it can't be inserted. Use Alternates for Current Selection to apply it. Ovaj glif nema znak, pa se ne može umetnuti. Koristite „Alternative za trenutni odabir“ da ga primijenite.
Double-click to insert Dvostruki klik za umetanje
Entire Font Cijeli font
glyph {id} glif {id}
diff --git a/crates/ui-egui/src/i18n/ca.tsv b/crates/ui-egui/src/i18n/ca.tsv
index 7af14f8aab..cc106dbafa 100644
--- a/crates/ui-egui/src/i18n/ca.tsv
+++ b/crates/ui-egui/src/i18n/ca.tsv
@@ -1208,8 +1208,18 @@
The selected character's font is not available. El tipus de lletra del caràcter seleccionat no està disponible.
No alternates for the selected character. No hi ha alternatives per al caràcter seleccionat.
Double-click to replace Feu doble clic per substituir
+ Select one character with the Type tool first. Selecciona primer un caràcter amb l’eina de text.
+ {glyph} is a glyph of {font}, not of the font the selected text is set in ({text_font}). {glyph} és un glif de {font}, no del tipus de lletra amb què està escrit el text seleccionat ({text_font}).
+ {glyph} belongs to {base} (U+{base_code}) {place}; your selection is {sel} (U+{code}). {glyph} pertany a {base} (U+{base_code}) {place}; la teva selecció és {sel} (U+{code}).
+ at the start of a word a l’inici d’una paraula
+ in the middle of a word al mig d’una paraula
+ at the end of a word al final d’una paraula
+ standing alone quan va sol
+ {glyph} is not an alternate of {sel} (U+{code}) as it stands in its word. {glyph} no és una alternativa de {sel} (U+{code}) tal com es troba a la seva paraula.
+ No OpenType feature reaches {glyph}, so it cannot be applied to text. Cap funció OpenType arriba a {glyph}, de manera que no es pot aplicar al text.
+ Double-click to insert {base} (U+{code}) instead: a presentation form does not join, the letter does. Feu doble clic per inserir {base} (U+{code}): una forma de presentació no s’uneix, la lletra sí.
+ Inserted {base} (U+{code}) instead of a presentation form: the font joins it to its neighbors by position. S’ha inserit {base} (U+{code}) en lloc d’una forma de presentació: el tipus de lletra l’uneix als veïns segons la posició.
No OpenType feature draws this glyph here, so it replaces the character's glyph and may not join its neighbors. Cap funció OpenType dibuixa aquest glif aquí, de manera que substitueix el glif del caràcter i pot no unir-se als caràcters veïns.
- This glyph has no character, so it can't be inserted. Use Alternates for Current Selection to apply it. Aquest glif no té cap caràcter, de manera que no es pot inserir. Feu servir «Alternatives per a la selecció actual» per aplicar-lo.
Double-click to insert Feu doble clic per inserir
Double-click to rename Feu doble clic per canviar el nom
Double-click to target the contents Feu doble clic per apuntar al contingut
diff --git a/crates/ui-egui/src/i18n/cs.tsv b/crates/ui-egui/src/i18n/cs.tsv
index 93c816d1c6..c7be3ff0dd 100644
--- a/crates/ui-egui/src/i18n/cs.tsv
+++ b/crates/ui-egui/src/i18n/cs.tsv
@@ -1578,8 +1578,18 @@ digits Malayalam Malajálamské
The selected character's font is not available. Písmo vybraného znaku není k dispozici.
No alternates for the selected character. Vybraný znak nemá žádné alternativy.
Double-click to replace Dvojklikem nahradíte
+ Select one character with the Type tool first. Nejprve nástrojem Text vyberte jeden znak.
+ {glyph} is a glyph of {font}, not of the font the selected text is set in ({text_font}). {glyph} je glyf písma {font}, ne písma, kterým je vybraný text nastaven ({text_font}).
+ {glyph} belongs to {base} (U+{base_code}) {place}; your selection is {sel} (U+{code}). {glyph} patří znaku {base} (U+{base_code}) {place}; váš výběr je {sel} (U+{code}).
+ at the start of a word na začátku slova
+ in the middle of a word uprostřed slova
+ at the end of a word na konci slova
+ standing alone když stojí samostatně
+ {glyph} is not an alternate of {sel} (U+{code}) as it stands in its word. {glyph} není alternativou znaku {sel} (U+{code}) tak, jak stojí ve svém slově.
+ No OpenType feature reaches {glyph}, so it cannot be applied to text. Žádná funkce OpenType nedává {glyph}, proto ho nelze použít na text.
+ Double-click to insert {base} (U+{code}) instead: a presentation form does not join, the letter does. Dvojklikem vložíte {base} (U+{code}): prezentační tvar se nespojuje, písmeno ano.
+ Inserted {base} (U+{code}) instead of a presentation form: the font joins it to its neighbors by position. Vloženo {base} (U+{code}) místo prezentačního tvaru: písmo ho podle polohy spojí se sousedy.
No OpenType feature draws this glyph here, so it replaces the character's glyph and may not join its neighbors. Žádná funkce OpenType tento glyf zde nekreslí, proto nahradí glyf znaku a nemusí se spojit se sousedními znaky.
- This glyph has no character, so it can't be inserted. Use Alternates for Current Selection to apply it. Tento glyf nemá žádný znak, takže ho nelze vložit. Použijte „Alternativy pro aktuální výběr“.
Double-click to insert Dvojklikem vložíte
Entire Font Celé písmo
glyph {id} glyf {id}
diff --git a/crates/ui-egui/src/i18n/da.tsv b/crates/ui-egui/src/i18n/da.tsv
index 4917bd8168..681444c936 100644
--- a/crates/ui-egui/src/i18n/da.tsv
+++ b/crates/ui-egui/src/i18n/da.tsv
@@ -1488,8 +1488,18 @@ digits Malayalam Malayalam
The selected character's font is not available. Skrifttypen for det markerede tegn er ikke tilgængelig.
No alternates for the selected character. Ingen alternativer til det markerede tegn.
Double-click to replace Dobbeltklik for at erstatte
+ Select one character with the Type tool first. Markér først ét tegn med tekstværktøjet.
+ {glyph} is a glyph of {font}, not of the font the selected text is set in ({text_font}). {glyph} er et glyf i {font}, ikke i den skrifttype, den markerede tekst er sat i ({text_font}).
+ {glyph} belongs to {base} (U+{base_code}) {place}; your selection is {sel} (U+{code}). {glyph} hører til {base} (U+{base_code}) {place}; din markering er {sel} (U+{code}).
+ at the start of a word i begyndelsen af et ord
+ in the middle of a word midt i et ord
+ at the end of a word i slutningen af et ord
+ standing alone når det står alene
+ {glyph} is not an alternate of {sel} (U+{code}) as it stands in its word. {glyph} er ikke et alternativ til {sel} (U+{code}), som det står i sit ord.
+ No OpenType feature reaches {glyph}, so it cannot be applied to text. Ingen OpenType-funktion giver {glyph}, så det kan ikke anvendes på tekst.
+ Double-click to insert {base} (U+{code}) instead: a presentation form does not join, the letter does. Dobbeltklik for at indsætte {base} (U+{code}) i stedet: en præsentationsform forbindes ikke, bogstavet gør.
+ Inserted {base} (U+{code}) instead of a presentation form: the font joins it to its neighbors by position. {base} (U+{code}) er indsat i stedet for en præsentationsform: skrifttypen forbinder det med naboerne efter position.
No OpenType feature draws this glyph here, so it replaces the character's glyph and may not join its neighbors. Ingen OpenType-funktion tegner dette glyf her, så det erstatter tegnets glyf og forbindes måske ikke med nabotegnene.
- This glyph has no character, so it can't be inserted. Use Alternates for Current Selection to apply it. Dette glyf har intet tegn og kan derfor ikke indsættes. Brug „Alternativer til den aktuelle markering“ for at anvende det.
Double-click to insert Dobbeltklik for at indsætte
Entire Font Hele skrifttypen
glyph {id} glyf {id}
diff --git a/crates/ui-egui/src/i18n/de.tsv b/crates/ui-egui/src/i18n/de.tsv
index 678162e111..10b78c831d 100644
--- a/crates/ui-egui/src/i18n/de.tsv
+++ b/crates/ui-egui/src/i18n/de.tsv
@@ -840,8 +840,18 @@ font-technology Variable Variabel
The selected character's font is not available. Die Schrift des ausgewählten Zeichens ist nicht verfügbar.
No alternates for the selected character. Keine Alternativen für das ausgewählte Zeichen.
Double-click to replace Doppelklicken zum Ersetzen
+ Select one character with the Type tool first. Wählen Sie zuerst mit dem Text-Werkzeug ein Zeichen aus.
+ {glyph} is a glyph of {font}, not of the font the selected text is set in ({text_font}). {glyph} ist eine Glyphe von {font}, nicht der Schrift, in der der ausgewählte Text gesetzt ist ({text_font}).
+ {glyph} belongs to {base} (U+{base_code}) {place}; your selection is {sel} (U+{code}). {glyph} gehört zu {base} (U+{base_code}) {place}; Ihre Auswahl ist {sel} (U+{code}).
+ at the start of a word am Wortanfang
+ in the middle of a word in der Wortmitte
+ at the end of a word am Wortende
+ standing alone allein stehend
+ {glyph} is not an alternate of {sel} (U+{code}) as it stands in its word. {glyph} ist keine Alternative zu {sel} (U+{code}), so wie es in seinem Wort steht.
+ No OpenType feature reaches {glyph}, so it cannot be applied to text. Keine OpenType-Funktion liefert {glyph}, daher lässt es sich nicht auf Text anwenden.
+ Double-click to insert {base} (U+{code}) instead: a presentation form does not join, the letter does. Doppelklicken, um stattdessen {base} (U+{code}) einzufügen: Eine Darstellungsform verbindet sich nicht, der Buchstabe schon.
+ Inserted {base} (U+{code}) instead of a presentation form: the font joins it to its neighbors by position. {base} (U+{code}) statt einer Darstellungsform eingefügt: Die Schrift verbindet es je nach Position mit den Nachbarn.
No OpenType feature draws this glyph here, so it replaces the character's glyph and may not join its neighbors. Keine OpenType-Funktion zeichnet diese Glyphe hier, daher ersetzt sie die Glyphe des Zeichens und verbindet sich möglicherweise nicht mit den Nachbarzeichen.
- This glyph has no character, so it can't be inserted. Use Alternates for Current Selection to apply it. Diese Glyphe hat kein Zeichen und lässt sich daher nicht einfügen. Verwenden Sie „Alternativen für aktuelle Auswahl“, um sie anzuwenden.
Double-click to insert Doppelklicken zum Einfügen
Double-click to rename Doppelklicken zum Umbenennen
Double-click to target the contents Doppelklicken, um den Inhalt als Ziel auszuwählen
diff --git a/crates/ui-egui/src/i18n/el.tsv b/crates/ui-egui/src/i18n/el.tsv
index 3e5d56de29..a222d9274f 100644
--- a/crates/ui-egui/src/i18n/el.tsv
+++ b/crates/ui-egui/src/i18n/el.tsv
@@ -819,8 +819,18 @@ font-technology Variable Μεταβλητές
The selected character's font is not available. Η γραμματοσειρά του επιλεγμένου χαρακτήρα δεν είναι διαθέσιμη.
No alternates for the selected character. Δεν υπάρχουν εναλλακτικοί για τον επιλεγμένο χαρακτήρα.
Double-click to replace Διπλό κλικ για αντικατάσταση
+ Select one character with the Type tool first. Επιλέξτε πρώτα έναν χαρακτήρα με το εργαλείο κειμένου.
+ {glyph} is a glyph of {font}, not of the font the selected text is set in ({text_font}). Ο γλύφος {glyph} ανήκει στη γραμματοσειρά {font}, όχι σε αυτήν του επιλεγμένου κειμένου ({text_font}).
+ {glyph} belongs to {base} (U+{base_code}) {place}; your selection is {sel} (U+{code}). Ο γλύφος {glyph} ανήκει στο {base} (U+{base_code}) {place}· η επιλογή σας είναι {sel} (U+{code}).
+ at the start of a word στην αρχή μιας λέξης
+ in the middle of a word στη μέση μιας λέξης
+ at the end of a word στο τέλος μιας λέξης
+ standing alone όταν στέκεται μόνος
+ {glyph} is not an alternate of {sel} (U+{code}) as it stands in its word. Ο γλύφος {glyph} δεν είναι εναλλακτικός του {sel} (U+{code}) όπως βρίσκεται στη λέξη του.
+ No OpenType feature reaches {glyph}, so it cannot be applied to text. Καμία λειτουργία OpenType δεν δίνει τον γλύφο {glyph}, οπότε δεν μπορεί να εφαρμοστεί στο κείμενο.
+ Double-click to insert {base} (U+{code}) instead: a presentation form does not join, the letter does. Διπλό κλικ για εισαγωγή του {base} (U+{code}): μια μορφή παρουσίασης δεν συνδέεται, το γράμμα συνδέεται.
+ Inserted {base} (U+{code}) instead of a presentation form: the font joins it to its neighbors by position. Εισήχθη το {base} (U+{code}) αντί για μορφή παρουσίασης: η γραμματοσειρά το συνδέει με τους γείτονες ανάλογα με τη θέση.
No OpenType feature draws this glyph here, so it replaces the character's glyph and may not join its neighbors. Καμία λειτουργία OpenType δεν σχεδιάζει αυτόν τον γλύφο εδώ, οπότε αντικαθιστά τον γλύφο του χαρακτήρα και μπορεί να μην συνδέεται με τους γειτονικούς χαρακτήρες.
- This glyph has no character, so it can't be inserted. Use Alternates for Current Selection to apply it. Αυτός ο γλύφος δεν έχει χαρακτήρα και δεν μπορεί να εισαχθεί. Χρησιμοποιήστε τους «Εναλλακτικούς για την τρέχουσα επιλογή» για να τον εφαρμόσετε.
Double-click to insert Διπλό κλικ για εισαγωγή
Double-click to rename Διπλό κλικ για μετονομασία
Double-click to target the contents Διπλό κλικ για επιλογή του περιεχομένου ως στόχου
diff --git a/crates/ui-egui/src/i18n/es.tsv b/crates/ui-egui/src/i18n/es.tsv
index 5a262f20d5..8d93dfbbe5 100644
--- a/crates/ui-egui/src/i18n/es.tsv
+++ b/crates/ui-egui/src/i18n/es.tsv
@@ -861,8 +861,18 @@ font-technology Variable Variables
The selected character's font is not available. La fuente del carácter seleccionado no está disponible.
No alternates for the selected character. No hay alternativas para el carácter seleccionado.
Double-click to replace Doble clic para reemplazar
+ Select one character with the Type tool first. Primero selecciona un carácter con la herramienta Texto.
+ {glyph} is a glyph of {font}, not of the font the selected text is set in ({text_font}). {glyph} es un glifo de {font}, no de la fuente del texto seleccionado ({text_font}).
+ {glyph} belongs to {base} (U+{base_code}) {place}; your selection is {sel} (U+{code}). {glyph} pertenece a {base} (U+{base_code}) {place}; tu selección es {sel} (U+{code}).
+ at the start of a word al principio de una palabra
+ in the middle of a word en medio de una palabra
+ at the end of a word al final de una palabra
+ standing alone cuando está solo
+ {glyph} is not an alternate of {sel} (U+{code}) as it stands in its word. {glyph} no es una alternativa de {sel} (U+{code}) tal como está en su palabra.
+ No OpenType feature reaches {glyph}, so it cannot be applied to text. Ninguna función OpenType llega a {glyph}, así que no se puede aplicar al texto.
+ Double-click to insert {base} (U+{code}) instead: a presentation form does not join, the letter does. Doble clic para insertar {base} (U+{code}) en su lugar: una forma de presentación no se une, la letra sí.
+ Inserted {base} (U+{code}) instead of a presentation form: the font joins it to its neighbors by position. Se insertó {base} (U+{code}) en lugar de una forma de presentación: la fuente la une a sus vecinas según la posición.
No OpenType feature draws this glyph here, so it replaces the character's glyph and may not join its neighbors. Ninguna función OpenType dibuja este glifo aquí, así que sustituye el glifo del carácter y puede que no se una a los caracteres vecinos.
- This glyph has no character, so it can't be inserted. Use Alternates for Current Selection to apply it. Este glifo no tiene carácter, así que no se puede insertar. Usa «Alternativas para la selección actual» para aplicarlo.
Double-click to insert Doble clic para insertar
Double-click to rename Doble clic para cambiar el nombre
Double-click to target the contents Doble clic para seleccionar el contenido como destino
diff --git a/crates/ui-egui/src/i18n/fi.tsv b/crates/ui-egui/src/i18n/fi.tsv
index 202e008e12..0673e39ce8 100644
--- a/crates/ui-egui/src/i18n/fi.tsv
+++ b/crates/ui-egui/src/i18n/fi.tsv
@@ -1551,8 +1551,18 @@ digits Malayalam Malajalam
The selected character's font is not available. Valitun merkin fontti ei ole saatavilla.
No alternates for the selected character. Valitulla merkillä ei ole vaihtoehtoja.
Double-click to replace Korvaa kaksoisnapsauttamalla
+ Select one character with the Type tool first. Valitse ensin yksi merkki tekstityökalulla.
+ {glyph} is a glyph of {font}, not of the font the selected text is set in ({text_font}). {glyph} on fontin {font} glyfi, ei valitun tekstin fontin ({text_font}).
+ {glyph} belongs to {base} (U+{base_code}) {place}; your selection is {sel} (U+{code}). {glyph} kuuluu merkille {base} (U+{base_code}) {place}; valintasi on {sel} (U+{code}).
+ at the start of a word sanan alussa
+ in the middle of a word sanan keskellä
+ at the end of a word sanan lopussa
+ standing alone yksinään
+ {glyph} is not an alternate of {sel} (U+{code}) as it stands in its word. {glyph} ei ole merkin {sel} (U+{code}) vaihtoehto sellaisena kuin se on sanassaan.
+ No OpenType feature reaches {glyph}, so it cannot be applied to text. Mikään OpenType-ominaisuus ei tuota glyfiä {glyph}, joten sitä ei voi käyttää tekstiin.
+ Double-click to insert {base} (U+{code}) instead: a presentation form does not join, the letter does. Lisää kaksoisnapsauttamalla sen sijaan {base} (U+{code}): esitysmuoto ei liity, kirjain liittyy.
+ Inserted {base} (U+{code}) instead of a presentation form: the font joins it to its neighbors by position. Lisättiin {base} (U+{code}) esitysmuodon sijaan: fontti liittää sen naapureihin sijainnin mukaan.
No OpenType feature draws this glyph here, so it replaces the character's glyph and may not join its neighbors. Mikään OpenType-ominaisuus ei piirrä tätä glyfiä tässä, joten se korvaa merkin glyfin eikä välttämättä liity viereisiin merkkeihin.
- This glyph has no character, so it can't be inserted. Use Alternates for Current Selection to apply it. Tällä glyfillä ei ole merkkiä, joten sitä ei voi lisätä. Käytä toimintoa ”Nykyisen valinnan vaihtoehdot” sen käyttöön.
Double-click to insert Lisää kaksoisnapsauttamalla
Entire Font Koko fontti
glyph {id} merkki {id}
diff --git a/crates/ui-egui/src/i18n/fr.tsv b/crates/ui-egui/src/i18n/fr.tsv
index f93b915232..fb5619bb2a 100644
--- a/crates/ui-egui/src/i18n/fr.tsv
+++ b/crates/ui-egui/src/i18n/fr.tsv
@@ -864,8 +864,18 @@ font-technology Variable Variables
The selected character's font is not available. La police du caractère sélectionné n’est pas disponible.
No alternates for the selected character. Aucune variante pour le caractère sélectionné.
Double-click to replace Double-cliquez pour remplacer
+ Select one character with the Type tool first. Sélectionnez d’abord un caractère avec l’outil Texte.
+ {glyph} is a glyph of {font}, not of the font the selected text is set in ({text_font}). {glyph} est un glyphe de {font}, et non de la police du texte sélectionné ({text_font}).
+ {glyph} belongs to {base} (U+{base_code}) {place}; your selection is {sel} (U+{code}). {glyph} appartient à {base} (U+{base_code}) {place} ; votre sélection est {sel} (U+{code}).
+ at the start of a word au début d’un mot
+ in the middle of a word au milieu d’un mot
+ at the end of a word à la fin d’un mot
+ standing alone isolé
+ {glyph} is not an alternate of {sel} (U+{code}) as it stands in its word. {glyph} n’est pas une variante de {sel} (U+{code}) tel qu’il se trouve dans son mot.
+ No OpenType feature reaches {glyph}, so it cannot be applied to text. Aucune fonction OpenType n’atteint {glyph} ; il ne peut donc pas être appliqué au texte.
+ Double-click to insert {base} (U+{code}) instead: a presentation form does not join, the letter does. Double-cliquez pour insérer {base} (U+{code}) à la place : une forme de présentation ne se lie pas, la lettre si.
+ Inserted {base} (U+{code}) instead of a presentation form: the font joins it to its neighbors by position. {base} (U+{code}) a été inséré à la place d’une forme de présentation : la police le lie à ses voisins selon la position.
No OpenType feature draws this glyph here, so it replaces the character's glyph and may not join its neighbors. Aucune fonction OpenType ne dessine ce glyphe ici : il remplace le glyphe du caractère et peut ne pas se lier aux caractères voisins.
- This glyph has no character, so it can't be inserted. Use Alternates for Current Selection to apply it. Ce glyphe n’a pas de caractère et ne peut donc pas être inséré. Utilisez « Variantes de la sélection » pour l’appliquer.
Double-click to insert Double-cliquez pour insérer
Double-click to rename Double-cliquez pour renommer
Double-click to target the contents Double-cliquez pour cibler le contenu
diff --git a/crates/ui-egui/src/i18n/hi.tsv b/crates/ui-egui/src/i18n/hi.tsv
index 5b8b11cc50..5274287e32 100644
--- a/crates/ui-egui/src/i18n/hi.tsv
+++ b/crates/ui-egui/src/i18n/hi.tsv
@@ -1344,8 +1344,18 @@
Double-click to insert सम्मिलित करने के लिए डबल क्लिक करें
Double-click to rename नाम बदलने के लिए डबल क्लिक करें
Double-click to replace प्रतिस्थापित करने के लिए डबल क्लिक करें
+ Select one character with the Type tool first. पहले टाइप टूल से एक कैरेक्टर सिलेक्ट करें।
+ {glyph} is a glyph of {font}, not of the font the selected text is set in ({text_font}). {glyph} {font} का ग्लिफ़ है, उस फ़ॉन्ट का नहीं जिसमें सिलेक्ट किया टेक्स्ट सेट है ({text_font})।
+ {glyph} belongs to {base} (U+{base_code}) {place}; your selection is {sel} (U+{code}). {glyph} {base} (U+{base_code}) का है, {place}; आपका सिलेक्शन {sel} (U+{code}) है।
+ at the start of a word शब्द की शुरुआत में
+ in the middle of a word शब्द के बीच में
+ at the end of a word शब्द के अंत में
+ standing alone अकेले खड़े होने पर
+ {glyph} is not an alternate of {sel} (U+{code}) as it stands in its word. {glyph}, {sel} (U+{code}) का विकल्प नहीं है, जैसा वह अपने शब्द में खड़ा है।
+ No OpenType feature reaches {glyph}, so it cannot be applied to text. कोई OpenType फ़ीचर {glyph} तक नहीं पहुँचता, इसलिए इसे टेक्स्ट पर लागू नहीं किया जा सकता।
+ Double-click to insert {base} (U+{code}) instead: a presentation form does not join, the letter does. इसके बजाय {base} (U+{code}) सम्मिलित करने के लिए डबल क्लिक करें: प्रेज़ेंटेशन फ़ॉर्म नहीं जुड़ता, अक्षर जुड़ता है।
+ Inserted {base} (U+{code}) instead of a presentation form: the font joins it to its neighbors by position. प्रेज़ेंटेशन फ़ॉर्म की जगह {base} (U+{code}) सम्मिलित किया गया: फ़ॉन्ट उसे स्थिति के अनुसार पड़ोसियों से जोड़ता है।
No OpenType feature draws this glyph here, so it replaces the character's glyph and may not join its neighbors. यहाँ कोई OpenType फ़ीचर इस ग्लिफ़ को नहीं बनाता, इसलिए यह कैरेक्टर के ग्लिफ़ की जगह लेता है और पड़ोसी कैरेक्टरों से जुड़ नहीं भी सकता।
- This glyph has no character, so it can't be inserted. Use Alternates for Current Selection to apply it. इस ग्लिफ़ का कोई कैरेक्टर नहीं है, इसलिए इसे सम्मिलित नहीं किया जा सकता। इसे लागू करने के लिए “वर्तमान सिलेक्शन के लिए ऑप्शन” का उपयोग करें।
Double-click to target the contents सामग्री को लक्षित करने के लिए डबल क्लिक करें
Down (right reading) नीचे (दाएं पढ़ने)
Download डाउनलोड
diff --git a/crates/ui-egui/src/i18n/hu.tsv b/crates/ui-egui/src/i18n/hu.tsv
index 516ccf02de..b89d479ef6 100644
--- a/crates/ui-egui/src/i18n/hu.tsv
+++ b/crates/ui-egui/src/i18n/hu.tsv
@@ -1114,8 +1114,18 @@ font-technology Variable Változó
The selected character's font is not available. A kijelölt karakter betűtípusa nem érhető el.
No alternates for the selected character. A kijelölt karakternek nincsenek változatai.
Double-click to replace Csere dupla kattintással
+ Select one character with the Type tool first. Előbb jelölj ki egy karaktert a Szöveg eszközzel.
+ {glyph} is a glyph of {font}, not of the font the selected text is set in ({text_font}). A(z) {glyph} a(z) {font} glifája, nem a kijelölt szöveg betűtípusáé ({text_font}).
+ {glyph} belongs to {base} (U+{base_code}) {place}; your selection is {sel} (U+{code}). A(z) {glyph} ehhez tartozik: {base} (U+{base_code}) {place}; a te kijelölésed: {sel} (U+{code}).
+ at the start of a word szó elején
+ in the middle of a word szó közepén
+ at the end of a word szó végén
+ standing alone önállóan állva
+ {glyph} is not an alternate of {sel} (U+{code}) as it stands in its word. A(z) {glyph} nem változata a(z) {sel} (U+{code}) karakternek úgy, ahogy a szavában áll.
+ No OpenType feature reaches {glyph}, so it cannot be applied to text. Egyetlen OpenType-funkció sem éri el a(z) {glyph} glifát, ezért nem alkalmazható a szövegre.
+ Double-click to insert {base} (U+{code}) instead: a presentation form does not join, the letter does. Dupla kattintással ezt szúrod be helyette: {base} (U+{code}); a megjelenítési forma nem kapcsolódik, a betű igen.
+ Inserted {base} (U+{code}) instead of a presentation form: the font joins it to its neighbors by position. A megjelenítési forma helyett ez lett beszúrva: {base} (U+{code}); a betűtípus pozíció szerint kapcsolja a szomszédokhoz.
No OpenType feature draws this glyph here, so it replaces the character's glyph and may not join its neighbors. Itt egyetlen OpenType-funkció sem rajzolja meg ezt a glifát, ezért a karakter glifáját váltja fel, és előfordulhat, hogy nem kapcsolódik a szomszédos karakterekhez.
- This glyph has no character, so it can't be inserted. Use Alternates for Current Selection to apply it. Ennek a glifának nincs karaktere, ezért nem szúrható be. Az alkalmazásához használd az „Az aktuális kijelölés változatai” nézetet.
Double-click to insert Beszúrás dupla kattintással
Double-click to rename Dupla kattintással átnevezhető
Double-click to target the contents Dupla kattintással a tartalom lesz a cél
diff --git a/crates/ui-egui/src/i18n/id.tsv b/crates/ui-egui/src/i18n/id.tsv
index 6b488a850e..64d77d1617 100644
--- a/crates/ui-egui/src/i18n/id.tsv
+++ b/crates/ui-egui/src/i18n/id.tsv
@@ -1488,8 +1488,18 @@ digits Malayalam Malayalam
The selected character's font is not available. Font karakter yang dipilih tidak tersedia.
No alternates for the selected character. Tidak ada alternatif untuk karakter yang dipilih.
Double-click to replace Klik ganda untuk mengganti
+ Select one character with the Type tool first. Pilih satu karakter dengan alat teks terlebih dahulu.
+ {glyph} is a glyph of {font}, not of the font the selected text is set in ({text_font}). {glyph} adalah glif {font}, bukan dari font yang dipakai teks terpilih ({text_font}).
+ {glyph} belongs to {base} (U+{base_code}) {place}; your selection is {sel} (U+{code}). {glyph} milik {base} (U+{base_code}) {place}; pilihan Anda adalah {sel} (U+{code}).
+ at the start of a word di awal kata
+ in the middle of a word di tengah kata
+ at the end of a word di akhir kata
+ standing alone saat berdiri sendiri
+ {glyph} is not an alternate of {sel} (U+{code}) as it stands in its word. {glyph} bukan alternatif {sel} (U+{code}) sebagaimana posisinya di dalam katanya.
+ No OpenType feature reaches {glyph}, so it cannot be applied to text. Tidak ada fitur OpenType yang menghasilkan {glyph}, jadi tidak bisa diterapkan ke teks.
+ Double-click to insert {base} (U+{code}) instead: a presentation form does not join, the letter does. Klik ganda untuk menyisipkan {base} (U+{code}) sebagai gantinya: bentuk presentasi tidak menyambung, hurufnya menyambung.
+ Inserted {base} (U+{code}) instead of a presentation form: the font joins it to its neighbors by position. {base} (U+{code}) disisipkan sebagai ganti bentuk presentasi: font menyambungkannya ke huruf di sebelahnya menurut posisi.
No OpenType feature draws this glyph here, so it replaces the character's glyph and may not join its neighbors. Tidak ada fitur OpenType yang menggambar glif ini di sini, jadi glif ini menggantikan glif karakter dan mungkin tidak menyambung dengan karakter di sebelahnya.
- This glyph has no character, so it can't be inserted. Use Alternates for Current Selection to apply it. Glif ini tidak punya karakter, sehingga tidak bisa disisipkan. Gunakan “Alternatif untuk Pilihan Saat Ini” untuk menerapkannya.
Double-click to insert Klik ganda untuk menyisipkan
Entire Font Seluruh font
glyph {id} glif {id}
diff --git a/crates/ui-egui/src/i18n/it.tsv b/crates/ui-egui/src/i18n/it.tsv
index 433847dbf1..8b9d0331f7 100644
--- a/crates/ui-egui/src/i18n/it.tsv
+++ b/crates/ui-egui/src/i18n/it.tsv
@@ -861,8 +861,18 @@ font-technology Variable Variabili
The selected character's font is not available. Il font del carattere selezionato non è disponibile.
No alternates for the selected character. Nessuna alternativa per il carattere selezionato.
Double-click to replace Fai doppio clic per sostituire
+ Select one character with the Type tool first. Seleziona prima un carattere con lo strumento Testo.
+ {glyph} is a glyph of {font}, not of the font the selected text is set in ({text_font}). {glyph} è un glifo di {font}, non del font del testo selezionato ({text_font}).
+ {glyph} belongs to {base} (U+{base_code}) {place}; your selection is {sel} (U+{code}). {glyph} appartiene a {base} (U+{base_code}) {place}; la tua selezione è {sel} (U+{code}).
+ at the start of a word all’inizio di una parola
+ in the middle of a word in mezzo a una parola
+ at the end of a word alla fine di una parola
+ standing alone da solo
+ {glyph} is not an alternate of {sel} (U+{code}) as it stands in its word. {glyph} non è un’alternativa di {sel} (U+{code}) così come si trova nella sua parola.
+ No OpenType feature reaches {glyph}, so it cannot be applied to text. Nessuna funzione OpenType raggiunge {glyph}, quindi non può essere applicato al testo.
+ Double-click to insert {base} (U+{code}) instead: a presentation form does not join, the letter does. Fai doppio clic per inserire invece {base} (U+{code}): una forma di presentazione non si unisce, la lettera sì.
+ Inserted {base} (U+{code}) instead of a presentation form: the font joins it to its neighbors by position. Inserito {base} (U+{code}) al posto di una forma di presentazione: il font lo unisce ai vicini in base alla posizione.
No OpenType feature draws this glyph here, so it replaces the character's glyph and may not join its neighbors. Nessuna funzione OpenType disegna questo glifo qui: sostituisce il glifo del carattere e potrebbe non unirsi ai caratteri vicini.
- This glyph has no character, so it can't be inserted. Use Alternates for Current Selection to apply it. Questo glifo non ha un carattere e non può essere inserito. Usa «Alternative per la selezione corrente» per applicarlo.
Double-click to insert Fai doppio clic per inserire
Double-click to rename Fai doppio clic per rinominare
Double-click to target the contents Fai doppio clic per impostare il contenuto come destinazione
diff --git a/crates/ui-egui/src/i18n/ja.tsv b/crates/ui-egui/src/i18n/ja.tsv
index e0d8f5c2e8..5a3de37329 100644
--- a/crates/ui-egui/src/i18n/ja.tsv
+++ b/crates/ui-egui/src/i18n/ja.tsv
@@ -1424,8 +1424,18 @@ digits Malayalam マラヤーラム数字
The selected character's font is not available. 選択した文字のフォントを使用できません。
No alternates for the selected character. 選択した文字には異体字がありません。
Double-click to replace ダブルクリックで置換
+ Select one character with the Type tool first. 先に文字ツールで文字を 1 つ選択してください。
+ {glyph} is a glyph of {font}, not of the font the selected text is set in ({text_font}). {glyph} は {font} のグリフで、選択したテキストのフォント ({text_font}) のものではありません。
+ {glyph} belongs to {base} (U+{base_code}) {place}; your selection is {sel} (U+{code}). {glyph} は {base} (U+{base_code}) の{place}のグリフです。選択中の文字は {sel} (U+{code}) です。
+ at the start of a word 語頭
+ in the middle of a word 語中
+ at the end of a word 語末
+ standing alone 単独のとき
+ {glyph} is not an alternate of {sel} (U+{code}) as it stands in its word. {glyph} は、{sel} (U+{code}) が単語の中で置かれている位置での異体字ではありません。
+ No OpenType feature reaches {glyph}, so it cannot be applied to text. {glyph} に到達する OpenType フィーチャーはないため、テキストに適用できません。
+ Double-click to insert {base} (U+{code}) instead: a presentation form does not join, the letter does. ダブルクリックで代わりに {base} (U+{code}) を挿入: 表示形は前後とつながりませんが、文字はつながります。
+ Inserted {base} (U+{code}) instead of a presentation form: the font joins it to its neighbors by position. 表示形の代わりに {base} (U+{code}) を挿入しました。フォントが位置に応じて前後の文字とつなげます。
No OpenType feature draws this glyph here, so it replaces the character's glyph and may not join its neighbors. このグリフを表示する OpenType フィーチャーがないため、文字のグリフが置き換わり、前後の文字とつながらないことがあります。
- This glyph has no character, so it can't be inserted. Use Alternates for Current Selection to apply it. このグリフには対応する文字がないため、挿入できません。適用するには「選択文字の異体字」を使用してください。
Double-click to insert ダブルクリックで挿入
Double-click to rename ダブルクリックで名前を変更
Double-click to target the contents ダブルクリックで内容をターゲット
diff --git a/crates/ui-egui/src/i18n/ka.tsv b/crates/ui-egui/src/i18n/ka.tsv
index 9e473e2f3d..9d48ca867a 100644
--- a/crates/ui-egui/src/i18n/ka.tsv
+++ b/crates/ui-egui/src/i18n/ka.tsv
@@ -1414,8 +1414,18 @@ digits Malayalam მალაიალამური
The selected character's font is not available. მონიშნული სიმბოლოს შრიფტი მიუწვდომელია.
No alternates for the selected character. მონიშნულ სიმბოლოს ალტერნატივები არ აქვს.
Double-click to replace ორჯერ დაწკაპეთ ჩასანაცვლებლად
+ Select one character with the Type tool first. ჯერ ტექსტის ხელსაწყოთი მონიშნეთ ერთი სიმბოლო.
+ {glyph} is a glyph of {font}, not of the font the selected text is set in ({text_font}). {glyph} არის {font}-ის გლიფი და არა იმ შრიფტისა, რომლითაც მონიშნული ტექსტია დაწყობილი ({text_font}).
+ {glyph} belongs to {base} (U+{base_code}) {place}; your selection is {sel} (U+{code}). {glyph} ეკუთვნის სიმბოლოს {base} (U+{base_code}) {place}; თქვენი მონიშნულია {sel} (U+{code}).
+ at the start of a word სიტყვის დასაწყისში
+ in the middle of a word სიტყვის შუაში
+ at the end of a word სიტყვის ბოლოს
+ standing alone როცა ცალკე დგას
+ {glyph} is not an alternate of {sel} (U+{code}) as it stands in its word. {glyph} არ არის {sel} (U+{code})-ის ალტერნატივა იმ სახით, როგორც ის თავის სიტყვაშია.
+ No OpenType feature reaches {glyph}, so it cannot be applied to text. არც ერთი OpenType ფუნქცია არ იძლევა {glyph}-ს, ამიტომ მისი ტექსტზე გამოყენება შეუძლებელია.
+ Double-click to insert {base} (U+{code}) instead: a presentation form does not join, the letter does. ორჯერ დაწკაპეთ ამის ნაცვლად {base} (U+{code}) ჩასასმელად: გამოსახვის ფორმა არ უკავშირდება, ასო უკავშირდება.
+ Inserted {base} (U+{code}) instead of a presentation form: the font joins it to its neighbors by position. გამოსახვის ფორმის ნაცვლად ჩასმულია {base} (U+{code}): შრიფტი მას მეზობლებს პოზიციის მიხედვით აკავშირებს.
No OpenType feature draws this glyph here, so it replaces the character's glyph and may not join its neighbors. აქ არც ერთი OpenType ფუნქცია არ ხატავს ამ გლიფს, ამიტომ ის ანაცვლებს სიმბოლოს გლიფს და შეიძლება არ დაუკავშირდეს მეზობელ სიმბოლოებს.
- This glyph has no character, so it can't be inserted. Use Alternates for Current Selection to apply it. ამ გლიფს სიმბოლო არ აქვს, ამიტომ მისი ჩასმა შეუძლებელია. გამოსაყენებლად გამოიყენეთ „მიმდინარე მონიშვნის ალტერნატივები“.
Double-click to insert ორჯერ დაწკაპეთ ჩასასმელად
Entire Font მთელი შრიფტი
glyph {id} გლიფი {id}
diff --git a/crates/ui-egui/src/i18n/ko.tsv b/crates/ui-egui/src/i18n/ko.tsv
index 8333201b82..e23a535e28 100644
--- a/crates/ui-egui/src/i18n/ko.tsv
+++ b/crates/ui-egui/src/i18n/ko.tsv
@@ -1414,8 +1414,18 @@ digits Malayalam 말라얄람어
The selected character's font is not available. 선택한 문자의 글꼴을 사용할 수 없습니다.
No alternates for the selected character. 선택한 문자의 대체 글리프가 없습니다.
Double-click to replace 더블클릭하여 바꾸기
+ Select one character with the Type tool first. 먼저 문자 도구로 문자 하나를 선택하세요.
+ {glyph} is a glyph of {font}, not of the font the selected text is set in ({text_font}). {glyph}은(는) {font}의 글리프이며, 선택한 텍스트의 글꼴({text_font})의 것이 아닙니다.
+ {glyph} belongs to {base} (U+{base_code}) {place}; your selection is {sel} (U+{code}). {glyph}은(는) {base}(U+{base_code})의 {place} 글리프입니다. 선택한 문자는 {sel}(U+{code})입니다.
+ at the start of a word 단어 처음
+ in the middle of a word 단어 중간
+ at the end of a word 단어 끝
+ standing alone 단독일 때
+ {glyph} is not an alternate of {sel} (U+{code}) as it stands in its word. {glyph}은(는) 단어 안에 있는 {sel}(U+{code})의 대체 글리프가 아닙니다.
+ No OpenType feature reaches {glyph}, so it cannot be applied to text. {glyph}에 도달하는 OpenType 기능이 없으므로 텍스트에 적용할 수 없습니다.
+ Double-click to insert {base} (U+{code}) instead: a presentation form does not join, the letter does. 더블클릭하여 대신 {base}(U+{code}) 삽입: 표시 형태는 연결되지 않지만 글자는 연결됩니다.
+ Inserted {base} (U+{code}) instead of a presentation form: the font joins it to its neighbors by position. 표시 형태 대신 {base}(U+{code})을(를) 삽입했습니다. 글꼴이 위치에 따라 이웃 문자와 연결합니다.
No OpenType feature draws this glyph here, so it replaces the character's glyph and may not join its neighbors. 이 글리프를 그리는 OpenType 기능이 없으므로 문자의 글리프를 대체하며 이웃 문자와 연결되지 않을 수 있습니다.
- This glyph has no character, so it can't be inserted. Use Alternates for Current Selection to apply it. 이 글리프에는 문자가 없어 삽입할 수 없습니다. 적용하려면 ‘현재 선택 항목의 대체 글리프’를 사용하세요.
Double-click to insert 더블클릭하여 삽입
Double-click to rename 더블클릭하여 이름 변경
Double-click to target the contents 더블클릭하여 내용 선택
diff --git a/crates/ui-egui/src/i18n/nl.tsv b/crates/ui-egui/src/i18n/nl.tsv
index 8fb204e2d6..c352a8184b 100644
--- a/crates/ui-egui/src/i18n/nl.tsv
+++ b/crates/ui-egui/src/i18n/nl.tsv
@@ -1502,8 +1502,18 @@ digits Malayalam Malayalam
The selected character's font is not available. Het lettertype van het geselecteerde teken is niet beschikbaar.
No alternates for the selected character. Geen alternatieven voor het geselecteerde teken.
Double-click to replace Dubbelklik om te vervangen
+ Select one character with the Type tool first. Selecteer eerst één teken met het Type-gereedschap.
+ {glyph} is a glyph of {font}, not of the font the selected text is set in ({text_font}). {glyph} is een glyph van {font}, niet van het lettertype van de geselecteerde tekst ({text_font}).
+ {glyph} belongs to {base} (U+{base_code}) {place}; your selection is {sel} (U+{code}). {glyph} hoort bij {base} (U+{base_code}) {place}; uw selectie is {sel} (U+{code}).
+ at the start of a word aan het begin van een woord
+ in the middle of a word midden in een woord
+ at the end of a word aan het einde van een woord
+ standing alone los staand
+ {glyph} is not an alternate of {sel} (U+{code}) as it stands in its word. {glyph} is geen alternatief voor {sel} (U+{code}) zoals het in zijn woord staat.
+ No OpenType feature reaches {glyph}, so it cannot be applied to text. Geen enkele OpenType-functie levert {glyph}, dus het kan niet op tekst worden toegepast.
+ Double-click to insert {base} (U+{code}) instead: a presentation form does not join, the letter does. Dubbelklik om in plaats daarvan {base} (U+{code}) in te voegen: een presentatievorm sluit niet aan, de letter wel.
+ Inserted {base} (U+{code}) instead of a presentation form: the font joins it to its neighbors by position. {base} (U+{code}) ingevoegd in plaats van een presentatievorm: het lettertype verbindt het naar positie met de buren.
No OpenType feature draws this glyph here, so it replaces the character's glyph and may not join its neighbors. Geen enkele OpenType-functie tekent deze glyph hier, dus hij vervangt de glyph van het teken en sluit mogelijk niet aan op de buurtekens.
- This glyph has no character, so it can't be inserted. Use Alternates for Current Selection to apply it. Deze glyph heeft geen teken en kan dus niet worden ingevoegd. Gebruik ‘Alternatieven voor huidige selectie’ om hem toe te passen.
Double-click to insert Dubbelklik om in te voegen
Entire Font Volledig lettertype
glyph {id} glyph {id}
diff --git a/crates/ui-egui/src/i18n/pl.tsv b/crates/ui-egui/src/i18n/pl.tsv
index 86af456b81..93e96b79d6 100644
--- a/crates/ui-egui/src/i18n/pl.tsv
+++ b/crates/ui-egui/src/i18n/pl.tsv
@@ -1491,8 +1491,18 @@ digits Malayalam Malajalam
The selected character's font is not available. Czcionka zaznaczonego znaku jest niedostępna.
No alternates for the selected character. Brak alternatyw dla zaznaczonego znaku.
Double-click to replace Kliknij dwukrotnie, aby zastąpić
+ Select one character with the Type tool first. Najpierw zaznacz jeden znak narzędziem Tekst.
+ {glyph} is a glyph of {font}, not of the font the selected text is set in ({text_font}). {glyph} to glif czcionki {font}, a nie czcionki zaznaczonego tekstu ({text_font}).
+ {glyph} belongs to {base} (U+{base_code}) {place}; your selection is {sel} (U+{code}). {glyph} należy do znaku {base} (U+{base_code}) {place}; Twoje zaznaczenie to {sel} (U+{code}).
+ at the start of a word na początku wyrazu
+ in the middle of a word w środku wyrazu
+ at the end of a word na końcu wyrazu
+ standing alone gdy stoi osobno
+ {glyph} is not an alternate of {sel} (U+{code}) as it stands in its word. {glyph} nie jest alternatywą znaku {sel} (U+{code}) w takiej postaci, w jakiej stoi w swoim wyrazie.
+ No OpenType feature reaches {glyph}, so it cannot be applied to text. Żadna funkcja OpenType nie daje glifu {glyph}, więc nie można go zastosować do tekstu.
+ Double-click to insert {base} (U+{code}) instead: a presentation form does not join, the letter does. Kliknij dwukrotnie, aby wstawić zamiast tego {base} (U+{code}): forma prezentacyjna się nie łączy, litera tak.
+ Inserted {base} (U+{code}) instead of a presentation form: the font joins it to its neighbors by position. Wstawiono {base} (U+{code}) zamiast formy prezentacyjnej: czcionka łączy go z sąsiadami zależnie od pozycji.
No OpenType feature draws this glyph here, so it replaces the character's glyph and may not join its neighbors. Żadna funkcja OpenType nie rysuje tutaj tego glifu, więc zastępuje on glif znaku i może nie łączyć się z sąsiednimi znakami.
- This glyph has no character, so it can't be inserted. Use Alternates for Current Selection to apply it. Ten glif nie ma znaku, więc nie można go wstawić. Użyj opcji „Alternatywy bieżącego zaznaczenia”, aby go zastosować.
Double-click to insert Kliknij dwukrotnie, aby wstawić
Entire Font Cała czcionka
glyph {id} glif {id}
diff --git a/crates/ui-egui/src/i18n/pt-br.tsv b/crates/ui-egui/src/i18n/pt-br.tsv
index 5e5a4396cf..05b84eb732 100644
--- a/crates/ui-egui/src/i18n/pt-br.tsv
+++ b/crates/ui-egui/src/i18n/pt-br.tsv
@@ -771,8 +771,18 @@
The selected character's font is not available. A fonte do caractere selecionado não está disponível.
No alternates for the selected character. Nenhuma alternativa para o caractere selecionado.
Double-click to replace Clique duplo para substituir
+ Select one character with the Type tool first. Primeiro selecione um caractere com a ferramenta Texto.
+ {glyph} is a glyph of {font}, not of the font the selected text is set in ({text_font}). {glyph} é um glifo de {font}, não da fonte do texto selecionado ({text_font}).
+ {glyph} belongs to {base} (U+{base_code}) {place}; your selection is {sel} (U+{code}). {glyph} pertence a {base} (U+{base_code}) {place}; sua seleção é {sel} (U+{code}).
+ at the start of a word no início de uma palavra
+ in the middle of a word no meio de uma palavra
+ at the end of a word no fim de uma palavra
+ standing alone quando está sozinho
+ {glyph} is not an alternate of {sel} (U+{code}) as it stands in its word. {glyph} não é uma alternativa de {sel} (U+{code}) como ele está em sua palavra.
+ No OpenType feature reaches {glyph}, so it cannot be applied to text. Nenhum recurso OpenType chega a {glyph}, então ele não pode ser aplicado ao texto.
+ Double-click to insert {base} (U+{code}) instead: a presentation form does not join, the letter does. Clique duplo para inserir {base} (U+{code}) no lugar: uma forma de apresentação não se une, a letra sim.
+ Inserted {base} (U+{code}) instead of a presentation form: the font joins it to its neighbors by position. {base} (U+{code}) foi inserido no lugar de uma forma de apresentação: a fonte o une aos vizinhos conforme a posição.
No OpenType feature draws this glyph here, so it replaces the character's glyph and may not join its neighbors. Nenhum recurso OpenType desenha este glifo aqui, então ele substitui o glifo do caractere e pode não se unir aos caracteres vizinhos.
- This glyph has no character, so it can't be inserted. Use Alternates for Current Selection to apply it. Este glifo não tem caractere e não pode ser inserido. Use “Alternativas para a seleção atual” para aplicá-lo.
Double-click to insert Clique duplo para inserir
Double-click to rename Clique duplo para renomear
Double-click to target the contents Clique duplo para direcionar o conteúdo
diff --git a/crates/ui-egui/src/i18n/ru.tsv b/crates/ui-egui/src/i18n/ru.tsv
index 6de9bb6713..f7bab2610e 100644
--- a/crates/ui-egui/src/i18n/ru.tsv
+++ b/crates/ui-egui/src/i18n/ru.tsv
@@ -859,8 +859,18 @@ font-technology Variable Вариативные
The selected character's font is not available. Шрифт выделенного символа недоступен.
No alternates for the selected character. У выделенного символа нет альтернатив.
Double-click to replace Дважды щёлкните для замены
+ Select one character with the Type tool first. Сначала выделите один символ инструментом «Текст».
+ {glyph} is a glyph of {font}, not of the font the selected text is set in ({text_font}). {glyph} — глиф шрифта {font}, а не шрифта выделенного текста ({text_font}).
+ {glyph} belongs to {base} (U+{base_code}) {place}; your selection is {sel} (U+{code}). {glyph} относится к символу {base} (U+{base_code}) {place}; выделен символ {sel} (U+{code}).
+ at the start of a word в начале слова
+ in the middle of a word в середине слова
+ at the end of a word в конце слова
+ standing alone в отдельном виде
+ {glyph} is not an alternate of {sel} (U+{code}) as it stands in its word. {glyph} не является альтернативой символа {sel} (U+{code}) в том виде, как он стоит в своём слове.
+ No OpenType feature reaches {glyph}, so it cannot be applied to text. Ни одна функция OpenType не даёт {glyph}, поэтому его нельзя применить к тексту.
+ Double-click to insert {base} (U+{code}) instead: a presentation form does not join, the letter does. Дважды щёлкните, чтобы вставить вместо него {base} (U+{code}): форма представления не соединяется, буква соединяется.
+ Inserted {base} (U+{code}) instead of a presentation form: the font joins it to its neighbors by position. Вместо формы представления вставлено {base} (U+{code}): шрифт соединяет его с соседними по положению.
No OpenType feature draws this glyph here, so it replaces the character's glyph and may not join its neighbors. Ни одна функция OpenType не рисует здесь этот глиф, поэтому он заменяет глиф символа и может не соединяться с соседними символами.
- This glyph has no character, so it can't be inserted. Use Alternates for Current Selection to apply it. У этого глифа нет символа, поэтому его нельзя вставить. Чтобы применить его, используйте «Альтернативы для текущего выделения».
Double-click to insert Дважды щёлкните для вставки
Double-click to rename Дважды щёлкните для переименования
Double-click to target the contents Дважды щёлкните, чтобы выбрать содержимое как объект
diff --git a/crates/ui-egui/src/i18n/sv.tsv b/crates/ui-egui/src/i18n/sv.tsv
index 87735c99d9..e7a42b0a03 100644
--- a/crates/ui-egui/src/i18n/sv.tsv
+++ b/crates/ui-egui/src/i18n/sv.tsv
@@ -848,8 +848,18 @@ font-technology Variable Variabla
The selected character's font is not available. Typsnittet för det markerade tecknet är inte tillgängligt.
No alternates for the selected character. Inga alternativ för det markerade tecknet.
Double-click to replace Dubbelklicka för att ersätta
+ Select one character with the Type tool first. Markera först ett tecken med textverktyget.
+ {glyph} is a glyph of {font}, not of the font the selected text is set in ({text_font}). {glyph} är en glyf i {font}, inte i det typsnitt den markerade texten är satt i ({text_font}).
+ {glyph} belongs to {base} (U+{base_code}) {place}; your selection is {sel} (U+{code}). {glyph} hör till {base} (U+{base_code}) {place}; din markering är {sel} (U+{code}).
+ at the start of a word i början av ett ord
+ in the middle of a word mitt i ett ord
+ at the end of a word i slutet av ett ord
+ standing alone när det står ensamt
+ {glyph} is not an alternate of {sel} (U+{code}) as it stands in its word. {glyph} är inget alternativ till {sel} (U+{code}) som det står i sitt ord.
+ No OpenType feature reaches {glyph}, so it cannot be applied to text. Ingen OpenType-funktion ger {glyph}, så den kan inte användas på text.
+ Double-click to insert {base} (U+{code}) instead: a presentation form does not join, the letter does. Dubbelklicka för att infoga {base} (U+{code}) i stället: en presentationsform ansluter inte, bokstaven gör det.
+ Inserted {base} (U+{code}) instead of a presentation form: the font joins it to its neighbors by position. {base} (U+{code}) infogades i stället för en presentationsform: typsnittet ansluter det till grannarna efter position.
No OpenType feature draws this glyph here, so it replaces the character's glyph and may not join its neighbors. Ingen OpenType-funktion ritar den här glyfen här, så den ersätter tecknets glyf och ansluter kanske inte till grannteckenen.
- This glyph has no character, so it can't be inserted. Use Alternates for Current Selection to apply it. Den här glyfen har inget tecken och kan därför inte infogas. Använd ”Alternativ för aktuell markering” för att tillämpa den.
Double-click to insert Dubbelklicka för att infoga
Double-click to rename Dubbelklicka för att byta namn
Double-click to target the contents Dubbelklicka för att välja innehållet som mål
diff --git a/crates/ui-egui/src/i18n/tr.tsv b/crates/ui-egui/src/i18n/tr.tsv
index 13c68896dd..4d286f83d8 100644
--- a/crates/ui-egui/src/i18n/tr.tsv
+++ b/crates/ui-egui/src/i18n/tr.tsv
@@ -840,8 +840,18 @@ font-technology Variable Değişken
The selected character's font is not available. Seçili karakterin yazı tipi kullanılamıyor.
No alternates for the selected character. Seçili karakter için alternatif yok.
Double-click to replace Değiştirmek için çift tıklayın
+ Select one character with the Type tool first. Önce Yazım aracıyla bir karakter seçin.
+ {glyph} is a glyph of {font}, not of the font the selected text is set in ({text_font}). {glyph}, seçili metnin yazı tipine ({text_font}) değil, {font} yazı tipine ait bir glif.
+ {glyph} belongs to {base} (U+{base_code}) {place}; your selection is {sel} (U+{code}). {glyph}, {base} (U+{base_code}) karakterine aittir ({place}); seçiminiz {sel} (U+{code}).
+ at the start of a word kelimenin başında
+ in the middle of a word kelimenin ortasında
+ at the end of a word kelimenin sonunda
+ standing alone tek başına dururken
+ {glyph} is not an alternate of {sel} (U+{code}) as it stands in its word. {glyph}, kendi kelimesinde durduğu hâliyle {sel} (U+{code}) karakterinin alternatifi değil.
+ No OpenType feature reaches {glyph}, so it cannot be applied to text. Hiçbir OpenType özelliği {glyph} glifine ulaşmaz; bu yüzden metne uygulanamaz.
+ Double-click to insert {base} (U+{code}) instead: a presentation form does not join, the letter does. Bunun yerine {base} (U+{code}) eklemek için çift tıklayın: sunum biçimi bağlanmaz, harf bağlanır.
+ Inserted {base} (U+{code}) instead of a presentation form: the font joins it to its neighbors by position. Sunum biçimi yerine {base} (U+{code}) eklendi: yazı tipi onu konumuna göre komşularına bağlar.
No OpenType feature draws this glyph here, so it replaces the character's glyph and may not join its neighbors. Hiçbir OpenType özelliği bu glifi burada çizmez; bu yüzden karakterin glifinin yerine geçer ve komşu karakterlere bağlanmayabilir.
- This glyph has no character, so it can't be inserted. Use Alternates for Current Selection to apply it. Bu glifin karakteri yok, bu yüzden eklenemez. Uygulamak için “Geçerli Seçim için Alternatifler”i kullanın.
Double-click to insert Eklemek için çift tıklayın
Double-click to rename Yeniden adlandırmak için çift tıklayın
Double-click to target the contents İçeriği hedeflemek için çift tıklayın
diff --git a/crates/ui-egui/src/i18n/uk.tsv b/crates/ui-egui/src/i18n/uk.tsv
index 6a8756e775..094b517f0b 100644
--- a/crates/ui-egui/src/i18n/uk.tsv
+++ b/crates/ui-egui/src/i18n/uk.tsv
@@ -855,8 +855,18 @@ font-technology Variable Варіативні
The selected character's font is not available. Шрифт виділеного символу недоступний.
No alternates for the selected character. Виділений символ не має альтернатив.
Double-click to replace Двічі клацніть для заміни
+ Select one character with the Type tool first. Спершу виділіть один символ інструментом «Текст».
+ {glyph} is a glyph of {font}, not of the font the selected text is set in ({text_font}). {glyph} — гліф шрифту {font}, а не шрифту виділеного тексту ({text_font}).
+ {glyph} belongs to {base} (U+{base_code}) {place}; your selection is {sel} (U+{code}). {glyph} належить символу {base} (U+{base_code}) {place}; виділено символ {sel} (U+{code}).
+ at the start of a word на початку слова
+ in the middle of a word у середині слова
+ at the end of a word наприкінці слова
+ standing alone в окремому вигляді
+ {glyph} is not an alternate of {sel} (U+{code}) as it stands in its word. {glyph} не є альтернативою символу {sel} (U+{code}) у тому вигляді, в якому він стоїть у своєму слові.
+ No OpenType feature reaches {glyph}, so it cannot be applied to text. Жодна функція OpenType не дає {glyph}, тому його не можна застосувати до тексту.
+ Double-click to insert {base} (U+{code}) instead: a presentation form does not join, the letter does. Двічі клацніть, щоб вставити замість нього {base} (U+{code}): форма подання не з’єднується, літера з’єднується.
+ Inserted {base} (U+{code}) instead of a presentation form: the font joins it to its neighbors by position. Замість форми подання вставлено {base} (U+{code}): шрифт з’єднує його із сусідніми за положенням.
No OpenType feature draws this glyph here, so it replaces the character's glyph and may not join its neighbors. Жодна функція OpenType не малює тут цей гліф, тому він замінює гліф символу й може не з’єднуватися із сусідніми символами.
- This glyph has no character, so it can't be inserted. Use Alternates for Current Selection to apply it. У цього гліфа немає символу, тому його не можна вставити. Щоб застосувати його, скористайтеся «Альтернативи для поточного виділення».
Double-click to insert Двічі клацніть для вставки
Double-click to rename Двічі клацніть для перейменування
Double-click to target the contents Двічі клацніть, щоб вибрати вміст як об’єкт
diff --git a/crates/ui-egui/src/i18n/vi.tsv b/crates/ui-egui/src/i18n/vi.tsv
index 1e1061a79b..d06d1368f8 100644
--- a/crates/ui-egui/src/i18n/vi.tsv
+++ b/crates/ui-egui/src/i18n/vi.tsv
@@ -999,8 +999,18 @@ font-technology Variable Biến thiên
The selected character's font is not available. Phông chữ của ký tự đã chọn không khả dụng.
No alternates for the selected character. Ký tự đã chọn không có glyph thay thế.
Double-click to replace Bấm đúp để thay thế
+ Select one character with the Type tool first. Trước hết hãy chọn một ký tự bằng công cụ Chữ.
+ {glyph} is a glyph of {font}, not of the font the selected text is set in ({text_font}). {glyph} là glyph của {font}, không phải của phông chữ mà văn bản đã chọn đang dùng ({text_font}).
+ {glyph} belongs to {base} (U+{base_code}) {place}; your selection is {sel} (U+{code}). {glyph} thuộc về {base} (U+{base_code}) {place}; ký tự bạn chọn là {sel} (U+{code}).
+ at the start of a word ở đầu từ
+ in the middle of a word ở giữa từ
+ at the end of a word ở cuối từ
+ standing alone khi đứng riêng
+ {glyph} is not an alternate of {sel} (U+{code}) as it stands in its word. {glyph} không phải glyph thay thế của {sel} (U+{code}) như ký tự đó đang đứng trong từ của nó.
+ No OpenType feature reaches {glyph}, so it cannot be applied to text. Không có tính năng OpenType nào đạt tới {glyph}, nên không thể áp dụng cho văn bản.
+ Double-click to insert {base} (U+{code}) instead: a presentation form does not join, the letter does. Bấm đúp để chèn {base} (U+{code}) thay thế: dạng trình bày không nối, còn chữ cái thì nối.
+ Inserted {base} (U+{code}) instead of a presentation form: the font joins it to its neighbors by position. Đã chèn {base} (U+{code}) thay cho dạng trình bày: phông chữ nối nó với các ký tự bên cạnh theo vị trí.
No OpenType feature draws this glyph here, so it replaces the character's glyph and may not join its neighbors. Không có tính năng OpenType nào vẽ glyph này ở đây, nên nó thay glyph của ký tự và có thể không nối với các ký tự bên cạnh.
- This glyph has no character, so it can't be inserted. Use Alternates for Current Selection to apply it. Glyph này không có ký tự nên không thể chèn. Hãy dùng “Glyph thay thế cho vùng chọn hiện tại” để áp dụng.
Double-click to insert Bấm đúp để chèn
Double-click to rename Bấm đúp để đổi tên
Double-click to target the contents Bấm đúp để chọn nội dung
diff --git a/crates/ui-egui/src/i18n/zh-hans.tsv b/crates/ui-egui/src/i18n/zh-hans.tsv
index ae82c23144..dffc014d0d 100644
--- a/crates/ui-egui/src/i18n/zh-hans.tsv
+++ b/crates/ui-egui/src/i18n/zh-hans.tsv
@@ -803,8 +803,18 @@
The selected character's font is not available. 所选字符的字体不可用。
No alternates for the selected character. 所选字符没有替代字。
Double-click to replace 双击以替换
+ Select one character with the Type tool first. 请先用文字工具选择一个字符。
+ {glyph} is a glyph of {font}, not of the font the selected text is set in ({text_font}). {glyph} 是 {font} 的字形,不是所选文本所用字体({text_font})的字形。
+ {glyph} belongs to {base} (U+{base_code}) {place}; your selection is {sel} (U+{code}). {glyph} 属于 {base}(U+{base_code}){place};您选择的是 {sel}(U+{code})。
+ at the start of a word 位于词首
+ in the middle of a word 位于词中
+ at the end of a word 位于词尾
+ standing alone 单独出现时
+ {glyph} is not an alternate of {sel} (U+{code}) as it stands in its word. {glyph} 不是 {sel}(U+{code})在其单词中所处位置的替代字。
+ No OpenType feature reaches {glyph}, so it cannot be applied to text. 没有任何 OpenType 功能能得到 {glyph},因此无法应用于文本。
+ Double-click to insert {base} (U+{code}) instead: a presentation form does not join, the letter does. 双击以改为插入 {base}(U+{code}):表现形式不会连接,字母会连接。
+ Inserted {base} (U+{code}) instead of a presentation form: the font joins it to its neighbors by position. 已插入 {base}(U+{code})以代替表现形式:字体会按位置将其与相邻字符连接。
No OpenType feature draws this glyph here, so it replaces the character's glyph and may not join its neighbors. 没有任何 OpenType 功能会在此处绘制该字形,因此它会替换该字符的字形,并且可能无法与相邻字符连接。
- This glyph has no character, so it can't be inserted. Use Alternates for Current Selection to apply it. 此字形没有对应的字符,无法插入。请使用“当前所选字符的替代字”来应用它。
Double-click to insert 双击以插入
Double-click to rename 双击以重命名
Double-click to target the contents 双击以指定内容
diff --git a/crates/ui-egui/src/i18n/zh-hant.tsv b/crates/ui-egui/src/i18n/zh-hant.tsv
index 841cff8558..d581b8963d 100644
--- a/crates/ui-egui/src/i18n/zh-hant.tsv
+++ b/crates/ui-egui/src/i18n/zh-hant.tsv
@@ -823,8 +823,18 @@ font-technology Variable 可變
The selected character's font is not available. 選取字元的字體無法使用。
No alternates for the selected character. 選取的字元沒有替代字。
Double-click to replace 按兩下以取代
+ Select one character with the Type tool first. 請先用文字工具選取一個字元。
+ {glyph} is a glyph of {font}, not of the font the selected text is set in ({text_font}). {glyph} 是 {font} 的字形,不是所選文字所用字體({text_font})的字形。
+ {glyph} belongs to {base} (U+{base_code}) {place}; your selection is {sel} (U+{code}). {glyph} 屬於 {base}(U+{base_code}){place};您選取的是 {sel}(U+{code})。
+ at the start of a word 位於詞首
+ in the middle of a word 位於詞中
+ at the end of a word 位於詞尾
+ standing alone 單獨出現時
+ {glyph} is not an alternate of {sel} (U+{code}) as it stands in its word. {glyph} 不是 {sel}(U+{code})在其單字中所處位置的替代字。
+ No OpenType feature reaches {glyph}, so it cannot be applied to text. 沒有任何 OpenType 功能能得到 {glyph},因此無法套用到文字。
+ Double-click to insert {base} (U+{code}) instead: a presentation form does not join, the letter does. 按兩下以改為插入 {base}(U+{code}):表現形式不會連接,字母會連接。
+ Inserted {base} (U+{code}) instead of a presentation form: the font joins it to its neighbors by position. 已插入 {base}(U+{code})以取代表現形式:字體會依位置將其與相鄰字元連接。
No OpenType feature draws this glyph here, so it replaces the character's glyph and may not join its neighbors. 沒有任何 OpenType 功能會在此處繪製該字形,因此它會取代該字元的字形,且可能無法與相鄰字元連接。
- This glyph has no character, so it can't be inserted. Use Alternates for Current Selection to apply it. 此字形沒有對應的字元,無法插入。請使用「目前選取字元的替代字」來套用。
Double-click to insert 按兩下以插入
Double-click to rename 按兩下以重新命名
Double-click to target the contents 按兩下以指定內容
diff --git a/crates/ui-egui/src/panels/glyphs.rs b/crates/ui-egui/src/panels/glyphs.rs
index b4d0ab6904..80163af8bc 100644
--- a/crates/ui-egui/src/panels/glyphs.rs
+++ b/crates/ui-egui/src/panels/glyphs.rs
@@ -4,7 +4,10 @@
//! The character selected with the Type tool is highlighted in the grid.
//! Show › Alternates for Current Selection lists that character's alternate glyphs instead (as it
//! stands in its word), and a double-click applies the OpenType feature that draws one to that
-//! character alone, so the text stays real text that joins its neighbors.
+//! character alone, so the text stays real text that joins its neighbors. A double-click on a
+//! glyph no character maps in Entire Font does the same when the selected character's font gives
+//! that glyph as one of its alternates in its word; any other glyph is drawn dimmed, and its tooltip
+//! says why it cannot be applied to the selection.
use std::collections::HashMap;
@@ -253,6 +256,8 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) {
let list_h = (ui.available_height() - 64.0).max(cell * 3.0);
let hover_id = egui::Id::unique("gl-hover");
let selected = selected_char(app);
+ // What a double-click on an unencoded glyph can do to the selected character.
+ let fit = (!unencoded.is_empty()).then(|| Fit::of(app, ui, &face));
// The selected character and the rows shown last frame: the grid scrolls to the selected glyph
// only when the selection moves to another character, so it still scrolls freely meanwhile.
let follow_id = egui::Id::unique("gl-follow");
@@ -264,7 +269,7 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) {
&& !shown.contains(&(i / cols))
{
let row_h = cell + ui.spacing().item_spacing.y;
- area = area.vertical_scroll_offset(((i / cols) as f32 + 0.5) * row_h - list_h * 0.5);
+ area = area.vertical_scroll_offset((((i / cols) as f32 + 0.5) * row_h - list_h * 0.5).max(0.0));
}
let shown = area.show_rows(ui, cell, rows, |ui, range| {
for r in range.clone() {
@@ -285,19 +290,42 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) {
}
let edge = if chosen { Stroke::new(1.5, t.accent) } else { Stroke::new(0.5, t.border) };
ui.painter().rect_stroke(rect.shrink(0.5), 0, edge, StrokeKind::Inside);
- let tex = glyph_texture(ui.ctx(), &face, gid, px);
- ui.painter().image(tex.id(), rect, egui::Rect::from_min_max(egui::pos2(0.0, 0.0), egui::pos2(1.0, 1.0)), t.text_strong);
- let tip = match c {
- Some(c) => format!("U+{:04X} {c} (glyph {gid})\n{}", c as u32, tl!("Double-click to insert")),
- None => unencoded_tip(&face, gid),
+ // A glyph no character maps is dimmed when it cannot go on the selection.
+ let pick = match (c, &fit) {
+ (None, Some(fit)) => Some(fit.pick(gid)),
+ _ => None,
};
- let resp = resp.on_hover_text(tip);
+ let ink = if matches!(pick, Some(None)) { t.text_disabled } else { t.text_strong };
+ let tex = glyph_texture(ui.ctx(), &face, gid, px);
+ ui.painter().image(tex.id(), rect, egui::Rect::from_min_max(egui::pos2(0.0, 0.0), egui::pos2(1.0, 1.0)), ink);
+ // The tooltip is made when it is shown: working out why a glyph cannot be
+ // applied reads the font, which hundreds of cells cannot do each frame.
+ let resp = resp.on_hover_ui(|ui| {
+ let tip = match (c, &pick) {
+ (Some(c), _) => encoded_tip(&face, c, gid),
+ (None, Some(Some(_))) => format!("{}\n{}", unencoded_tip(&face, gid), tl!("Double-click to replace")),
+ (None, Some(None)) => {
+ let why = fit.as_ref().map(|f| f.why(&face, gid)).unwrap_or_default();
+ format!("{}\n{why}", unencoded_tip(&face, gid))
+ }
+ (None, None) => unencoded_tip(&face, gid),
+ };
+ ui.label(tip);
+ });
if resp.double_clicked() {
- match c {
- Some(c) => insert(app, ui.ctx(), c),
- None => app.status(tl!(
- "This glyph has no character, so it can't be inserted. Use Alternates for Current Selection to apply it."
- )),
+ match (c, pick) {
+ (Some(c), _) => insert_character(app, ui.ctx(), &face, c, gid),
+ (None, Some(Some(pick))) => {
+ if let Some(fit) = &fit {
+ fit.apply(app, pick);
+ }
+ }
+ (None, Some(None)) => {
+ if let Some(fit) = &fit {
+ app.status(fit.why(&face, gid));
+ }
+ }
+ (None, None) => {}
}
}
}
@@ -513,6 +541,167 @@ fn apply_alternate(app: &mut VectorcraftApp, id: NodeId, bytes: (usize, usize),
}
}
+/// The Type tool's selected character, and what its word offers, for a double-click on a glyph no
+/// character maps in Show › Entire Font ([`Fit::pick`]).
+struct Fit {
+ /// The selected character: its text, where it is and how it is styled. `None` without one.
+ target: Option,
+ /// The id of the face the panel browses; its glyph ids are what is applied.
+ face_id: u32,
+}
+
+struct FitTarget {
+ id: NodeId,
+ byte: usize,
+ ch: char,
+ features: Vec,
+ has_choice: bool,
+ /// The family the selected text is set in, and whether the app has it.
+ family: String,
+ available: bool,
+ /// The alternates of the character in its word, from the face of its text.
+ found: Option,
+}
+
+impl Fit {
+ fn of(app: &VectorcraftApp, ui: &Ui, face: &vectorcraft_text::FontFace) -> Self {
+ let db = FontDb::global();
+ let target = selected_target(app).map(|t| {
+ let text_face = db.style_face(t.style).filter(|_| db.has_family(&t.style.font_family));
+ let found = text_face.as_ref().and_then(|f| alternates_found(app, ui, t.id, t.byte, f.id()));
+ FitTarget {
+ id: t.id,
+ byte: t.byte,
+ ch: t.ch,
+ features: t.style.features.clone(),
+ has_choice: t.style.glyph_alternate.is_some(),
+ family: t.style.font_family.clone(),
+ available: text_face.is_some(),
+ found,
+ }
+ });
+ Fit { target, face_id: face.id() }
+ }
+
+ /// What a double-click on glyph `gid` does to the selected character: the way to draw it with
+ /// that glyph, when it is the character's default glyph or one of its alternates as it stands
+ /// in its word, in the face the selected text is set in. `None` when it is not.
+ fn pick(&self, gid: u32) -> Option {
+ let t = self.target.as_ref().filter(|t| t.available)?;
+ let found = t.found.as_ref().filter(|f| f.font_id == self.face_id)?;
+ if gid == found.default {
+ return Some(Pick::Default);
+ }
+ let a = found.alternates.iter().find(|a| a.gid == gid)?;
+ Some(a.apply.clone().map_or(Pick::Glyph(gid), Pick::Feature))
+ }
+
+ /// Why [`Self::pick`] finds nothing for glyph `gid` of `face`, in the user's words: what the
+ /// glyph belongs to (its letter and place in a word, from the font's own substitutions) and what
+ /// the selection is, that no feature reaches it, or that nothing is selected.
+ fn why(&self, face: &vectorcraft_text::FontFace, gid: u32) -> String {
+ let Some(t) = &self.target else { return tl!("Select one character with the Type tool first.").to_string() };
+ if !t.available {
+ return tl!("The selected character's font is not available.").to_string();
+ }
+ let name = glyph_label(face, gid);
+ if let Some(found) = t.found.as_ref().filter(|f| f.font_id != self.face_id) {
+ let other = FontDb::global().face_by_id(found.font_id).map_or_else(|| t.family.clone(), |f| format!("{} {}", f.family, f.style));
+ return crate::i18n::fmt(
+ tl!("{glyph} is a glyph of {font}, not of the font the selected text is set in ({text_font})."),
+ &[("glyph", &name), ("font", &format!("{} {}", face.family, face.style)), ("text_font", &other)],
+ );
+ }
+ let (sel, sel_code) = (t.ch.to_string(), format!("{:04X}", t.ch as u32));
+ let not_alternate = || {
+ crate::i18n::fmt(
+ tl!("{glyph} is not an alternate of {sel} (U+{code}) as it stands in its word."),
+ &[("glyph", &name), ("sel", &sel), ("code", &sel_code)],
+ )
+ };
+ let Some(origin) = face.glyph_origin(gid) else {
+ return match face.feature_reaching(gid) {
+ Some(_) => not_alternate(),
+ None => crate::i18n::fmt(tl!("No OpenType feature reaches {glyph}, so it cannot be applied to text."), &[("glyph", &name)]),
+ };
+ };
+ let place = match origin.position() {
+ Some(vectorcraft_text::JoiningPosition::Initial) => Some(tl!("at the start of a word")),
+ Some(vectorcraft_text::JoiningPosition::Medial) => Some(tl!("in the middle of a word")),
+ Some(vectorcraft_text::JoiningPosition::Final) => Some(tl!("at the end of a word")),
+ Some(vectorcraft_text::JoiningPosition::Isolated) => Some(tl!("standing alone")),
+ None => None,
+ };
+ let (Some(place), true) = (place, origin.ch != t.ch) else { return not_alternate() };
+ crate::i18n::fmt(
+ tl!("{glyph} belongs to {base} (U+{base_code}) {place}; your selection is {sel} (U+{code})."),
+ &[
+ ("glyph", &name),
+ ("base", &origin.ch.to_string()),
+ ("base_code", &format!("{:04X}", origin.ch as u32)),
+ ("place", place),
+ ("sel", &sel),
+ ("code", &sel_code),
+ ],
+ )
+ }
+
+ /// Apply `pick` to the selected character, as a double-click on a variant of Show › Alternates
+ /// for Current Selection does.
+ fn apply(&self, app: &mut VectorcraftApp, pick: Pick) {
+ let Some(t) = &self.target else { return };
+ super::character::end_typing(app);
+ apply_alternate(app, t.id, (t.byte, t.byte + t.ch.len_utf8()), &t.features, t.has_choice, pick);
+ }
+}
+
+/// `name` of glyph `gid`, or `gid N`.
+fn glyph_label(face: &vectorcraft_text::FontFace, gid: u32) -> String {
+ face.glyph_name(gid).unwrap_or_else(|| format!("gid {gid}"))
+}
+
+/// The letter to insert in place of character `c`, an Arabic presentation form: the letter whose
+/// glyph the font substitutes with `init`, `medi`, `fina` or `isol` to get `c`'s glyph. The
+/// presentation form itself is a fixed shape that neither joins its neighbors nor is joined by
+/// them; the letter takes the form its place in the word asks for, so the word stays joined.
+/// `None` when `c` is not such a form in this font.
+fn base_letter(face: &vectorcraft_text::FontFace, c: char, gid: u32) -> Option {
+ let origin = face.glyph_origin(gid)?;
+ origin.position()?;
+ (origin.ch != c).then_some(origin.ch)
+}
+
+/// The tooltip of an encoded glyph (a character): its code point and glyph, and what a
+/// double-click inserts.
+fn encoded_tip(face: &vectorcraft_text::FontFace, c: char, gid: u32) -> String {
+ let head = format!("U+{:04X} {c} (glyph {gid})", c as u32);
+ match base_letter(face, c, gid) {
+ Some(base) => format!(
+ "{head}\n{}",
+ crate::i18n::fmt(
+ tl!("Double-click to insert {base} (U+{code}) instead: a presentation form does not join, the letter does."),
+ &[("base", &base.to_string()), ("code", &format!("{:04X}", base as u32))]
+ )
+ ),
+ None => format!("{head}\n{}", tl!("Double-click to insert")),
+ }
+}
+
+/// Double-click on character `c` of the grid: insert it, or its letter when it is an Arabic
+/// presentation form ([`base_letter`]), which the status says.
+fn insert_character(app: &mut VectorcraftApp, ctx: &egui::Context, face: &vectorcraft_text::FontFace, c: char, gid: u32) {
+ match base_letter(face, c, gid) {
+ Some(base) => {
+ insert(app, ctx, base);
+ app.status(crate::i18n::fmt(
+ tl!("Inserted {base} (U+{code}) instead of a presentation form: the font joins it to its neighbors by position."),
+ &[("base", &base.to_string()), ("code", &format!("{:04X}", base as u32))],
+ ));
+ }
+ None => insert(app, ctx, c),
+ }
+}
+
/// The tooltip of a glyph no character maps (Show › Entire Font): its name from the font, `gid N`
/// when it has none, its id, and the feature that gives it, if one does.
fn unencoded_tip(face: &vectorcraft_text::FontFace, gid: u32) -> String {
@@ -898,12 +1087,17 @@ mod tests {
/// Double-click the glyph cell at `at`.
fn double_click(ctx: &egui::Context, app: &mut VectorcraftApp, at: Pos2) {
+ double_click_in(ctx, app, at, vec2(280.0, 420.0));
+ }
+
+ /// Double-click the glyph cell at `at` in a panel `size` large.
+ fn double_click_in(ctx: &egui::Context, app: &mut VectorcraftApp, at: Pos2, size: egui::Vec2) {
let button = |pressed| egui::Event::PointerButton { pos: at, button: egui::PointerButton::Primary, pressed, modifiers: Default::default() };
// Time only moves forward in one egui context.
let mut time = ctx.input(|i| i.time) + 10.0;
let mut timed = |app: &mut VectorcraftApp, events: Vec| {
time += 0.05;
- let screen = Some(Rect::from_min_size(Pos2::ZERO, vec2(280.0, 420.0)));
+ let screen = Some(Rect::from_min_size(Pos2::ZERO, size));
let mut out = ctx.run_ui(egui::RawInput { screen_rect: screen, time: Some(time), events, ..Default::default() }, |ui| show(app, ui));
out.textures_delta.clear();
};
@@ -1074,4 +1268,215 @@ mod tests {
app.ui.glyphs_preview = 99;
assert_eq!(cell_index(&app), CELL_DEFAULT);
}
+ /// The panel tall enough to draw every row of the grid, so each glyph has a cell.
+ const TALL: egui::Vec2 = vec2(280.0, 8000.0);
+
+ fn tall_shapes(ctx: &egui::Context, app: &mut VectorcraftApp) -> Vec {
+ let raw = egui::RawInput { screen_rect: Some(Rect::from_min_size(Pos2::ZERO, TALL)), ..Default::default() };
+ let mut out = ctx.run_ui(raw, |ui| show(app, ui));
+ out.textures_delta.clear();
+ out.shapes.into_iter().map(|c| c.shape).collect()
+ }
+
+ /// Each glyph preview with the colour it is drawn in, in the order of the grid.
+ fn inks(shapes: &[Shape]) -> Vec<(Rect, Color32)> {
+ fn walk(s: &Shape, out: &mut Vec<(Rect, Color32)>) {
+ match s {
+ Shape::Mesh(m) => {
+ let r = m.calc_bounds();
+ if (r.width() - r.height()).abs() < 1.0 && r.width() >= 14.0 {
+ out.push((r, m.vertices.first().map_or(Color32::TRANSPARENT, |v| v.color)));
+ }
+ }
+ Shape::Vec(v) => v.iter().for_each(|s| walk(s, out)),
+ _ => {}
+ }
+ }
+ let mut out = vec![];
+ shapes.iter().for_each(|s| walk(s, &mut out));
+ out
+ }
+
+ /// What a double-click on glyph `gid` of `face` would do to the selection ([`Fit::pick`], [`Fit::why`]).
+ fn verdict(ctx: &egui::Context, app: &VectorcraftApp, face: &vectorcraft_text::FontFace, gid: u32) -> Result {
+ let mut out = Err(String::new());
+ let raw = egui::RawInput { screen_rect: Some(Rect::from_min_size(Pos2::ZERO, TALL)), ..Default::default() };
+ let mut frame = ctx.run_ui(raw, |ui| {
+ let fit = Fit::of(app, ui, face);
+ out = fit.pick(gid).ok_or_else(|| fit.why(face, gid));
+ });
+ frame.textures_delta.clear();
+ out
+ }
+
+ /// The cell of glyph `gid` in Show › Entire Font of `face`: the grid lists the characters, then
+ /// the glyphs no character maps. Its centre, and the colour its glyph is drawn in.
+ fn entire_font_cell(ctx: &egui::Context, app: &mut VectorcraftApp, face: &vectorcraft_text::FontFace, gid: u32) -> (Pos2, Color32) {
+ let chars = face.chars().into_iter().filter(|(c, _)| !c.is_control() && !c.is_whitespace()).count();
+ let unencoded = face.unencoded_glyphs();
+ let cells = inks(&tall_shapes(ctx, app));
+ assert_eq!(cells.len(), chars + unencoded.len(), "every glyph has a cell");
+ let at = chars + unencoded.iter().position(|g| *g == gid).expect("an unencoded glyph");
+ (cells[at].0.center(), cells[at].1)
+ }
+
+ /// The owner's Kufi Murabba PIIIX Heavy (`~/Library/Fonts`), in the app's font database; `None`
+ /// when it is not installed. A file that is there but gives no face is a failure.
+ fn kufi_heavy() -> Option> {
+ let path = std::path::Path::new(&std::env::var_os("HOME")?).join("Library/Fonts/KufiMurabbaPIIIX-Heavy.ttf");
+ if !path.exists() {
+ eprintln!("skipped: {} is not installed", path.display());
+ return None;
+ }
+ let bytes = std::fs::read(&path).unwrap_or_else(|e| panic!("{} exists but cannot be read: {e}", path.display()));
+ FontDb::global().add_font(bytes);
+ let face =
+ FontDb::global().face("Kufi Murabba PIIIX", "Heavy").unwrap_or_else(|| panic!("{} exists but its face is missing", path.display()));
+ assert_eq!((face.family.as_str(), face.style.as_str()), ("Kufi Murabba PIIIX", "Heavy"), "the installed font, not a fallback");
+ Some(face)
+ }
+
+ fn gid_named(face: &vectorcraft_text::FontFace, name: &str) -> u32 {
+ (0..face.glyph_count()).find(|g| face.glyph_name(*g).as_deref() == Some(name)).unwrap_or_else(|| panic!("{name} missing"))
+ }
+
+ /// The word al-Khwarizmi ending in `letter`, in Kufi Murabba PIIIX Heavy.
+ fn khwarizmi_in_heavy(letter: char) -> (VectorcraftApp, serde_json::Value, usize) {
+ let word = format!("\u{627}\u{644}\u{62E}\u{648}\u{627}\u{631}\u{632}\u{645}{letter}");
+ let (mut app, id) = editing(&word);
+ app.run("text.setRangeStyle", json!({"id": id, "font": "Kufi Murabba PIIIX", "style": "Heavy"})).unwrap();
+ (app, id, word.len() - letter.len_utf8())
+ }
+
+ /// Applying each alternate of the final letter of a word in Kufi Murabba PIIIX Heavy, with the
+ /// letter selected as the Type tool selects it (the UTF-8 byte range of the character, the
+ /// last one of a right-to-left word), by a double-click on its cell in Alternates for Current
+ /// Selection and in Entire Font: the same glyph, one undo step, the rest of the word as it was.
+ /// Persian yeh, Arabic yeh and alef maksura each have their own `.fina.alt1` to `.fina.alt6`.
+ #[test]
+ fn a_final_letters_alternates_apply_from_alternates_and_from_entire_font() {
+ let Some(face) = kufi_heavy() else { return };
+ for (letter, base) in [('\u{06CC}', "yehfarsi"), ('\u{064A}', "yeh"), ('\u{0649}', "alefmaksura")] {
+ let (mut app, id, last) = khwarizmi_in_heavy(letter);
+ let plain = glyph_ids(&app, &id);
+ let ends = plain.len() - 1;
+ assert_eq!(plain[ends], gid_named(&face, &format!("{base}.fina")), "{base}: the final form");
+ select(&mut app, last, last + letter.len_utf8());
+ assert_eq!(selected_char(&app), Some(letter), "{base}: the selection is the last letter");
+ let alternates: Vec = (1..=6).map(|n| gid_named(&face, &format!("{base}.fina.alt{n}"))).collect();
+ let ctx = egui::Context::default();
+ for view in ["Alternates for Current Selection", "Entire Font"] {
+ for (n, alt) in alternates.iter().enumerate() {
+ choose(&ctx, &mut app, view);
+ let undo = app.session.active().unwrap().history.undo.len();
+ let at = if view == "Entire Font" {
+ let (at, ink) = entire_font_cell(&ctx, &mut app, &face, *alt);
+ assert_eq!(ink, Tokens::get(&ctx).text_strong, "{base} {view}: {alt} is not dimmed for its own letter");
+ at
+ } else {
+ let cells = squares(&shapes(&ctx, &mut app, vec![]));
+ assert_eq!(cells.len(), 7, "{base}: the final form and its six alternates");
+ cells[n + 1].center()
+ };
+ double_click_in(&ctx, &mut app, at, TALL);
+ let after = glyph_ids(&app, &id);
+ let mut want = plain.clone();
+ want[ends] = *alt;
+ assert_eq!(after, want, "{base} {view} alt{}: only the last letter changes; status: {}", n + 1, app.ui.status);
+ assert_eq!(app.session.active().unwrap().history.undo.len(), undo + 1, "{base} {view} alt{}: one undo step", n + 1);
+ app.run("edit.undo", json!({})).unwrap();
+ assert_eq!(glyph_ids(&app, &id), plain, "{base} {view}: undo");
+ }
+ }
+ }
+ }
+
+ /// A glyph of another letter is dimmed in Entire Font and a double-click changes nothing: the
+ /// tooltip's reason names the glyph's letter, where in a word it stands and the selected letter.
+ #[test]
+ fn a_glyph_that_cannot_go_on_the_selection_is_dimmed_and_says_why() {
+ let Some(face) = kufi_heavy() else { return };
+ let glyph = gid_named(&face, "yehfarsi.fina.alt2");
+ assert_eq!(glyph, 820, "the owner's glyph");
+ for (letter, selected) in [('\u{064A}', "ي (U+064A)"), ('\u{0649}', "ى (U+0649)")] {
+ let (mut app, id, last) = khwarizmi_in_heavy(letter);
+ let plain = glyph_ids(&app, &id);
+ select(&mut app, last, last + letter.len_utf8());
+ let ctx = egui::Context::default();
+ let t = Tokens::get(&ctx);
+ let why = verdict(&ctx, &app, &face, glyph).err().expect("not applicable");
+ assert_eq!(why, format!("yehfarsi.fina.alt2 belongs to ی (U+06CC) at the end of a word; your selection is {selected}."));
+ let (at, ink) = entire_font_cell(&ctx, &mut app, &face, glyph);
+ assert_eq!(ink, t.text_disabled, "{letter}: dimmed");
+ let undo = app.session.active().unwrap().history.undo.len();
+ double_click_in(&ctx, &mut app, at, TALL);
+ assert_eq!(glyph_ids(&app, &id), plain, "{letter}: nothing changes");
+ assert_eq!(app.session.active().unwrap().history.undo.len(), undo);
+ assert_eq!(app.ui.status, why, "the status says why");
+ }
+ // The same glyph on Persian yeh applies.
+ let (mut app, id, last) = khwarizmi_in_heavy('\u{06CC}');
+ select(&mut app, last, last + 2);
+ let ctx = egui::Context::default();
+ let (at, ink) = entire_font_cell(&ctx, &mut app, &face, glyph);
+ assert_eq!(ink, Tokens::get(&ctx).text_strong);
+ double_click_in(&ctx, &mut app, at, TALL);
+ assert_eq!(glyph_ids(&app, &id).last(), Some(&glyph));
+ // Nothing selected, a glyph no feature reaches, and a variant of the letter's other form.
+ select(&mut app, 0, 0);
+ assert_eq!(verdict(&ctx, &app, &face, glyph).err().as_deref(), Some("Select one character with the Type tool first."));
+ let (_, ink) = entire_font_cell(&ctx, &mut app, &face, glyph);
+ assert_eq!(ink, Tokens::get(&ctx).text_disabled, "dimmed without a selection");
+ select(&mut app, last, last + 2);
+ let medial = gid_named(&face, "yehfarsi.medi.alt1");
+ assert_eq!(
+ verdict(&ctx, &app, &face, medial).err().as_deref(),
+ Some("yehfarsi.medi.alt1 is not an alternate of ی (U+06CC) as it stands in its word."),
+ "its own letter, another place in a word"
+ );
+ let unreached = face.unencoded_glyphs().iter().copied().find(|g| face.glyph_origin(*g).is_none() && face.feature_reaching(*g).is_none());
+ if let Some(g) = unreached {
+ let why = verdict(&ctx, &app, &face, g).err().expect("not applicable");
+ assert!(why.starts_with("No OpenType feature reaches "), "{why}");
+ }
+ }
+
+ /// A presentation form picked in Entire Font (beh medial, U+FE92) with the caret before the last
+ /// letter would be a fixed shape that does not join the word: the letter (beh) is inserted, so
+ /// the word is shaped as if it were typed, and the status says so.
+ #[test]
+ fn a_presentation_form_inserts_its_letter_so_the_word_joins() {
+ let Some(face) = kufi_heavy() else { return };
+ let (mut app, id, last) = khwarizmi_in_heavy('\u{06CC}');
+ let typed = {
+ let word = "\u{627}\u{644}\u{62E}\u{648}\u{627}\u{631}\u{632}\u{645}\u{628}\u{6CC}";
+ let (mut other, other_id) = editing(word);
+ other.run("text.setRangeStyle", json!({"id": other_id, "font": "Kufi Murabba PIIIX", "style": "Heavy"})).unwrap();
+ glyph_ids(&other, &other_id)
+ };
+ let gid_of = |c| face.chars().into_iter().find(|(ch, _)| *ch == c).expect("the character is in the font").1;
+ // Typed as the presentation form itself, the same word is shaped differently: it does not join.
+ let literal = {
+ let word = "\u{627}\u{644}\u{62E}\u{648}\u{627}\u{631}\u{632}\u{645}\u{FE92}\u{6CC}";
+ let (mut other, other_id) = editing(word);
+ other.run("text.setRangeStyle", json!({"id": other_id, "font": "Kufi Murabba PIIIX", "style": "Heavy"})).unwrap();
+ glyph_ids(&other, &other_id)
+ };
+ eprintln!("typed with beh {typed:?}\ntyped with U+FE92 {literal:?}");
+ assert_ne!(literal, typed, "the presentation form is not shaped like the letter");
+ assert_eq!(base_letter(&face, '\u{FE92}', gid_of('\u{FE92}')), Some('\u{0628}'), "medial beh is a form of beh");
+ assert_eq!(base_letter(&face, '\u{0628}', gid_of('\u{0628}')), None, "beh itself inserts as it is");
+ let tip = encoded_tip(&face, '\u{FE92}', gid_of('\u{FE92}'));
+ assert!(tip.contains("(U+0628)"), "{tip}");
+ select(&mut app, last, last);
+ let ctx = egui::Context::default();
+ let chars = face.chars().into_iter().filter(|(c, _)| !c.is_control() && !c.is_whitespace()).collect::>();
+ let cells = inks(&tall_shapes(&ctx, &mut app));
+ let at = cells[chars.iter().position(|(c, _)| *c == '\u{FE92}').expect("the presentation form is in the font")].0.center();
+ double_click_in(&ctx, &mut app, at, TALL);
+ let NodeKind::Text(text) = &app.session.active().unwrap().doc.node(NodeId(id.as_u64().unwrap())).unwrap().kind else { panic!("text") };
+ assert!(text.plain_text().ends_with("\u{645}\u{628}\u{6CC}"), "the letter is in the text: {}", text.plain_text());
+ assert_eq!(glyph_ids(&app, &id), typed, "shaped as the word typed with beh");
+ assert!(app.ui.status.starts_with("Inserted ب (U+0628)"), "{}", app.ui.status);
+ }
}
diff --git a/docs/gaps.md b/docs/gaps.md
index cb915bd80a..97b3c1fb90 100644
--- a/docs/gaps.md
+++ b/docs/gaps.md
@@ -372,7 +372,7 @@ from G14 and the budget run of G15: **~170–270 h** one agent, ~50–80 h wall
## G4 Advanced type
-- **Done (#5514):** the Glyphs panel's Alternates for Current Selection lists the alternates of the glyph the shaper makes for the selected character in its word, and a double-click applies the OpenType feature value that draws one (`salt=2`, `ss03`) to that character alone, in one undo step, so the text stays real Unicode and an Arabic word keeps its joining forms: runs that differ only in their features are shaped as one piece of text. A variant no feature draws there is still drawn as a replacement glyph, which may not join. Show › Entire Font lists the unencoded glyphs by id, with their name and the feature that gives them. Left: alternates reached through contextual or ligature lookups, and `cv01`–`cv99` and `hist`, which have no style tag; estimate unchanged.
+- **Done (#5514):** the Glyphs panel's Alternates for Current Selection lists the alternates of the glyph the shaper makes for the selected character in its word, and a double-click applies the OpenType feature value that draws one (`salt=2`, `ss03`) to that character alone, in one undo step, so the text stays real Unicode and an Arabic word keeps its joining forms: runs that differ only in their features are shaped as one piece of text. A variant no feature draws there is still drawn as a replacement glyph, which may not join. Show › Entire Font lists the unencoded glyphs by id, with their name and the feature that gives them; a double-click on one applies it to the selected character when the font gives it as that character's alternate in its word (`yehfarsi.fina.alt2` on a final Persian yeh), and any other is drawn dimmed with a tooltip that says why (it belongs to another letter or place in a word, no feature reaches it, nothing is selected). An Arabic presentation form picked there inserts its letter, so the word still joins. Left: alternates reached through contextual or ligature lookups, and `cv01`–`cv99` and `hist`, which have no style tag; estimate unchanged.
- **Fixed:** fullwidth `!` / `?` stay upright in vertical point and area type when a font has no vertical alternate. Existing `vert` substitutions and the rotated fallback for brackets and long vowel marks are preserved; the broader CJK gap and estimate are unchanged.
diff --git a/docs/type-parity.md b/docs/type-parity.md
index 26a1fdaa07..0483216854 100644
--- a/docs/type-parity.md
+++ b/docs/type-parity.md
@@ -31,7 +31,7 @@ against Illustrator's public documentation; **estimated** unless noted.
| Character size and horizontal scale after object/group scaling | done | #1034: fields and setters compensate the stored affine; point size follows the transformed em-height axis, not the glyph ink bounds. Range edits, keyboard increments, leading and baseline shift use document points; rotation, reflection and shear remain editable. |
| Preserve font sizes while scaling a group | missing | Separate enhancement; scaling still scales text with the other artwork. |
| OpenType panel, Glyphs panel, alternates | done | Highlight Alternate Glyphs preference (#394, off by default) paints pink behind OpenType substitutions on screen when on (required shaping such as Arabic joining forms and the lam-alef ligature, including a lam-alef older fonts file under `liga`, is not highlighted; an optional feature that changes the ligature is); stylistic sets ss01–ss20, stylistic and titling alternates, proportional and lining figures, superscript and subscript are character-style features (`text.setStyle {features}`, #1607); the OpenType panel's Position menu sets superscript, subscript, numerator or denominator glyphs (`sups`, `subs`, `numr`, `dnom`). The OpenType panel's Stylistic Sets menu lists the selected font's `ss01`–`ss20` features, using a font-supplied name where available and `Set N` otherwise; several sets can be active, and selecting an active one removes it. For a mixed-font selection it uses the first selected character's font, a VectorCraft design choice ([public help](https://helpx.adobe.com/illustrator/desktop/design-with-text/special-characters-glyphs/add-stylistic-sets-to-selected-text.html), [OpenType feature specification](https://learn.microsoft.com/en-us/typography/opentype/spec/features_pt)). The Glyphs panel highlights the character selected with the Type tool (a range's first character, else the one before the caret) and scrolls it into view as the selection moves (#4908; `panels::glyphs` tests); its Show menu lists Unicode ranges and Alternates for Current Selection, the selected character's alternate glyphs from the font's `GSUB` table, one of which a double-click draws for that character (`text.setGlyphAlternate`); the glyph-cell pop-up of alternates is not there yet |
-| Glyphs panel: alternates as OpenType feature values, unencoded glyphs | done | #5514, as [Replace characters with alternate glyphs](https://helpx.adobe.com/illustrator/desktop/design-with-text/special-characters-glyphs/replace-characters-with-alternate-glyphs.html) describes (Show › Alternates For Current Selection; replace the selected character by double-clicking the glyph). A character-style feature takes a value (`"salt=2"`, the font's alternate number; `ss01`–`ss20`, `swsh`, `titl` and `aalt` too) in `features` and `featureChanges`, in SVG `font-feature-settings` (`"salt" 2`) and in native files as the plain string. Runs that differ only in their features shape as one piece of text, each run's features over its own characters, so a variant on one letter of an Arabic word keeps its neighbours' initial, medial and final forms and the letters still touch. The panel lists the alternates of the glyph the shaper makes for the selected character in its word (a medial letter shows medial variants), and a double-click applies the feature that draws the variant to that character alone, in one undo step (`text.setRangeStyle`); a variant no feature draws there is drawn as a replacement glyph (`text.setGlyphAlternate`), which its tooltip says may not join. Show › Entire Font also lists the glyphs no character maps, with their name or `gid N` and the feature that gives them. The text crate reads each face's `GSUB` once (alternate sets, single substitutions, the feature that reaches a glyph). Not done: alternates reached through contextual or ligature lookups, and features of the font that the style has no tag for (`cv01`–`cv99`, `hist`). Evidence: `tests::glyph_alternates` in the text crate (a fixture font with a medial teh at `salt=1` to `3`, the neighbours' forms, no gap; Kufi Murabba PIIIX when it is installed), `features::tests::feature_values_are_parsed_validated_and_shaped`, `tests::textedit::feature_values_are_set_replaced_and_saved`, the `panels::glyphs` tests. |
+| Glyphs panel: alternates as OpenType feature values, unencoded glyphs | done | #5514, as [Replace characters with alternate glyphs](https://helpx.adobe.com/illustrator/desktop/design-with-text/special-characters-glyphs/replace-characters-with-alternate-glyphs.html) describes (Show › Alternates For Current Selection; replace the selected character by double-clicking the glyph). A character-style feature takes a value (`"salt=2"`, the font's alternate number; `ss01`–`ss20`, `swsh`, `titl` and `aalt` too) in `features` and `featureChanges`, in SVG `font-feature-settings` (`"salt" 2`) and in native files as the plain string. Show › Entire Font applies an unencoded glyph the same way when it is an alternate of the selected character in its word, and dims the others with the reason in the tooltip (e.g. `yehfarsi.fina.alt2 belongs to ی (U+06CC) at the end of a word; your selection is ي (U+064A)`); an Arabic presentation form picked there inserts its letter so the word joins. Runs that differ only in their features shape as one piece of text, each run's features over its own characters, so a variant on one letter of an Arabic word keeps its neighbours' initial, medial and final forms and the letters still touch. The panel lists the alternates of the glyph the shaper makes for the selected character in its word (a medial letter shows medial variants), and a double-click applies the feature that draws the variant to that character alone, in one undo step (`text.setRangeStyle`); a variant no feature draws there is drawn as a replacement glyph (`text.setGlyphAlternate`), which its tooltip says may not join. Show › Entire Font also lists the glyphs no character maps, with their name or `gid N` and the feature that gives them. The text crate reads each face's `GSUB` once (alternate sets, single substitutions, the feature that reaches a glyph). Not done: alternates reached through contextual or ligature lookups, and features of the font that the style has no tag for (`cv01`–`cv99`, `hist`). Evidence: `tests::glyph_alternates` in the text crate (a fixture font with a medial teh at `salt=1` to `3`, the neighbours' forms, no gap; Kufi Murabba PIIIX when it is installed), `features::tests::feature_values_are_parsed_validated_and_shaped`, `tests::textedit::feature_values_are_set_replaced_and_saved`, the `panels::glyphs` tests. |
| Font menu: samples, live preview, filters, favourites, recent families; font matching by any name; Find Font | done | #3515: recently applied families appear first, once each; missing families are omitted and the existing Number of Recent Fonts setting applies; #339; script filter: Japanese, Latin, Thai; the font scan on Linux also reads the folders fontconfig's configuration lists, such as TeX fonts under `/usr/share/texmf/fonts` (#1548, fixes #1529; not verified on Linux) Replace With Font lists family and style rows, one click setting both, with a search over them; its list scrolls on its own, apart from the document's font list (#4972). |
| Missing glyphs set in an installed font made for their script (Thai, Lao, Khmer, Myanmar first; then any font that has them) | done | Thai: Leelawadee UI, Tahoma, Thonburi, Sukhumvit Set, Noto Sans Thai, TLWG |
| 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 |
@@ -80,7 +80,7 @@ Character Styles menu New/Redefine and footer New capture the first selected cha
| Date | Change | Summary |
|---|---|---|
-| 2026-11-07 | minor | Glyphs panel (#5514): Alternates for Current Selection lists the alternates of the selected character's glyph in its word and a double-click applies the OpenType feature that draws one to that letter alone (`salt=2`; `features` and `featureChanges` take `tag=N`), so an Arabic word stays joined, and Entire Font lists the unencoded glyphs. Runs that differ only in their features shape as one piece of text. Scores and estimates unchanged. |
+| 2026-11-07 | minor | Glyphs panel (#5514): Alternates for Current Selection lists the alternates of the selected character's glyph in its word and a double-click applies the OpenType feature that draws one to that letter alone (`salt=2`; `features` and `featureChanges` take `tag=N`), so an Arabic word stays joined, and Entire Font lists the unencoded glyphs, applies the ones that are the selected letter's alternates and dims, with the reason, those that are not. Runs that differ only in their features shape as one piece of text. Scores and estimates unchanged. |
| 2026-11-07 | minor | Indian-language hyphenation (#4947): eleven MIT-licensed pattern sets, with grapheme and shaping-cluster protection in both composers and existing paragraph limits. Assamese and Sanskrit are selectable; Sanskrit automatic patterns remain pending an allowlisted source. Scores and estimates unchanged. |
| 2026-11-06 | minor | Alt + numeric-keypad codes (`Alt+0176` → °) type their character at the caret on Windows, and `type.insert` inserts a character by `code` or `unicode` (#5108); scores and estimates unchanged |
| 2026-11-06 | minor | Snap to Glyph › Glyph Bounds: each glyph's box gives four corners a drawn point lands on, also on a curved side (`B`), and its box and edges turn with rotated type (#5112); scores and estimates unchanged |
From c81f3b4515d4679cf66ccceda4af325efd0347b7 Mon Sep 17 00:00:00 2001
From: Taha-Mahmoodi <85902429+Taha-Mahmoodi@users.noreply.github.com>
Date: Sat, 7 Nov 2026 16:31:46 +0430
Subject: [PATCH 09/86] Glyph alternates: read tag=0 as off in the OpenType
dialog; one accurate doc statement of how alternates apply
---
.../ui-egui/src/dialogs/char_style_options.rs | 32 ++++++++++++++++---
docs/type-parity.md | 4 +--
2 files changed, 29 insertions(+), 7 deletions(-)
diff --git a/crates/ui-egui/src/dialogs/char_style_options.rs b/crates/ui-egui/src/dialogs/char_style_options.rs
index 32333848c7..64f492066b 100644
--- a/crates/ui-egui/src/dialogs/char_style_options.rs
+++ b/crates/ui-egui/src/dialogs/char_style_options.rs
@@ -189,6 +189,18 @@ fn bare(tag: &str) -> &str {
tag.split_once('=').map_or(tag, |(name, _)| name)
}
+/// Is the feature a tag of a style's list sets on? A `tag=N` of a valued feature is off for 0,
+/// which a `-` prefix check misses; every other tag is on unless it starts with `-`.
+fn tag_on(tag: &str) -> bool {
+ let name = bare(tag);
+ let valued = name == "swsh" || vectorcraft_text::EXTRA_FEATURES.contains(&name);
+ if valued && vectorcraft_text::OtFeatures::known_tag(tag) {
+ vectorcraft_text::OtFeatures::from_tags([tag]).feature_value(name).is_some()
+ } else {
+ !tag.starts_with('-')
+ }
+}
+
/// The OpenType Features fields for a style's `features`: each control is unset unless the list
/// names one of its tags, on or off. Figure is also unset when the list turns one of its tags on
/// or off against every Figure menu entry, for example `pnum` alone.
@@ -199,9 +211,9 @@ fn opentype_fields(features: Option<&Value>) -> Map {
let mut fields = Map::new();
for (field, tag) in OT_SWITCHES {
let last = tags.iter().rev().find(|t| bare(t) == *tag);
- fields.insert((*field).into(), last.map_or(Value::Null, |t| json!(!t.starts_with('-'))));
+ fields.insert((*field).into(), last.map_or(Value::Null, |t| json!(tag_on(t))));
}
- let last_on = |tag: &str| tags.iter().rev().find(|t| bare(t) == tag).map(|t| !t.starts_with('-'));
+ let last_on = |tag: &str| tags.iter().rev().find(|t| bare(t) == tag).map(|t| tag_on(t));
let figure = names(&FIGURE_TAGS)
.then(|| FIGURE_VALUES.get(crate::panels::opentype::figure(&f)))
.flatten()
@@ -212,7 +224,7 @@ fn opentype_fields(features: Option<&Value>) -> Map {
fields.insert("ot_position".into(), position.map_or(Value::Null, |v| json!(v)));
let sets: Vec = vectorcraft_text::EXTRA_FEATURES[..20]
.iter()
- .filter_map(|set| tags.iter().rev().find(|t| bare(t) == *set).map(|t| if t.starts_with('-') { format!("-{set}") } else { set.to_string() }))
+ .filter_map(|set| tags.iter().rev().find(|t| bare(t) == *set).map(|t| if tag_on(t) { set.to_string() } else { format!("-{set}") }))
.collect();
fields.insert("ot_sets".into(), if sets.is_empty() { Value::Null } else { json!(sets) });
fields
@@ -488,11 +500,11 @@ fn opentype_attrs(d: &Dialog, original: &Map, original_fields: &M
if !unchanged("ot_sets") {
let sets = &vectorcraft_text::EXTRA_FEATURES[..20];
// A set still on keeps the value its tag gives (`ss01=2`).
- let before: Vec = tags.iter().filter(|t| sets.contains(&bare(t)) && !t.starts_with('-')).cloned().collect();
+ let before: Vec = tags.iter().filter(|t| sets.contains(&bare(t))).cloned().collect();
tags.retain(|t| !sets.contains(&bare(t)));
let listed = d.fields.get("ot_sets").and_then(Value::as_array).into_iter().flatten().filter_map(Value::as_str);
tags.extend(listed.filter(|t| sets.contains(&bare(t))).map(|t| {
- let kept = before.iter().rev().find(|b| !t.starts_with('-') && bare(b) == bare(t));
+ let kept = before.iter().rev().find(|b| bare(b) == bare(t)).filter(|b| !t.starts_with('-') && tag_on(b));
kept.map_or_else(|| t.to_string(), Clone::clone)
}));
}
@@ -1337,6 +1349,16 @@ mod tests {
assert_eq!(edited_attrs(&d).unwrap(), Map::new());
}
+ #[test]
+ fn a_stored_zero_value_reads_as_off_and_turning_it_on_saves_it() {
+ let mut d = dialog_for(json!({"features": ["ss01=0", "salt=0", "ss02=2"]}));
+ assert_eq!(d.fields["ot_salt"], json!(false));
+ assert_eq!(d.fields["ot_sets"], json!(["-ss01", "ss02"]));
+ d.fields.insert("ot_sets".into(), json!(["ss01", "ss02"]));
+ d.fields.insert("ot_salt".into(), json!(true));
+ assert_eq!(edited_attrs(&d).unwrap()["features"], json!(["salt", "ss01", "ss02=2"]));
+ }
+
#[test]
fn figure_tags_no_menu_entry_matches_leave_the_figure_unset() {
for features in [json!(["pnum"]), json!(["lnum"]), json!(["onum", "lnum"]), json!(["tnum", "pnum"])] {
diff --git a/docs/type-parity.md b/docs/type-parity.md
index 0483216854..f8c48191a1 100644
--- a/docs/type-parity.md
+++ b/docs/type-parity.md
@@ -30,8 +30,8 @@ against Illustrator's public documentation; **estimated** unless noted.
| Copy or Cut selected characters, then paste as separate text art | done | A Type-range copy keeps the selected characters' font family, size and color when pasted on the canvas after leaving text editing, in the same document or another. On desktop the clipboard offers plain text to text consumers and rich art for canvas Paste; Paste Without Formatting while editing uses the destination text's style. `tests::clipboard::type_range_copy_stays_styled_when_pasted_as_art` (#5636) |
| Character size and horizontal scale after object/group scaling | done | #1034: fields and setters compensate the stored affine; point size follows the transformed em-height axis, not the glyph ink bounds. Range edits, keyboard increments, leading and baseline shift use document points; rotation, reflection and shear remain editable. |
| Preserve font sizes while scaling a group | missing | Separate enhancement; scaling still scales text with the other artwork. |
-| OpenType panel, Glyphs panel, alternates | done | Highlight Alternate Glyphs preference (#394, off by default) paints pink behind OpenType substitutions on screen when on (required shaping such as Arabic joining forms and the lam-alef ligature, including a lam-alef older fonts file under `liga`, is not highlighted; an optional feature that changes the ligature is); stylistic sets ss01–ss20, stylistic and titling alternates, proportional and lining figures, superscript and subscript are character-style features (`text.setStyle {features}`, #1607); the OpenType panel's Position menu sets superscript, subscript, numerator or denominator glyphs (`sups`, `subs`, `numr`, `dnom`). The OpenType panel's Stylistic Sets menu lists the selected font's `ss01`–`ss20` features, using a font-supplied name where available and `Set N` otherwise; several sets can be active, and selecting an active one removes it. For a mixed-font selection it uses the first selected character's font, a VectorCraft design choice ([public help](https://helpx.adobe.com/illustrator/desktop/design-with-text/special-characters-glyphs/add-stylistic-sets-to-selected-text.html), [OpenType feature specification](https://learn.microsoft.com/en-us/typography/opentype/spec/features_pt)). The Glyphs panel highlights the character selected with the Type tool (a range's first character, else the one before the caret) and scrolls it into view as the selection moves (#4908; `panels::glyphs` tests); its Show menu lists Unicode ranges and Alternates for Current Selection, the selected character's alternate glyphs from the font's `GSUB` table, one of which a double-click draws for that character (`text.setGlyphAlternate`); the glyph-cell pop-up of alternates is not there yet |
-| Glyphs panel: alternates as OpenType feature values, unencoded glyphs | done | #5514, as [Replace characters with alternate glyphs](https://helpx.adobe.com/illustrator/desktop/design-with-text/special-characters-glyphs/replace-characters-with-alternate-glyphs.html) describes (Show › Alternates For Current Selection; replace the selected character by double-clicking the glyph). A character-style feature takes a value (`"salt=2"`, the font's alternate number; `ss01`–`ss20`, `swsh`, `titl` and `aalt` too) in `features` and `featureChanges`, in SVG `font-feature-settings` (`"salt" 2`) and in native files as the plain string. Show › Entire Font applies an unencoded glyph the same way when it is an alternate of the selected character in its word, and dims the others with the reason in the tooltip (e.g. `yehfarsi.fina.alt2 belongs to ی (U+06CC) at the end of a word; your selection is ي (U+064A)`); an Arabic presentation form picked there inserts its letter so the word joins. Runs that differ only in their features shape as one piece of text, each run's features over its own characters, so a variant on one letter of an Arabic word keeps its neighbours' initial, medial and final forms and the letters still touch. The panel lists the alternates of the glyph the shaper makes for the selected character in its word (a medial letter shows medial variants), and a double-click applies the feature that draws the variant to that character alone, in one undo step (`text.setRangeStyle`); a variant no feature draws there is drawn as a replacement glyph (`text.setGlyphAlternate`), which its tooltip says may not join. Show › Entire Font also lists the glyphs no character maps, with their name or `gid N` and the feature that gives them. The text crate reads each face's `GSUB` once (alternate sets, single substitutions, the feature that reaches a glyph). Not done: alternates reached through contextual or ligature lookups, and features of the font that the style has no tag for (`cv01`–`cv99`, `hist`). Evidence: `tests::glyph_alternates` in the text crate (a fixture font with a medial teh at `salt=1` to `3`, the neighbours' forms, no gap; Kufi Murabba PIIIX when it is installed), `features::tests::feature_values_are_parsed_validated_and_shaped`, `tests::textedit::feature_values_are_set_replaced_and_saved`, the `panels::glyphs` tests. |
+| OpenType panel, Glyphs panel, alternates | done | Highlight Alternate Glyphs preference (#394, off by default) paints pink behind OpenType substitutions on screen when on (required shaping such as Arabic joining forms and the lam-alef ligature, including a lam-alef older fonts file under `liga`, is not highlighted; an optional feature that changes the ligature is); stylistic sets ss01–ss20, stylistic and titling alternates, proportional and lining figures, superscript and subscript are character-style features (`text.setStyle {features}`, #1607); the OpenType panel's Position menu sets superscript, subscript, numerator or denominator glyphs (`sups`, `subs`, `numr`, `dnom`). The OpenType panel's Stylistic Sets menu lists the selected font's `ss01`–`ss20` features, using a font-supplied name where available and `Set N` otherwise; several sets can be active, and selecting an active one removes it. For a mixed-font selection it uses the first selected character's font, a VectorCraft design choice ([public help](https://helpx.adobe.com/illustrator/desktop/design-with-text/special-characters-glyphs/add-stylistic-sets-to-selected-text.html), [OpenType feature specification](https://learn.microsoft.com/en-us/typography/opentype/spec/features_pt)). The Glyphs panel highlights the character selected with the Type tool (a range's first character, else the one before the caret) and scrolls it into view as the selection moves (#4908; `panels::glyphs` tests); its Show menu lists Unicode ranges and Alternates for Current Selection, the selected character's alternate glyphs from the font's `GSUB` table, a double-click applies the OpenType feature that draws one (`salt=N`, `ssNN`) to that character alone; a variant no feature reaches is drawn through `text.setGlyphAlternate`, which may not join; Show › Entire Font applies the selected letter's alternates the same way and dims the others with the reason; the glyph-cell pop-up of alternates is not there yet |
+| Glyphs panel: alternates as OpenType feature values, unencoded glyphs | done | #5514, as [Replace characters with alternate glyphs](https://helpx.adobe.com/illustrator/desktop/design-with-text/special-characters-glyphs/replace-characters-with-alternate-glyphs.html) describes (Show › Alternates For Current Selection; replace the selected character by double-clicking the glyph). A character-style feature takes a value (`"salt=2"`, the font's alternate number; `ss01`–`ss20`, `swsh`, `titl` and `aalt` too) in `features` and `featureChanges`, in SVG `font-feature-settings` (`"salt" 2`) and in native files as the plain string. Show › Entire Font applies an unencoded glyph the same way when it is an alternate of the selected character in its word, and dims the others with the reason in the tooltip (e.g. `yehfarsi.fina.alt2 belongs to ی (U+06CC) at the end of a word; your selection is ي (U+064A)`); an Arabic presentation form picked there inserts its letter so the word joins. Runs that differ only in their features shape as one piece of text, each run's features over its own characters, so a variant on one letter of an Arabic word keeps its neighbours' initial, medial and final forms and the letters still touch. The panel lists the alternates of the glyph the shaper makes for the selected character in its word (a medial letter shows medial variants), and a double-click applies the OpenType feature that draws the variant (`salt=N` or `ssNN`) to that character alone, in one undo step (`text.setRangeStyle`); a variant no feature reaches falls back to a replacement glyph (`text.setGlyphAlternate`), which its tooltip says may not join. Show › Entire Font also lists the glyphs no character maps, with their name or `gid N` and the feature that gives them. The text crate reads each face's `GSUB` once (alternate sets, single substitutions, the feature that reaches a glyph). Not done: alternates reached through contextual or ligature lookups, and features of the font that the style has no tag for (`cv01`–`cv99`, `hist`). Evidence: `tests::glyph_alternates` in the text crate (a fixture font with a medial teh at `salt=1` to `3`, the neighbours' forms, no gap; Kufi Murabba PIIIX when it is installed), `features::tests::feature_values_are_parsed_validated_and_shaped`, `tests::textedit::feature_values_are_set_replaced_and_saved`, the `panels::glyphs` tests. |
| Font menu: samples, live preview, filters, favourites, recent families; font matching by any name; Find Font | done | #3515: recently applied families appear first, once each; missing families are omitted and the existing Number of Recent Fonts setting applies; #339; script filter: Japanese, Latin, Thai; the font scan on Linux also reads the folders fontconfig's configuration lists, such as TeX fonts under `/usr/share/texmf/fonts` (#1548, fixes #1529; not verified on Linux) Replace With Font lists family and style rows, one click setting both, with a search over them; its list scrolls on its own, apart from the document's font list (#4972). |
| Missing glyphs set in an installed font made for their script (Thai, Lao, Khmer, Myanmar first; then any font that has them) | done | Thai: Leelawadee UI, Tahoma, Thonburi, Sukhumvit Set, Noto Sans Thai, TLWG |
| 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 |
From ff6a4be4750acdb9d477912cc3f0384a1d08a4ae Mon Sep 17 00:00:00 2001
From: Taha-Mahmoodi <85902429+Taha-Mahmoodi@users.noreply.github.com>
Date: Sun, 8 Nov 2026 15:06:29 +0430
Subject: [PATCH 10/86] Glyphs: Entire Font applies a glyph no feature reaches
to the selected letter as a glyph choice; text tests for ligatures, word
probe, candidate cap and aalt order
---
crates/engine/src/cmd/textedit.rs | 4 +-
crates/text/src/fontdb/alternates.rs | 32 ++++++++
crates/text/src/fontdb/build.rs | 1 +
crates/text/src/fontdb/face.rs | 2 +
crates/text/src/test_fonts.rs | 43 +++++++++++
crates/text/src/tests/glyph_alternates.rs | 94 +++++++++++++++++++++++
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 -
crates/ui-egui/src/panels/glyphs.rs | 74 +++++++++++++-----
docs/gaps.md | 2 +-
docs/mcp.md | 7 +-
docs/type-parity.md | 4 +-
37 files changed, 237 insertions(+), 53 deletions(-)
diff --git a/crates/engine/src/cmd/textedit.rs b/crates/engine/src/cmd/textedit.rs
index 6aa546767b..17549f7ddf 100644
--- a/crates/engine/src/cmd/textedit.rs
+++ b/crates/engine/src/cmd/textedit.rs
@@ -58,7 +58,7 @@ pub fn specs() -> Vec {
"Glyphs",
[],
None,
- "{id, start: byte (a character of it), gid: glyph id|null} draw that one character with another glyph of its font as one undo step, what a double-click in the Glyphs panel's Show › Alternates for Current Selection does: gid is one of the glyphs text.glyphAlternates lists for it; its own default glyph, or null, puts the default glyph back. The text keeps the character, and its other attributes do not change. The choice is saved with the character's style and names the face it was made in (PostScript name and version): set in another font or version, or after the font goes missing, the character takes its default glyph; typed characters take their default glyphs. Choosing the glyph the character already draws is not an edit. An error when the character's font is not available, the glyph is not one of its alternates, or the character's style does not draw it (All Caps, Small Caps, another numeral system) → {id, start, end (the character's bytes), gid (the glyph drawn)}",
+ "{id, start: byte (a character of it), gid: glyph id|null} draw that one character with another glyph of its font as one undo step, what a double-click in the Glyphs panel's Show › Alternates for Current Selection does: gid is one of the glyphs text.glyphAlternates lists for it, a glyph no character maps and no feature reaches whose name is not another letter's (`a.salt` for a), its own default glyph, or null, puts the default glyph back. The text keeps the character, and its other attributes do not change. The choice is saved with the character's style and names the face it was made in (PostScript name and version): set in another font or version, or after the font goes missing, the character takes its default glyph; typed characters take their default glyphs. Choosing the glyph the character already draws is not an edit. An error when the character's font is not available, the glyph is not one of those, or the character's style does not draw it (All Caps, Small Caps, another numeral system) → {id, start, end (the character's bytes), gid (the glyph drawn)}",
has_doc,
set_glyph_alternate
),
@@ -1294,7 +1294,7 @@ fn set_glyph_alternate(s: &mut Session, p: &Value) -> Result {
let substituted = unchosen.is_some_and(|g| g != default);
let choice = match gid.filter(|g| *g != default || substituted) {
None => None,
- Some(g) if g == default || face.alternates(ch).iter().any(|a| a.gid == g) => Some(face.choice(ch, g)),
+ Some(g) if g == default || face.alternates(ch).iter().any(|a| a.gid == g) || face.is_unlisted_alternate(ch, g) => Some(face.choice(ch, g)),
Some(g) => return Err(bad(C, format!("glyph {g} is not an alternate of {ch} in {} {}", face.family, face.style))),
};
let drawn = choice.as_ref().map_or(unchosen.unwrap_or(default), |c| c.gid);
diff --git a/crates/text/src/fontdb/alternates.rs b/crates/text/src/fontdb/alternates.rs
index d74a55ff64..c39067f0b6 100644
--- a/crates/text/src/fontdb/alternates.rs
+++ b/crates/text/src/fontdb/alternates.rs
@@ -67,6 +67,38 @@ impl FontFace {
.clone()
}
+ /// Is glyph `gid` one no character maps and no feature reaches, whose name does not give it
+ /// to another letter than `c` (`a.salt` for `a` in a font with no `salt`; `A.salt` is not
+ /// `a`'s)? Such a glyph is not among [`Self::alternates`], yet it can be drawn for `c` as a
+ /// glyph choice ([`Self::choice`]).
+ pub fn is_unlisted_alternate(&self, c: char, gid: u32) -> bool {
+ self.unencoded_glyphs().binary_search(&gid).is_ok()
+ && self.feature_reaching(gid).is_none()
+ && self.glyph_origin(gid).is_none()
+ && self.name_owner(gid).is_none_or(|owner| owner == c)
+ }
+
+ /// The character a glyph's name says it belongs to: the one whose glyph carries the name up
+ /// to its first `.` (`A` for `A.salt`, `s` for `s.ss1`; the lowest character when several map
+ /// that glyph). `None` when the glyph has no name, or no character maps a glyph of that name.
+ fn name_owner(&self, gid: u32) -> Option {
+ let base = self.glyph_name(gid)?;
+ let base = base.split('.').next().unwrap_or_default();
+ let names = self.named_chars.get_or_init(|| {
+ let Some(font) = self.skrifa() else { return Default::default() };
+ let mut by_char: Vec<(char, u32)> = font.charmap().mappings().filter_map(|(c, g)| Some((char::from_u32(c)?, g.to_u32()))).collect();
+ by_char.sort_unstable();
+ let mut out = std::collections::HashMap::new();
+ for (c, g) in by_char {
+ if let Some(name) = self.glyph_name(g) {
+ out.entry(name).or_insert(c);
+ }
+ }
+ out
+ });
+ names.get(base).copied()
+ }
+
/// How many glyphs the face has (its `maxp` table; 0 when it can't be read).
pub fn glyph_count(&self) -> u32 {
self.skrifa().and_then(|f| f.maxp().ok()).map_or(0, |m| u32::from(m.num_glyphs()))
diff --git a/crates/text/src/fontdb/build.rs b/crates/text/src/fontdb/build.rs
index 261e1b2877..ebf2d1eab9 100644
--- a/crates/text/src/fontdb/build.rs
+++ b/crates/text/src/fontdb/build.rs
@@ -80,6 +80,7 @@ pub(super) fn make_face(bytes: FontBytes, index: u32, spec: FaceStyle, path: Opt
svg_glyphs,
gsub: std::sync::OnceLock::new(),
unencoded: std::sync::OnceLock::new(),
+ named_chars: std::sync::OnceLock::new(),
})
}
diff --git a/crates/text/src/fontdb/face.rs b/crates/text/src/fontdb/face.rs
index 3e03e5c1da..7be0664895 100644
--- a/crates/text/src/fontdb/face.rs
+++ b/crates/text/src/fontdb/face.rs
@@ -204,6 +204,8 @@ pub struct FontFace {
pub(super) gsub: std::sync::OnceLock,
/// [`Self::unencoded_glyphs`], read once.
pub(super) unencoded: std::sync::OnceLock>,
+ /// [`Self::name_owner`]'s glyph names to characters, read once.
+ pub(super) named_chars: std::sync::OnceLock>,
}
/// Where the ideographic character face (ICF) lies inside the ideographic em box: its distance
diff --git a/crates/text/src/test_fonts.rs b/crates/text/src/test_fonts.rs
index 77929bbee2..b021c493d6 100644
--- a/crates/text/src/test_fonts.rs
+++ b/crates/text/src/test_fonts.rs
@@ -735,6 +735,49 @@ pub fn alternates_font(aalt: bool) -> Option> {
Some(builder.build())
}
+/// The family of [`capped_candidates_font`].
+pub const CAPPED_CANDIDATES_FAMILY: &str = "Captest Sans";
+
+/// The bundled Source Sans 3 Regular, its `cmap` kept, with a `GSUB` table for `a` of an `aalt`
+/// alternate substitution that lists `b` 512 times (the longest set read: 512 values to try) and a
+/// `salt` single substitution that gives `c`. Family [`CAPPED_CANDIDATES_FAMILY`]. `None` when it
+/// can't be built.
+pub fn capped_candidates_font() -> Option> {
+ use write_fonts::tables::gsub::{
+ AlternateSet, AlternateSubstFormat1, Gsub, SingleSubst, SingleSubstFormat2, SubstitutionLookup, SubstitutionLookupList,
+ };
+ use write_fonts::tables::layout::{CoverageFormat1, Feature, FeatureList, FeatureRecord, LangSys, Lookup, Script, ScriptList, ScriptRecord};
+ use write_fonts::types::GlyphId16;
+
+ const BASE: &[u8] = include_bytes!("../../../assets/fonts/SourceSans3-Regular.ttf");
+ let font = skrifa::FontRef::new(BASE).ok()?;
+ let charmap = font.charmap();
+ let name =
+ name_table(&[(1, CAPPED_CANDIDATES_FAMILY), (2, "Regular"), (4, "Captest Sans Regular"), (5, "Version 1.000"), (6, "CaptestSans-Regular")])?;
+ let mut builder = FontBuilder::new();
+ builder.add_raw(Tag::new(b"name"), name);
+ let gid = |c| Some(GlyphId16::new(u16::try_from(charmap.map(c)?.to_u32()).ok()?));
+ let a = gid('a')?;
+ let cover = || CoverageFormat1::new(vec![a]);
+ let alternates = AlternateSubstFormat1::new(cover().into(), vec![AlternateSet::new(vec![gid('b')?; 512])]);
+ let salt = SingleSubst::from(SingleSubstFormat2::new(cover().into(), vec![gid('c')?]));
+ let lookups = SubstitutionLookupList::new(vec![
+ SubstitutionLookup::Alternate(Lookup::new(Default::default(), vec![alternates])),
+ SubstitutionLookup::Single(Lookup::new(Default::default(), vec![salt])),
+ ]);
+ let records =
+ vec![FeatureRecord::new(Tag::new(b"aalt"), Feature::new(None, vec![0])), FeatureRecord::new(Tag::new(b"salt"), Feature::new(None, vec![1]))];
+ let scripts = ScriptList::new(vec![ScriptRecord::new(Tag::new(b"DFLT"), Script::new(Some(LangSys::new(vec![0, 1])), vec![]))]);
+ builder.add_table(&Gsub::new(scripts, FeatureList::new(records), lookups)).ok()?;
+ for r in font.table_directory.table_records() {
+ let tag = Tag::new(&r.tag().to_be_bytes());
+ if !builder.contains(tag) {
+ builder.add_raw(tag, font.table_data(r.tag())?.as_bytes());
+ }
+ }
+ Some(builder.build())
+}
+
/// The family of [`joining_alternates_font`].
pub const JOINING_ALTERNATES_FAMILY: &str = "Jointest Sans";
diff --git a/crates/text/src/tests/glyph_alternates.rs b/crates/text/src/tests/glyph_alternates.rs
index 992dc9f711..6a47a3b0ee 100644
--- a/crates/text/src/tests/glyph_alternates.rs
+++ b/crates/text/src/tests/glyph_alternates.rs
@@ -346,6 +346,100 @@ fn a_variant_no_feature_draws_has_no_feature_to_apply() {
assert!(face.unencoded_glyphs().contains(&u1) && !face.unencoded_glyphs().contains(&face.glyph_for('a')));
}
+/// The alternates are listed `aalt` first, then `salt`, the stylistic sets, `swsh` and `cv01`, each
+/// glyph once under the first feature that lists it, whichever feature draws it in the text.
+#[test]
+fn alternates_are_listed_aalt_first_under_the_first_feature_that_lists_them() {
+ use crate::test_fonts::{alternates_family, alternates_font, unencoded_glyphs};
+ let db = FontDb::with_font_dirs(vec![]);
+ db.add_font(alternates_font(true).expect("the test font builds"));
+ let [u1, u2, u3, u4] = unencoded_glyphs().expect("unencoded glyphs");
+ let face = db.face(alternates_family(true), "Regular").expect("the test face");
+ let st = CharStyle { font_family: alternates_family(true).into(), size: 100.0, ..CharStyle::default() };
+ let found = alternates_in_context(&db, &TextObject::point(Point::ZERO, "xay", st), 1).expect("the a");
+ let listed: Vec<(u32, [u8; 4], Option)> = found.alternates.iter().map(|a| (a.gid, a.feature, a.apply.clone())).collect();
+ let (a, b, c) = (face.glyph_for('a'), face.glyph_for('b'), face.glyph_for('c'));
+ assert_eq!(
+ listed,
+ [
+ (a, *b"aalt", Some("aalt".to_string())),
+ (u1, *b"aalt", Some("swsh=2".to_string())),
+ (b, *b"aalt", Some("salt".to_string())),
+ (u2, *b"ss01", Some("ss01".to_string())),
+ (u4, *b"ss02", Some("ss02".to_string())),
+ (u3, *b"swsh", Some("swsh".to_string())),
+ (c, *b"cv01", None),
+ ],
+ "aalt's set first, with the default glyph it lists; b is aalt's though salt draws it"
+ );
+}
+
+/// A glyph that more than its character makes (the lam-alef ligature) has no alternates of its own
+/// there, though the font substitutes it (`salt`).
+#[test]
+fn a_ligature_has_no_alternates_in_context() {
+ use crate::test_fonts::{LAM_ALEF_SALT, lam_alef_family, lam_alef_font};
+ let db = FontDb::with_font_dirs(vec![]);
+ db.add_font(lam_alef_font(b"rlig").expect("the test font builds"));
+ let family = lam_alef_family(b"rlig");
+ let face = db.face(family, "Regular").expect("the test face");
+ let lam_alef = TextObject::point(Point::ZERO, "\u{0644}\u{0627}", style(family, &[]));
+ let ligature = g('H');
+ assert_eq!(
+ face.substitutions(ligature).iter().map(|s| s.glyphs.clone()).collect::>(),
+ [vec![g(LAM_ALEF_SALT)]],
+ "the font gives the ligature an alternate"
+ );
+ let found = alternates_in_context(&db, &lam_alef, 0).expect("the ligature");
+ assert_eq!((found.default, found.current), (ligature, ligature));
+ assert!(found.alternates.is_empty(), "{:?}", found.alternates);
+ // The lam alone is its own character: it has no alternates because the font gives it none.
+ let alone = alternates_in_context(&db, &TextObject::point(Point::ZERO, "\u{0644}", style(family, &[])), 0).expect("the lam");
+ assert_eq!(alone.default, g('l'));
+}
+
+/// A word is shaped as one piece (to 256 characters each side of the selected one), the selected
+/// character found in it by its offset: a letter deep in a long word and the letters next to a
+/// space, a line feed or a line separator each get the form their place in their own word gives.
+#[test]
+fn the_probe_finds_the_character_in_its_word_across_a_long_word_and_breaks() {
+ let db = db();
+ let text = |s: &str| TextObject::point(Point::ZERO, s, style(JOINING_ALTERNATES_FAMILY, &[]));
+ let default_of = |t: &TextObject, byte: usize| {
+ let found = alternates_in_context(&db, t, byte).expect("a character");
+ (found.default, found.alternates.len())
+ };
+ // 700 letters: beh, 698 teh, noon. Each is found at its own offset, 300 of them past the
+ // 256 characters either side that are shaped.
+ let long = text(&format!("{BEH}{}{NOON}", TEH.repeat(698)));
+ assert_eq!(default_of(&long, 0), (g('B'), 0), "the first letter is initial");
+ assert_eq!(default_of(&long, 2 * 300), (g('u'), 3), "a medial teh deep in the word, with its alternates");
+ assert_eq!(default_of(&long, 2 * 699), (g('M'), 0), "the last letter is final");
+ // Two words: the break leaves each its own initial and final letters. The teh before the break
+ // ends its word (final) and the one after starts the next (initial), not medial.
+ for brk in [" ", "\n", "\u{2028}", "\u{2029}"] {
+ let two = text(&format!("{BEH}{TEH}{brk}{TEH}{NOON}"));
+ let after = 2 * 2 + brk.len();
+ assert_eq!(default_of(&two, 2), (g('U'), 2), "{brk:?}: final teh before the break, with its two alternates");
+ assert_eq!(default_of(&two, after), (g('T'), 0), "{brk:?}: initial teh after it");
+ assert_eq!(default_of(&two, after + 2), (g('M'), 0), "{brk:?}: the noon after it is final");
+ }
+}
+
+/// A word of alternates longer than the candidates tried (512) leaves the features after it
+/// untried: `aalt`'s 512 values use them all, so `salt`'s glyph is listed but no feature is applied
+/// for it.
+#[test]
+fn the_candidates_tried_are_capped() {
+ use crate::test_fonts::{CAPPED_CANDIDATES_FAMILY, capped_candidates_font};
+ let db = FontDb::with_font_dirs(vec![]);
+ db.add_font(capped_candidates_font().expect("the test font builds"));
+ let face = db.face(CAPPED_CANDIDATES_FAMILY, "Regular").expect("the test face");
+ let found = alternates_in_context(&db, &TextObject::point(Point::ZERO, "xay", style(CAPPED_CANDIDATES_FAMILY, &[])), 1).expect("the a");
+ let listed: Vec<(u32, Option)> = found.alternates.iter().map(|a| (a.gid, a.apply.clone())).collect();
+ assert_eq!(listed, [(face.glyph_for('b'), Some("aalt".to_string())), (face.glyph_for('c'), None)]);
+}
+
/// A feature on a one-byte character (Latin) reaches that character: the range of a feature is
/// its source bytes, and harfrust applies a range `a..b` to clusters `a..b - 1` unless the end is
/// inclusive, which would leave a one-byte character out.
diff --git a/crates/ui-egui/src/i18n/bg.tsv b/crates/ui-egui/src/i18n/bg.tsv
index 3539c19742..11abaee21f 100644
--- a/crates/ui-egui/src/i18n/bg.tsv
+++ b/crates/ui-egui/src/i18n/bg.tsv
@@ -854,7 +854,6 @@ font-technology Variable Променливи
at the end of a word в края на дума
standing alone когато стои сам
{glyph} is not an alternate of {sel} (U+{code}) as it stands in its word. {glyph} не е алтернатива на {sel} (U+{code}) там, където е в думата си.
- No OpenType feature reaches {glyph}, so it cannot be applied to text. Никоя OpenType функция не извежда {glyph}, затова не може да се приложи към текст.
Double-click to insert {base} (U+{code}) instead: a presentation form does not join, the letter does. Двойно щракване за вмъкване на {base} (U+{code}): представителната форма не се свързва, буквата се свързва.
Inserted {base} (U+{code}) instead of a presentation form: the font joins it to its neighbors by position. Вмъкнато е {base} (U+{code}) вместо представителна форма: шрифтът я свързва със съседите според позицията.
No OpenType feature draws this glyph here, so it replaces the character's glyph and may not join its neighbors. Нито една OpenType функция не рисува този глиф тук, затова той заменя глифа на знака и може да не се свързва със съседните знаци.
diff --git a/crates/ui-egui/src/i18n/bs.tsv b/crates/ui-egui/src/i18n/bs.tsv
index dd785fc5d5..358129cb03 100644
--- a/crates/ui-egui/src/i18n/bs.tsv
+++ b/crates/ui-egui/src/i18n/bs.tsv
@@ -1562,7 +1562,6 @@ digits Malayalam Malajalam
at the end of a word na kraju riječi
standing alone kad stoji sam
{glyph} is not an alternate of {sel} (U+{code}) as it stands in its word. {glyph} nije alternativa znaku {sel} (U+{code}) onako kako stoji u svojoj riječi.
- No OpenType feature reaches {glyph}, so it cannot be applied to text. Nijedna OpenType funkcija ne daje {glyph}, pa se ne može primijeniti na tekst.
Double-click to insert {base} (U+{code}) instead: a presentation form does not join, the letter does. Dvostruki klik za umetanje znaka {base} (U+{code}): oblik za prikaz se ne spaja, slovo se spaja.
Inserted {base} (U+{code}) instead of a presentation form: the font joins it to its neighbors by position. Umetnut je znak {base} (U+{code}) umjesto oblika za prikaz: font ga spaja sa susjedima prema položaju.
No OpenType feature draws this glyph here, so it replaces the character's glyph and may not join its neighbors. Nijedna OpenType funkcija ne crta ovaj glif ovdje, pa on zamjenjuje glif znaka i možda se neće spajati sa susjednim znakovima.
diff --git a/crates/ui-egui/src/i18n/ca.tsv b/crates/ui-egui/src/i18n/ca.tsv
index cc106dbafa..42a7b12922 100644
--- a/crates/ui-egui/src/i18n/ca.tsv
+++ b/crates/ui-egui/src/i18n/ca.tsv
@@ -1216,7 +1216,6 @@
at the end of a word al final d’una paraula
standing alone quan va sol
{glyph} is not an alternate of {sel} (U+{code}) as it stands in its word. {glyph} no és una alternativa de {sel} (U+{code}) tal com es troba a la seva paraula.
- No OpenType feature reaches {glyph}, so it cannot be applied to text. Cap funció OpenType arriba a {glyph}, de manera que no es pot aplicar al text.
Double-click to insert {base} (U+{code}) instead: a presentation form does not join, the letter does. Feu doble clic per inserir {base} (U+{code}): una forma de presentació no s’uneix, la lletra sí.
Inserted {base} (U+{code}) instead of a presentation form: the font joins it to its neighbors by position. S’ha inserit {base} (U+{code}) en lloc d’una forma de presentació: el tipus de lletra l’uneix als veïns segons la posició.
No OpenType feature draws this glyph here, so it replaces the character's glyph and may not join its neighbors. Cap funció OpenType dibuixa aquest glif aquí, de manera que substitueix el glif del caràcter i pot no unir-se als caràcters veïns.
diff --git a/crates/ui-egui/src/i18n/cs.tsv b/crates/ui-egui/src/i18n/cs.tsv
index c7be3ff0dd..20dd7d45b9 100644
--- a/crates/ui-egui/src/i18n/cs.tsv
+++ b/crates/ui-egui/src/i18n/cs.tsv
@@ -1586,7 +1586,6 @@ digits Malayalam Malajálamské
at the end of a word na konci slova
standing alone když stojí samostatně
{glyph} is not an alternate of {sel} (U+{code}) as it stands in its word. {glyph} není alternativou znaku {sel} (U+{code}) tak, jak stojí ve svém slově.
- No OpenType feature reaches {glyph}, so it cannot be applied to text. Žádná funkce OpenType nedává {glyph}, proto ho nelze použít na text.
Double-click to insert {base} (U+{code}) instead: a presentation form does not join, the letter does. Dvojklikem vložíte {base} (U+{code}): prezentační tvar se nespojuje, písmeno ano.
Inserted {base} (U+{code}) instead of a presentation form: the font joins it to its neighbors by position. Vloženo {base} (U+{code}) místo prezentačního tvaru: písmo ho podle polohy spojí se sousedy.
No OpenType feature draws this glyph here, so it replaces the character's glyph and may not join its neighbors. Žádná funkce OpenType tento glyf zde nekreslí, proto nahradí glyf znaku a nemusí se spojit se sousedními znaky.
diff --git a/crates/ui-egui/src/i18n/da.tsv b/crates/ui-egui/src/i18n/da.tsv
index 681444c936..016da20eb0 100644
--- a/crates/ui-egui/src/i18n/da.tsv
+++ b/crates/ui-egui/src/i18n/da.tsv
@@ -1496,7 +1496,6 @@ digits Malayalam Malayalam
at the end of a word i slutningen af et ord
standing alone når det står alene
{glyph} is not an alternate of {sel} (U+{code}) as it stands in its word. {glyph} er ikke et alternativ til {sel} (U+{code}), som det står i sit ord.
- No OpenType feature reaches {glyph}, so it cannot be applied to text. Ingen OpenType-funktion giver {glyph}, så det kan ikke anvendes på tekst.
Double-click to insert {base} (U+{code}) instead: a presentation form does not join, the letter does. Dobbeltklik for at indsætte {base} (U+{code}) i stedet: en præsentationsform forbindes ikke, bogstavet gør.
Inserted {base} (U+{code}) instead of a presentation form: the font joins it to its neighbors by position. {base} (U+{code}) er indsat i stedet for en præsentationsform: skrifttypen forbinder det med naboerne efter position.
No OpenType feature draws this glyph here, so it replaces the character's glyph and may not join its neighbors. Ingen OpenType-funktion tegner dette glyf her, så det erstatter tegnets glyf og forbindes måske ikke med nabotegnene.
diff --git a/crates/ui-egui/src/i18n/de.tsv b/crates/ui-egui/src/i18n/de.tsv
index 10b78c831d..4b64c8f134 100644
--- a/crates/ui-egui/src/i18n/de.tsv
+++ b/crates/ui-egui/src/i18n/de.tsv
@@ -848,7 +848,6 @@ font-technology Variable Variabel
at the end of a word am Wortende
standing alone allein stehend
{glyph} is not an alternate of {sel} (U+{code}) as it stands in its word. {glyph} ist keine Alternative zu {sel} (U+{code}), so wie es in seinem Wort steht.
- No OpenType feature reaches {glyph}, so it cannot be applied to text. Keine OpenType-Funktion liefert {glyph}, daher lässt es sich nicht auf Text anwenden.
Double-click to insert {base} (U+{code}) instead: a presentation form does not join, the letter does. Doppelklicken, um stattdessen {base} (U+{code}) einzufügen: Eine Darstellungsform verbindet sich nicht, der Buchstabe schon.
Inserted {base} (U+{code}) instead of a presentation form: the font joins it to its neighbors by position. {base} (U+{code}) statt einer Darstellungsform eingefügt: Die Schrift verbindet es je nach Position mit den Nachbarn.
No OpenType feature draws this glyph here, so it replaces the character's glyph and may not join its neighbors. Keine OpenType-Funktion zeichnet diese Glyphe hier, daher ersetzt sie die Glyphe des Zeichens und verbindet sich möglicherweise nicht mit den Nachbarzeichen.
diff --git a/crates/ui-egui/src/i18n/el.tsv b/crates/ui-egui/src/i18n/el.tsv
index a222d9274f..247f1d4752 100644
--- a/crates/ui-egui/src/i18n/el.tsv
+++ b/crates/ui-egui/src/i18n/el.tsv
@@ -827,7 +827,6 @@ font-technology Variable Μεταβλητές
at the end of a word στο τέλος μιας λέξης
standing alone όταν στέκεται μόνος
{glyph} is not an alternate of {sel} (U+{code}) as it stands in its word. Ο γλύφος {glyph} δεν είναι εναλλακτικός του {sel} (U+{code}) όπως βρίσκεται στη λέξη του.
- No OpenType feature reaches {glyph}, so it cannot be applied to text. Καμία λειτουργία OpenType δεν δίνει τον γλύφο {glyph}, οπότε δεν μπορεί να εφαρμοστεί στο κείμενο.
Double-click to insert {base} (U+{code}) instead: a presentation form does not join, the letter does. Διπλό κλικ για εισαγωγή του {base} (U+{code}): μια μορφή παρουσίασης δεν συνδέεται, το γράμμα συνδέεται.
Inserted {base} (U+{code}) instead of a presentation form: the font joins it to its neighbors by position. Εισήχθη το {base} (U+{code}) αντί για μορφή παρουσίασης: η γραμματοσειρά το συνδέει με τους γείτονες ανάλογα με τη θέση.
No OpenType feature draws this glyph here, so it replaces the character's glyph and may not join its neighbors. Καμία λειτουργία OpenType δεν σχεδιάζει αυτόν τον γλύφο εδώ, οπότε αντικαθιστά τον γλύφο του χαρακτήρα και μπορεί να μην συνδέεται με τους γειτονικούς χαρακτήρες.
diff --git a/crates/ui-egui/src/i18n/es.tsv b/crates/ui-egui/src/i18n/es.tsv
index 8d93dfbbe5..83e14cfa32 100644
--- a/crates/ui-egui/src/i18n/es.tsv
+++ b/crates/ui-egui/src/i18n/es.tsv
@@ -869,7 +869,6 @@ font-technology Variable Variables
at the end of a word al final de una palabra
standing alone cuando está solo
{glyph} is not an alternate of {sel} (U+{code}) as it stands in its word. {glyph} no es una alternativa de {sel} (U+{code}) tal como está en su palabra.
- No OpenType feature reaches {glyph}, so it cannot be applied to text. Ninguna función OpenType llega a {glyph}, así que no se puede aplicar al texto.
Double-click to insert {base} (U+{code}) instead: a presentation form does not join, the letter does. Doble clic para insertar {base} (U+{code}) en su lugar: una forma de presentación no se une, la letra sí.
Inserted {base} (U+{code}) instead of a presentation form: the font joins it to its neighbors by position. Se insertó {base} (U+{code}) en lugar de una forma de presentación: la fuente la une a sus vecinas según la posición.
No OpenType feature draws this glyph here, so it replaces the character's glyph and may not join its neighbors. Ninguna función OpenType dibuja este glifo aquí, así que sustituye el glifo del carácter y puede que no se una a los caracteres vecinos.
diff --git a/crates/ui-egui/src/i18n/fi.tsv b/crates/ui-egui/src/i18n/fi.tsv
index 0673e39ce8..1bc6558136 100644
--- a/crates/ui-egui/src/i18n/fi.tsv
+++ b/crates/ui-egui/src/i18n/fi.tsv
@@ -1559,7 +1559,6 @@ digits Malayalam Malajalam
at the end of a word sanan lopussa
standing alone yksinään
{glyph} is not an alternate of {sel} (U+{code}) as it stands in its word. {glyph} ei ole merkin {sel} (U+{code}) vaihtoehto sellaisena kuin se on sanassaan.
- No OpenType feature reaches {glyph}, so it cannot be applied to text. Mikään OpenType-ominaisuus ei tuota glyfiä {glyph}, joten sitä ei voi käyttää tekstiin.
Double-click to insert {base} (U+{code}) instead: a presentation form does not join, the letter does. Lisää kaksoisnapsauttamalla sen sijaan {base} (U+{code}): esitysmuoto ei liity, kirjain liittyy.
Inserted {base} (U+{code}) instead of a presentation form: the font joins it to its neighbors by position. Lisättiin {base} (U+{code}) esitysmuodon sijaan: fontti liittää sen naapureihin sijainnin mukaan.
No OpenType feature draws this glyph here, so it replaces the character's glyph and may not join its neighbors. Mikään OpenType-ominaisuus ei piirrä tätä glyfiä tässä, joten se korvaa merkin glyfin eikä välttämättä liity viereisiin merkkeihin.
diff --git a/crates/ui-egui/src/i18n/fr.tsv b/crates/ui-egui/src/i18n/fr.tsv
index fb5619bb2a..299af68266 100644
--- a/crates/ui-egui/src/i18n/fr.tsv
+++ b/crates/ui-egui/src/i18n/fr.tsv
@@ -872,7 +872,6 @@ font-technology Variable Variables
at the end of a word à la fin d’un mot
standing alone isolé
{glyph} is not an alternate of {sel} (U+{code}) as it stands in its word. {glyph} n’est pas une variante de {sel} (U+{code}) tel qu’il se trouve dans son mot.
- No OpenType feature reaches {glyph}, so it cannot be applied to text. Aucune fonction OpenType n’atteint {glyph} ; il ne peut donc pas être appliqué au texte.
Double-click to insert {base} (U+{code}) instead: a presentation form does not join, the letter does. Double-cliquez pour insérer {base} (U+{code}) à la place : une forme de présentation ne se lie pas, la lettre si.
Inserted {base} (U+{code}) instead of a presentation form: the font joins it to its neighbors by position. {base} (U+{code}) a été inséré à la place d’une forme de présentation : la police le lie à ses voisins selon la position.
No OpenType feature draws this glyph here, so it replaces the character's glyph and may not join its neighbors. Aucune fonction OpenType ne dessine ce glyphe ici : il remplace le glyphe du caractère et peut ne pas se lier aux caractères voisins.
diff --git a/crates/ui-egui/src/i18n/hi.tsv b/crates/ui-egui/src/i18n/hi.tsv
index 5274287e32..0d50845ab0 100644
--- a/crates/ui-egui/src/i18n/hi.tsv
+++ b/crates/ui-egui/src/i18n/hi.tsv
@@ -1352,7 +1352,6 @@
at the end of a word शब्द के अंत में
standing alone अकेले खड़े होने पर
{glyph} is not an alternate of {sel} (U+{code}) as it stands in its word. {glyph}, {sel} (U+{code}) का विकल्प नहीं है, जैसा वह अपने शब्द में खड़ा है।
- No OpenType feature reaches {glyph}, so it cannot be applied to text. कोई OpenType फ़ीचर {glyph} तक नहीं पहुँचता, इसलिए इसे टेक्स्ट पर लागू नहीं किया जा सकता।
Double-click to insert {base} (U+{code}) instead: a presentation form does not join, the letter does. इसके बजाय {base} (U+{code}) सम्मिलित करने के लिए डबल क्लिक करें: प्रेज़ेंटेशन फ़ॉर्म नहीं जुड़ता, अक्षर जुड़ता है।
Inserted {base} (U+{code}) instead of a presentation form: the font joins it to its neighbors by position. प्रेज़ेंटेशन फ़ॉर्म की जगह {base} (U+{code}) सम्मिलित किया गया: फ़ॉन्ट उसे स्थिति के अनुसार पड़ोसियों से जोड़ता है।
No OpenType feature draws this glyph here, so it replaces the character's glyph and may not join its neighbors. यहाँ कोई OpenType फ़ीचर इस ग्लिफ़ को नहीं बनाता, इसलिए यह कैरेक्टर के ग्लिफ़ की जगह लेता है और पड़ोसी कैरेक्टरों से जुड़ नहीं भी सकता।
diff --git a/crates/ui-egui/src/i18n/hu.tsv b/crates/ui-egui/src/i18n/hu.tsv
index b89d479ef6..1ac792288e 100644
--- a/crates/ui-egui/src/i18n/hu.tsv
+++ b/crates/ui-egui/src/i18n/hu.tsv
@@ -1122,7 +1122,6 @@ font-technology Variable Változó
at the end of a word szó végén
standing alone önállóan állva
{glyph} is not an alternate of {sel} (U+{code}) as it stands in its word. A(z) {glyph} nem változata a(z) {sel} (U+{code}) karakternek úgy, ahogy a szavában áll.
- No OpenType feature reaches {glyph}, so it cannot be applied to text. Egyetlen OpenType-funkció sem éri el a(z) {glyph} glifát, ezért nem alkalmazható a szövegre.
Double-click to insert {base} (U+{code}) instead: a presentation form does not join, the letter does. Dupla kattintással ezt szúrod be helyette: {base} (U+{code}); a megjelenítési forma nem kapcsolódik, a betű igen.
Inserted {base} (U+{code}) instead of a presentation form: the font joins it to its neighbors by position. A megjelenítési forma helyett ez lett beszúrva: {base} (U+{code}); a betűtípus pozíció szerint kapcsolja a szomszédokhoz.
No OpenType feature draws this glyph here, so it replaces the character's glyph and may not join its neighbors. Itt egyetlen OpenType-funkció sem rajzolja meg ezt a glifát, ezért a karakter glifáját váltja fel, és előfordulhat, hogy nem kapcsolódik a szomszédos karakterekhez.
diff --git a/crates/ui-egui/src/i18n/id.tsv b/crates/ui-egui/src/i18n/id.tsv
index 64d77d1617..2e58b21911 100644
--- a/crates/ui-egui/src/i18n/id.tsv
+++ b/crates/ui-egui/src/i18n/id.tsv
@@ -1496,7 +1496,6 @@ digits Malayalam Malayalam
at the end of a word di akhir kata
standing alone saat berdiri sendiri
{glyph} is not an alternate of {sel} (U+{code}) as it stands in its word. {glyph} bukan alternatif {sel} (U+{code}) sebagaimana posisinya di dalam katanya.
- No OpenType feature reaches {glyph}, so it cannot be applied to text. Tidak ada fitur OpenType yang menghasilkan {glyph}, jadi tidak bisa diterapkan ke teks.
Double-click to insert {base} (U+{code}) instead: a presentation form does not join, the letter does. Klik ganda untuk menyisipkan {base} (U+{code}) sebagai gantinya: bentuk presentasi tidak menyambung, hurufnya menyambung.
Inserted {base} (U+{code}) instead of a presentation form: the font joins it to its neighbors by position. {base} (U+{code}) disisipkan sebagai ganti bentuk presentasi: font menyambungkannya ke huruf di sebelahnya menurut posisi.
No OpenType feature draws this glyph here, so it replaces the character's glyph and may not join its neighbors. Tidak ada fitur OpenType yang menggambar glif ini di sini, jadi glif ini menggantikan glif karakter dan mungkin tidak menyambung dengan karakter di sebelahnya.
diff --git a/crates/ui-egui/src/i18n/it.tsv b/crates/ui-egui/src/i18n/it.tsv
index 8b9d0331f7..0d10bede2b 100644
--- a/crates/ui-egui/src/i18n/it.tsv
+++ b/crates/ui-egui/src/i18n/it.tsv
@@ -869,7 +869,6 @@ font-technology Variable Variabili
at the end of a word alla fine di una parola
standing alone da solo
{glyph} is not an alternate of {sel} (U+{code}) as it stands in its word. {glyph} non è un’alternativa di {sel} (U+{code}) così come si trova nella sua parola.
- No OpenType feature reaches {glyph}, so it cannot be applied to text. Nessuna funzione OpenType raggiunge {glyph}, quindi non può essere applicato al testo.
Double-click to insert {base} (U+{code}) instead: a presentation form does not join, the letter does. Fai doppio clic per inserire invece {base} (U+{code}): una forma di presentazione non si unisce, la lettera sì.
Inserted {base} (U+{code}) instead of a presentation form: the font joins it to its neighbors by position. Inserito {base} (U+{code}) al posto di una forma di presentazione: il font lo unisce ai vicini in base alla posizione.
No OpenType feature draws this glyph here, so it replaces the character's glyph and may not join its neighbors. Nessuna funzione OpenType disegna questo glifo qui: sostituisce il glifo del carattere e potrebbe non unirsi ai caratteri vicini.
diff --git a/crates/ui-egui/src/i18n/ja.tsv b/crates/ui-egui/src/i18n/ja.tsv
index 5a3de37329..510e544c8e 100644
--- a/crates/ui-egui/src/i18n/ja.tsv
+++ b/crates/ui-egui/src/i18n/ja.tsv
@@ -1432,7 +1432,6 @@ digits Malayalam マラヤーラム数字
at the end of a word 語末
standing alone 単独のとき
{glyph} is not an alternate of {sel} (U+{code}) as it stands in its word. {glyph} は、{sel} (U+{code}) が単語の中で置かれている位置での異体字ではありません。
- No OpenType feature reaches {glyph}, so it cannot be applied to text. {glyph} に到達する OpenType フィーチャーはないため、テキストに適用できません。
Double-click to insert {base} (U+{code}) instead: a presentation form does not join, the letter does. ダブルクリックで代わりに {base} (U+{code}) を挿入: 表示形は前後とつながりませんが、文字はつながります。
Inserted {base} (U+{code}) instead of a presentation form: the font joins it to its neighbors by position. 表示形の代わりに {base} (U+{code}) を挿入しました。フォントが位置に応じて前後の文字とつなげます。
No OpenType feature draws this glyph here, so it replaces the character's glyph and may not join its neighbors. このグリフを表示する OpenType フィーチャーがないため、文字のグリフが置き換わり、前後の文字とつながらないことがあります。
diff --git a/crates/ui-egui/src/i18n/ka.tsv b/crates/ui-egui/src/i18n/ka.tsv
index 9d48ca867a..a9c3648b72 100644
--- a/crates/ui-egui/src/i18n/ka.tsv
+++ b/crates/ui-egui/src/i18n/ka.tsv
@@ -1422,7 +1422,6 @@ digits Malayalam მალაიალამური
at the end of a word სიტყვის ბოლოს
standing alone როცა ცალკე დგას
{glyph} is not an alternate of {sel} (U+{code}) as it stands in its word. {glyph} არ არის {sel} (U+{code})-ის ალტერნატივა იმ სახით, როგორც ის თავის სიტყვაშია.
- No OpenType feature reaches {glyph}, so it cannot be applied to text. არც ერთი OpenType ფუნქცია არ იძლევა {glyph}-ს, ამიტომ მისი ტექსტზე გამოყენება შეუძლებელია.
Double-click to insert {base} (U+{code}) instead: a presentation form does not join, the letter does. ორჯერ დაწკაპეთ ამის ნაცვლად {base} (U+{code}) ჩასასმელად: გამოსახვის ფორმა არ უკავშირდება, ასო უკავშირდება.
Inserted {base} (U+{code}) instead of a presentation form: the font joins it to its neighbors by position. გამოსახვის ფორმის ნაცვლად ჩასმულია {base} (U+{code}): შრიფტი მას მეზობლებს პოზიციის მიხედვით აკავშირებს.
No OpenType feature draws this glyph here, so it replaces the character's glyph and may not join its neighbors. აქ არც ერთი OpenType ფუნქცია არ ხატავს ამ გლიფს, ამიტომ ის ანაცვლებს სიმბოლოს გლიფს და შეიძლება არ დაუკავშირდეს მეზობელ სიმბოლოებს.
diff --git a/crates/ui-egui/src/i18n/ko.tsv b/crates/ui-egui/src/i18n/ko.tsv
index e23a535e28..e78b69e58c 100644
--- a/crates/ui-egui/src/i18n/ko.tsv
+++ b/crates/ui-egui/src/i18n/ko.tsv
@@ -1422,7 +1422,6 @@ digits Malayalam 말라얄람어
at the end of a word 단어 끝
standing alone 단독일 때
{glyph} is not an alternate of {sel} (U+{code}) as it stands in its word. {glyph}은(는) 단어 안에 있는 {sel}(U+{code})의 대체 글리프가 아닙니다.
- No OpenType feature reaches {glyph}, so it cannot be applied to text. {glyph}에 도달하는 OpenType 기능이 없으므로 텍스트에 적용할 수 없습니다.
Double-click to insert {base} (U+{code}) instead: a presentation form does not join, the letter does. 더블클릭하여 대신 {base}(U+{code}) 삽입: 표시 형태는 연결되지 않지만 글자는 연결됩니다.
Inserted {base} (U+{code}) instead of a presentation form: the font joins it to its neighbors by position. 표시 형태 대신 {base}(U+{code})을(를) 삽입했습니다. 글꼴이 위치에 따라 이웃 문자와 연결합니다.
No OpenType feature draws this glyph here, so it replaces the character's glyph and may not join its neighbors. 이 글리프를 그리는 OpenType 기능이 없으므로 문자의 글리프를 대체하며 이웃 문자와 연결되지 않을 수 있습니다.
diff --git a/crates/ui-egui/src/i18n/nl.tsv b/crates/ui-egui/src/i18n/nl.tsv
index c352a8184b..ebd19dd101 100644
--- a/crates/ui-egui/src/i18n/nl.tsv
+++ b/crates/ui-egui/src/i18n/nl.tsv
@@ -1510,7 +1510,6 @@ digits Malayalam Malayalam
at the end of a word aan het einde van een woord
standing alone los staand
{glyph} is not an alternate of {sel} (U+{code}) as it stands in its word. {glyph} is geen alternatief voor {sel} (U+{code}) zoals het in zijn woord staat.
- No OpenType feature reaches {glyph}, so it cannot be applied to text. Geen enkele OpenType-functie levert {glyph}, dus het kan niet op tekst worden toegepast.
Double-click to insert {base} (U+{code}) instead: a presentation form does not join, the letter does. Dubbelklik om in plaats daarvan {base} (U+{code}) in te voegen: een presentatievorm sluit niet aan, de letter wel.
Inserted {base} (U+{code}) instead of a presentation form: the font joins it to its neighbors by position. {base} (U+{code}) ingevoegd in plaats van een presentatievorm: het lettertype verbindt het naar positie met de buren.
No OpenType feature draws this glyph here, so it replaces the character's glyph and may not join its neighbors. Geen enkele OpenType-functie tekent deze glyph hier, dus hij vervangt de glyph van het teken en sluit mogelijk niet aan op de buurtekens.
diff --git a/crates/ui-egui/src/i18n/pl.tsv b/crates/ui-egui/src/i18n/pl.tsv
index 93e96b79d6..9667fa5d57 100644
--- a/crates/ui-egui/src/i18n/pl.tsv
+++ b/crates/ui-egui/src/i18n/pl.tsv
@@ -1499,7 +1499,6 @@ digits Malayalam Malajalam
at the end of a word na końcu wyrazu
standing alone gdy stoi osobno
{glyph} is not an alternate of {sel} (U+{code}) as it stands in its word. {glyph} nie jest alternatywą znaku {sel} (U+{code}) w takiej postaci, w jakiej stoi w swoim wyrazie.
- No OpenType feature reaches {glyph}, so it cannot be applied to text. Żadna funkcja OpenType nie daje glifu {glyph}, więc nie można go zastosować do tekstu.
Double-click to insert {base} (U+{code}) instead: a presentation form does not join, the letter does. Kliknij dwukrotnie, aby wstawić zamiast tego {base} (U+{code}): forma prezentacyjna się nie łączy, litera tak.
Inserted {base} (U+{code}) instead of a presentation form: the font joins it to its neighbors by position. Wstawiono {base} (U+{code}) zamiast formy prezentacyjnej: czcionka łączy go z sąsiadami zależnie od pozycji.
No OpenType feature draws this glyph here, so it replaces the character's glyph and may not join its neighbors. Żadna funkcja OpenType nie rysuje tutaj tego glifu, więc zastępuje on glif znaku i może nie łączyć się z sąsiednimi znakami.
diff --git a/crates/ui-egui/src/i18n/pt-br.tsv b/crates/ui-egui/src/i18n/pt-br.tsv
index 05b84eb732..39ab094bfc 100644
--- a/crates/ui-egui/src/i18n/pt-br.tsv
+++ b/crates/ui-egui/src/i18n/pt-br.tsv
@@ -779,7 +779,6 @@
at the end of a word no fim de uma palavra
standing alone quando está sozinho
{glyph} is not an alternate of {sel} (U+{code}) as it stands in its word. {glyph} não é uma alternativa de {sel} (U+{code}) como ele está em sua palavra.
- No OpenType feature reaches {glyph}, so it cannot be applied to text. Nenhum recurso OpenType chega a {glyph}, então ele não pode ser aplicado ao texto.
Double-click to insert {base} (U+{code}) instead: a presentation form does not join, the letter does. Clique duplo para inserir {base} (U+{code}) no lugar: uma forma de apresentação não se une, a letra sim.
Inserted {base} (U+{code}) instead of a presentation form: the font joins it to its neighbors by position. {base} (U+{code}) foi inserido no lugar de uma forma de apresentação: a fonte o une aos vizinhos conforme a posição.
No OpenType feature draws this glyph here, so it replaces the character's glyph and may not join its neighbors. Nenhum recurso OpenType desenha este glifo aqui, então ele substitui o glifo do caractere e pode não se unir aos caracteres vizinhos.
diff --git a/crates/ui-egui/src/i18n/ru.tsv b/crates/ui-egui/src/i18n/ru.tsv
index f7bab2610e..c53e9a3946 100644
--- a/crates/ui-egui/src/i18n/ru.tsv
+++ b/crates/ui-egui/src/i18n/ru.tsv
@@ -867,7 +867,6 @@ font-technology Variable Вариативные
at the end of a word в конце слова
standing alone в отдельном виде
{glyph} is not an alternate of {sel} (U+{code}) as it stands in its word. {glyph} не является альтернативой символа {sel} (U+{code}) в том виде, как он стоит в своём слове.
- No OpenType feature reaches {glyph}, so it cannot be applied to text. Ни одна функция OpenType не даёт {glyph}, поэтому его нельзя применить к тексту.
Double-click to insert {base} (U+{code}) instead: a presentation form does not join, the letter does. Дважды щёлкните, чтобы вставить вместо него {base} (U+{code}): форма представления не соединяется, буква соединяется.
Inserted {base} (U+{code}) instead of a presentation form: the font joins it to its neighbors by position. Вместо формы представления вставлено {base} (U+{code}): шрифт соединяет его с соседними по положению.
No OpenType feature draws this glyph here, so it replaces the character's glyph and may not join its neighbors. Ни одна функция OpenType не рисует здесь этот глиф, поэтому он заменяет глиф символа и может не соединяться с соседними символами.
diff --git a/crates/ui-egui/src/i18n/sv.tsv b/crates/ui-egui/src/i18n/sv.tsv
index e7a42b0a03..532489cd11 100644
--- a/crates/ui-egui/src/i18n/sv.tsv
+++ b/crates/ui-egui/src/i18n/sv.tsv
@@ -856,7 +856,6 @@ font-technology Variable Variabla
at the end of a word i slutet av ett ord
standing alone när det står ensamt
{glyph} is not an alternate of {sel} (U+{code}) as it stands in its word. {glyph} är inget alternativ till {sel} (U+{code}) som det står i sitt ord.
- No OpenType feature reaches {glyph}, so it cannot be applied to text. Ingen OpenType-funktion ger {glyph}, så den kan inte användas på text.
Double-click to insert {base} (U+{code}) instead: a presentation form does not join, the letter does. Dubbelklicka för att infoga {base} (U+{code}) i stället: en presentationsform ansluter inte, bokstaven gör det.
Inserted {base} (U+{code}) instead of a presentation form: the font joins it to its neighbors by position. {base} (U+{code}) infogades i stället för en presentationsform: typsnittet ansluter det till grannarna efter position.
No OpenType feature draws this glyph here, so it replaces the character's glyph and may not join its neighbors. Ingen OpenType-funktion ritar den här glyfen här, så den ersätter tecknets glyf och ansluter kanske inte till grannteckenen.
diff --git a/crates/ui-egui/src/i18n/tr.tsv b/crates/ui-egui/src/i18n/tr.tsv
index 4d286f83d8..3f69ea6555 100644
--- a/crates/ui-egui/src/i18n/tr.tsv
+++ b/crates/ui-egui/src/i18n/tr.tsv
@@ -848,7 +848,6 @@ font-technology Variable Değişken
at the end of a word kelimenin sonunda
standing alone tek başına dururken
{glyph} is not an alternate of {sel} (U+{code}) as it stands in its word. {glyph}, kendi kelimesinde durduğu hâliyle {sel} (U+{code}) karakterinin alternatifi değil.
- No OpenType feature reaches {glyph}, so it cannot be applied to text. Hiçbir OpenType özelliği {glyph} glifine ulaşmaz; bu yüzden metne uygulanamaz.
Double-click to insert {base} (U+{code}) instead: a presentation form does not join, the letter does. Bunun yerine {base} (U+{code}) eklemek için çift tıklayın: sunum biçimi bağlanmaz, harf bağlanır.
Inserted {base} (U+{code}) instead of a presentation form: the font joins it to its neighbors by position. Sunum biçimi yerine {base} (U+{code}) eklendi: yazı tipi onu konumuna göre komşularına bağlar.
No OpenType feature draws this glyph here, so it replaces the character's glyph and may not join its neighbors. Hiçbir OpenType özelliği bu glifi burada çizmez; bu yüzden karakterin glifinin yerine geçer ve komşu karakterlere bağlanmayabilir.
diff --git a/crates/ui-egui/src/i18n/uk.tsv b/crates/ui-egui/src/i18n/uk.tsv
index 094b517f0b..61a891e021 100644
--- a/crates/ui-egui/src/i18n/uk.tsv
+++ b/crates/ui-egui/src/i18n/uk.tsv
@@ -863,7 +863,6 @@ font-technology Variable Варіативні
at the end of a word наприкінці слова
standing alone в окремому вигляді
{glyph} is not an alternate of {sel} (U+{code}) as it stands in its word. {glyph} не є альтернативою символу {sel} (U+{code}) у тому вигляді, в якому він стоїть у своєму слові.
- No OpenType feature reaches {glyph}, so it cannot be applied to text. Жодна функція OpenType не дає {glyph}, тому його не можна застосувати до тексту.
Double-click to insert {base} (U+{code}) instead: a presentation form does not join, the letter does. Двічі клацніть, щоб вставити замість нього {base} (U+{code}): форма подання не з’єднується, літера з’єднується.
Inserted {base} (U+{code}) instead of a presentation form: the font joins it to its neighbors by position. Замість форми подання вставлено {base} (U+{code}): шрифт з’єднує його із сусідніми за положенням.
No OpenType feature draws this glyph here, so it replaces the character's glyph and may not join its neighbors. Жодна функція OpenType не малює тут цей гліф, тому він замінює гліф символу й може не з’єднуватися із сусідніми символами.
diff --git a/crates/ui-egui/src/i18n/vi.tsv b/crates/ui-egui/src/i18n/vi.tsv
index d06d1368f8..69e3a248b4 100644
--- a/crates/ui-egui/src/i18n/vi.tsv
+++ b/crates/ui-egui/src/i18n/vi.tsv
@@ -1007,7 +1007,6 @@ font-technology Variable Biến thiên
at the end of a word ở cuối từ
standing alone khi đứng riêng
{glyph} is not an alternate of {sel} (U+{code}) as it stands in its word. {glyph} không phải glyph thay thế của {sel} (U+{code}) như ký tự đó đang đứng trong từ của nó.
- No OpenType feature reaches {glyph}, so it cannot be applied to text. Không có tính năng OpenType nào đạt tới {glyph}, nên không thể áp dụng cho văn bản.
Double-click to insert {base} (U+{code}) instead: a presentation form does not join, the letter does. Bấm đúp để chèn {base} (U+{code}) thay thế: dạng trình bày không nối, còn chữ cái thì nối.
Inserted {base} (U+{code}) instead of a presentation form: the font joins it to its neighbors by position. Đã chèn {base} (U+{code}) thay cho dạng trình bày: phông chữ nối nó với các ký tự bên cạnh theo vị trí.
No OpenType feature draws this glyph here, so it replaces the character's glyph and may not join its neighbors. Không có tính năng OpenType nào vẽ glyph này ở đây, nên nó thay glyph của ký tự và có thể không nối với các ký tự bên cạnh.
diff --git a/crates/ui-egui/src/i18n/zh-hans.tsv b/crates/ui-egui/src/i18n/zh-hans.tsv
index dffc014d0d..e6b123b96a 100644
--- a/crates/ui-egui/src/i18n/zh-hans.tsv
+++ b/crates/ui-egui/src/i18n/zh-hans.tsv
@@ -811,7 +811,6 @@
at the end of a word 位于词尾
standing alone 单独出现时
{glyph} is not an alternate of {sel} (U+{code}) as it stands in its word. {glyph} 不是 {sel}(U+{code})在其单词中所处位置的替代字。
- No OpenType feature reaches {glyph}, so it cannot be applied to text. 没有任何 OpenType 功能能得到 {glyph},因此无法应用于文本。
Double-click to insert {base} (U+{code}) instead: a presentation form does not join, the letter does. 双击以改为插入 {base}(U+{code}):表现形式不会连接,字母会连接。
Inserted {base} (U+{code}) instead of a presentation form: the font joins it to its neighbors by position. 已插入 {base}(U+{code})以代替表现形式:字体会按位置将其与相邻字符连接。
No OpenType feature draws this glyph here, so it replaces the character's glyph and may not join its neighbors. 没有任何 OpenType 功能会在此处绘制该字形,因此它会替换该字符的字形,并且可能无法与相邻字符连接。
diff --git a/crates/ui-egui/src/i18n/zh-hant.tsv b/crates/ui-egui/src/i18n/zh-hant.tsv
index d581b8963d..5cce7a89d4 100644
--- a/crates/ui-egui/src/i18n/zh-hant.tsv
+++ b/crates/ui-egui/src/i18n/zh-hant.tsv
@@ -831,7 +831,6 @@ font-technology Variable 可變
at the end of a word 位於詞尾
standing alone 單獨出現時
{glyph} is not an alternate of {sel} (U+{code}) as it stands in its word. {glyph} 不是 {sel}(U+{code})在其單字中所處位置的替代字。
- No OpenType feature reaches {glyph}, so it cannot be applied to text. 沒有任何 OpenType 功能能得到 {glyph},因此無法套用到文字。
Double-click to insert {base} (U+{code}) instead: a presentation form does not join, the letter does. 按兩下以改為插入 {base}(U+{code}):表現形式不會連接,字母會連接。
Inserted {base} (U+{code}) instead of a presentation form: the font joins it to its neighbors by position. 已插入 {base}(U+{code})以取代表現形式:字體會依位置將其與相鄰字元連接。
No OpenType feature draws this glyph here, so it replaces the character's glyph and may not join its neighbors. 沒有任何 OpenType 功能會在此處繪製該字形,因此它會取代該字元的字形,且可能無法與相鄰字元連接。
diff --git a/crates/ui-egui/src/panels/glyphs.rs b/crates/ui-egui/src/panels/glyphs.rs
index 80163af8bc..66954b5d1b 100644
--- a/crates/ui-egui/src/panels/glyphs.rs
+++ b/crates/ui-egui/src/panels/glyphs.rs
@@ -292,7 +292,7 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) {
ui.painter().rect_stroke(rect.shrink(0.5), 0, edge, StrokeKind::Inside);
// A glyph no character maps is dimmed when it cannot go on the selection.
let pick = match (c, &fit) {
- (None, Some(fit)) => Some(fit.pick(gid)),
+ (None, Some(fit)) => Some(fit.pick(&face, gid)),
_ => None,
};
let ink = if matches!(pick, Some(None)) { t.text_disabled } else { t.text_strong };
@@ -585,20 +585,24 @@ impl Fit {
/// What a double-click on glyph `gid` does to the selected character: the way to draw it with
/// that glyph, when it is the character's default glyph or one of its alternates as it stands
- /// in its word, in the face the selected text is set in. `None` when it is not.
- fn pick(&self, gid: u32) -> Option {
+ /// in its word, in the face the selected text is set in; or a glyph no feature reaches that
+ /// the font's naming does not give to another letter (`A.salt` for `A`, `s.ss1` in a font
+ /// with no `ss01`), which is drawn as a glyph choice. `None` when it is not.
+ fn pick(&self, face: &vectorcraft_text::FontFace, gid: u32) -> Option {
let t = self.target.as_ref().filter(|t| t.available)?;
let found = t.found.as_ref().filter(|f| f.font_id == self.face_id)?;
if gid == found.default {
return Some(Pick::Default);
}
- let a = found.alternates.iter().find(|a| a.gid == gid)?;
- Some(a.apply.clone().map_or(Pick::Glyph(gid), Pick::Feature))
+ if let Some(a) = found.alternates.iter().find(|a| a.gid == gid) {
+ return Some(a.apply.clone().map_or(Pick::Glyph(gid), Pick::Feature));
+ }
+ face.is_unlisted_alternate(t.ch, gid).then_some(Pick::Glyph(gid))
}
/// Why [`Self::pick`] finds nothing for glyph `gid` of `face`, in the user's words: what the
/// glyph belongs to (its letter and place in a word, from the font's own substitutions) and what
- /// the selection is, that no feature reaches it, or that nothing is selected.
+ /// the selection is, or that nothing is selected.
fn why(&self, face: &vectorcraft_text::FontFace, gid: u32) -> String {
let Some(t) = &self.target else { return tl!("Select one character with the Type tool first.").to_string() };
if !t.available {
@@ -619,12 +623,7 @@ impl Fit {
&[("glyph", &name), ("sel", &sel), ("code", &sel_code)],
)
};
- let Some(origin) = face.glyph_origin(gid) else {
- return match face.feature_reaching(gid) {
- Some(_) => not_alternate(),
- None => crate::i18n::fmt(tl!("No OpenType feature reaches {glyph}, so it cannot be applied to text."), &[("glyph", &name)]),
- };
- };
+ let Some(origin) = face.glyph_origin(gid) else { return not_alternate() };
let place = match origin.position() {
Some(vectorcraft_text::JoiningPosition::Initial) => Some(tl!("at the start of a word")),
Some(vectorcraft_text::JoiningPosition::Medial) => Some(tl!("in the middle of a word")),
@@ -1303,7 +1302,7 @@ mod tests {
let raw = egui::RawInput { screen_rect: Some(Rect::from_min_size(Pos2::ZERO, TALL)), ..Default::default() };
let mut frame = ctx.run_ui(raw, |ui| {
let fit = Fit::of(app, ui, face);
- out = fit.pick(gid).ok_or_else(|| fit.why(face, gid));
+ out = fit.pick(face, gid).ok_or_else(|| fit.why(face, gid));
});
frame.textures_delta.clear();
out
@@ -1422,7 +1421,7 @@ mod tests {
assert_eq!(ink, Tokens::get(&ctx).text_strong);
double_click_in(&ctx, &mut app, at, TALL);
assert_eq!(glyph_ids(&app, &id).last(), Some(&glyph));
- // Nothing selected, a glyph no feature reaches, and a variant of the letter's other form.
+ // Nothing selected, and a variant of the letter's other form.
select(&mut app, 0, 0);
assert_eq!(verdict(&ctx, &app, &face, glyph).err().as_deref(), Some("Select one character with the Type tool first."));
let (_, ink) = entire_font_cell(&ctx, &mut app, &face, glyph);
@@ -1434,11 +1433,50 @@ mod tests {
Some("yehfarsi.medi.alt1 is not an alternate of ی (U+06CC) as it stands in its word."),
"its own letter, another place in a word"
);
- let unreached = face.unencoded_glyphs().iter().copied().find(|g| face.glyph_origin(*g).is_none() && face.feature_reaching(*g).is_none());
- if let Some(g) = unreached {
- let why = verdict(&ctx, &app, &face, g).err().expect("not applicable");
- assert!(why.starts_with("No OpenType feature reaches "), "{why}");
+ }
+
+ /// A glyph no feature reaches (`a.a`, `a.b` of a font with no feature for them) is applied by
+ /// Entire Font to the selected character as a glyph choice, not dimmed; one the font's naming
+ /// gives to another letter (`a.a` on `y`) stays dimmed, and so does every glyph with no
+ /// character selected.
+ #[test]
+ fn entire_font_applies_a_glyph_no_feature_reaches_to_its_letter() {
+ use vectorcraft_text::test_fonts::{alternates_family, alternates_font};
+ FontDb::global().add_font(alternates_font(true).expect("the test font builds"));
+ let face = FontDb::global().face(alternates_family(true), "Regular").expect("the test face");
+ let glyph = gid_named(&face, "a.a");
+ assert!(face.feature_reaching(glyph).is_none() && face.glyph_origin(glyph).is_none(), "no feature reaches it");
+ let (mut app, id) = editing("xay");
+ app.run("text.setRangeStyle", json!({"id": id, "font": alternates_family(true)})).unwrap();
+ let ctx = egui::Context::default();
+ let t = Tokens::get(&ctx);
+ let plain = glyph_ids(&app, &id);
+ // The cell of the glyph and its colour: this font's grid is longer than the panel is tall,
+ // but the glyph is in the first rows.
+ let cell_of = |ctx: &egui::Context, app: &mut VectorcraftApp| {
+ let chars = face.chars().into_iter().filter(|(c, _)| !c.is_control() && !c.is_whitespace()).count();
+ let at = chars + face.unencoded_glyphs().iter().position(|g| *g == glyph).expect("an unencoded glyph");
+ let cells = inks(&tall_shapes(ctx, app));
+ (cells[at].0.center(), cells[at].1)
+ };
+ // Another letter, and nothing selected: dimmed, and a double-click changes nothing.
+ for (start, end) in [(2, 3), (0, 0)] {
+ select(&mut app, start, end);
+ assert!(verdict(&ctx, &app, &face, glyph).is_err());
+ let (at, ink) = cell_of(&ctx, &mut app);
+ assert_eq!(ink, t.text_disabled, "{start}..{end}: dimmed");
+ double_click_in(&ctx, &mut app, at, TALL);
+ assert_eq!(glyph_ids(&app, &id), plain);
}
+ // The letter itself: enabled, one undo step, drawn with that glyph.
+ select(&mut app, 1, 2);
+ assert!(matches!(verdict(&ctx, &app, &face, glyph), Ok(Pick::Glyph(g)) if g == glyph));
+ let (at, ink) = cell_of(&ctx, &mut app);
+ assert_eq!(ink, t.text_strong);
+ let undo = app.session.active().unwrap().history.undo.len();
+ double_click_in(&ctx, &mut app, at, TALL);
+ assert_eq!(app.session.active().unwrap().history.undo.len(), undo + 1);
+ assert_eq!(glyph_ids(&app, &id), [plain[0], glyph, plain[2]]);
}
/// A presentation form picked in Entire Font (beh medial, U+FE92) with the caret before the last
diff --git a/docs/gaps.md b/docs/gaps.md
index 97b3c1fb90..0e6c38b666 100644
--- a/docs/gaps.md
+++ b/docs/gaps.md
@@ -372,7 +372,7 @@ from G14 and the budget run of G15: **~170–270 h** one agent, ~50–80 h wall
## G4 Advanced type
-- **Done (#5514):** the Glyphs panel's Alternates for Current Selection lists the alternates of the glyph the shaper makes for the selected character in its word, and a double-click applies the OpenType feature value that draws one (`salt=2`, `ss03`) to that character alone, in one undo step, so the text stays real Unicode and an Arabic word keeps its joining forms: runs that differ only in their features are shaped as one piece of text. A variant no feature draws there is still drawn as a replacement glyph, which may not join. Show › Entire Font lists the unencoded glyphs by id, with their name and the feature that gives them; a double-click on one applies it to the selected character when the font gives it as that character's alternate in its word (`yehfarsi.fina.alt2` on a final Persian yeh), and any other is drawn dimmed with a tooltip that says why (it belongs to another letter or place in a word, no feature reaches it, nothing is selected). An Arabic presentation form picked there inserts its letter, so the word still joins. Left: alternates reached through contextual or ligature lookups, and `cv01`–`cv99` and `hist`, which have no style tag; estimate unchanged.
+- **Done (#5514):** the Glyphs panel's Alternates for Current Selection lists the alternates of the glyph the shaper makes for the selected character in its word, and a double-click applies the OpenType feature value that draws one (`salt=2`, `ss03`) to that character alone, in one undo step, so the text stays real Unicode and an Arabic word keeps its joining forms: runs that differ only in their features are shaped as one piece of text. A variant no feature draws there is still drawn as a replacement glyph, which may not join. Show › Entire Font lists the unencoded glyphs by id, with their name and the feature that gives them; a double-click on one applies it to the selected character when the font gives it as that character's alternate in its word (`yehfarsi.fina.alt2` on a final Persian yeh), and a glyph no feature reaches whose name does not belong to another letter (`A.salt`, `s.ss1` in a font with no such feature) is applied to the selected character as a glyph choice, which may not join; any other is drawn dimmed with a tooltip that says why (it belongs to another letter or place in a word, nothing is selected). An Arabic presentation form picked there inserts its letter, so the word still joins. Left: alternates reached through contextual or ligature lookups, and `cv01`–`cv99` and `hist`, which have no style tag; estimate unchanged.
- **Fixed:** fullwidth `!` / `?` stay upright in vertical point and area type when a font has no vertical alternate. Existing `vert` substitutions and the rotated fallback for brackets and long vowel marks are preserved; the broader CJK gap and estimate are unchanged.
diff --git a/docs/mcp.md b/docs/mcp.md
index 238bd07ce8..14b601aee7 100644
--- a/docs/mcp.md
+++ b/docs/mcp.md
@@ -5735,13 +5735,14 @@ drawn now, and `alternates` the glyphs the font's `GSUB` single and alternate su
glyph: `aalt` first, then `salt`, `ss01` to `ss20`, `swsh`, `cv01` to `cv99`, `hist`, `jalt`, `nalt`, `ornm` and
`titl`, in lookup order within each, each glyph once. The default glyph is listed only when `aalt` lists it. Ligatures
and contextual substitutions are not listed. `text.setGlyphAlternate {id, start, gid}` draws the character with glyph
-`gid`, one of those alternates or the default glyph, as one undo step; `gid: null` takes the choice away, and the
+`gid`, one of those alternates, a glyph that no character maps and no feature reaches whose name is not another letter's
+(`a.salt` for `a`, in a font with no `salt`), or the default glyph, as one undo step; `gid: null` takes the choice away, and the
character is drawn as its style draws it (a feature such as `salt` can draw another glyph than the default). The text
keeps the character and its other attributes, and characters typed next to it take their default glyphs. The choice is
saved with the character's style as `glyphAlternate: {ch, gid, font, version}`, the face's PostScript name and version
string: set in another font or version, or with the font missing, the character takes its default glyph. Both commands
-refuse a character whose font is not available, and `text.setGlyphAlternate` refuses a glyph that is not one of the
-character's alternates and a glyph that the character's style does not draw (All Caps, Small Caps or another numeral
+refuse a character whose font is not available, and `text.setGlyphAlternate` refuses a glyph that is not one of those
+and a glyph that the character's style does not draw (All Caps, Small Caps or another numeral
system). Choosing the glyph a character already draws is not an edit. Character styles do not take `glyphAlternate`:
`charStyle.new`, `charStyle.edit` and `charStyle.setAttrs` refuse it.
diff --git a/docs/type-parity.md b/docs/type-parity.md
index f8c48191a1..b0a9d2edaa 100644
--- a/docs/type-parity.md
+++ b/docs/type-parity.md
@@ -31,7 +31,7 @@ against Illustrator's public documentation; **estimated** unless noted.
| Character size and horizontal scale after object/group scaling | done | #1034: fields and setters compensate the stored affine; point size follows the transformed em-height axis, not the glyph ink bounds. Range edits, keyboard increments, leading and baseline shift use document points; rotation, reflection and shear remain editable. |
| Preserve font sizes while scaling a group | missing | Separate enhancement; scaling still scales text with the other artwork. |
| OpenType panel, Glyphs panel, alternates | done | Highlight Alternate Glyphs preference (#394, off by default) paints pink behind OpenType substitutions on screen when on (required shaping such as Arabic joining forms and the lam-alef ligature, including a lam-alef older fonts file under `liga`, is not highlighted; an optional feature that changes the ligature is); stylistic sets ss01–ss20, stylistic and titling alternates, proportional and lining figures, superscript and subscript are character-style features (`text.setStyle {features}`, #1607); the OpenType panel's Position menu sets superscript, subscript, numerator or denominator glyphs (`sups`, `subs`, `numr`, `dnom`). The OpenType panel's Stylistic Sets menu lists the selected font's `ss01`–`ss20` features, using a font-supplied name where available and `Set N` otherwise; several sets can be active, and selecting an active one removes it. For a mixed-font selection it uses the first selected character's font, a VectorCraft design choice ([public help](https://helpx.adobe.com/illustrator/desktop/design-with-text/special-characters-glyphs/add-stylistic-sets-to-selected-text.html), [OpenType feature specification](https://learn.microsoft.com/en-us/typography/opentype/spec/features_pt)). The Glyphs panel highlights the character selected with the Type tool (a range's first character, else the one before the caret) and scrolls it into view as the selection moves (#4908; `panels::glyphs` tests); its Show menu lists Unicode ranges and Alternates for Current Selection, the selected character's alternate glyphs from the font's `GSUB` table, a double-click applies the OpenType feature that draws one (`salt=N`, `ssNN`) to that character alone; a variant no feature reaches is drawn through `text.setGlyphAlternate`, which may not join; Show › Entire Font applies the selected letter's alternates the same way and dims the others with the reason; the glyph-cell pop-up of alternates is not there yet |
-| Glyphs panel: alternates as OpenType feature values, unencoded glyphs | done | #5514, as [Replace characters with alternate glyphs](https://helpx.adobe.com/illustrator/desktop/design-with-text/special-characters-glyphs/replace-characters-with-alternate-glyphs.html) describes (Show › Alternates For Current Selection; replace the selected character by double-clicking the glyph). A character-style feature takes a value (`"salt=2"`, the font's alternate number; `ss01`–`ss20`, `swsh`, `titl` and `aalt` too) in `features` and `featureChanges`, in SVG `font-feature-settings` (`"salt" 2`) and in native files as the plain string. Show › Entire Font applies an unencoded glyph the same way when it is an alternate of the selected character in its word, and dims the others with the reason in the tooltip (e.g. `yehfarsi.fina.alt2 belongs to ی (U+06CC) at the end of a word; your selection is ي (U+064A)`); an Arabic presentation form picked there inserts its letter so the word joins. Runs that differ only in their features shape as one piece of text, each run's features over its own characters, so a variant on one letter of an Arabic word keeps its neighbours' initial, medial and final forms and the letters still touch. The panel lists the alternates of the glyph the shaper makes for the selected character in its word (a medial letter shows medial variants), and a double-click applies the OpenType feature that draws the variant (`salt=N` or `ssNN`) to that character alone, in one undo step (`text.setRangeStyle`); a variant no feature reaches falls back to a replacement glyph (`text.setGlyphAlternate`), which its tooltip says may not join. Show › Entire Font also lists the glyphs no character maps, with their name or `gid N` and the feature that gives them. The text crate reads each face's `GSUB` once (alternate sets, single substitutions, the feature that reaches a glyph). Not done: alternates reached through contextual or ligature lookups, and features of the font that the style has no tag for (`cv01`–`cv99`, `hist`). Evidence: `tests::glyph_alternates` in the text crate (a fixture font with a medial teh at `salt=1` to `3`, the neighbours' forms, no gap; Kufi Murabba PIIIX when it is installed), `features::tests::feature_values_are_parsed_validated_and_shaped`, `tests::textedit::feature_values_are_set_replaced_and_saved`, the `panels::glyphs` tests. |
+| Glyphs panel: alternates as OpenType feature values, unencoded glyphs | done | #5514, as [Replace characters with alternate glyphs](https://helpx.adobe.com/illustrator/desktop/design-with-text/special-characters-glyphs/replace-characters-with-alternate-glyphs.html) describes (Show › Alternates For Current Selection; replace the selected character by double-clicking the glyph). A character-style feature takes a value (`"salt=2"`, the font's alternate number; `ss01`–`ss20`, `swsh`, `titl` and `aalt` too) in `features` and `featureChanges`, in SVG `font-feature-settings` (`"salt" 2`) and in native files as the plain string. Show › Entire Font applies an unencoded glyph the same way when it is an alternate of the selected character in its word, applies one no feature reaches (`A.salt` in a font with no `salt`) to the selected character as a glyph choice (it may not join), and dims the others with the reason in the tooltip (e.g. `yehfarsi.fina.alt2 belongs to ی (U+06CC) at the end of a word; your selection is ي (U+064A)`); an Arabic presentation form picked there inserts its letter so the word joins. Runs that differ only in their features shape as one piece of text, each run's features over its own characters, so a variant on one letter of an Arabic word keeps its neighbours' initial, medial and final forms and the letters still touch. The panel lists the alternates of the glyph the shaper makes for the selected character in its word (a medial letter shows medial variants), and a double-click applies the OpenType feature that draws the variant (`salt=N` or `ssNN`) to that character alone, in one undo step (`text.setRangeStyle`); a variant no feature reaches falls back to a replacement glyph (`text.setGlyphAlternate`), which its tooltip says may not join. Show › Entire Font also lists the glyphs no character maps, with their name or `gid N` and the feature that gives them. The text crate reads each face's `GSUB` once (alternate sets, single substitutions, the feature that reaches a glyph). Not done: alternates reached through contextual or ligature lookups, and features of the font that the style has no tag for (`cv01`–`cv99`, `hist`). Evidence: `tests::glyph_alternates` in the text crate (a fixture font with a medial teh at `salt=1` to `3`, the neighbours' forms, no gap; Kufi Murabba PIIIX when it is installed), `features::tests::feature_values_are_parsed_validated_and_shaped`, `tests::textedit::feature_values_are_set_replaced_and_saved`, the `panels::glyphs` tests. |
| Font menu: samples, live preview, filters, favourites, recent families; font matching by any name; Find Font | done | #3515: recently applied families appear first, once each; missing families are omitted and the existing Number of Recent Fonts setting applies; #339; script filter: Japanese, Latin, Thai; the font scan on Linux also reads the folders fontconfig's configuration lists, such as TeX fonts under `/usr/share/texmf/fonts` (#1548, fixes #1529; not verified on Linux) Replace With Font lists family and style rows, one click setting both, with a search over them; its list scrolls on its own, apart from the document's font list (#4972). |
| Missing glyphs set in an installed font made for their script (Thai, Lao, Khmer, Myanmar first; then any font that has them) | done | Thai: Leelawadee UI, Tahoma, Thonburi, Sukhumvit Set, Noto Sans Thai, TLWG |
| 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 |
@@ -80,7 +80,7 @@ Character Styles menu New/Redefine and footer New capture the first selected cha
| Date | Change | Summary |
|---|---|---|
-| 2026-11-07 | minor | Glyphs panel (#5514): Alternates for Current Selection lists the alternates of the selected character's glyph in its word and a double-click applies the OpenType feature that draws one to that letter alone (`salt=2`; `features` and `featureChanges` take `tag=N`), so an Arabic word stays joined, and Entire Font lists the unencoded glyphs, applies the ones that are the selected letter's alternates and dims, with the reason, those that are not. Runs that differ only in their features shape as one piece of text. Scores and estimates unchanged. |
+| 2026-11-07 | minor | Glyphs panel (#5514): Alternates for Current Selection lists the alternates of the selected character's glyph in its word and a double-click applies the OpenType feature that draws one to that letter alone (`salt=2`; `features` and `featureChanges` take `tag=N`), so an Arabic word stays joined, and Entire Font lists the unencoded glyphs, applies the ones that are the selected letter's alternates, or that no feature reaches and the font's names do not give to another letter, and dims, with the reason, those that are not. Runs that differ only in their features shape as one piece of text. Scores and estimates unchanged. |
| 2026-11-07 | minor | Indian-language hyphenation (#4947): eleven MIT-licensed pattern sets, with grapheme and shaping-cluster protection in both composers and existing paragraph limits. Assamese and Sanskrit are selectable; Sanskrit automatic patterns remain pending an allowlisted source. Scores and estimates unchanged. |
| 2026-11-06 | minor | Alt + numeric-keypad codes (`Alt+0176` → °) type their character at the caret on Windows, and `type.insert` inserts a character by `code` or `unicode` (#5108); scores and estimates unchanged |
| 2026-11-06 | minor | Snap to Glyph › Glyph Bounds: each glyph's box gives four corners a drawn point lands on, also on a curved side (`B`), and its box and edges turn with rotated type (#5112); scores and estimates unchanged |
From 699073d66ed4dfc5a5344da8bd125e5c11ad2479 Mon Sep 17 00:00:00 2001
From: Taha-Mahmoodi <85902429+Taha-Mahmoodi@users.noreply.github.com>
Date: Mon, 9 Nov 2026 17:11:14 +0430
Subject: [PATCH 11/86] Glyphs: accept positional glyph choices and tighten
reviewed style values
---
crates/engine/src/cmd/textedit.rs | 10 ++++-
crates/text/src/test_fonts.rs | 14 ++++---
.../ui-egui/src/dialogs/char_style_options.rs | 9 ++++-
crates/ui-egui/src/panels/glyphs.rs | 40 ++++++++++++++++++-
docs/gaps.md | 1 +
docs/target-app-parity.md | 5 ++-
docs/type-parity.md | 5 ++-
7 files changed, 70 insertions(+), 14 deletions(-)
diff --git a/crates/engine/src/cmd/textedit.rs b/crates/engine/src/cmd/textedit.rs
index 018a0a5b70..1b98d607a8 100644
--- a/crates/engine/src/cmd/textedit.rs
+++ b/crates/engine/src/cmd/textedit.rs
@@ -1299,7 +1299,15 @@ fn set_glyph_alternate(s: &mut Session, p: &Value) -> Result {
let substituted = unchosen.is_some_and(|g| g != default);
let choice = match gid.filter(|g| *g != default || substituted) {
None => None,
- Some(g) if g == default || face.alternates(ch).iter().any(|a| a.gid == g) || face.is_unlisted_alternate(ch, g) => Some(face.choice(ch, g)),
+ Some(g)
+ if g == default
+ || face.alternates(ch).iter().any(|a| a.gid == g)
+ || face.is_unlisted_alternate(ch, g)
+ || vectorcraft_text::alternates_in_context(vectorcraft_text::FontDb::global(), &t, bytes.start)
+ .is_some_and(|f| f.font_id == face.id() && f.alternates.iter().any(|a| a.gid == g)) =>
+ {
+ Some(face.choice(ch, g))
+ }
Some(g) => return Err(bad(C, format!("glyph {g} is not an alternate of {ch} in {} {}", face.family, face.style))),
};
let drawn = choice.as_ref().map_or(unchosen.unwrap_or(default), |c| c.gid);
diff --git a/crates/text/src/test_fonts.rs b/crates/text/src/test_fonts.rs
index c17ba25248..4e4f927e7a 100644
--- a/crates/text/src/test_fonts.rs
+++ b/crates/text/src/test_fonts.rs
@@ -810,8 +810,9 @@ pub const JOINING_LETTERS: [char; 4] = ['\u{0628}', '\u{062A}', '\u{0646}', '\u{
/// alternate substitutions of the medial teh u (w, x, y: three alternates), of the final teh U
/// (W, X), of the isolated teh t (E) and of the medial beh v (z), and `ss01` single substitutions
/// u to w, U to W, t to E and v to z. A set differs by position, as in a font that draws each
-/// letter's alternates for the position they join in. Family [`JOINING_ALTERNATES_FAMILY`]. `None`
-/// when it can't be built.
+/// letter's alternates for the position they join in. `cv01` gives the medial noon m the glyph d,
+/// an alternate that has no style tag. Family [`JOINING_ALTERNATES_FAMILY`]. `None` when it can't
+/// be built.
pub fn joining_alternates_font() -> Option> {
use write_fonts::tables::cmap::Cmap;
use write_fonts::tables::gsub::{
@@ -857,20 +858,21 @@ pub fn joining_alternates_font() -> Option> {
vec![AlternateSubstFormat1::new(CoverageFormat1::new(from).into(), sets)],
)))
};
- // Lookups: 0 init, 1 medi, 2 fina, 3 salt, 4 ss01.
+ // Lookups: 0 init, 1 medi, 2 fina, 3 salt, 4 ss01, 5 cv01.
let lookups = SubstitutionLookupList::new(vec![
single(&[('b', 'B'), ('t', 'T'), ('n', 'N')])?,
single(&[('b', 'v'), ('t', 'u'), ('n', 'm')])?,
single(&[('b', 'V'), ('t', 'U'), ('n', 'M'), ('I', 'A')])?,
alternates(&[('u', &['w', 'x', 'y']), ('U', &['W', 'X']), ('t', &['E']), ('v', &['z'])])?,
single(&[('u', 'w'), ('U', 'W'), ('t', 'E'), ('v', 'z')])?,
+ single(&[('m', 'd')])?,
]);
- // Features sorted by tag, as OpenType requires: fina, init, medi, salt, ss01.
- let records: Vec = [(b"fina", 2), (b"init", 0), (b"medi", 1), (b"salt", 3), (b"ss01", 4)]
+ // Features sorted by tag, as OpenType requires: cv01, fina, init, medi, salt, ss01.
+ let records: Vec = [(b"cv01", 5), (b"fina", 2), (b"init", 0), (b"medi", 1), (b"salt", 3), (b"ss01", 4)]
.into_iter()
.map(|(tag, lookup)| FeatureRecord::new(Tag::new(tag), Feature::new(None, vec![lookup])))
.collect();
- let scripts = ScriptList::new(vec![ScriptRecord::new(Tag::new(b"arab"), Script::new(Some(LangSys::new(vec![0, 1, 2, 3, 4])), vec![]))]);
+ let scripts = ScriptList::new(vec![ScriptRecord::new(Tag::new(b"arab"), Script::new(Some(LangSys::new(vec![0, 1, 2, 3, 4, 5])), vec![]))]);
builder.add_table(&Gsub::new(scripts, FeatureList::new(records), lookups)).ok()?;
for r in font.table_directory.table_records() {
let tag = Tag::new(&r.tag().to_be_bytes());
diff --git a/crates/ui-egui/src/dialogs/char_style_options.rs b/crates/ui-egui/src/dialogs/char_style_options.rs
index 2ac8da9586..7d11146c50 100644
--- a/crates/ui-egui/src/dialogs/char_style_options.rs
+++ b/crates/ui-egui/src/dialogs/char_style_options.rs
@@ -505,7 +505,9 @@ fn opentype_attrs(d: &Dialog, original: &Map, original_fields: &M
tags.retain(|t| !sets.contains(&bare(t)));
let listed = d.fields.get("ot_sets").and_then(Value::as_array).into_iter().flatten().filter_map(Value::as_str);
tags.extend(listed.filter(|t| sets.contains(&bare(t))).map(|t| {
- let kept = before.iter().rev().find(|b| bare(b) == bare(t)).filter(|b| !t.starts_with('-') && tag_on(b));
+ let kept = before.iter().rev().find(|b| bare(b) == bare(t)).filter(|b| {
+ !t.starts_with('-') && vectorcraft_text::OtFeatures::known_tag(b) && tag_on(b)
+ });
kept.map_or_else(|| t.to_string(), Clone::clone)
}));
}
@@ -1650,6 +1652,11 @@ mod tests {
d.fields.insert("ot_sets".into(), json!(["ss01", "ss02"]));
d.fields.insert("ot_salt".into(), json!(true));
assert_eq!(edited_attrs(&d).unwrap()["features"], json!(["salt", "ss01", "ss02=2"]));
+ for last in ["ss01=0", "-ss01", "ss01=65536"] {
+ let mut d = dialog_for(json!({"features": ["ss01=2", last]}));
+ d.fields.insert("ot_sets".into(), json!(["ss01", "ss02"]));
+ assert_eq!(edited_attrs(&d).unwrap()["features"], json!(["ss01", "ss02"]), "{last}: only a valid last on-value is kept");
+ }
}
#[test]
diff --git a/crates/ui-egui/src/panels/glyphs.rs b/crates/ui-egui/src/panels/glyphs.rs
index 66954b5d1b..5922d4be4b 100644
--- a/crates/ui-egui/src/panels/glyphs.rs
+++ b/crates/ui-egui/src/panels/glyphs.rs
@@ -6,8 +6,9 @@
//! stands in its word), and a double-click applies the OpenType feature that draws one to that
//! character alone, so the text stays real text that joins its neighbors. A double-click on a
//! glyph no character maps in Entire Font does the same when the selected character's font gives
-//! that glyph as one of its alternates in its word; any other glyph is drawn dimmed, and its tooltip
-//! says why it cannot be applied to the selection.
+//! that glyph as one of its alternates in its word. A glyph no feature reaches whose name does
+//! not belong to another letter is applied as a glyph choice, which may not join. Other glyphs
+//! are drawn dimmed, and their tooltips say why they cannot be applied to the selection.
use std::collections::HashMap;
@@ -1188,6 +1189,41 @@ mod tests {
assert!(text.runs.iter().all(|r| r.style.glyph_alternate.is_none()), "the choice is gone");
}
+ /// A positional alternate without a supported feature still goes through the engine's glyph
+ /// choice validation, even though the isolated character has different alternates.
+ #[test]
+ fn a_positional_variant_no_feature_draws_replaces_the_glyph() {
+ use vectorcraft_text::test_fonts::{JOINING_ALTERNATES_FAMILY, joining_alternates_font, source_sans_gid};
+ FontDb::global().add_font(joining_alternates_font().expect("the test font builds"));
+ let face = FontDb::global().face(JOINING_ALTERNATES_FAMILY, "Regular").expect("the test face");
+ let gid = u32::from(source_sans_gid('d').expect("a glyph"));
+ assert!(face.alternates('\u{646}').is_empty(), "the isolated noon has no alternates");
+ assert!(!face.is_unlisted_alternate('\u{646}', gid), "cv01 reaches the glyph");
+ for view in ["Alternates for Current Selection", "Entire Font"] {
+ let (mut app, id) = editing("\u{628}\u{646}\u{62A}");
+ app.run("text.setRangeStyle", json!({"id": id, "font": JOINING_ALTERNATES_FAMILY})).unwrap();
+ let plain = glyph_ids(&app, &id);
+ select(&mut app, 2, 4);
+ let ctx = egui::Context::default();
+ choose(&ctx, &mut app, view);
+ let at = if view == "Entire Font" {
+ entire_font_cell(&ctx, &mut app, &face, gid).0
+ } else {
+ let cells = squares(&shapes(&ctx, &mut app, vec![]));
+ assert_eq!(cells.len(), 2, "the medial noon and its cv01 alternate");
+ cells[1].center()
+ };
+ let undo = app.session.active().unwrap().history.undo.len();
+ double_click_in(&ctx, &mut app, at, if view == "Entire Font" { TALL } else { vec2(280.0, 420.0) });
+ assert_eq!(app.session.active().unwrap().history.undo.len(), undo + 1, "{view}: {}", app.ui.status);
+ assert_eq!(glyph_ids(&app, &id)[1], gid, "{view}");
+ let NodeKind::Text(text) = &app.session.active().unwrap().doc.node(NodeId(id.as_u64().unwrap())).unwrap().kind else { panic!("text") };
+ assert_eq!(text.plain_text(), "\u{628}\u{646}\u{62A}", "the characters stay unchanged");
+ app.run("edit.undo", json!({})).unwrap();
+ assert_eq!(glyph_ids(&app, &id), plain, "{view}: undo restores the word");
+ }
+ }
+
/// Show › Entire Font also lists the glyphs no character maps.
#[test]
fn entire_font_lists_the_unencoded_glyphs() {
diff --git a/docs/gaps.md b/docs/gaps.md
index 07b11c609f..ec13186293 100644
--- a/docs/gaps.md
+++ b/docs/gaps.md
@@ -839,6 +839,7 @@ trap, and the defaults (0.25 pt, 100%, 40%) are VectorCraft's own, the pages giv
| Date | Change | Summary |
|---|---|---|
+| 2026-11-09 | minor | G4 review (#6084): positional variants no supported feature draws can be applied as glyph choices in both Glyphs views; unlisted glyphs still respect name ownership. OpenType style options keep only a valid last on-value when enabling a set. Existing contextual/ligature lookup and unsupported-feature gaps remain; estimate unchanged. |
| 2026-11-07 | minor | G4: the Glyphs panel's Alternates for Current Selection applies the OpenType feature that draws a variant to that letter alone (`salt=2`; the style commands take `tag=N`; runs that differ only in their features shape as one piece of text), so an Arabic word stays joined, and Show › Entire Font lists the unencoded glyphs (#5514). Estimate unchanged. |
| 2026-11-08 | minor | Floating tab groups collapse into the side icon rail and opened rail panels fold back into it (#5925, in part); tab groups remain intact. Scores and estimates unchanged. |
| 2026-11-06 | minor | Hindi (`hi`): 7,877-row machine-assisted catalog, zero/one singular plurals, desktop font fallback and optional Devanagari craft-fonts fallback; all 55 translation tests pass; native review and a web font remain; scores and estimates unchanged |
diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md
index 500fa15f2e..6419d4ac3e 100644
--- a/docs/target-app-parity.md
+++ b/docs/target-app-parity.md
@@ -1074,8 +1074,8 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur
The Character panel's Anti-aliasing Method dropdown, under Character Rotation, sets how the edges of the selected type objects are drawn as pixels (`text.setFormat {antiAlias}`), for the whole object, with each frame of a thread keeping its own method, on the canvas, in raster exports and when the type is rasterized. VectorCraft defines the methods: Sharp, the default, draws edges as before; None paints a pixel only where the type covers at least half of it; Crisp shows a pixel covered by a share c at `2c - 0.5`, and Strong shows it at `2c`, each kept between 0 and 1. SVG, PDF and EPS export are unchanged. Scores and hours unchanged.
The OpenType panel's Stylistic Alternates and Titling Alternates checkboxes turn `salt` and `titl` on and off for the selected type, and its Figure menu lists Default Figure, Tabular Lining, Proportional Lining, Proportional Oldstyle and Tabular Oldstyle; Proportional Lining sets `pnum` and `lnum` and turns `tnum` and `onum` off, Tabular Lining turns `pnum` off and keeps `lnum`, Proportional Oldstyle turns `lnum` off and keeps `pnum`, and Default Figure and Tabular Oldstyle turn `pnum` and `lnum` off. Behavior reference: [Format text with OpenType features](https://helpx.adobe.com/illustrator/desktop/design-with-text/special-characters-glyphs/format-text-with-opentype-features.html) and [Use swashes, titling alternates, or stylistic alternates](https://helpx.adobe.com/illustrator/desktop/design-with-text/special-characters-glyphs/use-swashes-titling-alternates-or-stylistic-alternates.html) (public documentation). Tests: `panels::opentype::tests`.
The OpenType panel's Position menu lists Default Position, Superscript, Subscript, Numerator and Denominator, as [Format text with OpenType features](https://helpx.adobe.com/illustrator/desktop/design-with-text/special-characters-glyphs/format-text-with-opentype-features.html) describes; Superscript, Subscript, Numerator and Denominator set `sups`, `subs`, `numr` and `dnom` for the selected type and turn the other three off, and Default Position turns all four off. The menu shows Mixed when the characters it acts on differ, and an entry chosen then applies to all of them. These two rules are VectorCraft's own, as is setting the feature on type whose font lacks the glyphs, which then draws unchanged. The panel's menus and checkboxes change only their own features on each selected character, so characters keep their other OpenType features (`text.setStyle` and `text.setRangeStyle` `{featureChanges}`). Tests: `panels::opentype::tests`, `features::tests::numerators_and_denominators_are_kept_and_shaped`.
- The Glyphs panel's Show menu and panel menu have Alternates for Current Selection, as [Replace characters with alternate glyphs](https://helpx.adobe.com/illustrator/desktop/design-with-text/special-characters-glyphs/replace-characters-with-alternate-glyphs.html) describes: the grid lists the alternate glyphs of the character selected with the Type tool, and a double-click draws that character with the glyph as one undo step (`text.glyphAlternates`, `text.setGlyphAlternate`). The alternates come from the font's `GSUB` single and alternate substitutions of `aalt`, then `salt`, `ss01` to `ss20`, `swsh`, `cv01` to `cv99` and a few other alternate features, each glyph once. The text keeps the character, native files keep the choice with the face it was made in, and type set in another font or version draws the default glyph. The list uses the font of the character's text even when the panel browses another font, and the panel shows a note when the character has no alternates, its font is missing or no character is selected; these rules are VectorCraft's own. Tests: `panels::glyphs::tests`, `tests::alternates`, `tests::textedit::a_glyph_alternate_replaces_one_character_through_undo_and_reopen`.
- The Glyphs panel's Alternates for Current Selection (#5514) applies the OpenType feature that draws the glyph to the selected character alone, as [Replace characters with alternate glyphs](https://helpx.adobe.com/illustrator/desktop/design-with-text/special-characters-glyphs/replace-characters-with-alternate-glyphs.html) describes (Alternates For Current Selection in the Show menu; double-click the glyph to replace the selected character). One deliberate difference from a literal reading: the text keeps the Unicode character and gains a character-style feature with a value (`salt=2`, `ss03`; `text.setRangeStyle {featureChanges}`) instead of taking an unencoded glyph in its place. The grid lists the alternates of the glyph the shaper makes for the character in its word (a medial letter shows its medial variants; the glyph it has comes first and is highlighted); a variant no feature draws there stays enabled as a replacement glyph (`text.setGlyphAlternate`), and its tooltip says it may not join its neighbors; the default glyph takes the character's alternate features off again. Runs that differ only in their OpenType features are shaped as one piece of text, each run's features over its own characters, so the letters of an Arabic word keep their joining forms. Show › Entire Font also lists the glyphs no character maps, with the font's name for them (or `gid N`) and the feature that gives them. These rules are VectorCraft's own. Tests: `tests::glyph_alternates` in the text crate (a fixture font; Kufi Murabba PIIIX when it is installed), `panels::glyphs::tests`, `tests::textedit::feature_values_are_set_replaced_and_saved`.
+ The Glyphs panel's Show menu and panel menu have Alternates for Current Selection, as [Replace characters with alternate glyphs](https://helpx.adobe.com/illustrator/desktop/design-with-text/special-characters-glyphs/replace-characters-with-alternate-glyphs.html) describes. The grid lists the selected character's alternate glyphs from `GSUB` single and alternate substitutions of `aalt`, then `salt`, `ss01` to `ss20`, `swsh`, `cv01` to `cv99` and a few other alternate features, each glyph once. A double-click applies the feature that draws the variant, or a glyph choice if no supported feature draws it, as detailed below. The list uses the font of the character's text even when the panel browses another font, and the panel shows a note when the character has no alternates, its font is missing or no character is selected; these rules are VectorCraft's own. Native files keep a glyph choice with the face it was made in; another font or version draws the default glyph. Tests: `panels::glyphs::tests`, `tests::alternates`, `tests::textedit::a_glyph_alternate_replaces_one_character_through_undo_and_reopen`.
+ The Glyphs panel's Alternates for Current Selection (#5514) applies the OpenType feature that draws the glyph to the selected character alone, as [Replace characters with alternate glyphs](https://helpx.adobe.com/illustrator/desktop/design-with-text/special-characters-glyphs/replace-characters-with-alternate-glyphs.html) describes (Alternates For Current Selection in the Show menu; double-click the glyph to replace the selected character). One deliberate difference from a literal reading: the text keeps the Unicode character and gains a character-style feature with a value (`salt=2`, `ss03`; `text.setRangeStyle {featureChanges}`) instead of taking an unencoded glyph in its place. The grid lists the alternates of the glyph the shaper makes for the character in its word (a medial letter shows its medial variants; the default glyph comes first and the current glyph is highlighted); a variant no supported feature draws there stays enabled as a replacement glyph (`text.setGlyphAlternate`, including positional alternates), and its tooltip says it may not join its neighbors; the default glyph takes the character's alternate features off again. Runs that differ only in their OpenType features are shaped as one piece of text, each run's features over its own characters, so the letters of an Arabic word keep their joining forms. Show › Entire Font also lists the glyphs no character maps, with the font's name for them (or `gid N`) and the feature that gives them. It applies the selected letter's contextual alternates the same way, and applies a glyph no feature reaches whose name does not belong to another letter as a glyph choice; other glyphs are dimmed with the reason. An Arabic presentation form picked there inserts its base letter. These rules are VectorCraft's own. Tests: `tests::glyph_alternates` in the text crate (a fixture font; Kufi Murabba PIIIX when it is installed), `panels::glyphs::tests` (including positional glyph choices, unlisted glyph name ownership, apply and Undo), `tests::textedit::feature_values_are_set_replaced_and_saved`.
The OpenType panel's Stylistic Sets menu offers `ss01`–`ss20` sets present in the selected text's font, using the font's custom labels when available and numbered labels otherwise. Sets can be combined and deselected individually. With mixed fonts, the menu shows the first selected character's font's sets and applies the feature tag to all selected text; a font without that set draws unchanged. The mixed-font rule is a VectorCraft design choice. Behavior reference: [Add stylistic sets to selected text](https://helpx.adobe.com/illustrator/desktop/design-with-text/special-characters-glyphs/add-stylistic-sets-to-selected-text.html) and the [OpenType specification](https://learn.microsoft.com/en-us/typography/opentype/spec/features_pt).
`text.setStyle {stroke, strokeWidth}` sets the character stroke's color and weight of the selected type or of a range of its characters (the weight in document points, as `stroke.set` sets it), in the same undo step as the command's other changes. Tests: `tests::textedit::set_style_sets_the_character_stroke`.
- **Paragraph alignment:** the shared Paragraph/Properties alignment row leaves every button unselected when the paragraphs it acts on differ. Auto resolves per paragraph; choosing an alignment uses the existing command. Headless paragraph_mixed_alignment_* regressions exercise mixed → Center → Undo, caret/uniform states, Unicode range boundaries, Auto/direction, split runs and grouped text. These regressions and full integration CI pass. Original captures confirm mixed → Center → Undo in the existing alignment-button layout. The [public Paragraph panel guide](https://helpx.adobe.com/illustrator/desktop/design-with-text/edit-format-text/paragraph-panel-overview.html) lists the seven controls, but does not specify mixed-state rendering. Other paragraph fields remain outside this change; scores and hours unchanged.
@@ -1892,6 +1892,7 @@ Edit › Copy and Cut put the selected ruler guides on the clipboard, with the s
| Date | Change | Summary |
|---|---|---|
+| 2026-11-09 | minor | Glyphs review (#6084): positional alternates with no supported feature are accepted as glyph choices in both panel views; Entire Font's unlisted-glyph and name-ownership behavior is retained. OpenType style options keep only a valid last on-value when enabling a set. Contradictory old glyph-choice evidence corrected; scores and estimates unchanged. |
| 2026-11-07 | minor | Type core: the Glyphs panel's Alternates for Current Selection applies the OpenType feature that draws a variant to the selected letter alone, so an Arabic word stays joined (`salt=2`; the style commands take `tag=N`), and Show › Entire Font lists the glyphs no character maps (#5514; [Replace characters with alternate glyphs](https://helpx.adobe.com/illustrator/desktop/design-with-text/special-characters-glyphs/replace-characters-with-alternate-glyphs.html)); evidence added; scores unchanged. |
| 2026-11-08 | minor | Floating tab groups collapse into the side icon rail and opened rail panels fold back into it (#5925, in part); tab groups remain intact. Scores and estimates unchanged. |
| 2026-11-07 | minor | File formats and Type core: `.ai`/EPS type keeps its paragraphs' tab stops, and right-aligned area type sets the text after a line's last tab against the right edge, the text before it at its stops (#6147); evidence added; scores unchanged. |
diff --git a/docs/type-parity.md b/docs/type-parity.md
index 4ce5569822..4647b4901a 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-11-08 · **Change:** minor (the Glyphs panel's Alternates for Current Selection applies the OpenType feature that draws a variant to that letter alone, so an Arabic word stays joined, and Entire Font lists the unencoded glyphs, #5514; right-aligned area type sets the text after a line's last tab against the right edge; a soft return in justified type no longer keeps its line from being justified; Character Styles panel: a double-click on a style's row opens its Character Style Options; the dialog keeps a fixed width and scrolls its sections, so its buttons stay on screen) · **Target:** Adobe Illustrator 2026 (30.x)
+> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-09 · **Change:** minor (Glyphs review: a positional variant no supported feature draws is accepted as a glyph choice; invalid or off stylistic-set values are not preserved when a set is enabled; the Glyphs panel's Alternates for Current Selection applies the OpenType feature that draws a variant to that letter alone, so an Arabic word stays joined, and Entire Font lists the unencoded glyphs, #5514; right-aligned area type sets the text after a line's last tab against the right edge; a soft return in justified type no longer keeps its line from being justified; Character Styles panel: a double-click on a style's row opens its Character Style Options; the dialog keeps a fixed width and scrolls its sections, so its buttons stay on screen) · **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
@@ -30,7 +30,7 @@ against Illustrator's public documentation; **estimated** unless noted.
| Copy or Cut selected characters, then paste as separate text art | done | A Type-range copy keeps the selected characters' font family, size and color when pasted on the canvas after leaving text editing, in the same document or another. On desktop the clipboard offers plain text to text consumers and rich art for canvas Paste; Paste Without Formatting while editing uses the destination text's style. `tests::clipboard::type_range_copy_stays_styled_when_pasted_as_art` (#5636) |
| Character size and horizontal scale after object/group scaling | done | #1034: fields and setters compensate the stored affine; point size follows the transformed em-height axis, not the glyph ink bounds. Range edits, keyboard increments, leading and baseline shift use document points; rotation, reflection and shear remain editable. |
| Preserve font sizes while scaling a group | missing | Separate enhancement; scaling still scales text with the other artwork. |
-| OpenType panel, Glyphs panel, alternates | done | Highlight Alternate Glyphs preference (#394, off by default) paints pink behind OpenType substitutions on screen when on (required shaping such as Arabic joining forms and the lam-alef ligature, including a lam-alef older fonts file under `liga`, is not highlighted; an optional feature that changes the ligature is); stylistic sets ss01–ss20, stylistic and titling alternates, proportional and lining figures, superscript and subscript are character-style features (`text.setStyle {features}`, #1607); the OpenType panel's Position menu sets superscript, subscript, numerator or denominator glyphs (`sups`, `subs`, `numr`, `dnom`). The OpenType panel's Stylistic Sets menu lists the selected font's `ss01`–`ss20` features, using a font-supplied name where available and `Set N` otherwise; several sets can be active, and selecting an active one removes it. For a mixed-font selection it uses the first selected character's font, a VectorCraft design choice ([public help](https://helpx.adobe.com/illustrator/desktop/design-with-text/special-characters-glyphs/add-stylistic-sets-to-selected-text.html), [OpenType feature specification](https://learn.microsoft.com/en-us/typography/opentype/spec/features_pt)). The Glyphs panel highlights the character selected with the Type tool (a range's first character, else the one before the caret) and scrolls it into view as the selection moves (#4908; `panels::glyphs` tests); its Show menu lists Unicode ranges and Alternates for Current Selection, the selected character's alternate glyphs from the font's `GSUB` table, a double-click applies the OpenType feature that draws one (`salt=N`, `ssNN`) to that character alone; a variant no feature reaches is drawn through `text.setGlyphAlternate`, which may not join; Show › Entire Font applies the selected letter's alternates the same way and dims the others with the reason; the glyph-cell pop-up of alternates is not there yet |
+| OpenType panel, Glyphs panel, alternates | done | Highlight Alternate Glyphs preference (#394, off by default) paints pink behind OpenType substitutions on screen when on (required shaping such as Arabic joining forms and the lam-alef ligature, including a lam-alef older fonts file under `liga`, is not highlighted; an optional feature that changes the ligature is); stylistic sets ss01–ss20, stylistic and titling alternates, proportional and lining figures, superscript and subscript are character-style features (`text.setStyle {features}`, #1607); the OpenType panel's Position menu sets superscript, subscript, numerator or denominator glyphs (`sups`, `subs`, `numr`, `dnom`). The OpenType panel's Stylistic Sets menu lists the selected font's `ss01`–`ss20` features, using a font-supplied name where available and `Set N` otherwise; several sets can be active, and selecting an active one removes it. For a mixed-font selection it uses the first selected character's font, a VectorCraft design choice ([public help](https://helpx.adobe.com/illustrator/desktop/design-with-text/special-characters-glyphs/add-stylistic-sets-to-selected-text.html), [OpenType feature specification](https://learn.microsoft.com/en-us/typography/opentype/spec/features_pt)). The Glyphs panel highlights the character selected with the Type tool (a range's first character, else the one before the caret) and scrolls it into view as the selection moves (#4908; `panels::glyphs` tests); its Show menu lists Unicode ranges and Alternates for Current Selection, the selected character's alternate glyphs from the font's `GSUB` table. A double-click applies the OpenType feature that draws a variant to that character alone, or uses a glyph choice when no supported feature draws it; see the Glyphs panel row below for Entire Font and the joining limits. The glyph-cell pop-up of alternates is not there yet |
| Glyphs panel: alternates as OpenType feature values, unencoded glyphs | done | #5514, as [Replace characters with alternate glyphs](https://helpx.adobe.com/illustrator/desktop/design-with-text/special-characters-glyphs/replace-characters-with-alternate-glyphs.html) describes (Show › Alternates For Current Selection; replace the selected character by double-clicking the glyph). A character-style feature takes a value (`"salt=2"`, the font's alternate number; `ss01`–`ss20`, `swsh`, `titl` and `aalt` too) in `features` and `featureChanges`, in SVG `font-feature-settings` (`"salt" 2`) and in native files as the plain string. Show › Entire Font applies an unencoded glyph the same way when it is an alternate of the selected character in its word, applies one no feature reaches (`A.salt` in a font with no `salt`) to the selected character as a glyph choice (it may not join), and dims the others with the reason in the tooltip (e.g. `yehfarsi.fina.alt2 belongs to ی (U+06CC) at the end of a word; your selection is ي (U+064A)`); an Arabic presentation form picked there inserts its letter so the word joins. Runs that differ only in their features shape as one piece of text, each run's features over its own characters, so a variant on one letter of an Arabic word keeps its neighbours' initial, medial and final forms and the letters still touch. The panel lists the alternates of the glyph the shaper makes for the selected character in its word (a medial letter shows medial variants), and a double-click applies the OpenType feature that draws the variant (`salt=N` or `ssNN`) to that character alone, in one undo step (`text.setRangeStyle`); a variant no feature reaches falls back to a replacement glyph (`text.setGlyphAlternate`), which its tooltip says may not join. Show › Entire Font also lists the glyphs no character maps, with their name or `gid N` and the feature that gives them. The text crate reads each face's `GSUB` once (alternate sets, single substitutions, the feature that reaches a glyph). Not done: alternates reached through contextual or ligature lookups, and features of the font that the style has no tag for (`cv01`–`cv99`, `hist`). Evidence: `tests::glyph_alternates` in the text crate (a fixture font with a medial teh at `salt=1` to `3`, the neighbours' forms, no gap; Kufi Murabba PIIIX when it is installed), `features::tests::feature_values_are_parsed_validated_and_shaped`, `tests::textedit::feature_values_are_set_replaced_and_saved`, the `panels::glyphs` tests. |
| Font menu: samples, live preview, filters, favourites, recent families; font matching by any name; Find Font | done | #3515: recently applied families appear first, once each; missing families are omitted and the existing Number of Recent Fonts setting applies; #339; script filter: Japanese, Latin, Thai; the font scan on Linux also reads the folders fontconfig's configuration lists, such as TeX fonts under `/usr/share/texmf/fonts` (#1548, fixes #1529; not verified on Linux); Select Text by Font takes a scope: the document, chosen artboards or chosen layers; Find Font's Find In offers Document, Artboard (the active one) and Layers (the selected ones, else the current one) (#5529, a VectorCraft addition); Replace With Font lists family and style rows, one click setting both, with a search over them; its list scrolls on its own, apart from the document's font list (#4972). |
| Missing glyphs set in an installed font made for their script (Thai, Lao, Khmer, Myanmar first; then any font that has them) | done | Thai: Leelawadee UI, Tahoma, Thonburi, Sukhumvit Set, Noto Sans Thai, TLWG |
@@ -82,6 +82,7 @@ Character Styles menu New/Redefine and footer New capture the first selected cha
| Date | Change | Summary |
|---|---|---|
+| 2026-11-09 | minor | Glyphs review (#6084): the engine accepts a positional alternate as a glyph choice when no supported feature draws it, not only an isolated character's alternates; both panel views exercise apply and Undo. OpenType style options preserve only a valid last on-value when enabling a stylistic set. Scores and estimates unchanged. |
| 2026-11-07 | minor | Glyphs panel (#5514): Alternates for Current Selection lists the alternates of the selected character's glyph in its word and a double-click applies the OpenType feature that draws one to that letter alone (`salt=2`; `features` and `featureChanges` take `tag=N`), so an Arabic word stays joined, and Entire Font lists the unencoded glyphs, applies the ones that are the selected letter's alternates, or that no feature reaches and the font's names do not give to another letter, and dims, with the reason, those that are not. Runs that differ only in their features shape as one piece of text. Scores and estimates unchanged. |
| 2026-11-07 | minor | Tabs: in right-aligned area type, the text before a line's last tab sits at its stops, measured from the left edge, and the text after it (none, when the tab ends the line) against the right edge; the line was aligned as a whole, which moved its start off the left edge. Illustrator's own layout in a user's `.ai` (used locally, not committed) sets its price lists so. Centred and justified lines keep their alignment as a whole. Scores and estimates unchanged. |
| 2026-11-07 | minor | Soft return: in Justify Left, Center or Right, the line a soft return ends is justified as a wrapped line is; only the paragraph's last line keeps its alignment. Illustrator's own layout in a user's `.ai` (used locally, not committed) justifies those lines. Scores and estimates unchanged. |
From 1ba9025583c10a2cf20c03543c284a10e0504464 Mon Sep 17 00:00:00 2001
From: Taha-Mahmoodi <85902429+Taha-Mahmoodi@users.noreply.github.com>
Date: Mon, 9 Nov 2026 17:35:08 +0430
Subject: [PATCH 12/86] Glyphs: warn for Entire Font glyph choices and fit
regression previews
---
crates/ui-egui/src/panels/glyphs.rs | 7 +++++++
1 file changed, 7 insertions(+)
diff --git a/crates/ui-egui/src/panels/glyphs.rs b/crates/ui-egui/src/panels/glyphs.rs
index 5922d4be4b..5a05c9d736 100644
--- a/crates/ui-egui/src/panels/glyphs.rs
+++ b/crates/ui-egui/src/panels/glyphs.rs
@@ -304,6 +304,12 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) {
let resp = resp.on_hover_ui(|ui| {
let tip = match (c, &pick) {
(Some(c), _) => encoded_tip(&face, c, gid),
+ (None, Some(Some(Pick::Glyph(_)))) => format!(
+ "{}\n{}\n{}",
+ unencoded_tip(&face, gid),
+ tl!("Double-click to replace"),
+ tl!("No OpenType feature draws this glyph here, so it replaces the character's glyph and may not join its neighbors.")
+ ),
(None, Some(Some(_))) => format!("{}\n{}", unencoded_tip(&face, gid), tl!("Double-click to replace")),
(None, Some(None)) => {
let why = fit.as_ref().map(|f| f.why(&face, gid)).unwrap_or_default();
@@ -1202,6 +1208,7 @@ mod tests {
for view in ["Alternates for Current Selection", "Entire Font"] {
let (mut app, id) = editing("\u{628}\u{646}\u{62A}");
app.run("text.setRangeStyle", json!({"id": id, "font": JOINING_ALTERNATES_FAMILY})).unwrap();
+ set_cell_index(&mut app, 0);
let plain = glyph_ids(&app, &id);
select(&mut app, 2, 4);
let ctx = egui::Context::default();
From cd11b62b74a8b50c299690007bf83d1989b9f69d Mon Sep 17 00:00:00 2001
From: Taha-Mahmoodi <85902429+Taha-Mahmoodi@users.noreply.github.com>
Date: Wed, 11 Nov 2026 13:13:48 +0430
Subject: [PATCH 13/86] Keep authored File Info titles unchanged on native
restore
---
crates/engine/src/cmd/fileio/load.rs | 9 ++++-----
crates/engine/src/tests/fileinfo.rs | 14 ++++++++++++++
docs/file-format-parity.md | 3 ++-
3 files changed, 20 insertions(+), 6 deletions(-)
diff --git a/crates/engine/src/cmd/fileio/load.rs b/crates/engine/src/cmd/fileio/load.rs
index 3824dbf226..26380383bd 100644
--- a/crates/engine/src/cmd/fileio/load.rs
+++ b/crates/engine/src/cmd/fileio/load.rs
@@ -522,12 +522,11 @@ pub fn load_with(name: &str, bytes: &[u8], opts: &LoadOptions) -> Result
super::super::colormgmt::set_color_mode(&mut doc, mode, true, None, opts.grays);
}
// The title the file gives itself (an SVG's ``, a PDF's, `.ai`'s or `.ait`'s XMP `dc:title`
- // or Info `Title`, which the readers put in File Info; the one a native document holds) is the
- // File Info title, cleaned as `file.info` takes one. A file restored from the editing data it
- // carries gives the title that document had, unless that is the file's name. The document's own
- // name stays what it has always been: the file's name (#3626).
+ // or Info `Title`, which the readers put in File Info) is separate from the document's name.
+ // Existing File Info is preserved exactly; only a restored document's legacy name fallback is
+ // cleaned as imported text. The document's name stays the file's name (#3626).
let held = restored.then_some(doc.title.as_str()).filter(|t| *t != title);
- doc.metadata.title = doc.metadata.title.as_deref().or(held).and_then(imported_title);
+ doc.metadata.title = doc.metadata.title.or_else(|| held.and_then(imported_title));
// Imports are named after the file; a native document keeps its own title (the tab shows the
// file name once it has a path).
if !matches!(format.id, "vectorcraft" | "template") || doc.title.is_empty() {
diff --git a/crates/engine/src/tests/fileinfo.rs b/crates/engine/src/tests/fileinfo.rs
index 249af96abc..daae68d86e 100644
--- a/crates/engine/src/tests/fileinfo.rs
+++ b/crates/engine/src/tests/fileinfo.rs
@@ -402,6 +402,20 @@ fn file_info_edits_a_files_own_title_and_the_document_follows() {
assert_eq!(titles(&mut s), ["box.svg", "Access paths"]);
}
+#[test]
+fn restored_file_info_title_keeps_authored_spaces() {
+ let mut s = session();
+ std::sync::Arc::make_mut(&mut s.doc_mut().unwrap().doc).metadata.title = Some("Release Plan".into());
+ for format in ["vectorcraft", "svg", "pdf"] {
+ let bytes = b64(&s.execute("document.export", &json!({"format": format, "preserveEditing": true})).unwrap());
+ let mut reopened = Session::new();
+ let reply = open_named(&mut reopened, &format!("again.{format}"), &bytes);
+ assert_eq!(reply["restored"], format != "vectorcraft", "{format}");
+ assert_eq!(reopened.doc().unwrap().doc.metadata.title.as_deref(), Some("Release Plan"), "{format}");
+ assert_eq!(titles(&mut reopened)[1], "Release Plan", "{format}");
+ }
+}
+
#[test]
fn the_files_own_title_is_kept_by_saves_and_written_by_exports() {
let mut s = Session::new();
diff --git a/docs/file-format-parity.md b/docs/file-format-parity.md
index 1b7b7ce089..159a53fe55 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-11 · **Change:** minor (`.ai` and EPS import: warps of type open as mesh envelopes on their saved grid, #3179; `.ai` and EPS import: stroked type keeps its stroke's cap, join and miter limit from the text document, #6472; `.ai` save: type in a variable font's named instance names its design-space setting, #6388; `.ai` and EPS import: envelopes of type open live where they draw as their drawn art and the layers with them draw as the page, #3179; `.ai` and EPS import: a note for type in an envelope, #3179; `.ai` and EPS import: the live envelope and warped type check sees white art; PDF import: DeviceN images of one ink or of process plates keep their inks, #6348; `.ai` import: images in a spot ink's Separation space keep their ink, #6350; PDF import: Separation images keep their ink, #6314; PDF editable import preserves authored ActualText once, including empty/nested spans and authored ordering (#6165); cross-line/style layout remains approximate; scores and estimates unchanged; PDF marked-text export (#6309): compatible glyphs stay on one layout line with per-glyph offsets; scores and estimates unchanged; PDF import: image masks open colorized by the color they are painted in, #6313; EPS and `.ai` editing data: a ligature the page names by a private-use glyph matches its text object; EPS import: CIDFonts only named, over a Unicode CMap, set as live type; EPS import: CMap mappings to base-font codes and glyph names; EPS import: CID-keyed fonts in FontSets (CFF) draw their glyphs; EPS import: `kshow` procedures kern live type; EPS import: embedded Type 1 fonts' glyphs and widths; EPS import: CID-keyed fonts draw their glyphs; EPS import: Type 42 fonts' characters and widths from the font they carry, show operators' spacing kept as tracking; EPS import: an image decoder's own buffers stop counting against the memory budget once it is decoded; EPS import: sampled functions interpolate between their samples; `.ai` and EPS type from the text document keeps its tab stops; it keeps auto leading and hyphenation options, and may show more of the file's last lines in an area frame; `.ai` and EPS import: Art brushes in the older form of their objects come into the Brushes panel; EPS import: gradients drawn as abutting strips come in as one gradient, without seams; EPS import: composite fonts draw their Type 42 glyphs; 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-11 · **Change:** minor (restored File Info titles retain authored spaces, #3626; previously: `.ai` and EPS import: warps of type open as mesh envelopes on their saved grid, #3179; `.ai` and EPS import: stroked type keeps its stroke's cap, join and miter limit from the text document, #6472; `.ai` save: type in a variable font's named instance names its design-space setting, #6388; `.ai` and EPS import: envelopes of type open live where they draw as their drawn art and the layers with them draw as the page, #3179; `.ai` and EPS import: a note for type in an envelope, #3179; `.ai` and EPS import: the live envelope and warped type check sees white art; PDF import: DeviceN images of one ink or of process plates keep their inks, #6348; `.ai` import: images in a spot ink's Separation space keep their ink, #6350; PDF import: Separation images keep their ink, #6314; PDF editable import preserves authored ActualText once, including empty/nested spans and authored ordering (#6165); cross-line/style layout remains approximate; scores and estimates unchanged; PDF marked-text export (#6309): compatible glyphs stay on one layout line with per-glyph offsets; scores and estimates unchanged; PDF import: image masks open colorized by the color they are painted in, #6313; EPS and `.ai` editing data: a ligature the page names by a private-use glyph matches its text object; EPS import: CIDFonts only named, over a Unicode CMap, set as live type; EPS import: CMap mappings to base-font codes and glyph names; EPS import: CID-keyed fonts in FontSets (CFF) draw their glyphs; EPS import: `kshow` procedures kern live type; EPS import: embedded Type 1 fonts' glyphs and widths; EPS import: CID-keyed fonts draw their glyphs; EPS import: Type 42 fonts' characters and widths from the font they carry, show operators' spacing kept as tracking; EPS import: an image decoder's own buffers stop counting against the memory budget once it is decoded; EPS import: sampled functions interpolate between their samples; `.ai` and EPS type from the text document keeps its tab stops; it keeps auto leading and hyphenation options, and may show more of the file's last lines in an area frame; `.ai` and EPS import: Art brushes in the older form of their objects come into the Brushes panel; EPS import: gradients drawn as abutting strips come in as one gradient, without seams; EPS import: composite fonts draw their Type 42 glyphs; 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
@@ -339,6 +339,7 @@ Stop opacity on translated artboards remains outside this regression coverage.
| Date | Change | Summary |
|---|---|---|
+| 2026-11-11 | minor | Native and Preserve Editing restores keep an existing File Info title exactly, including repeated spaces; imported-text fallback titles alone are normalized (#3626). |
| 2026-11-03 | minor | An imported document's own title goes into File Info only (#3626): an SVG's root ``, a PDF's, `.ai`'s or `.ait`'s XMP `dc:title` else its Info `Title` is File Info's title (`metadata.title` in the native format, left out when unset so other documents save unchanged and older versions ignore it; written by exports); the document's name, which the tab, Save As and the default export names use, stays the file's name. |
| 2026-11-11 | minor | `.ai` and EPS import: a warp of type (an envelope made with a warp around type) whose own warp, VectorCraft's, doesn't draw as its drawn art is tried again as a mesh envelope on the warp grid the file saves, with the same drawn-art check, budget and page comparison as #6421: where it passes, the type is editable and keeps the file's shape, and the warp's settings (style, bend) are lost. A second try is only made while more checks are left than there are envelopes of type and warped text objects still to come. Type with strokes in an envelope with Distort Appearance on is also tried mapped from its box with strokes, where the size the file saves for it doesn't already give that box (a rule chosen by measuring real files). An envelope's grid (warp, mesh or top object) now reads its way round from all its rows and columns, not its first row alone: a mesh bent far round (its end columns leaning more than halfway over) reads the way it runs, not turned a quarter round, and a grid symmetric about its middle reads the same wherever it is on the page. Tests `tests_native::a_warp_of_type_opens_as_a_mesh_on_its_saved_grid_and_keeps_its_shape_when_its_text_changes`, `a_warp_of_stroked_type_maps_from_its_bounds_with_strokes_where_its_drawn_art_does`, `a_warp_of_type_without_a_saved_grid_keeps_its_drawn_art`, `a_warp_of_type_tried_again_leaves_the_next_envelope_its_check`, `ai::tests::a_mesh_envelope_bent_far_round_opens_the_way_it_runs`, `a_mesh_envelope_bent_halfway_round_opens_the_same_way_wherever_it_is` (part of #3179) |
| 2026-11-11 | minor | `.ai` and EPS import: type stroked in the text document keeps its stroke's cap, join and miter limit (keys 61, 62 and 64 of its character attributes, numbered as PostScript's `setlinecap` and `setlinejoin`: 0 butt or miter, 1 round, 2 projecting or bevel). They were left at a miter join with a limit of 10 whatever the file said, so type the file strokes with round joins, or with the usual limit of 4 or less, drew spikes at its sharp corners, some many points long. In users' files, used locally and never committed, the page strokes the type with the cap, join and miter limit these keys give; no file had a bevel join, which is read by the same numbering. A value that isn't one of theirs keeps the default, a miter limit past 500 is 500, and type without a stroke keeps the defaults. Dashes are not read: no file had dashed type to tell which keys hold them. Tests `ate::tests::a_run_keeps_its_stroke_cap_join_and_miter_limit`, `tests_native::stroked_type_keeps_its_cap_join_and_miter_limit`; scores unchanged |
From 54f9c03dfffd90ad3b0bc3241be7753cf2b6fe7d Mon Sep 17 00:00:00 2001
From: Taha-Mahmoodi <85902429+Taha-Mahmoodi@users.noreply.github.com>
Date: Wed, 11 Nov 2026 19:32:15 +0430
Subject: [PATCH 14/86] Normalize imported SVG titles without changing restored
metadata
---
crates/engine/src/cmd/fileio/load.rs | 7 +++++--
crates/engine/src/tests/fileinfo.rs | 17 +++++++++++++++++
docs/file-format-parity.md | 3 ++-
3 files changed, 24 insertions(+), 3 deletions(-)
diff --git a/crates/engine/src/cmd/fileio/load.rs b/crates/engine/src/cmd/fileio/load.rs
index 26380383bd..97727c2251 100644
--- a/crates/engine/src/cmd/fileio/load.rs
+++ b/crates/engine/src/cmd/fileio/load.rs
@@ -523,8 +523,11 @@ pub fn load_with(name: &str, bytes: &[u8], opts: &LoadOptions) -> Result
}
// The title the file gives itself (an SVG's ``, a PDF's, `.ai`'s or `.ait`'s XMP `dc:title`
// or Info `Title`, which the readers put in File Info) is separate from the document's name.
- // Existing File Info is preserved exactly; only a restored document's legacy name fallback is
- // cleaned as imported text. The document's name stays the file's name (#3626).
+ // Restored File Info is preserved exactly; newly imported SVG candidates and a restored
+ // document's legacy name fallback are cleaned as imported text. The name stays the file's (#3626).
+ if !restored && matches!(format.id, "svg" | "svgz") {
+ doc.metadata.title = doc.metadata.title.as_deref().and_then(imported_title);
+ }
let held = restored.then_some(doc.title.as_str()).filter(|t| *t != title);
doc.metadata.title = doc.metadata.title.or_else(|| held.and_then(imported_title));
// Imports are named after the file; a native document keeps its own title (the tab shows the
diff --git a/crates/engine/src/tests/fileinfo.rs b/crates/engine/src/tests/fileinfo.rs
index daae68d86e..4d86a84b3c 100644
--- a/crates/engine/src/tests/fileinfo.rs
+++ b/crates/engine/src/tests/fileinfo.rs
@@ -365,6 +365,23 @@ fn import_puts_the_files_own_title_in_file_info_only() {
assert!(name.starts_with("Untitled-") && info == name, "{name} / {info}");
}
+#[test]
+fn imported_svg_titles_are_filtered_and_bounded() {
+ for (raw, expected) in [
+ ("Untitled-2".to_string(), None),
+ ("\u{7f}".to_string(), None),
+ ("Re\u{7f}lease Plan".to_string(), Some("Release Plan".to_string())),
+ ("λ".repeat(65_537), Some("λ".repeat(65_536))),
+ ] {
+ let svg = svg_titled(&format!("{raw} "));
+ for format in ["svg", "svgz"] {
+ let name = format!("box.{format}");
+ let bytes = if format == "svgz" { vectorcraft_svg::compress(&svg) } else { svg.as_bytes().to_vec() };
+ assert_eq!(opened(&name, &bytes), [name.clone(), expected.clone().unwrap_or(name)], "{format}");
+ }
+ }
+}
+
#[test]
fn a_file_with_a_title_opened_from_a_path_is_named_after_the_file() {
let dir = std::env::temp_dir().join(format!("vc-infotitle-open-{}", std::process::id()));
diff --git a/docs/file-format-parity.md b/docs/file-format-parity.md
index 11090d9d18..7f6407f5f0 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-11 · **Change:** minor (restored File Info titles retain authored spaces, #3626; `.ai` and EPS import, PDF import: tracking and manual kerning in horizontally scaled type take the Horizontal Scale, as the file's page spaces such type; `.ai` and EPS import: stroked type in an envelope maps from its glyphs grown by half its stroke, #3179; `.ai` and EPS import: warps of type open as mesh envelopes on their saved grid, #3179; `.ai` and EPS import: stroked type keeps its stroke's cap, join and miter limit from the text document, #6472; `.ai` save: type in a variable font's named instance names its design-space setting, #6388; `.ai` and EPS import: envelopes of type open live where they draw as their drawn art and the layers with them draw as the page, #3179; `.ai` and EPS import: a note for type in an envelope, #3179; `.ai` and EPS import: the live envelope and warped type check sees white art; PDF import: DeviceN images of one ink or of process plates keep their inks, #6348; `.ai` import: images in a spot ink's Separation space keep their ink, #6350; PDF import: Separation images keep their ink, #6314; PDF editable import preserves authored ActualText once, including empty/nested spans and authored ordering (#6165); cross-line/style layout remains approximate; scores and estimates unchanged; PDF marked-text export (#6309): compatible glyphs stay on one layout line with per-glyph offsets; scores and estimates unchanged; PDF import: image masks open colorized by the color they are painted in, #6313; EPS and `.ai` editing data: a ligature the page names by a private-use glyph matches its text object; EPS import: CIDFonts only named, over a Unicode CMap, set as live type; EPS import: CMap mappings to base-font codes and glyph names; EPS import: CID-keyed fonts in FontSets (CFF) draw their glyphs; EPS import: `kshow` procedures kern live type; EPS import: embedded Type 1 fonts' glyphs and widths; EPS import: CID-keyed fonts draw their glyphs; EPS import: Type 42 fonts' characters and widths from the font they carry, show operators' spacing kept as tracking; EPS import: an image decoder's own buffers stop counting against the memory budget once it is decoded; EPS import: sampled functions interpolate between their samples; `.ai` and EPS type from the text document keeps its tab stops; it keeps auto leading and hyphenation options, and may show more of the file's last lines in an area frame; `.ai` and EPS import: Art brushes in the older form of their objects come into the Brushes panel; EPS import: gradients drawn as abutting strips come in as one gradient, without seams; EPS import: composite fonts draw their Type 42 glyphs; 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-11 · **Change:** minor (newly imported SVG/SVGZ titles are filtered and bounded without changing restored titles, #3626; previously: restored File Info titles retain authored spaces, #3626; `.ai` and EPS import, PDF import: tracking and manual kerning in horizontally scaled type take the Horizontal Scale, as the file's page spaces such type; `.ai` and EPS import: stroked type in an envelope maps from its glyphs grown by half its stroke, #3179; `.ai` and EPS import: warps of type open as mesh envelopes on their saved grid, #3179; `.ai` and EPS import: stroked type keeps its stroke's cap, join and miter limit from the text document, #6472; `.ai` save: type in a variable font's named instance names its design-space setting, #6388; `.ai` and EPS import: envelopes of type open live where they draw as their drawn art and the layers with them draw as the page, #3179; `.ai` and EPS import: a note for type in an envelope, #3179; `.ai` and EPS import: the live envelope and warped type check sees white art; PDF import: DeviceN images of one ink or of process plates keep their inks, #6348; `.ai` import: images in a spot ink's Separation space keep their ink, #6350; PDF import: Separation images keep their ink, #6314; PDF editable import preserves authored ActualText once, including empty/nested spans and authored ordering (#6165); cross-line/style layout remains approximate; scores and estimates unchanged; PDF marked-text export (#6309): compatible glyphs stay on one layout line with per-glyph offsets; scores and estimates unchanged; PDF import: image masks open colorized by the color they are painted in, #6313; EPS and `.ai` editing data: a ligature the page names by a private-use glyph matches its text object; EPS import: CIDFonts only named, over a Unicode CMap, set as live type; EPS import: CMap mappings to base-font codes and glyph names; EPS import: CID-keyed fonts in FontSets (CFF) draw their glyphs; EPS import: `kshow` procedures kern live type; EPS import: embedded Type 1 fonts' glyphs and widths; EPS import: CID-keyed fonts draw their glyphs; EPS import: Type 42 fonts' characters and widths from the font they carry, show operators' spacing kept as tracking; EPS import: an image decoder's own buffers stop counting against the memory budget once it is decoded; EPS import: sampled functions interpolate between their samples; `.ai` and EPS type from the text document keeps its tab stops; it keeps auto leading and hyphenation options, and may show more of the file's last lines in an area frame; `.ai` and EPS import: Art brushes in the older form of their objects come into the Brushes panel; EPS import: gradients drawn as abutting strips come in as one gradient, without seams; EPS import: composite fonts draw their Type 42 glyphs; 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
@@ -339,6 +339,7 @@ Stop opacity on translated artboards remains outside this regression coverage.
| Date | Change | Summary |
|---|---|---|
+| 2026-11-11 | minor | Newly imported SVG/SVGZ titles use the imported-text rules: generated Untitled names and control-only titles give the filename fallback, other control characters are removed, and titles are limited to 65,536 characters. Native and Preserve Editing titles remain unchanged (#3626). |
| 2026-11-11 | minor | Native and Preserve Editing restores keep an existing File Info title exactly, including repeated spaces; imported-text fallback titles alone are normalized (#3626). |
| 2026-11-03 | minor | An imported document's own title goes into File Info only (#3626): an SVG's root ``, a PDF's, `.ai`'s or `.ait`'s XMP `dc:title` else its Info `Title` is File Info's title (`metadata.title` in the native format, left out when unset so other documents save unchanged and older versions ignore it; written by exports); the document's name, which the tab, Save As and the default export names use, stays the file's name. |
| 2026-11-11 | minor | `.ai` and EPS import, PDF import, SVG export and `.ai` save: type with a Horizontal Scale other than 100% and tracking or manual kerning is spaced as a `.ai`'s own page spaces it. Layout now measures tracking and manual kerning in thousandths of the em as the scale draws it (see type-parity.md, same date); they were added at the full em, so such type drifted from the page letter by letter. A `.ai`'s page writes that type's character spacing as the tracking itself in a text space as wide as the scale, which PDF scales by it (ISO 32000-1 §9.4.4). In 224 users' files (used locally, never committed), 93 render differently. Of the 25 whose render moved by more than 1% of its pixels against the page, 23 came closer. The note on the type differing from the page shows a smaller share in 5 files (11.0% to 4.4% at most) and a larger one in 2 (by 0.2 to 0.3 points). 9 more envelopes of type open live (113 to 122 of 203, none fewer in any file), and no file switched between its layers and its PDF part. PDF import: the tracking of horizontally scaled type is the file's `Tc` in thousandths of its size (it was that times the scale), the glyphs where they were (test `tests::text_placement::character_spacing_in_horizontally_scaled_type_is_the_files_tc`). SVG export writes the letter spacing in thousandths of the font size it writes (the vertical scale), so type scaled uniformly keeps its spacing in a viewer (test `text_layout::spacing_in_uniformly_scaled_type_takes_the_scale`); tracked type scaled horizontally alone still differs a little there, as before. A `.ai` saved here writes the same values as before; VectorCraft now lays them out as the file's own PDF part draws them. Scores and estimates unchanged. |
From 4f295e5fee8f98423720cf9b7cf370045c09d8ae Mon Sep 17 00:00:00 2001
From: ruchirguitar <113586074+ruchirguitar@users.noreply.github.com>
Date: Thu, 12 Nov 2026 01:06:28 +0530
Subject: [PATCH 15/86] UI: keep numeric field caret on short pointer drags
(#1326)
A pointer movement under the click tolerance was still treated as a tap,
so the focus click selected the whole numeric value. Any movement from the
press now keeps the caret; only a tap without movement selects all.
Regression test: a_sub_threshold_movement_released_as_a_click_keeps_its_caret.
---
crates/ui-egui/src/tests/numfields.rs | 17 +++++++++++++++++
crates/ui-egui/src/widgets/text.rs | 8 ++++----
docs/gaps.md | 2 +-
3 files changed, 22 insertions(+), 5 deletions(-)
diff --git a/crates/ui-egui/src/tests/numfields.rs b/crates/ui-egui/src/tests/numfields.rs
index 45801c22ec..d7ceed77b9 100644
--- a/crates/ui-egui/src/tests/numfields.rs
+++ b/crates/ui-egui/src/tests/numfields.rs
@@ -1361,6 +1361,23 @@ fn a_short_drag_released_as_a_click_keeps_its_caret() {
assert_eq!(f.frame(vec![enter()], &draw), Some(1273456.0), "typing lands where the drag left the caret");
}
+/// #1326: the smallest pointer movement egui still reports as a click (under the 3-point drag
+/// threshold) must keep the caret too; only a press released where it went down selects all.
+#[test]
+fn a_sub_threshold_movement_released_as_a_click_keeps_its_caret() {
+ let mut f = Field::new();
+ let draw = |ui: &mut egui::Ui| numeric_fixture(ui, 0);
+ f.frame(vec![], &draw);
+ let at = f.text_point(field_text(0), 2, 7.0);
+ f.frame(vec![Event::PointerMoved(at), pointer_button(at, true)], &draw);
+ let pressed = f.selection();
+ let nudge = egui::vec2(1.5, 0.0);
+ f.frame(vec![Event::PointerMoved(at + nudge)], &draw);
+ f.frame(vec![pointer_button(at + nudge, false)], &draw);
+ assert_ne!(f.selection(), Some((0, field_text(0).chars().count())), "a 1.5 px movement is a drag, not a tap (#1326)");
+ assert_eq!(f.selection(), pressed, "the movement keeps the caret where the press landed");
+}
+
/// A tap (no movement) on a fresh numeric field still selects all on release.
#[test]
fn a_tap_without_movement_still_selects_all_on_release() {
diff --git a/crates/ui-egui/src/widgets/text.rs b/crates/ui-egui/src/widgets/text.rs
index 3551baebd2..5ede108998 100644
--- a/crates/ui-egui/src/widgets/text.rs
+++ b/crates/ui-egui/src/widgets/text.rs
@@ -171,13 +171,13 @@ pub(super) fn select_all_on_focus(ui: &Ui, resp: &Response, text: &str) {
});
}
let clicked_focus = released && ui.data_mut(|d| d.remove_temp::(pending)).unwrap_or(false) && resp.clicked_by(egui::PointerButton::Primary);
- // A press released a few points away is a short drag, not a tap. egui still reports a
- // click, and a release inside one character leaves no text selection, so select-all on
- // release would replace the drag the user just made (#1326).
+ // Any pointer movement from the press is a drag, not a tap. egui still reports a click for
+ // a movement under its click tolerance, and a release inside one character leaves no text
+ // selection, so select-all on release would replace the drag the user just made (#1326).
let dragged = clicked_focus
&& ui
.data_mut(|d| d.remove_temp::(press_pos))
- .is_some_and(|p0| ui.input(|i| i.pointer.interact_pos()).is_some_and(|p1| (p1 - p0).length() > 3.0));
+ .is_some_and(|p0| ui.input(|i| i.pointer.interact_pos()).is_some_and(|p1| (p1 - p0).length() > 0.0));
if resp.lost_focus() {
ui.data_mut(|d| {
d.remove_temp::(pending);
diff --git a/docs/gaps.md b/docs/gaps.md
index 07308c801a..be3ffe3f41 100644
--- a/docs/gaps.md
+++ b/docs/gaps.md
@@ -320,7 +320,7 @@ macOS cursor delivery (#2292) is narrowed to a native host adapter using the exi
- **Done:** Divide splits filled paths along open cutting lines and curves (#1307), preserving the frontmost filled appearance, implicit filled closure, holes, unrelated objects and one Undo/Redo; the existing bounded-map fallback remains.
-- **Done:** Numeric fields keep a text drag's start when the pointer gains focus and movement exceeds the click threshold (#1326, in part). A completed focus click selects the whole value; keyboard focus, double-click and numeric stepping remain regression-tested. Short movements still classified as clicks can select all; that case remains open.
+- **Done:** Numeric fields keep a text drag's start when the pointer gains focus, including short movements that egui still classifies as clicks (#1326). Any pointer movement from the press keeps the caret; only a tap without movement selects the whole value. Keyboard focus, double-click and numeric stepping remain regression-tested (`tests/numfields.rs`, `a_short_drag_released_as_a_click_keeps_its_caret` and `a_sub_threshold_movement_released_as_a_click_keeps_its_caret`).
- **Done:** DXF reads control-point SPLINE closure (#1299 follow-up after #1320); open curves keep their open state. DXF export now recognizes circular cubic paths conservatively and writes native CIRCLE/ARC where stroke semantics permit; R12 weighted strokes remain POLYLINE, and a non-circular ellipse keeps the existing fallback. Alter Paths for Appearance outlines only the strokes a CAD line can't draw (aligned, profiled, offset dashes, wider than 2.11 mm, or in R13 and R14), so a 1 pt circle or arc stays a CIRCLE or ARC. CNC preset and VCarve QA remain open. Escape leaves Artboard editing for Selection; during a drag it first cancels the gesture. Headless egui key events cover idle Escape, active-drag cancellation and a subsequent Escape in `tests/distortkeys.rs`.
- **Done:** DXF reads control-point SPLINE closure (#1299 follow-up after #1320); open curves keep their open state. DXF export now recognizes circular cubic paths conservatively and writes native CIRCLE/ARC where stroke semantics permit; R12 weighted strokes remain POLYLINE. A closed four-cubic ellipse, or a circle scaled out of proportion, is written as a native ELLIPSE in R13 and later; R12 keeps the existing fallback. CNC preset and VCarve QA remain open. Escape leaves Artboard editing for Selection; during a drag it first cancels the gesture. Headless egui key events cover idle Escape, active-drag cancellation and a subsequent Escape in `tests/distortkeys.rs`.
From f0e50437c22649353e53f80ac6f572d7df686420 Mon Sep 17 00:00:00 2001
From: Ahmed Gomaa
Date: Wed, 11 Nov 2026 23:16:03 +0200
Subject: [PATCH 16/86] Save on a read-only location offers Save As
A plain Save that the location refuses (read-only filesystem or permission denied) now opens Save As with the name filled in, instead of ending in an error dialog. The refusal travels as a typed engine error (READ_ONLY_LOCATION), so the control protocol and MCP still return an error and never open a panel. Recovery failures, explicit destinations, a busy picker and Close/Quit keep their existing flow.
Closes #6146
---
apps/vectorcraft/src/host_services.rs | 2 +
crates/engine/src/cmd/fileio.rs | 11 +-
crates/engine/src/cmd/fileio/access.rs | 25 ++
crates/engine/src/cmd/fileio/save.rs | 9 +-
.../engine/src/cmd/fileio/tests_readonly.rs | 52 ++++
crates/engine/src/error.rs | 7 +
crates/engine/src/lib.rs | 2 +-
crates/format/src/atomic.rs | 48 +++-
crates/format/src/lib.rs | 2 +-
crates/mcp/src/tests/fileio.rs | 23 ++
crates/ui-egui/src/io.rs | 5 +
crates/ui-egui/src/io/read_only_save.rs | 64 +++++
crates/ui-egui/src/io/save.rs | 96 ++++++-
crates/ui-egui/src/io/tests_read_only_save.rs | 266 ++++++++++++++++++
crates/ui-egui/src/lib.rs | 3 +-
crates/ui-egui/src/menus/run.rs | 2 +-
crates/ui-egui/src/menus/ui_commands.rs | 2 +-
crates/ui-egui/src/picks.rs | 9 +
crates/ui-egui/src/services.rs | 11 +
docs/control-protocol.md | 25 +-
docs/file-format-parity.md | 5 +
docs/gaps.md | 5 +-
docs/mcp.md | 25 +-
docs/roadmap.md | 5 +-
docs/target-app-parity.md | 9 +-
docs/ui-parity.md | 4 +
26 files changed, 684 insertions(+), 33 deletions(-)
create mode 100644 crates/engine/src/cmd/fileio/tests_readonly.rs
create mode 100644 crates/ui-egui/src/io/read_only_save.rs
create mode 100644 crates/ui-egui/src/io/tests_read_only_save.rs
diff --git a/apps/vectorcraft/src/host_services.rs b/apps/vectorcraft/src/host_services.rs
index 187c42cde7..bd7c8aec80 100644
--- a/apps/vectorcraft/src/host_services.rs
+++ b/apps/vectorcraft/src/host_services.rs
@@ -358,6 +358,8 @@ pub(crate) fn services(parent: Parent, ui: egui::Context) -> Services {
exists: Some(Box::new(|p: &str| std::path::Path::new(p).exists())),
// Background Save and Export write from a worker thread.
write_shared: Some(std::sync::Arc::new(write_file)),
+ save_write: Some(std::sync::Arc::new(fileio::write_save_file)),
+ save_stream: Some(std::sync::Arc::new(fileio::write_save_file_with)),
// Native saves stream into the file as they are encoded.
stream_shared: Some(std::sync::Arc::new(|p: &str, fill: &mut dyn FnMut(&mut dyn std::io::Write) -> std::io::Result<()>| {
fileio::write_file_with(p, fill).map_err(|e| e.to_string())
diff --git a/crates/engine/src/cmd/fileio.rs b/crates/engine/src/cmd/fileio.rs
index 04a443ed4c..2b7969a434 100644
--- a/crates/engine/src/cmd/fileio.rs
+++ b/crates/engine/src/cmd/fileio.rs
@@ -89,9 +89,9 @@ pub use pdfimport::{LoadOptions, page_document};
pub(crate) use save::job_for;
pub(crate) use save::save as save_document;
pub use save::{
- SAVE_FORMATS, SaveJob, SaveMode, SavePlan, StreamWrite, TEMPLATE_FORMATS, dialog_folder, export_folder, reads_option as save_reads_option,
- remembered_save_options, save_filters, save_format, save_job, save_plan, save_with, stamp_save_dates, template_filters, templates_dialog_folder,
- templates_folder,
+ SAVE_FORMATS, SaveJob, SaveMode, SavePlan, StreamWrite, TEMPLATE_FORMATS, dialog_folder, documents_dialog_folder, export_folder,
+ reads_option as save_reads_option, remembered_save_options, save_filters, save_format, save_job, save_plan, save_with, stamp_save_dates,
+ template_filters, templates_dialog_folder, templates_folder,
};
pub(crate) use screens::{
PARAM_KEYS as SCREEN_PARAM_KEYS, ROW_KEYS as SCREEN_ROW_KEYS, UNWRITTEN_FORMATS as SCREEN_UNWRITTEN_FORMATS,
@@ -120,7 +120,10 @@ mod formats;
#[cfg(not(target_arch = "wasm32"))]
pub(crate) use access::list_dir;
-pub use access::write_file_with;
+pub use access::{write_file_with, write_save_file, write_save_file_with};
+
+#[cfg(all(test, not(target_arch = "wasm32")))]
+mod tests_readonly;
#[cfg(not(target_arch = "wasm32"))]
pub(crate) use access::{Placed, copy_new, file_metadata};
pub(crate) use access::{absolute_path, create_dir, document_path, file_created, file_stamp, read_file, read_file_head, write_file, write_new_file};
diff --git a/crates/engine/src/cmd/fileio/access.rs b/crates/engine/src/cmd/fileio/access.rs
index 4dce2023bb..0e69147a32 100644
--- a/crates/engine/src/cmd/fileio/access.rs
+++ b/crates/engine/src/cmd/fileio/access.rs
@@ -89,6 +89,31 @@ pub fn write_file_with(path: &str, fill: &mut dyn FnMut(&mut dyn std::io::Write)
vectorcraft_format::write_atomic_with(std::path::Path::new(path), |f| fill(f)).map_err(|e| EngineError::Other(format!("{path}: {e}")))
}
+/// The save commands' writer: preserves a filesystem location refusal as a typed error. Other
+/// writers keep their existing errors; an automation-root refusal is never reclassified.
+pub fn write_save_file(path: &str, bytes: &[u8]) -> Result<()> {
+ write_save_file_with(path, &mut |w| w.write_all(bytes))
+}
+
+/// [`write_save_file`] for a streamed save.
+#[cfg(not(target_arch = "wasm32"))]
+pub fn write_save_file_with(path: &str, fill: &mut dyn FnMut(&mut dyn std::io::Write) -> std::io::Result<()>) -> Result<()> {
+ vectorcraft_doc::cancel::check()?;
+ may_write(path)?;
+ vectorcraft_format::write_atomic_with(std::path::Path::new(path), |f| fill(f)).map_err(|e| save_write_error(path, e))
+}
+
+#[cfg(not(target_arch = "wasm32"))]
+pub(super) fn save_write_error(path: &str, error: std::io::Error) -> EngineError {
+ let reason = format!("{path}: {error}");
+ if vectorcraft_format::read_only_location(&error) { EngineError::ReadOnlyLocation(reason) } else { EngineError::Other(reason) }
+}
+
+#[cfg(target_arch = "wasm32")]
+pub fn write_save_file_with(path: &str, fill: &mut dyn FnMut(&mut dyn std::io::Write) -> std::io::Result<()>) -> Result<()> {
+ write_file_with(path, fill)
+}
+
#[cfg(not(target_arch = "wasm32"))]
pub(crate) fn create_dir(path: &str) -> Result<()> {
may_write(path)?;
diff --git a/crates/engine/src/cmd/fileio/save.rs b/crates/engine/src/cmd/fileio/save.rs
index 9f4e5b0572..791c25a9a2 100644
--- a/crates/engine/src/cmd/fileio/save.rs
+++ b/crates/engine/src/cmd/fileio/save.rs
@@ -157,6 +157,11 @@ fn documents_folder() -> Option {
Some(home.join("Documents"))
}
+/// A Save As after a refused Save starts in Documents, else home, never the refused folder.
+pub fn documents_dialog_folder() -> Option {
+ documents_folder().filter(|p| p.is_dir()).map(|p| p.to_string_lossy().into_owned()).or_else(home_folder)
+}
+
/// `XDG_DOCUMENTS_DIR` in a `user-dirs.dirs` file's `text`, `$HOME` being `home`. None when it isn't
/// set, is relative, or is the home folder itself (the spec's way of turning it off).
fn xdg_documents(text: &str, home: &std::path::Path) -> Option {
@@ -1032,7 +1037,7 @@ pub(crate) fn save(s: &mut Session, mode: SaveMode, p: &Value) -> Result
let plan = save_plan(s, mode, p)?;
let job = save_job(s, plan)?;
let uid = s.doc()?.uid;
- let out = job.write_streamed(&mut super::write_file_with)?;
+ let out = job.write_streamed(&mut super::write_save_file_with)?;
job.complete(s, uid);
Ok(out)
}
@@ -1104,7 +1109,7 @@ pub(super) fn specs() -> Vec {
"Save Document",
[],
None,
- "{path?, format?: vectorcraft|template|pdf|svg|svgz|ai|eps (default: the path's extension, else the document's own format), options?: {…the format's options, see file.formatOptions; default: as last saved}, svg?: {…SVG options} (an SVG save keeps hidden layers, display:none, unless hiddenLayers is false), …the SVG and PDF options given flat instead (an option given flat or in svg and in options with different values is an error), compress?: bool (native, also in options: gzip; default: the useCompression preference; warnings say when the format doesn't read compress, version, preview or precision given flat, or a key given in options), version?: 4 (3, 2 or 1: for older VectorCraft versions; 1 and 2 are never compressed), preview?: false (embed a PNG of the first artboard, at most 256 px), precision?: 0..9 (native: round path anchors and handles to this many decimal places, a smaller file; colors, transforms and type stay exact; default: exact, and a later save without it writes exactly again), separateArtboards?: false (native and .ai: also save each artboard of `range` to -. beside the file, holding that artboard and the art touching it; the result's files lists every file written), range?: \"1-3, 5\"|\"all\" (default; with separateArtboards), includeLinked?: false (keep linked files' own pixels, not just their previews; they stay linked), embedProfiles?: true (carry the ICC profiles loaded from files that the document is tagged with; opening the file installs them where missing), pdfCompatible?: true (native: also carry a PDF of every artboard; false omits it) | true for .ai (false: blank pages, only the native document; a .ai file's compress, default true, is its compression.compressText), modified?: Unix seconds|null (the File Info modified date, and created date when there is none, a save to a file stamps; default now, recorded in the journal so a replay matches; null: leave the dates)} → {path, format, bytes, warnings, files?: [path…] (the file and its artboards' files), linked?: [path…] (images an SVG links to)}. Save writes one artboard, except a .ai file: a PDF-compatible file of every artboard carrying the native document (preserveEditing always on; PDF options flat or in options), which document.open restores exactly, on the active artboard (the app's, else the one the document opened on; with editingData: false, the first); separateArtboards writes more files, one per artboard. EPS saves the chosen artboard (first by default), retaining its page margins and the whole editable document; its PostScript appearance flattens transparency, as EPS requires. Without a path it writes the document's own file in its own format: a document opened from or saved as SVG/PDF/EPS saves as that again (warnings name what the format loses). No path known (never saved, converted from an older version, restored by Data Recovery, or another format) → {dataBase64, format, name, folder?, warnings} and the document stays modified. A key that no save format takes, beside the path or in options, and an unknown key inside a PDF option's object or svg, are refused",
+ "{path?, format?: vectorcraft|template|pdf|svg|svgz|ai|eps (default: the path's extension, else the document's own format), options?: {…the format's options, see file.formatOptions; default: as last saved}, svg?: {…SVG options} (an SVG save keeps hidden layers, display:none, unless hiddenLayers is false), …the SVG and PDF options given flat instead (an option given flat or in svg and in options with different values is an error), compress?: bool (native, also in options: gzip; default: the useCompression preference; warnings say when the format doesn't read compress, version, preview or precision given flat, or a key given in options), version?: 4 (3, 2 or 1: for older VectorCraft versions; 1 and 2 are never compressed), preview?: false (embed a PNG of the first artboard, at most 256 px), precision?: 0..9 (native: round path anchors and handles to this many decimal places, a smaller file; colors, transforms and type stay exact; default: exact, and a later save without it writes exactly again), separateArtboards?: false (native and .ai: also save each artboard of `range` to -. beside the file, holding that artboard and the art touching it; the result's files lists every file written), range?: \"1-3, 5\"|\"all\" (default; with separateArtboards), includeLinked?: false (keep linked files' own pixels, not just their previews; they stay linked), embedProfiles?: true (carry the ICC profiles loaded from files that the document is tagged with; opening the file installs them where missing), pdfCompatible?: true (native: also carry a PDF of every artboard; false omits it) | true for .ai (false: blank pages, only the native document; a .ai file's compress, default true, is its compression.compressText), modified?: Unix seconds|null (the File Info modified date, and created date when there is none, a save to a file stamps; default now, recorded in the journal so a replay matches; null: leave the dates)} → {path, format, bytes, warnings, files?: [path…] (the file and its artboards' files), linked?: [path…] (images an SVG links to)}. Save writes one artboard, except a .ai file: a PDF-compatible file of every artboard carrying the native document (preserveEditing always on; PDF options flat or in options), which document.open restores exactly, on the active artboard (the app's, else the one the document opened on; with editingData: false, the first); separateArtboards writes more files, one per artboard. EPS saves the chosen artboard (first by default), retaining its page margins and the whole editable document; its PostScript appearance flattens transparency, as EPS requires. Without a path it writes the document's own file in its own format: a document opened from or saved as SVG/PDF/EPS saves as that again (warnings name what the format loses). No path known (never saved, converted from an older version, restored by Data Recovery, or another format) → {dataBase64, format, name, folder?, warnings} and the document stays modified. A key that no save format takes, beside the path or in options, and an unknown key inside a PDF option's object or svg, are refused. A terminal filesystem permission/read-only refusal returns READ_ONLY_LOCATION: followed by the reason; recovery-copy failures keep their original error. Non-UI callers never open a save panel",
has_doc,
|s, p| save(s, SaveMode::Save, p)
),
diff --git a/crates/engine/src/cmd/fileio/tests_readonly.rs b/crates/engine/src/cmd/fileio/tests_readonly.rs
new file mode 100644
index 0000000000..badf82a8cf
--- /dev/null
+++ b/crates/engine/src/cmd/fileio/tests_readonly.rs
@@ -0,0 +1,52 @@
+//! Save errors retain their type without changing the other filesystem writers (#6146).
+
+use std::io::{self, ErrorKind};
+
+use serde_json::json;
+
+use super::{SaveMode, access, save_plan, save_with, write_file_with, write_save_file_with};
+use crate::{EngineError, READ_ONLY_LOCATION, Session};
+
+#[test]
+fn readonly_error_is_typed_and_other_writers_keep_their_error() {
+ let dir = vectorcraft_testkit::temp_dir("save-refusal");
+ let path = dir.join("Poster.vectorcraft").to_string_lossy().into_owned();
+ for kind in [ErrorKind::PermissionDenied, ErrorKind::ReadOnlyFilesystem, ErrorKind::StorageFull, ErrorKind::NotFound, ErrorKind::Other] {
+ let typed = write_save_file_with(&path, &mut |_| Err(io::Error::new(kind, "injected"))).unwrap_err();
+ let ordinary = write_file_with(&path, &mut |_| Err(io::Error::new(kind, "injected"))).unwrap_err();
+ let reason = format!("{path}: injected");
+ assert!(matches!(&ordinary, EngineError::Other(_)));
+ assert_eq!(ordinary.to_string(), reason);
+ if matches!(kind, ErrorKind::PermissionDenied | ErrorKind::ReadOnlyFilesystem) {
+ assert!(matches!(&typed, EngineError::ReadOnlyLocation(_)));
+ assert_eq!(typed.to_string(), format!("{READ_ONLY_LOCATION}: {reason}"));
+ } else {
+ assert!(matches!(&typed, EngineError::Other(_)));
+ assert_eq!(typed.to_string(), reason);
+ }
+ assert!(!std::path::Path::new(&path).exists());
+ }
+ std::fs::remove_dir_all(dir).unwrap();
+}
+
+#[test]
+fn readonly_failed_save_keeps_document_and_options() {
+ let mut session = Session::new();
+ session.execute("file.new", &json!({"width": 40, "height": 40})).unwrap();
+ session.execute("shape.rectangle", &json!({"x": 10, "y": 10, "width": 10, "height": 10})).unwrap();
+ let st = session.doc_mut().unwrap();
+ st.path = Some("Poster.svg".into());
+ st.format = "svg";
+ st.save_options = json!({"decimals": 3}).as_object().unwrap().clone();
+ let before = serde_json::to_value(&*st.doc).unwrap();
+ let options = st.save_options.clone();
+ let plan = save_plan(&session, SaveMode::Save, &json!({"modified": null})).unwrap();
+ let e = save_with(&mut session, plan, |path, _| Err(access::save_write_error(path, io::Error::from(ErrorKind::PermissionDenied)))).unwrap_err();
+ assert!(matches!(e, EngineError::ReadOnlyLocation(_)));
+ let st = session.doc().unwrap();
+ assert_eq!(st.path.as_deref(), Some("Poster.svg"));
+ assert_eq!(st.format, "svg");
+ assert_eq!(st.save_options, options);
+ assert!(st.is_dirty());
+ assert_eq!(serde_json::to_value(&*st.doc).unwrap(), before);
+}
diff --git a/crates/engine/src/error.rs b/crates/engine/src/error.rs
index d32fda613d..6ab1608bd7 100644
--- a/crates/engine/src/error.rs
+++ b/crates/engine/src/error.rs
@@ -3,6 +3,9 @@ use super::{NodeId, cancel};
/// The text of [`EngineError::Busy`], which ends every error a frontend reports for it.
pub const SCRIPT_BUSY: &str = "a script is running: stop it or wait for it to finish";
+/// Stable prefix for a save refused by the filesystem: choose another destination.
+pub const READ_ONLY_LOCATION: &str = "READ_ONLY_LOCATION";
+
#[derive(Debug, thiserror::Error)]
pub enum EngineError {
#[error("unknown command `{0}`")]
@@ -20,6 +23,10 @@ pub enum EngineError {
NoNode(NodeId),
#[error("{0}")]
Other(String),
+ /// The save's terminal filesystem error refuses writes here. The reason keeps the path and
+ /// system message. This never means an automation-root refusal or a recovery-copy failure.
+ #[error("{code}: {0}", code = READ_ONLY_LOCATION)]
+ ReadOnlyLocation(String),
/// The caller asked for the command to stop ([`cancel`]). It changed nothing.
#[error("cancelled; nothing was changed")]
Cancelled,
diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs
index 091348a6bd..6bc570623d 100644
--- a/crates/engine/src/lib.rs
+++ b/crates/engine/src/lib.rs
@@ -370,7 +370,7 @@ pub(crate) use command_sets::SELECTS_GIVEN_IDS;
use command_sets::given_ids;
pub use command_sets::{FIT_ACTIVE_ARTBOARD, ON_ACTIVE_ARTBOARD, waits_for_tool_edit};
pub use doc_state::{DocState, OpenRows, RecalledSelection};
-pub use error::{EngineError, Result, SCRIPT_BUSY};
+pub use error::{EngineError, READ_ONLY_LOCATION, Result, SCRIPT_BUSY};
#[cfg(test)]
use history::history_budget_for;
pub use history::{History, HistoryEntry, NamedStep, default_history_budget};
diff --git a/crates/format/src/atomic.rs b/crates/format/src/atomic.rs
index 863c6fdd34..8e7149cad2 100644
--- a/crates/format/src/atomic.rs
+++ b/crates/format/src/atomic.rs
@@ -18,6 +18,25 @@ use std::io::{self, Write as _};
use std::path::{Path, PathBuf};
use std::sync::atomic::{AtomicU64, Ordering};
+/// Whether the returned write error refuses this location. Recovery errors must still be shown:
+/// their message names copies the user may need, even when their kind is PermissionDenied.
+pub fn read_only_location(error: &io::Error) -> bool {
+ matches!(error.kind(), io::ErrorKind::ReadOnlyFilesystem | io::ErrorKind::PermissionDenied)
+ && !error.get_ref().is_some_and(|e| e.is::())
+}
+
+/// Marks the existing recovery message without changing its text or the returned error kind.
+#[derive(Debug)]
+struct RecoveryError(String);
+
+impl std::fmt::Display for RecoveryError {
+ fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
+ f.write_str(&self.0)
+ }
+}
+
+impl std::error::Error for RecoveryError {}
+
/// Write `bytes` to `path` atomically (see the module docs).
pub fn write_atomic(path: &Path, bytes: &[u8]) -> io::Result<()> {
write_atomic_with(path, |f| f.write_all(bytes))
@@ -102,7 +121,7 @@ fn write_over(tmp: &Path, target: &Path, refused: io::Error) -> io::Result<()> {
let _ = std::fs::remove_file(old);
let why =
format!("{refused}, and the file can't be copied aside before it is overwritten ({e}); the new content is kept in {}", tmp.display());
- return Err(io::Error::new(refused.kind(), why));
+ return Err(io::Error::new(refused.kind(), RecoveryError(why)));
}
let copied = to.set_len(0).and_then(|()| io::copy(&mut from, &mut to)).and_then(|_| sync_file(&to));
drop((from, to));
@@ -115,10 +134,11 @@ fn write_over(tmp: &Path, target: &Path, refused: io::Error) -> io::Result<()> {
}
Ok(())
}
- (Err(e), Some(old)) => {
- Err(io::Error::new(e.kind(), format!("{e} (the new content is kept in {}, the file as it was in {})", tmp.display(), old.display())))
- }
- (Err(e), None) => Err(io::Error::new(e.kind(), format!("{e} (the new content is kept in {})", tmp.display()))),
+ (Err(e), Some(old)) => Err(io::Error::new(
+ e.kind(),
+ RecoveryError(format!("{e} (the new content is kept in {}, the file as it was in {})", tmp.display(), old.display())),
+ )),
+ (Err(e), None) => Err(io::Error::new(e.kind(), RecoveryError(format!("{e} (the new content is kept in {})", tmp.display())))),
}
}
@@ -318,6 +338,24 @@ fn keep_permissions(_: &Path, _: &Path) -> io::Result<()> {
mod tests {
use super::*;
+ #[test]
+ fn readonly_classification_uses_the_terminal_kind_and_excludes_recovery() {
+ for (kind, expected) in [
+ (io::ErrorKind::PermissionDenied, true),
+ (io::ErrorKind::ReadOnlyFilesystem, true),
+ (io::ErrorKind::StorageFull, false),
+ (io::ErrorKind::QuotaExceeded, false),
+ (io::ErrorKind::NotFound, false),
+ (io::ErrorKind::Other, false),
+ ] {
+ assert_eq!(read_only_location(&io::Error::new(kind, "injected")), expected, "{kind:?}");
+ let recovery = io::Error::new(kind, RecoveryError("the new content is kept in a.tmp".into()));
+ assert!(!read_only_location(&recovery), "recovery: {kind:?}");
+ assert_eq!(recovery.kind(), kind);
+ assert_eq!(recovery.to_string(), "the new content is kept in a.tmp");
+ }
+ }
+
fn dir(name: &str) -> PathBuf {
let d = std::env::temp_dir().join(format!("vc-atomic-{name}-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&d);
diff --git a/crates/format/src/lib.rs b/crates/format/src/lib.rs
index 38545b8292..ed1777b6de 100644
--- a/crates/format/src/lib.rs
+++ b/crates/format/src/lib.rs
@@ -55,7 +55,7 @@ use serde::{Deserializer, Serialize};
use vectorcraft_doc::{Document, ImageBlob};
#[cfg(not(target_arch = "wasm32"))]
-pub use atomic::{sync_file, write_atomic, write_atomic_with, write_new_with};
+pub use atomic::{read_only_location, sync_file, write_atomic, write_atomic_with, write_new_with};
pub use craft_vectorcraft_format::base64::{decode as base64_decode, decoded_len as base64_decoded_len, encode as base64_encode};
pub use craft_vectorcraft_format::{
BLOB_MAGIC, COMPRESSED_SINCE, EXTENSION, ICC_MIME, INLINE_MAX, LEGACY_EXTENSION, MIN_VERSION, PREVIEW_MAX, VERSION, is_compressed, json_part,
diff --git a/crates/mcp/src/tests/fileio.rs b/crates/mcp/src/tests/fileio.rs
index 00ff3a2d77..d29161a68b 100644
--- a/crates/mcp/src/tests/fileio.rs
+++ b/crates/mcp/src/tests/fileio.rs
@@ -357,6 +357,29 @@ fn save_file_waits_for_the_file_in_the_app() {
}
}
+#[test]
+fn readonly_save_file_keeps_the_stable_error_prefix() {
+ struct Refused(bool);
+ impl Backend for Refused {
+ fn call(&mut self, method: &str, params: Value) -> Result {
+ assert_eq!(method, "app.save");
+ assert_eq!(params.get("wait").and_then(Value::as_bool), self.0.then_some(true));
+ Err(vectorcraft_engine::EngineError::ReadOnlyLocation("Poster.svg: injected refusal".into()).to_string())
+ }
+ fn has_ui(&self) -> bool {
+ self.0
+ }
+ fn describe(&self) -> String {
+ "injected save refusal".into()
+ }
+ }
+ for desktop in [false, true] {
+ let r = call_tool(&mut Refused(desktop), "save_file", &json!({}));
+ assert!(r.is_error);
+ assert_eq!(r.content[0]["text"], "save_file: READ_ONLY_LOCATION: Poster.svg: injected refusal");
+ }
+}
+
fn pdf_pages(pdf: &[u8]) -> usize {
let mut h = Headless::new();
h.call("engine.execute", json!({"command": "document.open", "params": {"name": "p.pdf", "dataBase64": vectorcraft_format::base64_encode(pdf)}}))
diff --git a/crates/ui-egui/src/io.rs b/crates/ui-egui/src/io.rs
index cc6f5d2262..a3a41c922a 100644
--- a/crates/ui-egui/src/io.rs
+++ b/crates/ui-egui/src/io.rs
@@ -15,8 +15,12 @@ use crate::{FilePick, Services, VectorcraftApp, dialogs};
mod export;
mod open;
+mod read_only_save;
mod save;
+#[cfg(test)]
+mod tests_read_only_save;
+
pub use export::{EXPORT_CANCELLED, cancel_exports, export, export_for_screens, export_pdf, hide_exports, reveal, save_command_output};
pub(crate) use export::{
export_files, exported_with_notes, file_url, open_in_app, open_original, reveal_path, run_to_file, save_command_output_named,
@@ -29,6 +33,7 @@ pub use open::{
};
#[cfg(test)]
pub(crate) use open::{open_file, opened_with_notes};
+pub(crate) use save::save_current;
pub use save::{ask_format_options, ask_pdf_options, remember_active_artboard, remember_view, save, save_pdf};
pub(crate) use save::{export_path, is_web, report_saved, target_path, write_named, write_reporting, write_to};
#[cfg(test)]
diff --git a/crates/ui-egui/src/io/read_only_save.rs b/crates/ui-egui/src/io/read_only_save.rs
new file mode 100644
index 0000000000..f49603eba2
--- /dev/null
+++ b/crates/ui-egui/src/io/read_only_save.rs
@@ -0,0 +1,64 @@
+//! The continuation of ordinary File > Save when its own location refuses writes (#6146).
+//! It reuses Save As, including extension and overwrite checks, under its own picker entry.
+
+use std::rc::Rc;
+
+use serde_json::{Value, json};
+use vectorcraft_engine::cmd::fileio::{self, SaveMode, SavePlan};
+
+use crate::picks::{self, Entry};
+use crate::{FilePick, VectorcraftApp};
+
+pub(super) struct Retry {
+ path: Option,
+ pick: FilePick,
+ params: Value,
+}
+
+impl Retry {
+ pub(super) fn new(plan: &SavePlan, folder: Option) -> Self {
+ let name = plan.path.as_deref().map(fileio::file_name).unwrap_or_else(|| plan.name.clone());
+ let mut params = json!({ "format": plan.format.id, "options": plan.options });
+ if let Some(modified) = plan.modified {
+ params["modified"] = json!(modified);
+ }
+ Self { path: plan.path.clone(), pick: FilePick { name, folder, filters: vec![] }, params }
+ }
+
+ /// None when this continuation is stale or another picker prevents it. The existing failed-save
+ /// dialog then stays in charge; there is no queue of deferred Save As requests.
+ pub(super) fn instead(self, app: &mut VectorcraftApp, uid: u64) -> Option> {
+ if app.automation_is_acting() || app.services.pick_save.is_none() || app.picks.is_waiting() || super::save::is_web(app) {
+ return None;
+ }
+ let index = app.session.documents().iter().position(|st| st.uid == uid && st.path == self.path)?;
+ if app.session.active_index() != Some(index) && app.run("document.activate", json!({ "index": index })).is_err() {
+ return None;
+ }
+ let name = &self.pick.name;
+ let status = format!("Cannot write to the location of “{name}”; choose where to save it");
+ app.status(status.clone());
+ let save = Rc::new(move |app: &mut VectorcraftApp| {
+ // An agent can retarget this document while the sheet is open. Its answer must not
+ // replace that later choice. The picker itself already checks the document's uid.
+ if !app.session.active().is_some_and(|st| st.uid == uid && st.path == self.path) {
+ return Err("cancelled".into());
+ }
+ super::save::save_suggesting(app, SaveMode::SaveAs, &self.params, false, Some(&self.pick))
+ });
+ let again = save.clone();
+ let entry = Entry::Call(Rc::new(move |app| {
+ if let Err(e) = again(app) {
+ app.status(e);
+ }
+ }));
+ let r = picks::as_own_entry(app, entry, |app| save(app));
+ Some(match r {
+ Err(e) if e == "cancelled" && app.picks.is_waiting() => {
+ app.status(status);
+ Ok(json!({ "pending": "fileDialog" }))
+ }
+ r => r,
+ })
+ }
+}
diff --git a/crates/ui-egui/src/io/save.rs b/crates/ui-egui/src/io/save.rs
index cfbdaaadba..5efe776d64 100644
--- a/crates/ui-egui/src/io/save.rs
+++ b/crates/ui-egui/src/io/save.rs
@@ -241,6 +241,31 @@ fn plan(app: &VectorcraftApp, mode: SaveMode, p: &Value) -> Result Save alone offers another destination when its own location refuses writes.
+/// Save Changes during Close/Quit continues to use [`save`].
+pub(crate) fn save_current(app: &mut VectorcraftApp, p: &Value) -> Result {
+ save_current_in(app, p, fileio::documents_dialog_folder())
+}
+
+/// The same entry with a resolved Documents folder (also permits deterministic host-free tests).
+pub(super) fn save_current_in(app: &mut VectorcraftApp, p: &Value, folder: Option) -> Result {
+ remember_view(app);
+ let first = plan(app, SaveMode::Save, &acknowledged(p))?;
+ let own = !app.automation_is_acting()
+ && !is_web(app)
+ && p.get("path").is_none_or(Value::is_null)
+ && first.path.is_some()
+ && app.session.active().is_some_and(|st| !st.converted && !st.recovered && st.path == first.path && st.format == first.format.id);
+ if !own {
+ return save(app, SaveMode::Save, p, true);
+ }
+ if let Some(r) = first.path.as_deref().and_then(|path| ask_before_losing(app, path, "file.save", p)) {
+ return Ok(r);
+ }
+ let retry = super::read_only_save::Retry::new(&first, folder);
+ write_plan(app, first, Some(retry))
+}
+
/// File → Save, Save As…, Save a Copy…, Save as Template… and the options dialogs' OK. `p` is the
/// engine command's `{path?, format?, options?, svg?}`.
///
@@ -253,6 +278,17 @@ fn plan(app: &VectorcraftApp, mode: SaveMode, p: &Value) -> Result,
/// path?}` while a dialog is open.
pub fn save(app: &mut VectorcraftApp, mode: SaveMode, p: &Value, ask_options: bool) -> Result {
+ save_suggesting(app, mode, p, ask_options, None)
+}
+
+/// Reuse Save As with a captured name and folder, without changing the document before writing.
+pub(super) fn save_suggesting(
+ app: &mut VectorcraftApp,
+ mode: SaveMode,
+ p: &Value,
+ ask_options: bool,
+ suggestion: Option<&FilePick>,
+) -> Result {
remember_view(app);
// Writing over the file the document was read from is asked about here, not refused.
let first = plan(app, mode, &acknowledged(p))?;
@@ -264,7 +300,7 @@ pub fn save(app: &mut VectorcraftApp, mode: SaveMode, p: &Value, ask_options: bo
if ask && matches!(mode, SaveMode::SaveAs | SaveMode::Copy) && matches!(first.format.id, "svg" | "svgz") {
return ask_format_options(app, mode, first.format, &path);
}
- return write_plan(app, first);
+ return write_plan(app, first, None);
}
if is_web(app) && ask_options {
return Ok(open_options(app, mode.command(), first.format, &first.name, true));
@@ -273,8 +309,12 @@ pub fn save(app: &mut VectorcraftApp, mode: SaveMode, p: &Value, ask_options: bo
SaveMode::Template => fileio::template_filters(first.format.id),
_ => fileio::save_filters(first.format.id),
});
- let pick = FilePick { name: first.name.clone(), folder: first.folder.clone(), filters };
- let picked = pick_finished(app, &pick, |picked, file_type| with_picked_type(picked, &first.name, file_type, first.format, mode))?;
+ let pick = FilePick {
+ name: suggestion.map_or_else(|| first.name.clone(), |s| s.name.clone()),
+ folder: suggestion.map_or_else(|| first.folder.clone(), |s| s.folder.clone()),
+ filters,
+ };
+ let picked = pick_finished(app, &pick, |picked, file_type| with_picked_type(picked, &pick.name, file_type, first.format, mode))?;
// The picked file type wins over a `format` param.
let mut q = if p.is_object() { p.clone() } else { json!({}) };
if let Some(o) = q.as_object_mut() {
@@ -291,7 +331,7 @@ pub fn save(app: &mut VectorcraftApp, mode: SaveMode, p: &Value, ask_options: bo
crate::dialogs::opened_by_panel(app);
return r;
}
- write_plan(app, chosen)
+ write_plan(app, chosen, None)
}
/// The UI command that runs a save in `mode`.
@@ -418,7 +458,7 @@ pub(crate) fn saved_status(path: &str, r: &Value) -> String {
/// ([`background`]): this returns `{path, background: true, status, format, warnings}` at once
/// (`warnings`: what the format loses, then the save's notes on its params, such as an option the format
/// doesn't read), and the document takes the file (and counts as saved, as of the snapshot) once it is written.
-fn write_plan(app: &mut VectorcraftApp, plan: SavePlan) -> Result