From 94e2a8e24b177cabb16dafb191e96fbb71aa843b Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Fri, 13 Nov 2026 06:34:40 -0800 Subject: [PATCH 01/11] i18n: messages with literal braces match only their own catalog row MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The message scan read every message as a format string, so a plain literal such as `expected {"dataBase64": "…"}` became the catalog key `expected {"dataBase64"}`, a template whose placeholder took the rest of any message starting with "expected". The plug-in output error `expected value at line 1 column 1` was then shown in German as "erwartet: value at line 1 column 1". - The scan doubles every brace of a message that is not a format string (a plain literal or a `&str` constant); `format!` strings and `#[error(...)]` strings keep their `{{`, `}}` and placeholders. - 150 `@msg` rows in 23 catalogs are keyed by the full message with its braces doubled, and their translations write those braces doubled and carry the full JSON example of the English. - An `@msg` row with no placeholder is an exact message under its text with `{{` and `}}` read as braces, so it is found at any length, and a nested JSON part translated by such a row shows single braces. - The Hindi row for `give {key, value} or {values}` translates the whole message; it kept the English "or" and left out "give". Docs: docs/development.md, the zh-hant.tsv header, docs/target-app-parity.md. --- crates/ui-egui/src/i18n.rs | 8 ++- crates/ui-egui/src/i18n/bs.tsv | 2 +- crates/ui-egui/src/i18n/ca.tsv | 12 ++--- crates/ui-egui/src/i18n/catalog.rs | 26 ++++++---- crates/ui-egui/src/i18n/cs.tsv | 10 ++-- crates/ui-egui/src/i18n/de.tsv | 10 ++-- crates/ui-egui/src/i18n/el.tsv | 10 ++-- crates/ui-egui/src/i18n/es.tsv | 10 ++-- crates/ui-egui/src/i18n/fi.tsv | 10 ++-- crates/ui-egui/src/i18n/fr.tsv | 10 ++-- crates/ui-egui/src/i18n/hi.tsv | 44 ++++++++-------- crates/ui-egui/src/i18n/hu.tsv | 10 ++-- crates/ui-egui/src/i18n/it.tsv | 10 ++-- crates/ui-egui/src/i18n/ja.tsv | 10 ++-- crates/ui-egui/src/i18n/ka.tsv | 12 ++--- crates/ui-egui/src/i18n/ko.tsv | 10 ++-- crates/ui-egui/src/i18n/pl.tsv | 10 ++-- crates/ui-egui/src/i18n/pt-br.tsv | 12 ++--- crates/ui-egui/src/i18n/ru.tsv | 12 ++--- crates/ui-egui/src/i18n/sv.tsv | 44 ++++++++-------- crates/ui-egui/src/i18n/tests.rs | 80 ++++++++++++++++++++++++++--- crates/ui-egui/src/i18n/tr.tsv | 12 ++--- crates/ui-egui/src/i18n/uk.tsv | 10 ++-- crates/ui-egui/src/i18n/vi.tsv | 10 ++-- crates/ui-egui/src/i18n/zh-hans.tsv | 10 ++-- crates/ui-egui/src/i18n/zh-hant.tsv | 11 ++-- docs/development.md | 5 +- docs/target-app-parity.md | 1 + 28 files changed, 252 insertions(+), 169 deletions(-) diff --git a/crates/ui-egui/src/i18n.rs b/crates/ui-egui/src/i18n.rs index 4ec89b8e32..ef2c48407e 100644 --- a/crates/ui-egui/src/i18n.rs +++ b/crates/ui-egui/src/i18n.rs @@ -676,7 +676,7 @@ fn message_at(lang: Lang, s: &str, depth: usize) -> String { if depth <= 1 || quoted(v) { v.to_string() } else if v.starts_with(['{', '[']) { - lang.catalog().exact_message(v).unwrap_or(v).to_string() + exact_translation(lang.catalog(), v).unwrap_or_else(|| v.to_string()) } else { message_at(lang, v, depth - 1) } @@ -686,6 +686,12 @@ fn message_at(lang: Lang, s: &str, depth: usize) -> String { fill_once(template, &args) } +/// The translation of a message with an exact `@msg` row in `catalog`, its `{{` and `}}` shown as +/// `{` and `}`. +fn exact_translation(catalog: &Catalog, s: &str) -> Option { + catalog.exact_message(s).map(|tr| fill_once(tr, &[])) +} + /// Fill `{name}` placeholders in one pass: a value that holds `{name}` text stays as it is. /// Unknown placeholders are left as written. `{{` and `}}` are a literal `{` and `}`. fn fill_once(template: &str, args: &[(&str, &str)]) -> String { diff --git a/crates/ui-egui/src/i18n/bs.tsv b/crates/ui-egui/src/i18n/bs.tsv index 81fee3a079..c7aed47cdf 100644 --- a/crates/ui-egui/src/i18n/bs.tsv +++ b/crates/ui-egui/src/i18n/bs.tsv @@ -6384,7 +6384,7 @@ anchor Points: Tačke: Stop on Errors Zaustavi pri greškama Yonroku-ban Yonroku-ban @msg image '{key}' has no pixels and was left out slika '{key}' nema piksela i izostavljena je -@msg not an RTF file: it doesn't start with {\\rtf nije RTF datoteka: ne počinje s {\\rtf +@msg not an RTF file: it doesn't start with {{\\rtf nije RTF datoteka: ne počinje s {{\\rtf @msg this is a PostScript file (an .ai saved in an older format or without PDF compatibility, or an EPS file), which can't be opened yet: save it as PDF, PDF-compatible .ai or SVG ovo je PostScript datoteka (.ai sačuvan u starijem formatu ili bez PDF kompatibilnosti, ili EPS datoteka), koja se još ne može otvoriti: sačuvajte je kao PDF, .ai kompatibilan s PDF-om ili SVG font-script Japanese Japansko swatch [None] [Nijedna] diff --git a/crates/ui-egui/src/i18n/ca.tsv b/crates/ui-egui/src/i18n/ca.tsv index 02ae0f4a09..d0d8277211 100644 --- a/crates/ui-egui/src/i18n/ca.tsv +++ b/crates/ui-egui/src/i18n/ca.tsv @@ -5831,7 +5831,7 @@ digits Malayalam Malaiàlam @msg not an HP-GL file no és un fitxer HP-GL @msg not a PICT file no és un fitxer PICT @msg `pens` must be an array of 1 to 255 #rrggbb colors `pens` ha de ser una matriu d’1 a 255 colors #rrggbb -@msg not an RTF file: it doesn't start with {\\rtf no és un fitxer RTF: no comença amb {\\rtf +@msg not an RTF file: it doesn't start with {{\\rtf no és un fitxer RTF: no comença amb {{\\rtf @msg not found no s’ha trobat @msg not in pattern editing mode no esteu en mode d’edició de motius @msg finish editing the pattern first acabeu primer l’edició del motiu @@ -6391,8 +6391,8 @@ digits Malayalam Malaiàlam @msg an exporter declares 1 to {MAX_EXTENSIONS} `extensions` un exportador declara d’1 a {MAX_EXTENSIONS} `extensions` @msg at most {MAX_UNGROUPED} parameters outside groups: give the others a `group` com a màxim {MAX_UNGROUPED} paràmetres fora de grups: doneu un `group` als altres @msg can't encode the input no es pot codificar l’entrada -@msg expected {"dataBase64"} s’esperava {"dataBase64"} -@msg expected {"objects"} s’esperava {"objects"} +@msg expected {{"dataBase64": "…"}} s’esperava {{"dataBase64": "…"}} +@msg expected {{"objects": [...]}} s’esperava {{"objects": [...]}} @msg export `{name}`: {e} exportació del mòdul `{name}`: {e} @msg extension {e} is listed twice l’extensió {e} apareix dues vegades @msg extension {e}: use 1-16 lower-case letters and digits, without the dot extensió {e}: feu servir 1-16 lletres minúscules i dígits, sense el punt @@ -6456,11 +6456,11 @@ digits Malayalam Malaiàlam @msg a painted live effect returns only `objects` un efecte dinàmic de pintura només retorna `objects` @msg a painted live effect's piece has only `path`, `fillRule`, `fills` and `strokes` una peça d’un efecte dinàmic de pintura només té `path`, `fillRule`, `fills` i `strokes` @msg a read-only object was left out s’ha omès un objecte de només lectura -@msg a read-only object was left out: return it, as {"id"} alone or as it was given s’ha omès un objecte de només lectura: retorneu-lo com a {"id"} sol o tal com s’ha rebut +@msg a read-only object was left out: return it, as {{"id": …}} alone or as it was given s’ha omès un objecte de només lectura: retorneu-lo com a {{"id": …}} sol o tal com s’ha rebut @msg a string una cadena @msg a stroke has only `paint`, `width`, `join`, `cap` and `miterLimit` un traç només té `paint`, `width`, `join`, `cap` i `miterLimit` -@msg a stroke must be {"paint", "width"?, "join"?, "cap"?, "miterLimit"?} un traç ha de ser {"paint", "width"?, "join"?, "cap"?, "miterLimit"?} -@msg an effect must be {"id", "params"?, "visible"?} un efecte ha de ser {"id", "params"?, "visible"?} +@msg a stroke must be {{"paint", "width"?, "join"?, "cap"?, "miterLimit"?}} un traç ha de ser {{"paint", "width"?, "join"?, "cap"?, "miterLimit"?}} +@msg an effect must be {{"id", "params"?, "visible"?}} un efecte ha de ser {{"id", "params"?, "visible"?}} @msg an effect on a fill returns only `fills` un efecte en un farciment només retorna `fills` @msg an effect on a stroke returns only `strokes` un efecte en un traç només retorna `strokes` @msg an object must be a JSON object un objecte ha de ser un objecte JSON diff --git a/crates/ui-egui/src/i18n/catalog.rs b/crates/ui-egui/src/i18n/catalog.rs index ab3abff6f5..f2fab1fcfa 100644 --- a/crates/ui-egui/src/i18n/catalog.rs +++ b/crates/ui-egui/src/i18n/catalog.rs @@ -13,7 +13,8 @@ pub struct Catalog { ids: HashMap, /// plural messages: `one|other` → forms plurals: HashMap>, - /// status and error messages without placeholders (`@msg`): English → translation + /// status and error messages without placeholders (`@msg`): English, its `{{` and `}}` read as + /// `{` and `}` → translation, as written messages: HashMap, /// status and error messages with `{name}` placeholders (`@msg`, and each English form of a /// `@msgplural` row), most specific first @@ -193,13 +194,20 @@ impl Catalog { "@plural" => { c.plurals.insert(src, tr.split('|').map(str::to_string).collect()); } - "@msg" => { - if src.contains('{') || src.contains("}}") { - c.templates.extend(Template::parse(&src, vec![tr], false)); - } else { - c.messages.insert(src, tr); - } + "@msg" if !src.contains(['{', '}']) => { + c.messages.insert(src, tr); } + "@msg" => match Template::parse(&src, vec![tr], false) { + // A row without placeholders is an exact message, under its text with `{{` and + // `}}` read as literal braces. + Some(Template { pieces, mut forms, .. }) if matches!(pieces.as_slice(), [Piece::Text(_)]) => { + if let (Some(Piece::Text(text)), Some(tr)) = (pieces.into_iter().next(), forms.pop()) { + c.messages.insert(text, tr); + } + } + Some(template) => c.templates.push(template), + None => {} + }, "@msgplural" => { let forms: Vec = tr.split('|').map(str::to_string).collect(); c.templates.extend(src.split('|').filter_map(|english| Template::parse(english, forms.clone(), true))); @@ -249,8 +257,8 @@ impl Catalog { self.templates.iter().find_map(|t| t.captures(s).map(|caps| (t.forms.as_slice(), caps))) } - /// The translation of a status or error message that has an exact `@msg` entry; templates are - /// not tried. + /// The translation of a status or error message that has an exact `@msg` entry, as written in + /// the catalog (`{{` and `}}` for literal braces); templates are not tried. pub fn exact_message(&self, s: &str) -> Option<&str> { self.messages.get(s).map(String::as_str) } diff --git a/crates/ui-egui/src/i18n/cs.tsv b/crates/ui-egui/src/i18n/cs.tsv index 7e3794e8a1..672a9ae170 100644 --- a/crates/ui-egui/src/i18n/cs.tsv +++ b/crates/ui-egui/src/i18n/cs.tsv @@ -6306,8 +6306,8 @@ axis Y Axis osa Y @msg an exporter declares 1 to {MAX_EXTENSIONS} `extensions` exportní zásuvný modul deklaruje 1 až {MAX_EXTENSIONS} `extensions` @msg at most {MAX_UNGROUPED} parameters outside groups: give the others a `group` mimo skupiny smí být nejvýše {MAX_UNGROUPED} parametrů: ostatním přiřaďte `group` @msg can't encode the input vstup nelze zakódovat -@msg expected {"dataBase64"} očekáváno {"dataBase64"} -@msg expected {"objects"} očekáváno {"objects"} +@msg expected {{"dataBase64": "…"}} očekáváno {{"dataBase64": "…"}} +@msg expected {{"objects": [...]}} očekáváno {{"objects": [...]}} @msg export `{name}`: {e} export `{name}`: {e} @msg extension {e} is listed twice přípona {e} je uvedena dvakrát @msg extension {e}: use 1-16 lower-case letters and digits, without the dot přípona {e}: použijte 1-16 malých písmen a číslic, bez tečky @@ -6371,11 +6371,11 @@ axis Y Axis osa Y @msg a painted live effect returns only `objects` malující živý efekt vrací jen `objects` @msg a painted live effect's piece has only `path`, `fillRule`, `fills` and `strokes` část malujícího živého efektu má jen `path`, `fillRule`, `fills` a `strokes` @msg a read-only object was left out objekt jen pro čtení byl vynechán -@msg a read-only object was left out: return it, as {"id"} alone or as it was given objekt jen pro čtení byl vynechán: vraťte ho jako samotné {"id"} nebo tak, jak byl předán +@msg a read-only object was left out: return it, as {{"id": …}} alone or as it was given objekt jen pro čtení byl vynechán: vraťte ho jako samotné {{"id": …}} nebo tak, jak byl předán @msg a string řetězec @msg a stroke has only `paint`, `width`, `join`, `cap` and `miterLimit` tah má jen `paint`, `width`, `join`, `cap` a `miterLimit` -@msg a stroke must be {"paint", "width"?, "join"?, "cap"?, "miterLimit"?} tah musí být {"paint", "width"?, "join"?, "cap"?, "miterLimit"?} -@msg an effect must be {"id", "params"?, "visible"?} efekt musí být {"id", "params"?, "visible"?} +@msg a stroke must be {{"paint", "width"?, "join"?, "cap"?, "miterLimit"?}} tah musí být {{"paint", "width"?, "join"?, "cap"?, "miterLimit"?}} +@msg an effect must be {{"id", "params"?, "visible"?}} efekt musí být {{"id", "params"?, "visible"?}} @msg an effect on a fill returns only `fills` efekt na výplni vrací jen `fills` @msg an effect on a stroke returns only `strokes` efekt na tahu vrací jen `strokes` @msg an object must be a JSON object objekt musí být objekt JSON diff --git a/crates/ui-egui/src/i18n/de.tsv b/crates/ui-egui/src/i18n/de.tsv index 4174537f87..97c18987a2 100644 --- a/crates/ui-egui/src/i18n/de.tsv +++ b/crates/ui-egui/src/i18n/de.tsv @@ -5897,8 +5897,8 @@ axis Y Axis Y-Achse @msg an exporter declares 1 to {MAX_EXTENSIONS} `extensions` ein Export-Plug-in gibt 1 bis {MAX_EXTENSIONS} `extensions` an @msg at most {MAX_UNGROUPED} parameters outside groups: give the others a `group` höchstens {MAX_UNGROUPED} Parameter außerhalb von Gruppen: Geben Sie den anderen eine `group` @msg can't encode the input die Eingabe kann nicht codiert werden -@msg expected {"dataBase64"} erwartet: {"dataBase64"} -@msg expected {"objects"} erwartet: {"objects"} +@msg expected {{"dataBase64": "…"}} erwartet: {{"dataBase64": "…"}} +@msg expected {{"objects": [...]}} erwartet: {{"objects": [...]}} @msg export `{name}`: {e} Modulexport `{name}`: {e} @msg extension {e} is listed twice die Dateiendung {e} ist doppelt aufgeführt @msg extension {e}: use 1-16 lower-case letters and digits, without the dot Dateiendung {e}: Verwenden Sie 1-16 Kleinbuchstaben und Ziffern, ohne Punkt @@ -5962,11 +5962,11 @@ axis Y Axis Y-Achse @msg a painted live effect returns only `objects` ein malender Live-Effekt gibt nur `objects` zurück @msg a painted live effect's piece has only `path`, `fillRule`, `fills` and `strokes` ein Teil eines malenden Live-Effekts hat nur `path`, `fillRule`, `fills` und `strokes` @msg a read-only object was left out ein schreibgeschütztes Objekt wurde weggelassen -@msg a read-only object was left out: return it, as {"id"} alone or as it was given ein schreibgeschütztes Objekt wurde weggelassen: Geben Sie es zurück, als {"id"} allein oder so, wie es übergeben wurde +@msg a read-only object was left out: return it, as {{"id": …}} alone or as it was given ein schreibgeschütztes Objekt wurde weggelassen: Geben Sie es zurück, als {{"id": …}} allein oder so, wie es übergeben wurde @msg a string eine Zeichenkette @msg a stroke has only `paint`, `width`, `join`, `cap` and `miterLimit` eine Kontur hat nur `paint`, `width`, `join`, `cap` und `miterLimit` -@msg a stroke must be {"paint", "width"?, "join"?, "cap"?, "miterLimit"?} eine Kontur muss {"paint", "width"?, "join"?, "cap"?, "miterLimit"?} sein -@msg an effect must be {"id", "params"?, "visible"?} ein Effekt muss {"id", "params"?, "visible"?} sein +@msg a stroke must be {{"paint", "width"?, "join"?, "cap"?, "miterLimit"?}} eine Kontur muss {{"paint", "width"?, "join"?, "cap"?, "miterLimit"?}} sein +@msg an effect must be {{"id", "params"?, "visible"?}} ein Effekt muss {{"id", "params"?, "visible"?}} sein @msg an effect on a fill returns only `fills` ein Effekt auf einer Fläche gibt nur `fills` zurück @msg an effect on a stroke returns only `strokes` ein Effekt auf einer Kontur gibt nur `strokes` zurück @msg an object must be a JSON object ein Objekt muss ein JSON-Objekt sein diff --git a/crates/ui-egui/src/i18n/el.tsv b/crates/ui-egui/src/i18n/el.tsv index bde2fbee78..f485282ca4 100644 --- a/crates/ui-egui/src/i18n/el.tsv +++ b/crates/ui-egui/src/i18n/el.tsv @@ -6257,8 +6257,8 @@ axis Y Axis Άξονας Y @msg an exporter declares 1 to {MAX_EXTENSIONS} `extensions` ένα πρόσθετο εξαγωγής δηλώνει 1 έως {MAX_EXTENSIONS} `extensions` @msg at most {MAX_UNGROUPED} parameters outside groups: give the others a `group` το πολύ {MAX_UNGROUPED} παράμετροι εκτός ομάδων: δώστε στις υπόλοιπες ένα `group` @msg can't encode the input η είσοδος δεν μπορεί να κωδικοποιηθεί -@msg expected {"dataBase64"} αναμενόταν {"dataBase64"} -@msg expected {"objects"} αναμενόταν {"objects"} +@msg expected {{"dataBase64": "…"}} αναμενόταν {{"dataBase64": "…"}} +@msg expected {{"objects": [...]}} αναμενόταν {{"objects": [...]}} @msg export `{name}`: {e} εξαγωγή `{name}`: {e} @msg extension {e} is listed twice η επέκταση {e} αναφέρεται δύο φορές @msg extension {e}: use 1-16 lower-case letters and digits, without the dot επέκταση {e}: χρησιμοποιήστε 1-16 πεζά γράμματα και ψηφία, χωρίς την τελεία @@ -6322,11 +6322,11 @@ axis Y Axis Άξονας Y @msg a painted live effect returns only `objects` ένα ζωντανό εφέ βαφής επιστρέφει μόνο `objects` @msg a painted live effect's piece has only `path`, `fillRule`, `fills` and `strokes` ένα κομμάτι ζωντανού εφέ βαφής έχει μόνο `path`, `fillRule`, `fills` και `strokes` @msg a read-only object was left out παραλείφθηκε ένα αντικείμενο μόνο για ανάγνωση -@msg a read-only object was left out: return it, as {"id"} alone or as it was given παραλείφθηκε ένα αντικείμενο μόνο για ανάγνωση: επιστρέψτε το ως {"id"} μόνο ή όπως δόθηκε +@msg a read-only object was left out: return it, as {{"id": …}} alone or as it was given παραλείφθηκε ένα αντικείμενο μόνο για ανάγνωση: επιστρέψτε το ως {{"id": …}} μόνο ή όπως δόθηκε @msg a string συμβολοσειρά @msg a stroke has only `paint`, `width`, `join`, `cap` and `miterLimit` ένα περίγραμμα έχει μόνο `paint`, `width`, `join`, `cap` και `miterLimit` -@msg a stroke must be {"paint", "width"?, "join"?, "cap"?, "miterLimit"?} ένα περίγραμμα πρέπει να είναι {"paint", "width"?, "join"?, "cap"?, "miterLimit"?} -@msg an effect must be {"id", "params"?, "visible"?} ένα εφέ πρέπει να είναι {"id", "params"?, "visible"?} +@msg a stroke must be {{"paint", "width"?, "join"?, "cap"?, "miterLimit"?}} ένα περίγραμμα πρέπει να είναι {{"paint", "width"?, "join"?, "cap"?, "miterLimit"?}} +@msg an effect must be {{"id", "params"?, "visible"?}} ένα εφέ πρέπει να είναι {{"id", "params"?, "visible"?}} @msg an effect on a fill returns only `fills` ένα εφέ σε γέμισμα επιστρέφει μόνο `fills` @msg an effect on a stroke returns only `strokes` ένα εφέ σε περίγραμμα επιστρέφει μόνο `strokes` @msg an object must be a JSON object ένα αντικείμενο πρέπει να είναι αντικείμενο JSON diff --git a/crates/ui-egui/src/i18n/es.tsv b/crates/ui-egui/src/i18n/es.tsv index ff28d9dfc5..0f48fc6280 100644 --- a/crates/ui-egui/src/i18n/es.tsv +++ b/crates/ui-egui/src/i18n/es.tsv @@ -6248,8 +6248,8 @@ axis Y Axis Eje Y @msg an exporter declares 1 to {MAX_EXTENSIONS} `extensions` un plugin de exportación declara de 1 a {MAX_EXTENSIONS} `extensions` @msg at most {MAX_UNGROUPED} parameters outside groups: give the others a `group` como máximo {MAX_UNGROUPED} parámetros fuera de grupos: asigna un `group` a los demás @msg can't encode the input no se puede codificar la entrada -@msg expected {"dataBase64"} se esperaba {"dataBase64"} -@msg expected {"objects"} se esperaba {"objects"} +@msg expected {{"dataBase64": "…"}} se esperaba {{"dataBase64": "…"}} +@msg expected {{"objects": [...]}} se esperaba {{"objects": [...]}} @msg export `{name}`: {e} exportación del módulo `{name}`: {e} @msg extension {e} is listed twice la extensión {e} aparece dos veces @msg extension {e}: use 1-16 lower-case letters and digits, without the dot extensión {e}: usa 1-16 letras minúsculas y dígitos, sin el punto @@ -6313,11 +6313,11 @@ axis Y Axis Eje Y @msg a painted live effect returns only `objects` un efecto dinámico de pintura solo devuelve `objects` @msg a painted live effect's piece has only `path`, `fillRule`, `fills` and `strokes` una pieza de un efecto dinámico de pintura solo tiene `path`, `fillRule`, `fills` y `strokes` @msg a read-only object was left out se omitió un objeto de solo lectura -@msg a read-only object was left out: return it, as {"id"} alone or as it was given se omitió un objeto de solo lectura: devuélvalo como {"id"} solo o tal como se recibió +@msg a read-only object was left out: return it, as {{"id": …}} alone or as it was given se omitió un objeto de solo lectura: devuélvalo como {{"id": …}} solo o tal como se recibió @msg a string una cadena @msg a stroke has only `paint`, `width`, `join`, `cap` and `miterLimit` un trazo solo tiene `paint`, `width`, `join`, `cap` y `miterLimit` -@msg a stroke must be {"paint", "width"?, "join"?, "cap"?, "miterLimit"?} un trazo debe ser {"paint", "width"?, "join"?, "cap"?, "miterLimit"?} -@msg an effect must be {"id", "params"?, "visible"?} un efecto debe ser {"id", "params"?, "visible"?} +@msg a stroke must be {{"paint", "width"?, "join"?, "cap"?, "miterLimit"?}} un trazo debe ser {{"paint", "width"?, "join"?, "cap"?, "miterLimit"?}} +@msg an effect must be {{"id", "params"?, "visible"?}} un efecto debe ser {{"id", "params"?, "visible"?}} @msg an effect on a fill returns only `fills` un efecto en un relleno solo devuelve `fills` @msg an effect on a stroke returns only `strokes` un efecto en un trazo solo devuelve `strokes` @msg an object must be a JSON object un objeto debe ser un objeto JSON diff --git a/crates/ui-egui/src/i18n/fi.tsv b/crates/ui-egui/src/i18n/fi.tsv index bb74188ed8..81e4f1c49e 100644 --- a/crates/ui-egui/src/i18n/fi.tsv +++ b/crates/ui-egui/src/i18n/fi.tsv @@ -6336,8 +6336,8 @@ axis Y Axis Y-akseli @msg an exporter declares 1 to {MAX_EXTENSIONS} `extensions` vientilaajennus ilmoittaa 1–{MAX_EXTENSIONS} `extensions` @msg at most {MAX_UNGROUPED} parameters outside groups: give the others a `group` ryhmien ulkopuolella saa olla enintään {MAX_UNGROUPED} parametria: anna muille `group` @msg can't encode the input syötettä ei voi koodata -@msg expected {"dataBase64"} odotettiin {"dataBase64"} -@msg expected {"objects"} odotettiin {"objects"} +@msg expected {{"dataBase64": "…"}} odotettiin {{"dataBase64": "…"}} +@msg expected {{"objects": [...]}} odotettiin {{"objects": [...]}} @msg export `{name}`: {e} moduulin vienti `{name}`: {e} @msg extension {e} is listed twice tunniste {e} on luettelossa kahdesti @msg extension {e}: use 1-16 lower-case letters and digits, without the dot tunniste {e}: käytä 1-16 pientä kirjainta ja numeroa ilman pistettä @@ -6401,11 +6401,11 @@ axis Y Axis Y-akseli @msg a painted live effect returns only `objects` maalaava live-tehoste palauttaa vain kentän `objects` @msg a painted live effect's piece has only `path`, `fillRule`, `fills` and `strokes` maalaavan live-tehosteen osalla on vain `path`, `fillRule`, `fills` ja `strokes` @msg a read-only object was left out vain luku -objekti jätettiin pois -@msg a read-only object was left out: return it, as {"id"} alone or as it was given vain luku -objekti jätettiin pois: palauta se pelkkänä {"id"}-muotona tai sellaisena kuin se annettiin +@msg a read-only object was left out: return it, as {{"id": …}} alone or as it was given vain luku -objekti jätettiin pois: palauta se pelkkänä {{"id": …}}-muotona tai sellaisena kuin se annettiin @msg a string merkkijono @msg a stroke has only `paint`, `width`, `join`, `cap` and `miterLimit` viivalla on vain `paint`, `width`, `join`, `cap` ja `miterLimit` -@msg a stroke must be {"paint", "width"?, "join"?, "cap"?, "miterLimit"?} viivan on oltava muotoa {"paint", "width"?, "join"?, "cap"?, "miterLimit"?} -@msg an effect must be {"id", "params"?, "visible"?} tehosteen on oltava muotoa {"id", "params"?, "visible"?} +@msg a stroke must be {{"paint", "width"?, "join"?, "cap"?, "miterLimit"?}} viivan on oltava muotoa {{"paint", "width"?, "join"?, "cap"?, "miterLimit"?}} +@msg an effect must be {{"id", "params"?, "visible"?}} tehosteen on oltava muotoa {{"id", "params"?, "visible"?}} @msg an effect on a fill returns only `fills` täytön tehoste palauttaa vain kentän `fills` @msg an effect on a stroke returns only `strokes` viivan tehoste palauttaa vain kentän `strokes` @msg an object must be a JSON object objektin on oltava JSON-objekti diff --git a/crates/ui-egui/src/i18n/fr.tsv b/crates/ui-egui/src/i18n/fr.tsv index 09c9c35b0d..041c7f40eb 100644 --- a/crates/ui-egui/src/i18n/fr.tsv +++ b/crates/ui-egui/src/i18n/fr.tsv @@ -6301,8 +6301,8 @@ axis Y Axis Axe Y @msg an exporter declares 1 to {MAX_EXTENSIONS} `extensions` un module externe d’exportation déclare de 1 à {MAX_EXTENSIONS} `extensions` @msg at most {MAX_UNGROUPED} parameters outside groups: give the others a `group` {MAX_UNGROUPED} paramètres au plus hors des groupes : donnez un `group` aux autres @msg can't encode the input impossible d’encoder l’entrée -@msg expected {"dataBase64"} attendu : {"dataBase64"} -@msg expected {"objects"} attendu : {"objects"} +@msg expected {{"dataBase64": "…"}} attendu : {{"dataBase64": "…"}} +@msg expected {{"objects": [...]}} attendu : {{"objects": [...]}} @msg export `{name}`: {e} export du module `{name}` : {e} @msg extension {e} is listed twice l’extension {e} figure deux fois @msg extension {e}: use 1-16 lower-case letters and digits, without the dot extension {e} : utilisez 1-16 lettres minuscules et chiffres, sans le point @@ -6366,11 +6366,11 @@ axis Y Axis Axe Y @msg a painted live effect returns only `objects` un effet dynamique de peinture renvoie seulement `objects` @msg a painted live effect's piece has only `path`, `fillRule`, `fills` and `strokes` une pièce d’un effet dynamique de peinture a seulement `path`, `fillRule`, `fills` et `strokes` @msg a read-only object was left out un objet en lecture seule a été omis -@msg a read-only object was left out: return it, as {"id"} alone or as it was given un objet en lecture seule a été omis : renvoyez-le, sous la forme {"id"} seul ou tel qu’il a été fourni +@msg a read-only object was left out: return it, as {{"id": …}} alone or as it was given un objet en lecture seule a été omis : renvoyez-le, sous la forme {{"id": …}} seul ou tel qu’il a été fourni @msg a string une chaîne @msg a stroke has only `paint`, `width`, `join`, `cap` and `miterLimit` un contour a seulement `paint`, `width`, `join`, `cap` et `miterLimit` -@msg a stroke must be {"paint", "width"?, "join"?, "cap"?, "miterLimit"?} un contour doit être {"paint", "width"?, "join"?, "cap"?, "miterLimit"?} -@msg an effect must be {"id", "params"?, "visible"?} un effet doit être {"id", "params"?, "visible"?} +@msg a stroke must be {{"paint", "width"?, "join"?, "cap"?, "miterLimit"?}} un contour doit être {{"paint", "width"?, "join"?, "cap"?, "miterLimit"?}} +@msg an effect must be {{"id", "params"?, "visible"?}} un effet doit être {{"id", "params"?, "visible"?}} @msg an effect on a fill returns only `fills` un effet sur un fond renvoie seulement `fills` @msg an effect on a stroke returns only `strokes` un effet sur un contour renvoie seulement `strokes` @msg an object must be a JSON object un objet doit être un objet JSON diff --git a/crates/ui-egui/src/i18n/hi.tsv b/crates/ui-egui/src/i18n/hi.tsv index 9aa4bd7b99..7d692604ee 100644 --- a/crates/ui-egui/src/i18n/hi.tsv +++ b/crates/ui-egui/src/i18n/hi.tsv @@ -5524,11 +5524,11 @@ antiAlias Sharp शार्प @msg a painted live effect returns only `objects` पेंट करने वाला लाइव इफ़ेक्ट केवल `objects` लौटाता है @msg a painted live effect's piece has only `path`, `fillRule`, `fills` and `strokes` पेंट करने वाले लाइव इफ़ेक्ट के हिस्से में केवल `path`, `fillRule`, `fills` और `strokes` होते हैं @msg a read-only object was left out एक केवल-पढ़ने योग्य ऑब्जेक्ट छोड़ दिया गया -@msg a read-only object was left out: return it, as {"id"} alone or as it was given एक केवल-पढ़ने योग्य ऑब्जेक्ट छोड़ दिया गया: इसे केवल {"id"} के रूप में या जैसा दिया गया था वैसा लौटाएँ +@msg a read-only object was left out: return it, as {{"id": …}} alone or as it was given एक केवल-पढ़ने योग्य ऑब्जेक्ट छोड़ दिया गया: इसे केवल {{"id": …}} के रूप में या जैसा दिया गया था वैसा लौटाएँ @msg a string एक स्ट्रिंग @msg a stroke has only `paint`, `width`, `join`, `cap` and `miterLimit` स्ट्रोक में केवल `paint`, `width`, `join`, `cap` और `miterLimit` होते हैं -@msg a stroke must be {"paint", "width"?, "join"?, "cap"?, "miterLimit"?} स्ट्रोक {"paint", "width"?, "join"?, "cap"?, "miterLimit"?} होना चाहिए -@msg an effect must be {"id", "params"?, "visible"?} इफ़ेक्ट {"id", "params"?, "visible"?} होना चाहिए +@msg a stroke must be {{"paint", "width"?, "join"?, "cap"?, "miterLimit"?}} स्ट्रोक {{"paint", "width"?, "join"?, "cap"?, "miterLimit"?}} होना चाहिए +@msg an effect must be {{"id", "params"?, "visible"?}} इफ़ेक्ट {{"id", "params"?, "visible"?}} होना चाहिए @msg an effect on a fill returns only `fills` फ़िल पर इफ़ेक्ट केवल `fills` लौटाता है @msg an effect on a stroke returns only `strokes` स्ट्रोक पर इफ़ेक्ट केवल `strokes` लौटाता है @msg an object must be a JSON object ऑब्जेक्ट एक JSON ऑब्जेक्ट होना चाहिए @@ -5988,7 +5988,7 @@ antiAlias Sharp शार्प @msg `files` must be 1 to 1000 paths of font files the last text.findFontFiles search found `files` में पिछली text.findFontFiles सर्च में मिली फ़ॉन्ट फ़ाइलों के 1 से 1000 पाथ होने चाहिए @msg `folder` must be an absolute folder path `folder` एक पूर्ण फ़ोल्डर पाथ होना चाहिए @msg `follow` must be "average", "a" or "b" `follow` को "average", "a" या "b" होना चाहिए। -@msg `fontAxes` must be an object of axis tags and values, such as {"wght"} `fontAxes` को अक्ष टैग और मानों का एक ऑब्जेक्ट होना चाहिए, जैसे कि {"wght"} +@msg `fontAxes` must be an object of axis tags and values, such as {{"wght": 650}} `fontAxes` को अक्ष टैग और मानों का एक ऑब्जेक्ट होना चाहिए, जैसे कि {{"wght": 650}} @msg `fontHeightReference` must be "emBox", "capHeight", "xHeight" or "icfBox" `fontHeightReference` को "emBox", "capHeight", "xHeight" या "icfBox" होना चाहिए @msg `format` must be a string `format` एक स्ट्रिंग होना चाहिए @msg `format` must be one of {_1} `format` को {_1} में से एक होना चाहिए @@ -6022,12 +6022,12 @@ antiAlias Sharp शार्प @msg `index` must be a point index `index` एक बिंदु सूचकांक होना चाहिए @msg `index` must be a whole number or null `index` होना चाहिए पूरी संख्या या null होना चाहिए @msg `indices` must be a non-empty array of item indices `indices` आइटम सूचकांकों की एक गैर-खाली सरणी होना चाहिए -@msg `items` is a list of {kind, item} `items` {kind, item} की सूची है +@msg `items` is a list of {{kind, item}} `items` {{kind, item}} की सूची है @msg `items` is empty `items` खाली है @msg `items` must be a list of {{id, anchors?}}, not {v} `items` को {{id, anchors?}} की सूची होना चाहिए, {v} नहीं -@msg `items` must be a list of {id, points} `items` को {id, points} की सूची होना चाहिए +@msg `items` must be a list of {{id, points}} `items` को {{id, points}} की सूची होना चाहिए @msg `items` must be an array of {{id, corners?}}, not {v} `items` को {{id, corners?}} की ऐरे होना चाहिए, {v} नहीं -@msg `items` must be an array of {id, anchors} `items` को {id, anchors} की ऐरे होना चाहिए +@msg `items` must be an array of {{id, anchors}} `items` को {{id, anchors}} की ऐरे होना चाहिए @msg `kashida` must be "none", "short", "medium", "long" or "stylistic" `kashida` होना चाहिए "none", "short", "medium", "long" या "stylistic"। @msg `kind` is one of {_1} `kind` {_1} में से एक है @msg `kind` must be "line" or "paragraph", not {other} `kind` होना चाहिए "line" या "paragraph", नहीं {other} @@ -6312,7 +6312,7 @@ antiAlias Sharp शार्प @msg artboard {i} doesn't print आर्टबोर्ड {i} प्रिंट नहीं करता है @msg artboard is a 0-based index artboard: आर्टबोर्ड एक 0-आधारित इंडेक्स है @msg artboard must be a 0-based index artboard: आर्टबोर्ड एक 0-आधारित इंडेक्स होना चाहिए -@msg artboardLayout must be {layout?, columns?, spacing?, rightToLeft?} artboardLayout को {layout?, columns?, spacing?, rightToLeft?} होना चाहिए +@msg artboardLayout must be {{layout?, columns?, spacing?, rightToLeft?}} artboardLayout को {{layout?, columns?, spacing?, rightToLeft?}} होना चाहिए @msg artboardLayout.columns must be 1 or more artboardLayout.columns को 1 या अधिक होना चाहिए @msg artboardLayout.layout must be gridByRow, gridByColumn, row or column artboardLayout.layout को gridByRow, gridByColumn, row या column होना चाहिए @msg artboardLayout.rightToLeft must be true or false artboardLayout.rightToLeft को true या false होना चाहिए @@ -6674,13 +6674,13 @@ antiAlias Sharp शार्प @msg DXF scale must be a positive number up to {_1}, not {scale} DXF स्केल को {_1} तक सकारात्मक संख्या होना चाहिए, न कि {scale} @msg dxf unit `{u}`: a unit such as mm, cm, in or pt dxf इकाई `{u}`: एक इकाई जैसे mm, cm, in या pt @msg dxf: {e} Dxf: {e} -@msg each format is an object {format, scale?, suffix?} हर फ़ॉर्मैट एक ऑब्जेक्ट {format, scale?, suffix?} है +@msg each format is an object {{format, scale?, suffix?}} हर फ़ॉर्मैट एक ऑब्जेक्ट {{format, scale?, suffix?}} है @msg each item needs an id प्रत्येक आइटम को एक आईडी की आवश्यकता होती है @msg each match needs `kind` प्रत्येक मैच की जरूरत है `kind` @msg each match needs a `name` प्रत्येक मैच को `name` की आवश्यकता होती है -@msg each of `fonts` is {family, style?}; 1 to 1000 of them `fonts` का हर आइटम {family, style?} है; 1 से 1000 आइटम +@msg each of `fonts` is {{family, style?}}; 1 to 1000 of them `fonts` का हर आइटम {{family, style?}} है; 1 से 1000 आइटम @msg each of `items` is {{id, corners?}} with an object id, not {it} `items` का हर आइटम ऑब्जेक्ट आईडी वाला {{id, corners?}} है, {it} नहीं -@msg each of `items` is {kind, item} `items` का हर आइटम {kind, item} है +@msg each of `items` is {{kind, item}} `items` का हर आइटम {{kind, item}} है @msg each row needs `from` हर पंक्ति के लिए `from` चाहिए @msg each stop needs a position प्रत्येक स्टॉप को स्थिति की आवश्यकता होती है @msg each subpath needs an anchors array प्रत्येक उपपाथ को एक एंकर सरणी की आवश्यकता होती है @@ -6712,8 +6712,8 @@ antiAlias Sharp शार्प @msg Expand: no gradient fills विस्तार: कोई ग्रेडिएंट भरता नहीं @msg Expand: nothing to expand विस्तार: विस्तार करने के लिए कुछ नहीं @msg expanded SVG styles exceed the import size limit विस्तारित SVG स्टाइल्स आयात आकार सीमा से अधिक है -@msg expected {"dataBase64"} अपेक्षित: {"dataBase64"} -@msg expected {"objects"} अपेक्षित: {"objects"} +@msg expected {{"dataBase64": "…"}} अपेक्षित: {{"dataBase64": "…"}} +@msg expected {{"objects": [...]}} अपेक्षित: {{"objects": [...]}} @msg export `{name}`: {e} निर्यात `{name}`: {e} @msg Export cancelled निर्यात रद्द @msg Export for Screens writes png, png8, jpg, webp, gif, tiff, bmp, tga, psd, svg, svgz, pdf, eps, ai or vectorcraft स्क्रीन के लिए एक्सपोर्ट png, png8, jpg, webp, gif, tiff, bmp, tga, psd, svg, svgz, pdf, eps, ai या vectorcraft लिखता है @@ -6733,7 +6733,7 @@ antiAlias Sharp शार्प @msg features must be a list of tags features: विशेषताएं टैग की एक सूची होना चाहिए @msg fidelity must be 0..100 fidelity: निष्ठा 0..100 होना चाहिए @msg file version {0} is newer than this VectorCraft supports ({VERSION}) फ़ाइल संस्करण {0} इस वेक्टर क्रेफ्ट समर्थन ({VERSION}) की तुलना में नया है। -@msg files must be {name, dataBase64} files: फ़ाइलों को {name, dataBase64} होना चाहिए +@msg files must be {{name, dataBase64}} files: फ़ाइलों को {{name, dataBase64}} होना चाहिए @msg finish editing the pattern first पहले पैटर्न को संपादित करना @msg First measured segment no longer exists पहले मापा खंड अब मौजूद नहीं है @msg fit must be none|autoHeight|shrinkText|block, got {id} fit को none|autoHeight|shrinkText|block होना चाहिए, {id} मिला @@ -6759,7 +6759,7 @@ antiAlias Sharp शार्प @msg frequency must be between 1 and 300 lines per inch frequency: आवृत्ति प्रति इंच 1 और 300 लाइनों के बीच होना चाहिए @msg from and to must be finite से और होना चाहिए करने के लिए @msg from must be [x0, y0, x1, y1] from: से होना चाहिए [x0, y0, x1, y1] -@msg give {key, value} or {values} {key, value} or {values} +@msg give {{key, value}} or {{values}} {{key, value}} या {{values}} दें @msg give `{a}` and `{b}` देना `{a}` and `{b}` @msg give `anchor`, the index of the anchor to slide खिसकाने वाले एंकर का इंडेक्स `anchor` दें @msg give `before` or `after`, not both `before` या `after` दें, दोनों नहीं @@ -6777,7 +6777,7 @@ antiAlias Sharp शार्प @msg give `id` or `guide`, not both देना `id` or `guide`, दोनों नहीं @msg give `inkLimit`, `removeWhiteOverprint` or `overprintBlack` देना `inkLimit`, `removeWhiteOverprint` or `overprintBlack` @msg give `length` or `to` `length` या `to` देना -@msg give `matches`: [{kind, name, target}] `matches`: [{kind, name, target}] +@msg give `matches`: [{{kind, name, target}}] `matches`: [{{kind, name, target}}] @msg give `name` (or edit a pattern) `name` (या एक पैटर्न संपादित करें) @msg give `name` or `names` `name` या `names` दें @msg give `path` (a .wasm file) or `dataBase64` (the module) `path` (a .wasm file) या `dataBase64` (module) @@ -6834,7 +6834,7 @@ antiAlias Sharp शार्प @msg give the preset a `name` प्रीसेट देना `name` @msg give the preset a name प्रीसेट को एक नाम देना @msg give the preset a name (1–64 characters) प्रीसेट का नाम दें (1–64 अक्षर) -@msg give the settings to change, e.g. {"formats"}]} सेटिंग्स को बदलने के लिए, उदाहरण के लिए {"formats"}]} +@msg give the settings to change, e.g. {{"formats": [{{"format": "png", "scale": "2x"}}]}} सेटिंग्स को बदलने के लिए, उदाहरण के लिए {{"formats": [{{"format": "png", "scale": "2x"}}]}} @msg give the style a name स्टाइल को एक नाम दें @msg give two or more style `names` दो या अधिक स्टाइल `names` @msg give two or more swatches दो या अधिक swatch @@ -7053,7 +7053,7 @@ antiAlias Sharp शार्प @msg missing `library` (see graphicStyle.libraries) लापता `library` (graphicStyle.libraries देखें) @msg missing `library` (see swatch.library.list) लापता `library` (swatch.library.list देखें) @msg missing `library` (see symbol.libraries) `library` अनुपस्थित है (symbol.libraries देखें) -@msg missing `map` (rows [{from, to}] or {current}) लापता `map` (rows [{from, to}] या {current}) +@msg missing `map` (rows [{{from, to}}] or {{current: new}}) लापता `map` (rows [{{from, to}}] या {{current: new}}) @msg missing `matrix` [a,b,c,d,e,f] लापता `matrix` [a,b,c,d,e,f] @msg missing `name` लापता `name` @msg missing `name` (see gradient.presets.list) लापता `name` (view gradient.presets.list) @@ -7420,7 +7420,7 @@ antiAlias Sharp शार्प @msg not an export presets file (format `{_1}`) नहीं निर्यात presets फ़ाइल (format `{_1}`) @msg not an export presets file: {e} एक निर्यात प्रीसेट फ़ाइल नहीं: {e} @msg not an image VectorCraft reads (see document.formats) यह ऐसी इमेज नहीं है जिसे VectorCraft पढ़ सके (document.formats देखें) -@msg not an RTF file: it doesn't start with {\\rtf RTF फ़ाइल नहीं: यह {\rtf के साथ शुरू नहीं होता है +@msg not an RTF file: it doesn't start with {{\\rtf RTF फ़ाइल नहीं: यह {{\rtf के साथ शुरू नहीं होता है @msg not area type (text in a frame) outside perspective नहीं क्षेत्र के प्रकार (एक फ्रेम में टेक्स्ट) बाहर पर्सपेक्टिव @msg not editing an opacity mask एक opacity मास्क संपादित न करें @msg not found नहीं मिला @@ -7617,7 +7617,7 @@ antiAlias Sharp शार्प @msg printing isn't available here मुद्रण यहाँ उपलब्ध नहीं है @msg procedures nest too deeply प्रक्रियाओं घोंसला बहुत गहराई से @msg procedures nested too deeply प्रक्रियाओं ने बहुत गहराई से घोंसले -@msg quotes must be {double?, single?} quotes को {double?, single?} होना चाहिए +@msg quotes must be {{double?, single?}} quotes को {{double?, single?}} होना चाहिए @msg quotes.{k} must be an opening and a closing quote, e.g. "“”" उद्धरण.{k} एक उद्घाटन और एक समापन उद्धरण होना चाहिए, उदाहरण के लिए "“”" @msg range `{s}` is empty श्रेणी `{s}` खाली है @msg range `{s}`: `{part}` names no number सीमा `{s}`: `{part}` कोई संख्या नहीं बताता @@ -7856,14 +7856,14 @@ antiAlias Sharp शार्प @msg Switch x/y is for scatter graphs स्विच x/y स्कैटर ग्राफ़ के लिए है @msg t, dx and dy must be finite टी, डीएक्स और डीआई को परिमित होना चाहिए @msg Targa files are RGB: colorModel rgb Targa फ़ाइलें RGB हैं: colorModel rgb -@msg text must be {characterSet?, platform?, removeLineReturns?, removeParagraphReturns?, replaceSpaces?} text को {characterSet?, platform?, removeLineReturns?, removeParagraphReturns?, replaceSpaces?} होना चाहिए +@msg text must be {{characterSet?, platform?, removeLineReturns?, removeParagraphReturns?, replaceSpaces?}} text को {{characterSet?, platform?, removeLineReturns?, removeParagraphReturns?, replaceSpaces?}} होना चाहिए @msg Text options: {e} टेक्स्ट ऑप्शन्स: {e} @msg text transform is collapsed or unrepresentable टेक्स्ट रूपांतरण पतन या प्रतिनिधित्व करने योग्य है @msg Text Wrap: select a wrap object टेक्स्ट लपेटें: एक रैप ऑब्जेक्ट चुनें @msg text.{key} `{v}`: use "{_1}" or "{_2}" Text.{key} `{v}`: "{_1}" या "{_2}" का उपयोग करें। @msg text.replaceSpaces must be a whole number from 2 to 100 text.replaceSpaces को 2 से 100 के बीच पूर्णांक होना चाहिए @msg textureImage: {e} textureImage: {e} -@msg textureImage: give {path} or {name, dataBase64} of an image file, or a texture as effect.list gives it textureImage: किसी इमेज फ़ाइल का {path} या {name, dataBase64} दें, या effect.list द्वारा दिया गया टेक्सचर दें +@msg textureImage: give {{path}} or {{name, dataBase64}} of an image file, or a texture as effect.list gives it textureImage: किसी इमेज फ़ाइल का {{path}} या {{name, dataBase64}} दें, या effect.list द्वारा दिया गया टेक्सचर दें @msg that anchor can't slide: an end of an open path has nowhere to go कि एंकर स्लाइड नहीं कर सकते: एक खुले रास्ते का अंत कहीं नहीं जाना है @msg the active document changed while the question was open; nothing was deleted प्रश्न खुला रहते हुए सक्रिय डॉक्यूमेंट बदल गया; कुछ भी नहीं हटाया गया @msg that document is no longer open यह डॉक्यूमेंट अब खुला नहीं है diff --git a/crates/ui-egui/src/i18n/hu.tsv b/crates/ui-egui/src/i18n/hu.tsv index af6f586880..bfc95b271e 100644 --- a/crates/ui-egui/src/i18n/hu.tsv +++ b/crates/ui-egui/src/i18n/hu.tsv @@ -6289,8 +6289,8 @@ axis Y Axis Y tengely @msg an exporter declares 1 to {MAX_EXTENSIONS} `extensions` az exportáló bővítmény 1–{MAX_EXTENSIONS} `extensions` értéket ad meg @msg at most {MAX_UNGROUPED} parameters outside groups: give the others a `group` csoporton kívül legfeljebb {MAX_UNGROUPED} paraméter lehet: a többinek adj meg `group` értéket @msg can't encode the input a bemenet nem kódolható -@msg expected {"dataBase64"} várt formátum: {"dataBase64"} -@msg expected {"objects"} várt formátum: {"objects"} +@msg expected {{"dataBase64": "…"}} várt formátum: {{"dataBase64": "…"}} +@msg expected {{"objects": [...]}} várt formátum: {{"objects": [...]}} @msg export `{name}`: {e} `{name}` modulexport: {e} @msg extension {e} is listed twice a(z) {e} kiterjesztés kétszer szerepel @msg extension {e}: use 1-16 lower-case letters and digits, without the dot {e} kiterjesztés: 1-16 kisbetűt és számjegyet használj, pont nélkül @@ -6354,11 +6354,11 @@ axis Y Axis Y tengely @msg a painted live effect returns only `objects` a festő élő effektus csak `objects` értéket ad vissza @msg a painted live effect's piece has only `path`, `fillRule`, `fills` and `strokes` a festő élő effektus darabjának csak `path`, `fillRule`, `fills` és `strokes` kulcsa van @msg a read-only object was left out kimaradt egy írásvédett objektum -@msg a read-only object was left out: return it, as {"id"} alone or as it was given kimaradt egy írásvédett objektum: adja vissza önmagában {"id"} formában vagy úgy, ahogy megkapta +@msg a read-only object was left out: return it, as {{"id": …}} alone or as it was given kimaradt egy írásvédett objektum: adja vissza önmagában {{"id": …}} formában vagy úgy, ahogy megkapta @msg a string karakterlánc @msg a stroke has only `paint`, `width`, `join`, `cap` and `miterLimit` a körvonalnak csak `paint`, `width`, `join`, `cap` és `miterLimit` kulcsa van -@msg a stroke must be {"paint", "width"?, "join"?, "cap"?, "miterLimit"?} a körvonal formája {"paint", "width"?, "join"?, "cap"?, "miterLimit"?} legyen -@msg an effect must be {"id", "params"?, "visible"?} az effektus formája {"id", "params"?, "visible"?} legyen +@msg a stroke must be {{"paint", "width"?, "join"?, "cap"?, "miterLimit"?}} a körvonal formája {{"paint", "width"?, "join"?, "cap"?, "miterLimit"?}} legyen +@msg an effect must be {{"id", "params"?, "visible"?}} az effektus formája {{"id", "params"?, "visible"?}} legyen @msg an effect on a fill returns only `fills` a kitöltésen lévő effektus csak `fills` értéket ad vissza @msg an effect on a stroke returns only `strokes` a körvonalon lévő effektus csak `strokes` értéket ad vissza @msg an object must be a JSON object az objektum JSON-objektum legyen diff --git a/crates/ui-egui/src/i18n/it.tsv b/crates/ui-egui/src/i18n/it.tsv index ce0bc58ce5..99e23af43c 100644 --- a/crates/ui-egui/src/i18n/it.tsv +++ b/crates/ui-egui/src/i18n/it.tsv @@ -5931,8 +5931,8 @@ axis Y Axis Asse Y @msg an exporter declares 1 to {MAX_EXTENSIONS} `extensions` un plug-in di esportazione dichiara da 1 a {MAX_EXTENSIONS} `extensions` @msg at most {MAX_UNGROUPED} parameters outside groups: give the others a `group` al massimo {MAX_UNGROUPED} parametri fuori dai gruppi: assegna agli altri un `group` @msg can't encode the input impossibile codificare l’input -@msg expected {"dataBase64"} previsto {"dataBase64"} -@msg expected {"objects"} previsto {"objects"} +@msg expected {{"dataBase64": "…"}} previsto {{"dataBase64": "…"}} +@msg expected {{"objects": [...]}} previsto {{"objects": [...]}} @msg export `{name}`: {e} esportazione del modulo `{name}`: {e} @msg extension {e} is listed twice l’estensione {e} è elencata due volte @msg extension {e}: use 1-16 lower-case letters and digits, without the dot estensione {e}: usa 1-16 lettere minuscole e cifre, senza il punto @@ -5996,11 +5996,11 @@ axis Y Axis Asse Y @msg a painted live effect returns only `objects` un effetto dinamico di pittura restituisce solo `objects` @msg a painted live effect's piece has only `path`, `fillRule`, `fills` and `strokes` un pezzo di un effetto dinamico di pittura ha solo `path`, `fillRule`, `fills` e `strokes` @msg a read-only object was left out è stato omesso un oggetto di sola lettura -@msg a read-only object was left out: return it, as {"id"} alone or as it was given è stato omesso un oggetto di sola lettura: restituiscilo come {"id"} da solo o così come è stato fornito +@msg a read-only object was left out: return it, as {{"id": …}} alone or as it was given è stato omesso un oggetto di sola lettura: restituiscilo come {{"id": …}} da solo o così come è stato fornito @msg a string una stringa @msg a stroke has only `paint`, `width`, `join`, `cap` and `miterLimit` una traccia ha solo `paint`, `width`, `join`, `cap` e `miterLimit` -@msg a stroke must be {"paint", "width"?, "join"?, "cap"?, "miterLimit"?} una traccia deve essere {"paint", "width"?, "join"?, "cap"?, "miterLimit"?} -@msg an effect must be {"id", "params"?, "visible"?} un effetto deve essere {"id", "params"?, "visible"?} +@msg a stroke must be {{"paint", "width"?, "join"?, "cap"?, "miterLimit"?}} una traccia deve essere {{"paint", "width"?, "join"?, "cap"?, "miterLimit"?}} +@msg an effect must be {{"id", "params"?, "visible"?}} un effetto deve essere {{"id", "params"?, "visible"?}} @msg an effect on a fill returns only `fills` un effetto su un riempimento restituisce solo `fills` @msg an effect on a stroke returns only `strokes` un effetto su una traccia restituisce solo `strokes` @msg an object must be a JSON object un oggetto deve essere un oggetto JSON diff --git a/crates/ui-egui/src/i18n/ja.tsv b/crates/ui-egui/src/i18n/ja.tsv index 1ecce4618a..959ee010d2 100644 --- a/crates/ui-egui/src/i18n/ja.tsv +++ b/crates/ui-egui/src/i18n/ja.tsv @@ -5996,8 +5996,8 @@ axis Y Axis Y 軸 @msg an exporter declares 1 to {MAX_EXTENSIONS} `extensions` 書き出しプラグインは `extensions` を 1~{MAX_EXTENSIONS} 個宣言します @msg at most {MAX_UNGROUPED} parameters outside groups: give the others a `group` グループ外のパラメーターは {MAX_UNGROUPED} 個までです。ほかのパラメーターには `group` を指定してください @msg can't encode the input 入力をエンコードできません -@msg expected {"dataBase64"} {"dataBase64"} が必要です -@msg expected {"objects"} {"objects"} が必要です +@msg expected {{"dataBase64": "…"}} {{"dataBase64": "…"}} が必要です +@msg expected {{"objects": [...]}} {{"objects": [...]}} が必要です @msg export `{name}`: {e} モジュールのエクスポート `{name}`: {e} @msg extension {e} is listed twice 拡張子 {e} が 2 回指定されています @msg extension {e}: use 1-16 lower-case letters and digits, without the dot 拡張子 {e}: ドットを付けずに小文字と数字で 1-16 文字にしてください @@ -6061,11 +6061,11 @@ axis Y Axis Y 軸 @msg a painted live effect returns only `objects` ペイント型のライブ効果が返すのは `objects` だけです @msg a painted live effect's piece has only `path`, `fillRule`, `fills` and `strokes` ペイント型のライブ効果のピースに使えるのは `path`、`fillRule`、`fills`、`strokes` だけです @msg a read-only object was left out 読み取り専用のオブジェクトが省かれています -@msg a read-only object was left out: return it, as {"id"} alone or as it was given 読み取り専用のオブジェクトが省かれています: {"id"} だけの形か、渡されたとおりの形で返してください +@msg a read-only object was left out: return it, as {{"id": …}} alone or as it was given 読み取り専用のオブジェクトが省かれています: {{"id": …}} だけの形か、渡されたとおりの形で返してください @msg a string 文字列 @msg a stroke has only `paint`, `width`, `join`, `cap` and `miterLimit` 線に使えるのは `paint`、`width`、`join`、`cap`、`miterLimit` だけです -@msg a stroke must be {"paint", "width"?, "join"?, "cap"?, "miterLimit"?} 線は {"paint", "width"?, "join"?, "cap"?, "miterLimit"?} の形にしてください -@msg an effect must be {"id", "params"?, "visible"?} 効果は {"id", "params"?, "visible"?} の形にしてください +@msg a stroke must be {{"paint", "width"?, "join"?, "cap"?, "miterLimit"?}} 線は {{"paint", "width"?, "join"?, "cap"?, "miterLimit"?}} の形にしてください +@msg an effect must be {{"id", "params"?, "visible"?}} 効果は {{"id", "params"?, "visible"?}} の形にしてください @msg an effect on a fill returns only `fills` 塗りの効果が返すのは `fills` だけです @msg an effect on a stroke returns only `strokes` 線の効果が返すのは `strokes` だけです @msg an object must be a JSON object オブジェクトは JSON オブジェクトにしてください diff --git a/crates/ui-egui/src/i18n/ka.tsv b/crates/ui-egui/src/i18n/ka.tsv index 3d6425c373..4f16368cd0 100644 --- a/crates/ui-egui/src/i18n/ka.tsv +++ b/crates/ui-egui/src/i18n/ka.tsv @@ -6257,8 +6257,8 @@ axis Y Axis Y ღერძი @msg an exporter declares 1 to {MAX_EXTENSIONS} `extensions` ექსპორტის პლაგინი აცხადებს 1-დან {MAX_EXTENSIONS}-მდე `extensions`-ს @msg at most {MAX_UNGROUPED} parameters outside groups: give the others a `group` ჯგუფების გარეთ მაქსიმუმ {MAX_UNGROUPED} პარამეტრია დასაშვები: დანარჩენებს მიეცით `group` @msg can't encode the input შეყვანის კოდირება ვერ ხერხდება -@msg expected {"dataBase64"} მოსალოდნელი იყო {"dataBase64"} -@msg expected {"objects"} მოსალოდნელი იყო {"objects"} +@msg expected {{"dataBase64": "…"}} მოსალოდნელი იყო {{"dataBase64": "…"}} +@msg expected {{"objects": [...]}} მოსალოდნელი იყო {{"objects": [...]}} @msg export `{name}`: {e} მოდულის ექსპორტი `{name}`: {e} @msg extension {e} is listed twice გაფართოება {e} ორჯერაა მითითებული @msg extension {e}: use 1-16 lower-case letters and digits, without the dot გაფართოება {e}: გამოიყენეთ 1-16 პატარა ასო და ციფრი, წერტილის გარეშე @@ -6322,11 +6322,11 @@ axis Y Axis Y ღერძი @msg a painted live effect returns only `objects` მხატვრული ცოცხალი ეფექტი აბრუნებს მხოლოდ `objects`-ს @msg a painted live effect's piece has only `path`, `fillRule`, `fills` and `strokes` მხატვრული ცოცხალი ეფექტის ნაწილს აქვს მხოლოდ `path`, `fillRule`, `fills` და `strokes` @msg a read-only object was left out გამოტოვებულია მხოლოდ წასაკითხი ობიექტი -@msg a read-only object was left out: return it, as {"id"} alone or as it was given გამოტოვებულია მხოლოდ წასაკითხი ობიექტი: დააბრუნეთ ის მხოლოდ {"id"}-ის სახით ან ისე, როგორც მიიღეთ +@msg a read-only object was left out: return it, as {{"id": …}} alone or as it was given გამოტოვებულია მხოლოდ წასაკითხი ობიექტი: დააბრუნეთ ის მხოლოდ {{"id": …}}-ის სახით ან ისე, როგორც მიიღეთ @msg a string სტრიქონი @msg a stroke has only `paint`, `width`, `join`, `cap` and `miterLimit` კონტურს აქვს მხოლოდ `paint`, `width`, `join`, `cap` და `miterLimit` -@msg a stroke must be {"paint", "width"?, "join"?, "cap"?, "miterLimit"?} კონტური უნდა იყოს {"paint", "width"?, "join"?, "cap"?, "miterLimit"?} -@msg an effect must be {"id", "params"?, "visible"?} ეფექტი უნდა იყოს {"id", "params"?, "visible"?} +@msg a stroke must be {{"paint", "width"?, "join"?, "cap"?, "miterLimit"?}} კონტური უნდა იყოს {{"paint", "width"?, "join"?, "cap"?, "miterLimit"?}} +@msg an effect must be {{"id", "params"?, "visible"?}} ეფექტი უნდა იყოს {{"id", "params"?, "visible"?}} @msg an effect on a fill returns only `fills` შევსების ეფექტი აბრუნებს მხოლოდ `fills`-ს @msg an effect on a stroke returns only `strokes` კონტურის ეფექტი აბრუნებს მხოლოდ `strokes`-ს @msg an object must be a JSON object ობიექტი უნდა იყოს JSON ობიექტი @@ -7243,7 +7243,7 @@ font-script Japanese იაპონური Japanese Envelope #3 იაპონური კონვერტი #3 Japanese Envelope #4 იაპონური კონვერტი #4 @msg image '{key}' has no pixels and was left out გამოსახულებას '{key}' პიქსელები არ აქვს და გამოტოვდა -@msg not an RTF file: it doesn't start with {\\rtf RTF ფაილი არ არის: არ იწყება {\\rtf-ით +@msg not an RTF file: it doesn't start with {{\\rtf RTF ფაილი არ არის: არ იწყება {{\\rtf-ით # Type Tools add-on (Object › Plug-ins › Type Tools). Type Tools ტექსტის ხელსაწყოები Text Tweaker… ტექსტის მორგება… diff --git a/crates/ui-egui/src/i18n/ko.tsv b/crates/ui-egui/src/i18n/ko.tsv index fb87488d64..b6d20ca4b4 100644 --- a/crates/ui-egui/src/i18n/ko.tsv +++ b/crates/ui-egui/src/i18n/ko.tsv @@ -5892,8 +5892,8 @@ axis Y Axis Y축 @msg an exporter declares 1 to {MAX_EXTENSIONS} `extensions` 내보내기 플러그인은 `extensions`를 1~{MAX_EXTENSIONS}개 선언합니다 @msg at most {MAX_UNGROUPED} parameters outside groups: give the others a `group` 그룹 밖의 매개변수는 최대 {MAX_UNGROUPED}개입니다. 나머지에는 `group`을 지정하세요 @msg can't encode the input 입력을 인코딩할 수 없습니다 -@msg expected {"dataBase64"} {"dataBase64"} 형식이어야 합니다 -@msg expected {"objects"} {"objects"} 형식이어야 합니다 +@msg expected {{"dataBase64": "…"}} {{"dataBase64": "…"}} 형식이어야 합니다 +@msg expected {{"objects": [...]}} {{"objects": [...]}} 형식이어야 합니다 @msg export `{name}`: {e} 모듈 내보내기 `{name}`: {e} @msg extension {e} is listed twice 확장자 {e}이(가) 두 번 나열되었습니다 @msg extension {e}: use 1-16 lower-case letters and digits, without the dot 확장자 {e}: 점 없이 소문자와 숫자 1-16자를 사용하세요 @@ -5957,11 +5957,11 @@ axis Y Axis Y축 @msg a painted live effect returns only `objects` 페인트형 라이브 효과는 `objects`만 반환합니다 @msg a painted live effect's piece has only `path`, `fillRule`, `fills` and `strokes` 페인트형 라이브 효과의 조각에는 `path`, `fillRule`, `fills`, `strokes`만 있습니다 @msg a read-only object was left out 읽기 전용 개체가 빠졌습니다 -@msg a read-only object was left out: return it, as {"id"} alone or as it was given 읽기 전용 개체가 빠졌습니다: {"id"}만 있는 형태나 받은 그대로 반환하십시오 +@msg a read-only object was left out: return it, as {{"id": …}} alone or as it was given 읽기 전용 개체가 빠졌습니다: {{"id": …}}만 있는 형태나 받은 그대로 반환하십시오 @msg a string 문자열 @msg a stroke has only `paint`, `width`, `join`, `cap` and `miterLimit` 선에는 `paint`, `width`, `join`, `cap`, `miterLimit`만 있습니다 -@msg a stroke must be {"paint", "width"?, "join"?, "cap"?, "miterLimit"?} 선은 {"paint", "width"?, "join"?, "cap"?, "miterLimit"?} 형태여야 합니다 -@msg an effect must be {"id", "params"?, "visible"?} 효과는 {"id", "params"?, "visible"?} 형태여야 합니다 +@msg a stroke must be {{"paint", "width"?, "join"?, "cap"?, "miterLimit"?}} 선은 {{"paint", "width"?, "join"?, "cap"?, "miterLimit"?}} 형태여야 합니다 +@msg an effect must be {{"id", "params"?, "visible"?}} 효과는 {{"id", "params"?, "visible"?}} 형태여야 합니다 @msg an effect on a fill returns only `fills` 칠에 적용된 효과는 `fills`만 반환합니다 @msg an effect on a stroke returns only `strokes` 선에 적용된 효과는 `strokes`만 반환합니다 @msg an object must be a JSON object 개체는 JSON 개체여야 합니다 diff --git a/crates/ui-egui/src/i18n/pl.tsv b/crates/ui-egui/src/i18n/pl.tsv index 0729e35c79..2cd431a593 100644 --- a/crates/ui-egui/src/i18n/pl.tsv +++ b/crates/ui-egui/src/i18n/pl.tsv @@ -6160,8 +6160,8 @@ axis Y Axis Oś Y @msg an exporter declares 1 to {MAX_EXTENSIONS} `extensions` wtyczka eksportu deklaruje od 1 do {MAX_EXTENSIONS} `extensions` @msg at most {MAX_UNGROUPED} parameters outside groups: give the others a `group` najwyżej {MAX_UNGROUPED} parametrów poza grupami: pozostałym nadaj `group` @msg can't encode the input nie można zakodować danych wejściowych -@msg expected {"dataBase64"} oczekiwano {"dataBase64"} -@msg expected {"objects"} oczekiwano {"objects"} +@msg expected {{"dataBase64": "…"}} oczekiwano {{"dataBase64": "…"}} +@msg expected {{"objects": [...]}} oczekiwano {{"objects": [...]}} @msg export `{name}`: {e} eksport modułu `{name}`: {e} @msg extension {e} is listed twice rozszerzenie {e} występuje dwa razy @msg extension {e}: use 1-16 lower-case letters and digits, without the dot rozszerzenie {e}: użyj 1-16 małych liter i cyfr, bez kropki @@ -6225,11 +6225,11 @@ axis Y Axis Oś Y @msg a painted live effect returns only `objects` malujący efekt dynamiczny zwraca tylko `objects` @msg a painted live effect's piece has only `path`, `fillRule`, `fills` and `strokes` element malującego efektu dynamicznego ma tylko `path`, `fillRule`, `fills` i `strokes` @msg a read-only object was left out pominięto obiekt tylko do odczytu -@msg a read-only object was left out: return it, as {"id"} alone or as it was given pominięto obiekt tylko do odczytu: zwróć go jako samo {"id"} lub w postaci, w jakiej został przekazany +@msg a read-only object was left out: return it, as {{"id": …}} alone or as it was given pominięto obiekt tylko do odczytu: zwróć go jako samo {{"id": …}} lub w postaci, w jakiej został przekazany @msg a string ciągiem znaków @msg a stroke has only `paint`, `width`, `join`, `cap` and `miterLimit` obrys ma tylko `paint`, `width`, `join`, `cap` i `miterLimit` -@msg a stroke must be {"paint", "width"?, "join"?, "cap"?, "miterLimit"?} obrys musi mieć postać {"paint", "width"?, "join"?, "cap"?, "miterLimit"?} -@msg an effect must be {"id", "params"?, "visible"?} efekt musi mieć postać {"id", "params"?, "visible"?} +@msg a stroke must be {{"paint", "width"?, "join"?, "cap"?, "miterLimit"?}} obrys musi mieć postać {{"paint", "width"?, "join"?, "cap"?, "miterLimit"?}} +@msg an effect must be {{"id", "params"?, "visible"?}} efekt musi mieć postać {{"id", "params"?, "visible"?}} @msg an effect on a fill returns only `fills` efekt wypełnienia zwraca tylko `fills` @msg an effect on a stroke returns only `strokes` efekt obrysu zwraca tylko `strokes` @msg an object must be a JSON object obiekt musi być obiektem JSON diff --git a/crates/ui-egui/src/i18n/pt-br.tsv b/crates/ui-egui/src/i18n/pt-br.tsv index 36f778dbd5..0f0301aabf 100644 --- a/crates/ui-egui/src/i18n/pt-br.tsv +++ b/crates/ui-egui/src/i18n/pt-br.tsv @@ -6221,8 +6221,8 @@ axis Y Axis Eixo Y @msg an exporter declares 1 to {MAX_EXTENSIONS} `extensions` um plug-in de exportação declara de 1 a {MAX_EXTENSIONS} `extensions` @msg at most {MAX_UNGROUPED} parameters outside groups: give the others a `group` no máximo {MAX_UNGROUPED} parâmetros fora de grupos: dê um `group` aos outros @msg can't encode the input não é possível codificar a entrada -@msg expected {"dataBase64"} esperado {"dataBase64"} -@msg expected {"objects"} esperado {"objects"} +@msg expected {{"dataBase64": "…"}} esperado {{"dataBase64": "…"}} +@msg expected {{"objects": [...]}} esperado {{"objects": [...]}} @msg export `{name}`: {e} exportação do módulo `{name}`: {e} @msg extension {e} is listed twice a extensão {e} aparece duas vezes @msg extension {e}: use 1-16 lower-case letters and digits, without the dot extensão {e}: use 1-16 letras minúsculas e dígitos, sem o ponto @@ -6286,11 +6286,11 @@ axis Y Axis Eixo Y @msg a painted live effect returns only `objects` um efeito dinâmico de pintura retorna apenas `objects` @msg a painted live effect's piece has only `path`, `fillRule`, `fills` and `strokes` uma peça de um efeito dinâmico de pintura tem apenas `path`, `fillRule`, `fills` e `strokes` @msg a read-only object was left out um objeto somente leitura foi omitido -@msg a read-only object was left out: return it, as {"id"} alone or as it was given um objeto somente leitura foi omitido: retorne-o como {"id"} sozinho ou como foi recebido +@msg a read-only object was left out: return it, as {{"id": …}} alone or as it was given um objeto somente leitura foi omitido: retorne-o como {{"id": …}} sozinho ou como foi recebido @msg a string uma string @msg a stroke has only `paint`, `width`, `join`, `cap` and `miterLimit` um traçado tem apenas `paint`, `width`, `join`, `cap` e `miterLimit` -@msg a stroke must be {"paint", "width"?, "join"?, "cap"?, "miterLimit"?} um traçado deve ser {"paint", "width"?, "join"?, "cap"?, "miterLimit"?} -@msg an effect must be {"id", "params"?, "visible"?} um efeito deve ser {"id", "params"?, "visible"?} +@msg a stroke must be {{"paint", "width"?, "join"?, "cap"?, "miterLimit"?}} um traçado deve ser {{"paint", "width"?, "join"?, "cap"?, "miterLimit"?}} +@msg an effect must be {{"id", "params"?, "visible"?}} um efeito deve ser {{"id", "params"?, "visible"?}} @msg an effect on a fill returns only `fills` um efeito em um preenchimento retorna apenas `fills` @msg an effect on a stroke returns only `strokes` um efeito em um traçado retorna apenas `strokes` @msg an object must be a JSON object um objeto deve ser um objeto JSON @@ -7160,7 +7160,7 @@ swatch Lime Lima Line Weight: Espessura da linha: Load Variable Library Carregar biblioteca de variáveis swatch Magenta Magenta -@msg not an RTF file: it doesn't start with {\\rtf não é um arquivo RTF: ele não começa com {\\rtf +@msg not an RTF file: it doesn't start with {{\\rtf não é um arquivo RTF: ele não começa com {{\\rtf swatch Ocean Oceano shape Open Aberta swatch Orange Laranja diff --git a/crates/ui-egui/src/i18n/ru.tsv b/crates/ui-egui/src/i18n/ru.tsv index 442a9612d4..f86e17a2c9 100644 --- a/crates/ui-egui/src/i18n/ru.tsv +++ b/crates/ui-egui/src/i18n/ru.tsv @@ -5917,8 +5917,8 @@ axis Y Axis Ось Y @msg an exporter declares 1 to {MAX_EXTENSIONS} `extensions` модуль экспорта объявляет от 1 до {MAX_EXTENSIONS} `extensions` @msg at most {MAX_UNGROUPED} parameters outside groups: give the others a `group` вне групп может быть не более {MAX_UNGROUPED} параметров: задайте остальным `group` @msg can't encode the input не удаётся закодировать входные данные -@msg expected {"dataBase64"} ожидалось {"dataBase64"} -@msg expected {"objects"} ожидалось {"objects"} +@msg expected {{"dataBase64": "…"}} ожидалось {{"dataBase64": "…"}} +@msg expected {{"objects": [...]}} ожидалось {{"objects": [...]}} @msg export `{name}`: {e} экспорт модуля `{name}`: {e} @msg extension {e} is listed twice расширение {e} указано дважды @msg extension {e}: use 1-16 lower-case letters and digits, without the dot расширение {e}: используйте 1-16 строчных букв и цифр, без точки @@ -5982,11 +5982,11 @@ axis Y Axis Ось Y @msg a painted live effect returns only `objects` рисующий динамический эффект возвращает только `objects` @msg a painted live effect's piece has only `path`, `fillRule`, `fills` and `strokes` у части рисующего динамического эффекта есть только `path`, `fillRule`, `fills` и `strokes` @msg a read-only object was left out пропущен объект только для чтения -@msg a read-only object was left out: return it, as {"id"} alone or as it was given пропущен объект только для чтения: верните его как один {"id"} или в том виде, в каком он был передан +@msg a read-only object was left out: return it, as {{"id": …}} alone or as it was given пропущен объект только для чтения: верните его как один {{"id": …}} или в том виде, в каком он был передан @msg a string строка @msg a stroke has only `paint`, `width`, `join`, `cap` and `miterLimit` у обводки есть только `paint`, `width`, `join`, `cap` и `miterLimit` -@msg a stroke must be {"paint", "width"?, "join"?, "cap"?, "miterLimit"?} обводка должна иметь вид {"paint", "width"?, "join"?, "cap"?, "miterLimit"?} -@msg an effect must be {"id", "params"?, "visible"?} эффект должен иметь вид {"id", "params"?, "visible"?} +@msg a stroke must be {{"paint", "width"?, "join"?, "cap"?, "miterLimit"?}} обводка должна иметь вид {{"paint", "width"?, "join"?, "cap"?, "miterLimit"?}} +@msg an effect must be {{"id", "params"?, "visible"?}} эффект должен иметь вид {{"id", "params"?, "visible"?}} @msg an effect on a fill returns only `fills` эффект на заливке возвращает только `fills` @msg an effect on a stroke returns only `strokes` эффект на обводке возвращает только `strokes` @msg an object must be a JSON object объект должен быть объектом JSON @@ -7265,7 +7265,7 @@ breakText Glyphs Глифы @id pathfinder.add Объединить @msg give corners of the path: anchors between two straight sides укажите углы контура: опорные точки между двумя прямыми сторонами @msg image '{key}' has no pixels and was left out изображение '{key}' не содержит пикселов и было пропущено -@msg not an RTF file: it doesn't start with {\\rtf это не файл RTF: он не начинается с {\\rtf +@msg not an RTF file: it doesn't start with {{\\rtf это не файл RTF: он не начинается с {{\\rtf @msg this is a PostScript file (an .ai saved in an older format or without PDF compatibility, or an EPS file), which can't be opened yet: save it as PDF, PDF-compatible .ai or SVG это файл PostScript (.ai, сохранённый в старом формате или без совместимости с PDF, либо EPS-файл), который пока нельзя открыть: сохраните его как PDF, .ai с совместимостью с PDF или SVG @msg this kind of brush has no options dialog yet у кистей этого типа пока нет диалога параметров digits Hindi Хинди diff --git a/crates/ui-egui/src/i18n/sv.tsv b/crates/ui-egui/src/i18n/sv.tsv index 8a8f8453b6..2fb87bd7f1 100644 --- a/crates/ui-egui/src/i18n/sv.tsv +++ b/crates/ui-egui/src/i18n/sv.tsv @@ -6357,11 +6357,11 @@ axis Y Axis Y-axel @msg a painted live effect returns only `objects` en målande live-effekt returnerar bara `objects` @msg a painted live effect's piece has only `path`, `fillRule`, `fills` and `strokes` en del av en målande live-effekt har bara `path`, `fillRule`, `fills` och `strokes` @msg a read-only object was left out ett skrivskyddat objekt utelämnades -@msg a read-only object was left out: return it, as {"id"} alone or as it was given ett skrivskyddat objekt utelämnades: returnera det som enbart {"id"} eller som det gavs +@msg a read-only object was left out: return it, as {{"id": …}} alone or as it was given ett skrivskyddat objekt utelämnades: returnera det som enbart {{"id": …}} eller som det gavs @msg a string en sträng @msg a stroke has only `paint`, `width`, `join`, `cap` and `miterLimit` en kontur har bara `paint`, `width`, `join`, `cap` och `miterLimit` -@msg a stroke must be {"paint", "width"?, "join"?, "cap"?, "miterLimit"?} en kontur måste vara {"paint", "width"?, "join"?, "cap"?, "miterLimit"?} -@msg an effect must be {"id", "params"?, "visible"?} en effekt måste vara {"id", "params"?, "visible"?} +@msg a stroke must be {{"paint", "width"?, "join"?, "cap"?, "miterLimit"?}} en kontur måste vara {{"paint", "width"?, "join"?, "cap"?, "miterLimit"?}} +@msg an effect must be {{"id", "params"?, "visible"?}} en effekt måste vara {{"id", "params"?, "visible"?}} @msg an effect on a fill returns only `fills` en effekt på en fyllning returnerar bara `fills` @msg an effect on a stroke returns only `strokes` en effekt på en kontur returnerar bara `strokes` @msg an object must be a JSON object ett objekt måste vara ett JSON-objekt @@ -6879,7 +6879,7 @@ anchor Points: Punkter: VectorCraft Updates Uppdateringar för VectorCraft Yonroku-ban Yonroku-ban @msg image '{key}' has no pixels and was left out bilden '{key}' har inga pixlar och utelämnades -@msg not an RTF file: it doesn't start with {\\rtf inte en RTF-fil: den börjar inte med {\\rtf +@msg not an RTF file: it doesn't start with {{\\rtf inte en RTF-fil: den börjar inte med {{\\rtf shape Open Öppen # Color Effects add-on: Select by Color Kind. @@ -7478,7 +7478,7 @@ digits Hindi Hindi @msg `expand` must be true or false `expand` måste vara true eller false @msg `files` must be 1 to 1000 paths of font files the last text.findFontFiles search found `files` måste vara 1 till 1000 sökvägar till typsnittsfiler som den senaste text.findFontFiles-sökningen hittade @msg `folder` must be an absolute folder path `folder` måste vara en absolut mappsökväg -@msg `fontAxes` must be an object of axis tags and values, such as {"wght"} `fontAxes` måste vara ett objekt med axeltaggar och värden, till exempel {"wght"} +@msg `fontAxes` must be an object of axis tags and values, such as {{"wght": 650}} `fontAxes` måste vara ett objekt med axeltaggar och värden, till exempel {{"wght": 650}} @msg `format` must be a string `format` måste vara en sträng @msg `format` must be one of {_1} `format` måste vara något av {_1} @msg `fractions` is chop or scale `fractions` är chop eller scale @@ -7501,12 +7501,12 @@ digits Hindi Hindi @msg `index` must be a point index `index` måste vara ett punktindex @msg `index` must be a whole number or null `index` måste vara ett heltal eller null @msg `indices` must be a non-empty array of item indices `indices` måste vara en icke-tom array av objektindex -@msg `items` is a list of {kind, item} `items` är en lista med {kind, item} +@msg `items` is a list of {{kind, item}} `items` är en lista med {{kind, item}} @msg `items` is empty `items` är tom @msg `items` must be a list of {{id, anchors?}}, not {v} `items` måste vara en lista med {{id, anchors?}}, inte {v} -@msg `items` must be a list of {id, points} `items` måste vara en lista med {id, points} +@msg `items` must be a list of {{id, points}} `items` måste vara en lista med {{id, points}} @msg `items` must be an array of {{id, corners?}}, not {v} `items` måste vara en array av {{id, corners?}}, inte {v} -@msg `items` must be an array of {id, anchors} `items` måste vara en array av {id, anchors} +@msg `items` must be an array of {{id, anchors}} `items` måste vara en array av {{id, anchors}} @msg `kashida` must be "none", "short", "medium", "long" or "stylistic" `kashida` måste vara "none", "short", "medium", "long" eller "stylistic" @msg `kind` is one of {_1} `kind` är något av {_1} @msg `kind` must be "round", "invertedRound" or "chamfer", not {v} `kind` måste vara "round", "invertedRound" eller "chamfer", inte {v} @@ -7672,7 +7672,7 @@ digits Hindi Hindi @msg artboard {i} doesn't print rityta {i} skrivs inte ut @msg artboard is a 0-based index artboard är ett 0-baserat index @msg artboard must be a 0-based index artboard måste vara ett 0-baserat index -@msg artboardLayout must be {layout?, columns?, spacing?, rightToLeft?} artboardLayout måste vara {layout?, columns?, spacing?, rightToLeft?} +@msg artboardLayout must be {{layout?, columns?, spacing?, rightToLeft?}} artboardLayout måste vara {{layout?, columns?, spacing?, rightToLeft?}} @msg artboardLayout.columns must be 1 or more artboardLayout.columns måste vara 1 eller mer @msg artboardLayout.layout must be gridByRow, gridByColumn, row or column artboardLayout.layout måste vara gridByRow, gridByColumn, row eller column @msg artboardLayout.rightToLeft must be true or false artboardLayout.rightToLeft måste vara true eller false @@ -7820,13 +7820,13 @@ digits Hindi Hindi @msg DXF scale must be a positive number up to {_1}, not {scale} DXF-skalan måste vara ett positivt tal upp till {_1}, inte {scale} @msg dxf unit `{u}`: a unit such as mm, cm, in or pt dxf-enhet `{u}`: en enhet som mm, cm, in eller pt @msg dxf: {e} dxf: {e} -@msg each format is an object {format, scale?, suffix?} varje format är ett objekt {format, scale?, suffix?} +@msg each format is an object {{format, scale?, suffix?}} varje format är ett objekt {{format, scale?, suffix?}} @msg each item needs an id varje objekt behöver ett id @msg each match needs `kind` varje matchning behöver `kind` @msg each match needs a `name` varje matchning behöver ett `name` -@msg each of `fonts` is {family, style?}; 1 to 1000 of them varje post i `fonts` är {family, style?}; 1 till 1000 stycken +@msg each of `fonts` is {{family, style?}}; 1 to 1000 of them varje post i `fonts` är {{family, style?}}; 1 till 1000 stycken @msg each of `items` is {{id, corners?}} with an object id, not {it} varje post i `items` är {{id, corners?}} med ett objekt-id, inte {it} -@msg each of `items` is {kind, item} varje post i `items` är {kind, item} +@msg each of `items` is {{kind, item}} varje post i `items` är {{kind, item}} @msg each row needs `from` varje rad behöver `from` @msg each stop needs a position varje stopp behöver en position @msg each subpath needs an anchors array varje delbana behöver en anchors-array @@ -7844,8 +7844,8 @@ digits Hindi Hindi @msg EPS Save writes all artwork: use document.export for selected-only EPS När du sparar som EPS skrivs allt bildmaterial: använd document.export för EPS med bara det markerade @msg every numbered name for the result is taken alla numrerade namn för resultatet är upptagna @msg expanded SVG styles exceed the import size limit de expanderade SVG-stilarna överskrider storleksgränsen för import -@msg expected {"dataBase64"} förväntade {"dataBase64"} -@msg expected {"objects"} förväntade {"objects"} +@msg expected {{"dataBase64": "…"}} förväntade {{"dataBase64": "…"}} +@msg expected {{"objects": [...]}} förväntade {{"objects": [...]}} @msg export `{name}`: {e} export av `{name}`: {e} @msg Export for Screens writes png, png8, jpg, webp, gif, tiff, bmp, tga, psd, svg, svgz, pdf, eps, ai or vectorcraft Exportera för skärmar skriver png, png8, jpg, webp, gif, tiff, bmp, tga, psd, svg, svgz, pdf, eps, ai eller vectorcraft @msg export presets file is larger than {MAX_PRESET_FILE_BYTES} bytes filen med exportförinställningar är större än {MAX_PRESET_FILE_BYTES} byte @@ -7858,7 +7858,7 @@ digits Hindi Hindi @msg featureChanges must be a list of tags featureChanges måste vara en lista med taggar @msg features must be a list of tags features måste vara en lista med taggar @msg fidelity must be 0..100 fidelity måste vara 0..100 -@msg files must be {name, dataBase64} files måste vara {name, dataBase64} +@msg files must be {{name, dataBase64}} files måste vara {{name, dataBase64}} @msg fit must be none|autoHeight|shrinkText|block, got {id} fit måste vara none|autoHeight|shrinkText|block, fick {id} @msg Flare click coordinates must be finite numbers Klickkoordinaterna för Ljusreflex måste vara ändliga tal @msg flattener: {e} utplattning: {e} @@ -7876,7 +7876,7 @@ digits Hindi Hindi @msg frequency must be between 1 and 300 lines per inch frequency måste vara mellan 1 och 300 linjer per tum @msg from and to must be finite from och to måste vara ändliga @msg from must be [x0, y0, x1, y1] from måste vara [x0, y0, x1, y1] -@msg give {key, value} or {values} ange {key, value} eller {values} +@msg give {{key, value}} or {{values}} ange {{key, value}} eller {{values}} @msg give `before` or `after`, not both ange `before` eller `after`, inte båda @msg give `char` or `text` ange `char` eller `text` @msg give `colors`, or `color` and `rule` ange `colors`, eller `color` och `rule` @@ -7891,7 +7891,7 @@ digits Hindi Hindi @msg give `id` and `anchors`, or `items`, not both ange `id` och `anchors`, eller `items`, inte båda @msg give `id` or `guide`, not both ange `id` eller `guide`, inte båda @msg give `length` or `to` ange `length` eller `to` -@msg give `matches`: [{kind, name, target}] ange `matches`: [{kind, name, target}] +@msg give `matches`: [{{kind, name, target}}] ange `matches`: [{{kind, name, target}}] @msg give `name` (or edit a pattern) ange `name` (eller redigera ett mönster) @msg give `name` or `names` ange `name` eller `names` @msg give `path` (a .wasm file) or `dataBase64` (the module) ange `path` (en .wasm-fil) eller `dataBase64` (modulen) @@ -7927,7 +7927,7 @@ digits Hindi Hindi @msg give the preset a `name` ge förinställningen ett `name` @msg give the preset a name ge förinställningen ett namn @msg give the preset a name (1–64 characters) ge förinställningen ett namn (1–64 tecken) -@msg give the settings to change, e.g. {"formats"}]} ange inställningarna som ska ändras, t.ex. {"formats"}]} +@msg give the settings to change, e.g. {{"formats": [{{"format": "png", "scale": "2x"}}]}} ange inställningarna som ska ändras, t.ex. {{"formats": [{{"format": "png", "scale": "2x"}}]}} @msg give the style a name ge stilen ett namn @msg give two or more style `names` ange två eller fler stilnamn i `names` @msg give two or more swatches ange två eller fler färgrutor @@ -8032,7 +8032,7 @@ digits Hindi Hindi @msg missing `library` `library` saknas @msg missing `library` (see graphicStyle.libraries) `library` saknas (se graphicStyle.libraries) @msg missing `library` (see swatch.library.list) `library` saknas (se swatch.library.list) -@msg missing `map` (rows [{from, to}] or {current}) `map` saknas (rader [{from, to}] eller {current}) +@msg missing `map` (rows [{{from, to}}] or {{current: new}}) `map` saknas (rader [{{from, to}}] eller {{current: new}}) @msg missing `matrix` [a,b,c,d,e,f] `matrix` [a,b,c,d,e,f] saknas @msg missing `name` (see gradient.presets.list) `name` saknas (se gradient.presets.list) @msg missing `offset` (a distance in points) `offset` saknas (ett avstånd i punkter) @@ -8324,7 +8324,7 @@ digits Hindi Hindi @msg print settings: {e} utskriftsinställningar: {e} @msg procedures nest too deeply procedurerna är nästlade för djupt @msg procedures nested too deeply procedurerna nästlades för djupt -@msg quotes must be {double?, single?} quotes måste vara {double?, single?} +@msg quotes must be {{double?, single?}} quotes måste vara {{double?, single?}} @msg quotes.{k} must be an opening and a closing quote, e.g. "“”" quotes.{k} måste vara ett inledande och ett avslutande citattecken, t.ex. "“”" @msg rasterEffectsPpi must be 1–2400 (72 screen, 150 medium, 300 high) rasterEffectsPpi måste vara 1–2400 (72 skärm, 150 medel, 300 hög) @msg rating must be a whole number from 0 to {MAX_RATING} rating måste vara ett heltal från 0 till {MAX_RATING} @@ -8436,13 +8436,13 @@ digits Hindi Hindi @msg SVG is not UTF-8, UTF-16 or ISO 8859-1 SVG-filen är inte i UTF-8, UTF-16 eller ISO 8859-1 @msg swatchConflict must be "merge" or "add", not {v} swatchConflict måste vara "merge" eller "add", inte {v} @msg Switch x/y is for scatter graphs Växla x/y gäller spridningsdiagram -@msg text must be {characterSet?, platform?, removeLineReturns?, removeParagraphReturns?, replaceSpaces?} text måste vara {characterSet?, platform?, removeLineReturns?, removeParagraphReturns?, replaceSpaces?} +@msg text must be {{characterSet?, platform?, removeLineReturns?, removeParagraphReturns?, replaceSpaces?}} text måste vara {{characterSet?, platform?, removeLineReturns?, removeParagraphReturns?, replaceSpaces?}} @msg Text options: {e} Textalternativ: {e} @msg text transform is collapsed or unrepresentable texttransformeringen är kollapsad eller kan inte återges @msg text.{key} `{v}`: use "{_1}" or "{_2}" text.{key} `{v}`: använd "{_1}" eller "{_2}" @msg text.replaceSpaces must be a whole number from 2 to 100 text.replaceSpaces måste vara ett heltal från 2 till 100 @msg textureImage: {e} textureImage: {e} -@msg textureImage: give {path} or {name, dataBase64} of an image file, or a texture as effect.list gives it textureImage: ange {path} eller {name, dataBase64} för en bildfil, eller en textur så som effect.list anger den +@msg textureImage: give {{path}} or {{name, dataBase64}} of an image file, or a texture as effect.list gives it textureImage: ange {{path}} eller {{name, dataBase64}} för en bildfil, eller en textur så som effect.list anger den @msg that makes more than {MAX_PIECES} slices det ger fler än {MAX_PIECES} skivor @msg that object is not in the overlap det objektet ingår inte i överlappningen @msg the action set ids are not a list åtgärdsuppsättningarnas id:n är inte en lista diff --git a/crates/ui-egui/src/i18n/tests.rs b/crates/ui-egui/src/i18n/tests.rs index 66caa1c3a4..01594beb5b 100644 --- a/crates/ui-egui/src/i18n/tests.rs +++ b/crates/ui-egui/src/i18n/tests.rs @@ -1821,6 +1821,55 @@ fn message_keys_keep_doubled_braces() { assert_eq!(as_template(r"it doesn't start with {\rtf"), r"it doesn't start with {\rtf"); } +/// The braces of a message that is not a format string (a plain literal or a `&str` constant) are +/// literal, so its catalog key doubles them; a `format!` string or an `#[error(…)]` string keeps +/// its `{{`, `}}` and placeholders. +#[test] +fn plain_message_keys_double_their_braces() { + let code = concat!( + "const SHAPE: &str = \"a shape is {\\\"kind\\\": …}\";\n", + "fn f() -> Result<()> { Err(Error::Output(\"expected {\\\"dataBase64\\\": \\\"…\\\"}\".into())) }\n", + "fn g() -> Result<()> { Err(SHAPE.into()) }\n", + "fn h() -> Result<()> { Err(bad(C, \"not an RTF file: it doesn't start with {\\\\rtf\")) }\n", + "fn i() -> Result<()> { Err(bad(C, format!(\"`{key}` must be {{min?, max?}}\"))) }\n", + "#[error(\"the file is {0} bytes, not {{size}}\")]\n", + ); + let consts: std::collections::BTreeMap> = str_consts(code).into_iter().map(|(name, value)| (name, vec![value])).collect(); + // The engine's `error(` helper also occurs in each `#[error(` attribute. + let found: std::collections::BTreeSet = source_messages(code, &[("bad(", 1), ("error(", 0)], &consts).into_iter().collect(); + let want = [ + r#"expected {{"dataBase64": "…"}}"#, + r#"a shape is {{"kind": …}}"#, + r"not an RTF file: it doesn't start with {{\rtf", + "`{key}` must be {{min?, max?}}", + "the file is {0} bytes, not {{size}}", + ]; + assert_eq!(found, want.iter().map(|s| s.to_string()).collect::>(), "{found:#?}"); + assert_eq!(as_literal_key("a {b} c}"), "a {{b}} c}}"); +} + +/// A catalog row for a message with literal braces translates that message and no other: it has no +/// placeholder to take the rest of a message with the same start. +#[test] +fn literal_brace_rows_match_only_their_message() { + let c = Catalog::parse("@msg\texpected {{\"dataBase64\": \"…\"}}\terwartet: {{\"dataBase64\": \"…\"}}\n"); + let (form, caps) = c.message(r#"expected {"dataBase64": "…"}"#).expect("matches"); + assert!(caps.is_empty(), "{caps:?}"); + assert_eq!(fill_once(form, &caps), r#"erwartet: {"dataBase64": "…"}"#); + assert!(c.message("expected value at line 1 column 1").is_none()); + assert!(c.message(r#"expected {"dataBase64"}"#).is_none()); + assert_eq!(exact_translation(&c, r#"expected {"dataBase64": "…"}"#).as_deref(), Some(r#"erwartet: {"dataBase64": "…"}"#)); + // An exact message is found at any length; a template is matched only up to its limit. + let long = format!("{{{{{}}}}}", "x".repeat(3000)); + let c = Catalog::parse(&format!("@msg\t{long}\tlong {{{{}}}}\n@msg\tshort {{x}}\tshort {{x}} (de)\n")); + let shown = format!("{{{}}}", "x".repeat(3000)); + assert_eq!(c.message(&shown).map(|(f, _)| f), Some("long {{}}")); + assert_eq!(exact_translation(&c, &shown).as_deref(), Some("long {}")); + assert_eq!(c.message("short y").map(|(f, _)| f), Some("short {x} (de)")); + // In the real catalog, a serde reason after the plug-in output error stays English. + assert_eq!(message(de(), "plug-in output: expected value at line 1 column 1"), "Plug-in-Ausgabe: expected value at line 1 column 1"); +} + /// `plugin.run` of an importer, an exporter or a live effect names the command that runs it with /// the JSON that picks it, and every complete-message catalog translates that error with the /// plug-in's id and the JSON kept. @@ -3670,7 +3719,8 @@ const LATER_MESSAGE_VARIANTS: &[(&str, usize)] = &[("Disabled", 1)]; const NOT_MESSAGES: &[&str] = &["quit", "cancelled", "vc_manifest"]; /// Every status and error message literal in [`MESSAGE_CRATES`] (test modules aside), as catalog -/// templates: `{}` becomes `{_1}`, `{_2}` … and format specs are dropped (`{n:?}` → `{n}`). +/// templates: `{}` becomes `{_1}`, `{_2}` … and format specs are dropped (`{n:?}` → `{n}`), and +/// the braces of a message that is not a format string are doubled. pub fn message_literals() -> std::collections::BTreeSet { message_literals_in(MESSAGE_CRATES) } @@ -3790,27 +3840,35 @@ fn named_const<'a>(rest: &str, consts: &'a std::collections::BTreeMap>) -> Vec { let code = &without_comments(code); - let after_markers = MESSAGE_MARKERS.iter().flat_map(|marker| code.match_indices(marker).map(|(at, _)| &code[at + marker.len()..])); + let after_markers = + MESSAGE_MARKERS.iter().flat_map(|marker| code.match_indices(marker).map(|(at, _)| (&code[at + marker.len()..], *marker == "#[error("))); let in_variants = code.match_indices("Error::").filter_map(|(at, m)| { let rest = &code[at + m.len()..]; let (variant, args) = rest.split_at(rest.find(|c: char| !c.is_ascii_alphanumeric() && c != '_').unwrap_or(rest.len())); let named = variant.starts_with(|c: char| c.is_ascii_uppercase()) && !NAME_VARIANTS.contains(&variant); let before = LATER_MESSAGE_VARIANTS.iter().find(|(v, _)| *v == variant).map_or(0, |&(_, n)| n); - if named { skip_args(args.strip_prefix('(')?, before) } else { None } + if named { skip_args(args.strip_prefix('(')?, before).map(|rest| (rest, false)) } else { None } }); let in_helpers = helpers.iter().flat_map(|&(call, before)| { code.match_indices(call) .filter(|&(at, _)| call.starts_with('.') || !code[..at].ends_with(|c: char| c.is_alphanumeric() || c == '_' || c == '.')) - .filter_map(move |(at, _)| skip_args(&code[at + call.len()..], before)) + // `format!(` given as a helper, and an `error(` helper that is an `#[error(…)]` attribute, take a format string. + .filter_map(move |(at, _)| { + skip_args(&code[at + call.len()..], before).map(|rest| (rest, call == "format!(" || code[..at].ends_with("#["))) + }) }); let mut out = vec![]; - for rest in after_markers.chain(in_variants).chain(in_helpers) { + for (rest, takes_format) in after_markers.chain(in_variants).chain(in_helpers) { let rest = rest.trim_start(); let rest = rest.strip_prefix('&').map_or(rest, str::trim_start); - let rest = rest.strip_prefix("format!(").map_or(rest, str::trim_start); + let formatted = rest.strip_prefix("format!(").map(str::trim_start); + let format = takes_format || formatted.is_some(); + let rest = formatted.unwrap_or(rest); let lits = match rest.strip_prefix('"') { Some(quoted) => string_literal(quoted).into_iter().collect(), None => named_const(rest, consts).to_vec(), @@ -3819,7 +3877,7 @@ fn source_messages(code: &str, helpers: &[(&str, usize)], consts: &std::collecti let has_word = lit.as_bytes().windows(3).any(|w| w.iter().all(u8::is_ascii_lowercase)); let key_name = lit.len() > 1 && lit.starts_with('`') && lit.ends_with('`') && !lit.contains(' '); if has_word && !key_name && !NOT_MESSAGES.contains(&lit.as_str()) { - out.push(as_template(&lit)); + out.push(if format { as_template(&lit) } else { as_literal_key(&lit) }); } } } @@ -3926,6 +3984,12 @@ fn as_template(s: &str) -> String { out } +/// A message that is not a format string as a catalog key: each of its braces is literal, so it is +/// written `{{` or `}}`. +fn as_literal_key(s: &str) -> String { + s.replace('{', "{{").replace('}', "}}") +} + /// Render-site labels looked up in a context fall back to the plain row and then to English, and /// their templates fill in English as in any language. #[test] diff --git a/crates/ui-egui/src/i18n/tr.tsv b/crates/ui-egui/src/i18n/tr.tsv index ef8773c31b..1a1b27ec4b 100644 --- a/crates/ui-egui/src/i18n/tr.tsv +++ b/crates/ui-egui/src/i18n/tr.tsv @@ -4472,7 +4472,7 @@ axis Both Her İkisi @msg not an HP-GL file HP-GL dosyası değil @msg not a PICT file PICT dosyası değil @msg `pens` must be an array of 1 to 255 #rrggbb colors `pens` 1 ile 255 arasında #rrggbb renginden oluşan bir dizi olmalıdır -@msg not an RTF file: it doesn't start with {\\rtf bir RTF dosyası değil: {\\rtf ile başlamıyor +@msg not an RTF file: it doesn't start with {{\\rtf bir RTF dosyası değil: {{\\rtf ile başlamıyor @msg not found bulunamadı @msg not in pattern editing mode desen düzenleme modunda değil @msg finish editing the pattern first önce deseni düzenlemeyi bitir @@ -6262,8 +6262,8 @@ axis Y Axis Y ekseni @msg an exporter declares 1 to {MAX_EXTENSIONS} `extensions` dışa aktarma eklentisi 1 ile {MAX_EXTENSIONS} arasında `extensions` bildirir @msg at most {MAX_UNGROUPED} parameters outside groups: give the others a `group` gruplar dışında en fazla {MAX_UNGROUPED} parametre olabilir: diğerlerine bir `group` verin @msg can't encode the input girdi kodlanamıyor -@msg expected {"dataBase64"} beklenen: {"dataBase64"} -@msg expected {"objects"} beklenen: {"objects"} +@msg expected {{"dataBase64": "…"}} beklenen: {{"dataBase64": "…"}} +@msg expected {{"objects": [...]}} beklenen: {{"objects": [...]}} @msg export `{name}`: {e} modül dışa aktarımı `{name}`: {e} @msg extension {e} is listed twice {e} uzantısı iki kez listelenmiş @msg extension {e}: use 1-16 lower-case letters and digits, without the dot {e} uzantısı: nokta olmadan 1-16 küçük harf ve rakam kullanın @@ -6327,11 +6327,11 @@ axis Y Axis Y ekseni @msg a painted live effect returns only `objects` boyama türü canlı efekt yalnızca `objects` döndürür @msg a painted live effect's piece has only `path`, `fillRule`, `fills` and `strokes` boyama türü canlı efektin bir parçasında yalnızca `path`, `fillRule`, `fills` ve `strokes` bulunur @msg a read-only object was left out salt okunur bir nesne atlandı -@msg a read-only object was left out: return it, as {"id"} alone or as it was given salt okunur bir nesne atlandı: onu yalnızca {"id"} olarak veya verildiği gibi döndürün +@msg a read-only object was left out: return it, as {{"id": …}} alone or as it was given salt okunur bir nesne atlandı: onu yalnızca {{"id": …}} olarak veya verildiği gibi döndürün @msg a string bir dize @msg a stroke has only `paint`, `width`, `join`, `cap` and `miterLimit` bir konturda yalnızca `paint`, `width`, `join`, `cap` ve `miterLimit` bulunur -@msg a stroke must be {"paint", "width"?, "join"?, "cap"?, "miterLimit"?} bir kontur {"paint", "width"?, "join"?, "cap"?, "miterLimit"?} biçiminde olmalıdır -@msg an effect must be {"id", "params"?, "visible"?} bir efekt {"id", "params"?, "visible"?} biçiminde olmalıdır +@msg a stroke must be {{"paint", "width"?, "join"?, "cap"?, "miterLimit"?}} bir kontur {{"paint", "width"?, "join"?, "cap"?, "miterLimit"?}} biçiminde olmalıdır +@msg an effect must be {{"id", "params"?, "visible"?}} bir efekt {{"id", "params"?, "visible"?}} biçiminde olmalıdır @msg an effect on a fill returns only `fills` dolgudaki bir efekt yalnızca `fills` döndürür @msg an effect on a stroke returns only `strokes` konturdaki bir efekt yalnızca `strokes` döndürür @msg an object must be a JSON object bir nesne bir JSON nesnesi olmalıdır diff --git a/crates/ui-egui/src/i18n/uk.tsv b/crates/ui-egui/src/i18n/uk.tsv index 0bccbd8cb6..92d06a892d 100644 --- a/crates/ui-egui/src/i18n/uk.tsv +++ b/crates/ui-egui/src/i18n/uk.tsv @@ -5911,8 +5911,8 @@ axis Y Axis Вісь Y @msg an exporter declares 1 to {MAX_EXTENSIONS} `extensions` модуль експорту оголошує від 1 до {MAX_EXTENSIONS} `extensions` @msg at most {MAX_UNGROUPED} parameters outside groups: give the others a `group` поза групами може бути не більше {MAX_UNGROUPED} параметрів: задайте решті `group` @msg can't encode the input не вдається закодувати вхідні дані -@msg expected {"dataBase64"} очікувалося {"dataBase64"} -@msg expected {"objects"} очікувалося {"objects"} +@msg expected {{"dataBase64": "…"}} очікувалося {{"dataBase64": "…"}} +@msg expected {{"objects": [...]}} очікувалося {{"objects": [...]}} @msg export `{name}`: {e} експорт модуля `{name}`: {e} @msg extension {e} is listed twice розширення {e} вказано двічі @msg extension {e}: use 1-16 lower-case letters and digits, without the dot розширення {e}: використовуйте 1-16 малих літер і цифр, без крапки @@ -5976,11 +5976,11 @@ axis Y Axis Вісь Y @msg a painted live effect returns only `objects` малювальний динамічний ефект повертає лише `objects` @msg a painted live effect's piece has only `path`, `fillRule`, `fills` and `strokes` частина малювального динамічного ефекту має лише `path`, `fillRule`, `fills` і `strokes` @msg a read-only object was left out пропущено об’єкт лише для читання -@msg a read-only object was left out: return it, as {"id"} alone or as it was given пропущено об’єкт лише для читання: поверніть його як один {"id"} або в тому вигляді, у якому його передано +@msg a read-only object was left out: return it, as {{"id": …}} alone or as it was given пропущено об’єкт лише для читання: поверніть його як один {{"id": …}} або в тому вигляді, у якому його передано @msg a string рядок @msg a stroke has only `paint`, `width`, `join`, `cap` and `miterLimit` обведення має лише `paint`, `width`, `join`, `cap` і `miterLimit` -@msg a stroke must be {"paint", "width"?, "join"?, "cap"?, "miterLimit"?} обведення має мати вигляд {"paint", "width"?, "join"?, "cap"?, "miterLimit"?} -@msg an effect must be {"id", "params"?, "visible"?} ефект має мати вигляд {"id", "params"?, "visible"?} +@msg a stroke must be {{"paint", "width"?, "join"?, "cap"?, "miterLimit"?}} обведення має мати вигляд {{"paint", "width"?, "join"?, "cap"?, "miterLimit"?}} +@msg an effect must be {{"id", "params"?, "visible"?}} ефект має мати вигляд {{"id", "params"?, "visible"?}} @msg an effect on a fill returns only `fills` ефект на заливці повертає лише `fills` @msg an effect on a stroke returns only `strokes` ефект на обведенні повертає лише `strokes` @msg an object must be a JSON object об’єкт має бути об’єктом JSON diff --git a/crates/ui-egui/src/i18n/vi.tsv b/crates/ui-egui/src/i18n/vi.tsv index 4f084029db..e31a09de53 100644 --- a/crates/ui-egui/src/i18n/vi.tsv +++ b/crates/ui-egui/src/i18n/vi.tsv @@ -5912,8 +5912,8 @@ axis Y Axis Trục Y @msg an exporter declares 1 to {MAX_EXTENSIONS} `extensions` plug-in xuất khai báo từ 1 đến {MAX_EXTENSIONS} `extensions` @msg at most {MAX_UNGROUPED} parameters outside groups: give the others a `group` tối đa {MAX_UNGROUPED} tham số nằm ngoài nhóm: hãy đặt `group` cho các tham số còn lại @msg can't encode the input không thể mã hóa dữ liệu đầu vào -@msg expected {"dataBase64"} cần có {"dataBase64"} -@msg expected {"objects"} cần có {"objects"} +@msg expected {{"dataBase64": "…"}} cần có {{"dataBase64": "…"}} +@msg expected {{"objects": [...]}} cần có {{"objects": [...]}} @msg export `{name}`: {e} export `{name}` của mô-đun: {e} @msg extension {e} is listed twice phần mở rộng {e} được liệt kê hai lần @msg extension {e}: use 1-16 lower-case letters and digits, without the dot phần mở rộng {e}: dùng 1-16 chữ thường và chữ số, không có dấu chấm @@ -5977,11 +5977,11 @@ axis Y Axis Trục Y @msg a painted live effect returns only `objects` hiệu ứng động kiểu tô chỉ trả về `objects` @msg a painted live effect's piece has only `path`, `fillRule`, `fills` and `strokes` một phần của hiệu ứng động kiểu tô chỉ có `path`, `fillRule`, `fills` và `strokes` @msg a read-only object was left out một đối tượng chỉ đọc đã bị bỏ sót -@msg a read-only object was left out: return it, as {"id"} alone or as it was given một đối tượng chỉ đọc đã bị bỏ sót: hãy trả lại nó dưới dạng chỉ {"id"} hoặc như khi được nhận +@msg a read-only object was left out: return it, as {{"id": …}} alone or as it was given một đối tượng chỉ đọc đã bị bỏ sót: hãy trả lại nó dưới dạng chỉ {{"id": …}} hoặc như khi được nhận @msg a string một chuỗi @msg a stroke has only `paint`, `width`, `join`, `cap` and `miterLimit` nét chỉ có `paint`, `width`, `join`, `cap` và `miterLimit` -@msg a stroke must be {"paint", "width"?, "join"?, "cap"?, "miterLimit"?} nét phải có dạng {"paint", "width"?, "join"?, "cap"?, "miterLimit"?} -@msg an effect must be {"id", "params"?, "visible"?} hiệu ứng phải có dạng {"id", "params"?, "visible"?} +@msg a stroke must be {{"paint", "width"?, "join"?, "cap"?, "miterLimit"?}} nét phải có dạng {{"paint", "width"?, "join"?, "cap"?, "miterLimit"?}} +@msg an effect must be {{"id", "params"?, "visible"?}} hiệu ứng phải có dạng {{"id", "params"?, "visible"?}} @msg an effect on a fill returns only `fills` hiệu ứng trên phần tô chỉ trả về `fills` @msg an effect on a stroke returns only `strokes` hiệu ứng trên nét chỉ trả về `strokes` @msg an object must be a JSON object đối tượng phải là một đối tượng JSON diff --git a/crates/ui-egui/src/i18n/zh-hans.tsv b/crates/ui-egui/src/i18n/zh-hans.tsv index b04e65a65b..fa7f4998ac 100644 --- a/crates/ui-egui/src/i18n/zh-hans.tsv +++ b/crates/ui-egui/src/i18n/zh-hans.tsv @@ -6144,8 +6144,8 @@ axis Y Axis Y 轴 @msg an exporter declares 1 to {MAX_EXTENSIONS} `extensions` 导出增效工具声明 1 到 {MAX_EXTENSIONS} 个 `extensions` @msg at most {MAX_UNGROUPED} parameters outside groups: give the others a `group` 组外最多 {MAX_UNGROUPED} 个参数:请为其余参数指定 `group` @msg can't encode the input 无法编码输入 -@msg expected {"dataBase64"} 应为 {"dataBase64"} -@msg expected {"objects"} 应为 {"objects"} +@msg expected {{"dataBase64": "…"}} 应为 {{"dataBase64": "…"}} +@msg expected {{"objects": [...]}} 应为 {{"objects": [...]}} @msg export `{name}`: {e} 模块导出 `{name}`:{e} @msg extension {e} is listed twice 扩展名 {e} 列出了两次 @msg extension {e}: use 1-16 lower-case letters and digits, without the dot 扩展名 {e}:请使用 1-16 个小写字母和数字,不带点 @@ -6209,11 +6209,11 @@ axis Y Axis Y 轴 @msg a painted live effect returns only `objects` 绘制型实时效果只返回 `objects` @msg a painted live effect's piece has only `path`, `fillRule`, `fills` and `strokes` 绘制型实时效果的片段只有 `path`、`fillRule`、`fills` 和 `strokes` @msg a read-only object was left out 漏掉了一个只读对象 -@msg a read-only object was left out: return it, as {"id"} alone or as it was given 漏掉了一个只读对象:请以单独的 {"id"} 或原样返回它 +@msg a read-only object was left out: return it, as {{"id": …}} alone or as it was given 漏掉了一个只读对象:请以单独的 {{"id": …}} 或原样返回它 @msg a string 字符串 @msg a stroke has only `paint`, `width`, `join`, `cap` and `miterLimit` 描边只有 `paint`、`width`、`join`、`cap` 和 `miterLimit` -@msg a stroke must be {"paint", "width"?, "join"?, "cap"?, "miterLimit"?} 描边必须是 {"paint", "width"?, "join"?, "cap"?, "miterLimit"?} 形式 -@msg an effect must be {"id", "params"?, "visible"?} 效果必须是 {"id", "params"?, "visible"?} 形式 +@msg a stroke must be {{"paint", "width"?, "join"?, "cap"?, "miterLimit"?}} 描边必须是 {{"paint", "width"?, "join"?, "cap"?, "miterLimit"?}} 形式 +@msg an effect must be {{"id", "params"?, "visible"?}} 效果必须是 {{"id", "params"?, "visible"?}} 形式 @msg an effect on a fill returns only `fills` 填色上的效果只返回 `fills` @msg an effect on a stroke returns only `strokes` 描边上的效果只返回 `strokes` @msg an object must be a JSON object 对象必须是 JSON 对象 diff --git a/crates/ui-egui/src/i18n/zh-hant.tsv b/crates/ui-egui/src/i18n/zh-hant.tsv index be62fde8a7..eab4694fbe 100644 --- a/crates/ui-egui/src/i18n/zh-hant.tsv +++ b/crates/ui-egui/src/i18n/zh-hant.tsv @@ -7,6 +7,7 @@ # the translation is a single string (see `plural` in i18n.rs) # anything else a disambiguating context passed to `tr_ctx` # escapes \n \t \\ inside a column. `{name}` placeholders must appear in both columns. +# In @msg rows a literal brace is written {{ or }}, as in a Rust format string. # lines starting with # and blank lines are ignored. # # Clean-room: every entry was written from the plain meaning of the English label using the @@ -5949,8 +5950,8 @@ axis Y Axis Y 軸 @msg an exporter declares 1 to {MAX_EXTENSIONS} `extensions` 匯出增效模組宣告 1 到 {MAX_EXTENSIONS} 個 `extensions` @msg at most {MAX_UNGROUPED} parameters outside groups: give the others a `group` 群組外最多 {MAX_UNGROUPED} 個參數:請為其餘參數指定 `group` @msg can't encode the input 無法編碼輸入 -@msg expected {"dataBase64"} 應為 {"dataBase64"} -@msg expected {"objects"} 應為 {"objects"} +@msg expected {{"dataBase64": "…"}} 應為 {{"dataBase64": "…"}} +@msg expected {{"objects": [...]}} 應為 {{"objects": [...]}} @msg export `{name}`: {e} 模組匯出 `{name}`:{e} @msg extension {e} is listed twice 副檔名 {e} 列出了兩次 @msg extension {e}: use 1-16 lower-case letters and digits, without the dot 副檔名 {e}:請使用 1-16 個小寫字母和數字,不含點 @@ -6014,11 +6015,11 @@ axis Y Axis Y 軸 @msg a painted live effect returns only `objects` 繪製型即時效果只傳回 `objects` @msg a painted live effect's piece has only `path`, `fillRule`, `fills` and `strokes` 繪製型即時效果的片段只有 `path`、`fillRule`、`fills` 和 `strokes` @msg a read-only object was left out 漏掉了一個唯讀物件 -@msg a read-only object was left out: return it, as {"id"} alone or as it was given 漏掉了一個唯讀物件:請以單獨的 {"id"} 或原樣傳回它 +@msg a read-only object was left out: return it, as {{"id": …}} alone or as it was given 漏掉了一個唯讀物件:請以單獨的 {{"id": …}} 或原樣傳回它 @msg a string 字串 @msg a stroke has only `paint`, `width`, `join`, `cap` and `miterLimit` 筆畫只有 `paint`、`width`、`join`、`cap` 和 `miterLimit` -@msg a stroke must be {"paint", "width"?, "join"?, "cap"?, "miterLimit"?} 筆畫必須是 {"paint", "width"?, "join"?, "cap"?, "miterLimit"?} 形式 -@msg an effect must be {"id", "params"?, "visible"?} 效果必須是 {"id", "params"?, "visible"?} 形式 +@msg a stroke must be {{"paint", "width"?, "join"?, "cap"?, "miterLimit"?}} 筆畫必須是 {{"paint", "width"?, "join"?, "cap"?, "miterLimit"?}} 形式 +@msg an effect must be {{"id", "params"?, "visible"?}} 效果必須是 {{"id", "params"?, "visible"?}} 形式 @msg an effect on a fill returns only `fills` 填色上的效果只傳回 `fills` @msg an effect on a stroke returns only `strokes` 筆畫上的效果只傳回 `strokes` @msg an object must be a JSON object 物件必須是 JSON 物件 diff --git a/docs/development.md b/docs/development.md index 37f2b770ac..cbf3088faa 100644 --- a/docs/development.md +++ b/docs/development.md @@ -294,7 +294,10 @@ other `sv-*` locale resolve to it), Georgian (`ka`, complete; `ka-GE` and `ka_GE control channel, MCP and tests read them) and are translated only where the status bar draws them (`i18n::msg`). `@msg` catalog rows hold a whole message or a template such as `Couldn't open {name}: {e}`; `{_1}`, `{_2}` … stand for the format string's `{}`, and the values in the - placeholders are translated in turn (the reason after `: {e}` is often a message too). Czech, German, Greek, Spanish, French, Georgian, Hungarian, Italian, Japanese, Korean, Polish, Brazilian Portuguese, Russian, Simplified Chinese, Traditional Chinese, Swedish, Ukrainian, Vietnamese, Finnish, Turkish and Catalan + placeholders are translated in turn (the reason after `: {e}` is often a message too). A literal brace in a + row is written `{{` or `}}`, in the English and in the translation: a message made with `format!` keeps the + doubled braces of its format string, and a plain string literal has each of its braces doubled, so the row for + `"expected {\"objects\": [...]}"` is `expected {{"objects": [...]}}`. Czech, German, Greek, Spanish, French, Georgian, Hungarian, Italian, Japanese, Korean, Polish, Brazilian Portuguese, Russian, Simplified Chinese, Traditional Chinese, Swedish, Ukrainian, Vietnamese, Finnish, Turkish and Catalan translate status and error messages (`COMPLETE_MESSAGES` in `i18n::tests`); the untranslated report lists the message literals the scan finds that one of them has no row for. The scan reads `Err("…")`, `Other(…)`, `#[error(…)]` and `status(…)` messages, messages given to an error enum's variant (`PsError::Limit(…)`, the reason diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index f04ee3c910..9514a6a78e 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -847,6 +847,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur - The History panel, Edit › Undo and Edit › Redo translate the names of the steps that apply or redefine a character or paragraph style and of the step that redefines a graphic style: each such step keeps its template (`Apply {name}`, `Redefine {name}`, `Redefine Graphic Style “{name}”`) and the style's name, every complete catalog has a row for each template, and the panel and the menu show the style's name as the document holds it, except the Normal styles' names, which they show in the interface language (`steps_named_after_a_style_list_the_template_around_the_name`, `undo_and_redo_name_a_style_step_around_the_style_name`). - The status bar translates the limits a plug-in run can exceed (its instruction, time and memory budgets and its output size) and the error of a plug-in removal that leaves the plug-in's grant in the plug-in store, in every language that translates every message. The untranslated-strings report also lists messages that an error or a status takes from a `&str` constant (`plugin_limit_errors_are_translated`, `message_scan_reads_messages_held_in_constants`). - The status bar translates the plug-in host's other errors (manifest, module and run checks, the output an importer, exporter or effect returns, and the approval a plug-in's permissions need) in every language that translates every message. A JSON object or array inside a message stays as written, and a message that wraps another one, such as `plug-in manifest: {0}`, is matched by its own row before a row that starts with a placeholder. The reasons inside these errors (what is wrong with a parameter's spec or dialog keys, the value a parameter needs, what is wrong with an object, stroke, paint or effect in a plug-in's output) are translated with them, and the untranslated-strings report lists the reasons the plug-in host's error helpers take (`plugin_messages_are_translated_in_every_complete_catalog`, `plugin_errors_translate_with_their_values`, `plugin_error_reasons_are_translated_with_the_error`, `message_scan_reads_plugin_helpers`, `templates_that_start_with_text_match_first`). + - A status or error message whose braces are literal, such as a plug-in's `expected {"objects": [...]}` or a command's `quotes must be {double?, single?}`, is translated only when the whole message matches its row: the row writes those braces doubled and has no placeholder, so it no longer replaces a different message that starts with the same words, such as a JSON reason `expected value at line 1 column 1`, which stays in English. A row without placeholders is found at any message length (`plain_message_keys_double_their_braces`, `literal_brace_rows_match_only_their_message`). - The status bar translates the errors a script's `File` and `Folder` objects, `vc.run`, `$.evalFile`, the `$` object's properties and functions, `alert`, `confirm` and `prompt`, `vc.document` and `vc.log` raise, the limits a run can pass, and the errors of Drop Script Engine, and the line for a run that fails (a syntax error, a thrown error, a limit, a stop or an internal error, with the script's name, the line and column, the included file it is in and the files left written), in every language that translates every message. The script's name, paths, numbers, command ids and the text a script throws itself are shown as they are. A failure with no line and column, such as an error in code `eval` compiled, is shown with one colon after the script's name (`an_error_without_a_place_names_the_script_and_kind`). A message template that names a value twice matches only when both places hold the same text, so the line for a failed run is not taken for the message inside it (`a_repeated_placeholder_matches_the_same_text`). The untranslated-strings report also lists the messages the script host's `fail`, `value` and `over` helpers take and the errors the script interpreter throws, and a constant's text comes from the file or crate that declares it, so the Scripts menu's headers are not listed as messages (`script_errors_are_translated`, `script_host_errors_are_translated`, `message_scan_reads_script_interpreter_errors`, `message_scan_reads_constants_of_the_file_and_its_crate`, `message_scan_skips_test_modules_with_any_test_cfg`). - Localised UI: menus (in-window and native), panels, dialogs, toolbar and Preferences read from per-language catalogs (`crates/ui-egui/src/i18n`, see `docs/development.md`); Traditional Chinese (Taiwan), Simplified Chinese (mainland), Japanese, Korean, Vietnamese, German, Spanish, French, Italian, Polish, Brazilian Portuguese, Russian, Ukrainian, Georgian and Catalan ship complete, and Czech covers every menu label (and more), with system-locale detection on macOS, Windows, Linux and the web (the browser's languages, or `?lang=`), a Language preference and, on macOS, a VectorCraft ▸ Language menu. Names are never translated (fonts and their styles, layers, artboards, saved presets, user libraries, custom workspaces, recent files, plug-ins). The counts that Package's status and question, the folder search of Missing Fonts and Find Linked Files, the modified and missing linked file prompts and the CSS Properties panel's notes show use the interface language's plural forms instead of a fixed "(s)" form. Evidence: `the_missing_links_note_uses_each_count_form` in `crates/ui-egui/src/dialogs/tests_package.rs`, `the_modified_links_question_uses_each_count_form` in `crates/ui-egui/src/dialogs/missing_links.rs`, `search_counts_use_each_count_form` in `crates/ui-egui/src/dialogs/missing_fonts/tests.rs` and `the_unnamed_objects_note_uses_each_count_form` in `crates/ui-egui/src/panels/tests_css_properties.rs`. From 2b490a37ac07b292c53f2274515cf919ee288bed Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Fri, 13 Nov 2026 08:15:15 -0800 Subject: [PATCH 02/11] i18n: Dutch rows for literal-brace messages --- crates/ui-egui/src/i18n/nl.tsv | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/crates/ui-egui/src/i18n/nl.tsv b/crates/ui-egui/src/i18n/nl.tsv index 40facfda42..7a9a751e3f 100644 --- a/crates/ui-egui/src/i18n/nl.tsv +++ b/crates/ui-egui/src/i18n/nl.tsv @@ -287,8 +287,8 @@ @msg an exporter declares 1 to {MAX_EXTENSIONS} `extensions` een exportplug-in declareert 1 tot {MAX_EXTENSIONS} `extensions` @msg at most {MAX_UNGROUPED} parameters outside groups: give the others a `group` maximaal {MAX_UNGROUPED} parameters buiten groepen: geef de andere een `group` @msg can't encode the input de invoer kan niet worden gecodeerd -@msg expected {"dataBase64"} verwacht: {"dataBase64"} -@msg expected {"objects"} verwacht: {"objects"} +@msg expected {{"dataBase64": "…"}} verwacht: {{"dataBase64": "…"}} +@msg expected {{"objects": [...]}} verwacht: {{"objects": [...]}} @msg export `{name}`: {e} export `{name}`: {e} @msg extension {e} is listed twice extensie {e} staat twee keer in de lijst @msg extension {e}: use 1-16 lower-case letters and digits, without the dot extensie {e}: gebruik 1-16 kleine letters en cijfers, zonder de punt @@ -352,11 +352,11 @@ @msg a painted live effect returns only `objects` een geschilderd live-effect geeft alleen `objects` terug @msg a painted live effect's piece has only `path`, `fillRule`, `fills` and `strokes` een stuk van een geschilderd live-effect heeft alleen `path`, `fillRule`, `fills` en `strokes` @msg a read-only object was left out een alleen-lezen object is weggelaten -@msg a read-only object was left out: return it, as {"id"} alone or as it was given een alleen-lezen object is weggelaten: geef het terug, als alleen {"id"} of zoals het is gegeven +@msg a read-only object was left out: return it, as {{"id": …}} alone or as it was given een alleen-lezen object is weggelaten: geef het terug, als alleen {{"id": …}} of zoals het is gegeven @msg a string een string @msg a stroke has only `paint`, `width`, `join`, `cap` and `miterLimit` een contour heeft alleen `paint`, `width`, `join`, `cap` en `miterLimit` -@msg a stroke must be {"paint", "width"?, "join"?, "cap"?, "miterLimit"?} een contour moet {"paint", "width"?, "join"?, "cap"?, "miterLimit"?} zijn -@msg an effect must be {"id", "params"?, "visible"?} een effect moet {"id", "params"?, "visible"?} zijn +@msg a stroke must be {{"paint", "width"?, "join"?, "cap"?, "miterLimit"?}} een contour moet {{"paint", "width"?, "join"?, "cap"?, "miterLimit"?}} zijn +@msg an effect must be {{"id", "params"?, "visible"?}} een effect moet {{"id", "params"?, "visible"?}} zijn @msg an effect on a fill returns only `fills` een effect op een vulling geeft alleen `fills` terug @msg an effect on a stroke returns only `strokes` een effect op een contour geeft alleen `strokes` terug @msg an object must be a JSON object een object moet een JSON-object zijn From b39bfbed4f65664358abfa7b29962b47a26e576f Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Fri, 13 Nov 2026 19:43:38 -0800 Subject: [PATCH 03/11] Export Selection: the picked format's options dialog follows the save panel document.exportSelection run with no params or {} (a shortcut, or an automation call) wrote the picked file straight away with each format's defaults: a PDF used the default preset, so Save PDF's options, such as writing long pages in points, could not be chosen. It now takes Export As's route after the save panel: Save PDF without its Artboards section, SVG Options without its artboards, the raster options, EPS Options, DXF Options, Text Export Options with Selection Only on, or an exporter plug-in's parameters. OK writes the selection alone, cropped to it, with the chosen options. While a dialog is open the call returns null, and OK returns what file.exportAs returns, without format or bounds. With a path or any option it still writes straight away. - file.exportAs takes exportSelection: true and then writes the document that document.exportSelection writes (fileio::selection_doc), without artboard choices. The options dialogs and the replace-file questions pass it on. - Nothing selected stops before the save panel. - SVG Options opened by Export Selection leaves the remembered Use Artboards as it was. - Exports through file.exportAs (Export As and the options dialogs) apply Appearance of Black's rich black, as document.export does. Docs: docs/mcp.md, docs/control-protocol.md, docs/target-app-parity.md. --- crates/engine/src/cmd/fileio.rs | 2 +- crates/engine/src/cmd/fileio/commands.rs | 2 +- crates/engine/src/cmd/fileio/export.rs | 18 +- crates/mcp/src/ui_catalog.rs | 2 +- crates/ui-egui/src/dialogs.rs | 4 +- crates/ui-egui/src/dialogs/dxf_options.rs | 7 +- crates/ui-egui/src/dialogs/eps_options.rs | 4 +- crates/ui-egui/src/dialogs/export_as.rs | 77 +++++- crates/ui-egui/src/dialogs/png_options.rs | 8 +- crates/ui-egui/src/dialogs/save_pdf.rs | 15 +- crates/ui-egui/src/dialogs/svg_options.rs | 18 +- .../src/dialogs/tests_export_selection.rs | 238 ++++++++++++++++++ crates/ui-egui/src/dialogs/text_export.rs | 22 +- crates/ui-egui/src/io.rs | 7 +- crates/ui-egui/src/io/export.rs | 60 ++++- crates/ui-egui/src/menus/run.rs | 2 + crates/ui-egui/src/menus/ui_commands.rs | 2 +- crates/ui-egui/src/tests/saveext.rs | 9 + docs/control-protocol.md | 2 +- docs/mcp.md | 9 +- docs/target-app-parity.md | 1 + 21 files changed, 460 insertions(+), 49 deletions(-) create mode 100644 crates/ui-egui/src/dialogs/tests_export_selection.rs diff --git a/crates/engine/src/cmd/fileio.rs b/crates/engine/src/cmd/fileio.rs index d20c6c46b2..73203cc5d1 100644 --- a/crates/engine/src/cmd/fileio.rs +++ b/crates/engine/src/cmd/fileio.rs @@ -70,7 +70,7 @@ pub use encode::{ARTBOARD_PARAMS, ArtboardPick, Encoded, Note, NoteKind, encode, pub(crate) use encode::{anti_alias, background, with_single_artboard}; pub(crate) use export::with_artboard_fills; pub(crate) use export::{EXPORT_PARAMS, export_crop, isolated}; -pub use export::{export_doc, export_source, unused_option_notes}; +pub use export::{export_doc, export_source, selection_doc, unused_option_notes}; use load::err; pub(crate) use load::import_svg; pub(crate) use load::is_placeholder; diff --git a/crates/engine/src/cmd/fileio/commands.rs b/crates/engine/src/cmd/fileio/commands.rs index 28e12c115d..86449ab58b 100644 --- a/crates/engine/src/cmd/fileio/commands.rs +++ b/crates/engine/src/cmd/fileio/commands.rs @@ -45,7 +45,7 @@ pub fn specs() -> Vec { "Export Selection…", ["File"], None, - "{path?, format?: png|jpg|webp|svg|svgz|pdf|ai|gif|png8|tiff|bmp|tga|psd|txt|dxf|eps|emf|wmf|hpgl|vectorcraft|template (default: from the extension, else png), ppi?: number (raster formats: pixels per inch, stored in the file; wins over scale; without either, the document's raster effects resolution), scale?: number (pixels per point), …the format's options} the selected objects (template layers left out), each in the layers and groups that hold it, whose clipping paths, opacity masks, effects, opacity and blend modes apply as on the canvas (an object of a blend, envelope, repeat or intertwine on its own; txt: the selected type alone), cropped to the bounds the renderer measures for that art (a clip group by its clip) → {path, format, bytes, warnings, bounds} (no path → {dataBase64, format, bytes, warnings, bounds}); warnings say what the format approximated or left out, as for document.export. A key no format takes, and an unknown key inside a PDF or EPS option's object, are refused; a key only another format takes is not used, and warnings name it. Under automation read roots, svg and svgz are refused while the document links a JavaScript file outside them, as document.export refuses them", + "{path?, format?: png|jpg|webp|svg|svgz|pdf|ai|gif|png8|tiff|bmp|tga|psd|txt|dxf|eps|emf|wmf|hpgl|vectorcraft|template (default: from the extension, else png), ppi?: number (raster formats: pixels per inch, stored in the file; wins over scale; without either, the document's raster effects resolution), scale?: number (pixels per point), …the format's options} the selected objects (template layers left out), each in the layers and groups that hold it, whose clipping paths, opacity masks, effects, opacity and blend modes apply as on the canvas (an object of a blend, envelope, repeat or intertwine on its own; txt: the selected type alone), cropped to the bounds the renderer measures for that art (a clip group by its clip) → {path, format, bytes, warnings, bounds} (no path → {dataBase64, format, bytes, warnings, bounds}); warnings say what the format approximated or left out, as for document.export. A key no format takes, and an unknown key inside a PDF or EPS option's object, are refused; a key only another format takes is not used, and warnings name it. Under automation read roots, svg and svgz are refused while the document links a JavaScript file outside them, as document.export refuses them. In the app, with no params or {}, the save panel picks the file and its format's options dialog follows (Save PDF, SVG Options, the raster options, EPS Options, DXF Options, Text Export Options or an exporter plug-in's parameters; other formats are written at once), whose OK writes the selection: the call returns null while the dialog is open, and OK returns what file.exportAs returns, without format or bounds (Text Export Options: {path})", has_selection, export::export_selection ), diff --git a/crates/engine/src/cmd/fileio/export.rs b/crates/engine/src/cmd/fileio/export.rs index d8af86802f..14ac04cc8a 100644 --- a/crates/engine/src/cmd/fileio/export.rs +++ b/crates/engine/src/cmd/fileio/export.rs @@ -296,8 +296,14 @@ fn source<'a>(s: &'a mut Session, f: &Format, p: &Value, cmd: &str) -> Result<(C /// ([`isolated`]), with one artboard: their crop. With `text`, the objects alone on one layer, so a /// text export holds the type of the selected objects and not that of a clipping path around them. fn selection(s: &mut Session, cmd: &str, text: bool) -> Result<(Document, vectorcraft_geom::Rect)> { - let ids = selected_artwork(s)?; - let doc = &s.doc()?.doc; + selection_doc(s.doc()?, cmd, text) +} + +/// The document an Export Selection of `st` writes and its crop, as `document.exportSelection` +/// writes it ([`selection`]); `cmd` names the command in the error when nothing selected draws. +pub fn selection_doc(st: &DocState, cmd: &str, text: bool) -> Result<(Document, vectorcraft_geom::Rect)> { + let ids = artwork_of(st); + let doc = &st.doc; let (mut d, bounds) = isolated(doc, &ids, "Selection").ok_or_else(|| bad(cmd, "select something to export"))?; if text { let mut layer = Node::layer(d.alloc_id(), "Selection", vectorcraft_doc::LayerColor::Preset(0)); @@ -313,12 +319,16 @@ fn selection(s: &mut Session, cmd: &str, text: bool) -> Result<(Document, vector /// layers are guides and are left out. With an object of a dynamic symbol instance's art selected, /// the instance is exported whole. pub(crate) fn selected_artwork(s: &Session) -> Result> { - let st = s.doc()?; + Ok(artwork_of(s.doc()?)) +} + +/// [`selected_artwork`] of document `st`. +fn artwork_of(st: &DocState) -> Vec { let ids = edit::roots_of(&st.doc, st.selection.in_paint_order(&st.doc)); let is_template = |id| st.doc.node(id).is_some_and(|l| matches!(l.kind, NodeKind::Layer { template: true, .. })); // On a template layer or sublayer, at any depth. let on_template = |id| st.doc.ancestry(id).is_some_and(|a| a.into_iter().any(is_template)); - Ok(ids.into_iter().filter(|id| !on_template(*id)).collect()) + ids.into_iter().filter(|id| !on_template(*id)).collect() } /// The crop of an export of objects `ids` of `doc` alone ([`isolated`]): the bounds the renderer diff --git a/crates/mcp/src/ui_catalog.rs b/crates/mcp/src/ui_catalog.rs index e3b57166b7..aa102d4181 100644 --- a/crates/mcp/src/ui_catalog.rs +++ b/crates/mcp/src/ui_catalog.rs @@ -1330,7 +1330,7 @@ pub const UI_COMMANDS: &[(&str, &str, &str, &str)] = &[ "file.exportAs", "Export As…", "", - "{format?, useArtboards?, range?} opens Export As (then the format's options); with {path, …document.export options} writes the file(s) → {path, warnings, files?}; under automation read roots, an SVG or SVGZ is refused while the document links a JavaScript file outside them, as document.export refuses it", + "{format?, useArtboards?, range?} opens Export As (then the format's options); with {path, …document.export options} writes the file(s) → {path, warnings, files?}; exportSelection: true writes the selection alone, cropped to it, as document.exportSelection writes it (artboard options are not used); under automation read roots, an SVG or SVGZ is refused while the document links a JavaScript file outside them, as document.export refuses it", ), ( "ui.fileInfoDialog", diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index 1f3a01528c..e0622b1209 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -153,7 +153,7 @@ pub use color_picker::open as open_color_picker; pub use document_setup::open as open_document_setup; pub use effect::open as open_effect_dialog; pub(crate) use effect::{FILM_GRAIN_FIELDS, PAINT_DAUB_BRUSH_TYPE_LABELS, SHADES, shown_choices}; -pub use export_as::open as open_export_as; +pub use export_as::{export_selection, open as open_export_as}; pub(crate) use export_for_screens::drop_unread_screen_settings; pub use export_for_screens::open as open_export_for_screens; pub(crate) use export_for_screens::{ @@ -945,6 +945,8 @@ mod tests_accessibility; #[cfg(test)] mod tests_export; #[cfg(test)] +mod tests_export_selection; +#[cfg(test)] mod tests_import_pdf; #[cfg(test)] mod tests_package; diff --git a/crates/ui-egui/src/dialogs/dxf_options.rs b/crates/ui-egui/src/dialogs/dxf_options.rs index 498d854fe9..2e0aefc54c 100644 --- a/crates/ui-egui/src/dialogs/dxf_options.rs +++ b/crates/ui-egui/src/dialogs/dxf_options.rs @@ -158,8 +158,11 @@ fn body(app: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) -> bool { } }); ui.add_space(4.0); + // Hidden for Export Selection, which writes the selected art alone. let selection = app.session.active().is_some_and(|st| !st.selection.is_empty()); - if widgets::check(ui, tl!("Export Selected Art Only"), d.bool("selectedOnly") && selection, selection) { + if !d.bool(super::export_as::SELECTION_FIELD) + && widgets::check(ui, tl!("Export Selected Art Only"), d.bool("selectedOnly") && selection, selection) + { d.fields.insert("selectedOnly".into(), json!(!d.bool("selectedOnly"))); } check(ui, d, "alterPaths", tl!("Alter Paths for Appearance")); @@ -227,5 +230,5 @@ fn confirm(app: &mut VectorcraftApp, d: &Dialog) -> Result { app.ui.dxf_options = Value::Object(remembered.into_iter().flatten().collect()); app.ui.dialog = None; let path = d.fields.get("path").and_then(Value::as_str).map(str::to_string); - io::export(app, Some("dxf"), path, ¶ms) + io::export(app, Some("dxf"), path, &super::export_as::exported(d, params)) } diff --git a/crates/ui-egui/src/dialogs/eps_options.rs b/crates/ui-egui/src/dialogs/eps_options.rs index 3a20a6799b..8d34706df4 100644 --- a/crates/ui-egui/src/dialogs/eps_options.rs +++ b/crates/ui-egui/src/dialogs/eps_options.rs @@ -30,7 +30,7 @@ pub(super) const SPEC: DialogSpec = DialogSpec { }; /// Fields that are not remembered between exports (they belong to one export). -const PER_EXPORT: [&str; 7] = ["format", "path", "useArtboards", "range", "artboard", "artboards", "selectedOnly"]; +const PER_EXPORT: [&str; 8] = ["format", "path", "useArtboards", "range", "artboard", "artboards", "selectedOnly", super::export_as::SELECTION_FIELD]; /// The options a first EPS Options dialog starts from (the export's defaults). fn defaults() -> Map { @@ -133,5 +133,5 @@ fn confirm(app: &mut VectorcraftApp, d: &Dialog) -> Result { Value::Object(d.fields.iter().filter(|(k, _)| !PER_EXPORT.contains(&k.as_str())).map(|(k, v)| (k.clone(), v.clone())).collect()); app.ui.dialog = None; let path = d.fields.get("path").and_then(Value::as_str).map(str::to_string); - io::export(app, Some("eps"), path, ¶ms) + io::export(app, Some("eps"), path, &super::export_as::exported(d, params)) } diff --git a/crates/ui-egui/src/dialogs/export_as.rs b/crates/ui-egui/src/dialogs/export_as.rs index bd9ee6ca49..add0fbed73 100644 --- a/crates/ui-egui/src/dialogs/export_as.rs +++ b/crates/ui-egui/src/dialogs/export_as.rs @@ -4,8 +4,10 @@ //! picks the file, then the format's options dialog follows (PNG/JPEG/WebP/PNG-8/GIF Options, SVG //! Options, Text Export Options, DXF Options, EPS Options, and Save PDF), //! else the file is written. DWG is listed, greyed out, with what to use instead. The formats of -//! installed exporter plug-ins come after the built-in ones: they write the whole document, and one -//! with parameters asks for them before writing (`plugin::open_export`). +//! installed exporter plug-ins come after the built-in ones: they write the whole document (from +//! Export Selection, the selection), and one with parameters asks for them before writing +//! (`plugin::open_export`). Export Selection ([`export_selection`]) takes the same route after its +//! save panel, and every format then writes the selection alone, cropped to it. use std::sync::LazyLock; @@ -144,12 +146,52 @@ fn confirm(app: &mut VectorcraftApp, d: &Dialog) -> Result { app.ui.dialog = None; let path = io::export_path(app, None, f)?; let range = if all { "all".to_string() } else { range }; - let r = options_or_write(app, f, plugin, path, use_artboards, all, range); + let r = options_or_write(app, f, plugin, path, Pages { use_artboards, all, range, selection: false }); // The options follow the save panel: the Enter that closed it isn't theirs (as in Save As). super::opened_by_panel(app); r } +/// The options dialogs' field that is true when Export Selection opened them: OK then writes the +/// selection alone ([`exported`]). Not remembered with the dialog's options. +pub(super) const SELECTION_FIELD: &str = "__selection"; + +/// `params` of an options dialog `d` for [`io::export`]: with [`io::EXPORT_SELECTION`] when +/// Export Selection opened `d`. +pub(super) fn exported(d: &Dialog, mut params: Value) -> Value { + if d.bool(SELECTION_FIELD) + && let Some(o) = params.as_object_mut() + { + o.insert(io::EXPORT_SELECTION.into(), json!(true)); + } + params +} + +/// File › Export Selection (`document.exportSelection` without params): the save panel picks the +/// file and its format, then the format's options dialog follows as in Export As, else the file is +/// written. Each route writes the selected objects alone, cropped to them. Nothing selected to +/// export stops before the save panel. +pub fn export_selection(app: &mut VectorcraftApp) -> Result { + let st = app.session.active().ok_or("no document")?; + fileio::selection_doc(st, "document.exportSelection", false).map_err(|e| e.to_string())?; + let path = io::selection_path(app)?; + let f = fileio::writable_format(None, Some(&path))?; + let plugin = vectorcraft_plugins::export::by_format(f.id); + let pages = Pages { use_artboards: false, all: true, range: String::new(), selection: true }; + let r = options_or_write(app, f, plugin, path, pages); + super::opened_by_panel(app); + r +} + +/// What an export writes: with `use_artboards` the artboards (`all`, else the 1-based `range`), +/// else the bounds of the visible art, or with `selection` the selection alone. +struct Pages { + use_artboards: bool, + all: bool, + range: String, + selection: bool, +} + /// After the save panel picked `path`: the options dialog of format `f` (or of its exporter /// plug-in), else the file written. fn options_or_write( @@ -157,11 +199,10 @@ fn options_or_write( f: &'static Format, plugin: Option>, path: String, - use_artboards: bool, - all: bool, - range: String, + pages: Pages, ) -> Result { - match f.id { + let Pages { use_artboards, all, range, selection } = pages; + let r = match f.id { "svg" | "svgz" => { svg_options::open(app, svg_options::Mode::Export, Some(&path)); if let Some(o) = app.ui.dialog.as_mut() { @@ -169,24 +210,33 @@ fn options_or_write( } Ok(Value::Null) } - // Save PDF writes the artboards as pages, or one page of the art's bounds. + // Save PDF writes the artboards as pages, or one page of the art's bounds or the selection. "pdf" if use_artboards => save_pdf::open(app, &json!({ "path": path, "range": (!all).then_some(range) })), "pdf" => save_pdf::open(app, &json!({ "path": path, "useArtboards": false })), "txt" => { text_export::open(app, &path); + if let Some(o) = app.ui.dialog.as_mut().filter(|_| selection) { + o.fields.insert("selectionOnly".into(), json!(true)); + } Ok(Value::Null) } _ => { let mut params = json!({ "format": f.id, "path": path }); // A format that holds the whole document (a template) has no Use Artboards: the // export is not given the option, which its warnings would name as not used. - if plugin.is_some() || f.options.iter().any(|o| o.name == "useArtboards") { + if !selection && (plugin.is_some() || f.options.iter().any(|o| o.name == "useArtboards")) { params["useArtboards"] = json!(use_artboards); if use_artboards { params["range"] = json!(range); } } - if let Some(plugin) = plugin.filter(|p| !p.manifest().params.is_empty()) { + let plugin = plugin.filter(|p| !p.manifest().params.is_empty()); + let dialog = plugin.is_none() && (f.raster || matches!(f.id, "dxf" | "eps")); + if selection { + // An options dialog keeps the mark as a field of its own, which OK hands on. + params[if dialog { SELECTION_FIELD } else { io::EXPORT_SELECTION }] = json!(true); + } + if let Some(plugin) = plugin { plugin::open_export(app, &plugin, params); return Ok(Value::Null); } @@ -202,7 +252,12 @@ fn options_or_write( eps_options::open(app, ¶ms); return Ok(Value::Null); } - io::export(app, Some(f.id), Some(path), ¶ms) + return io::export(app, Some(f.id), Some(path), ¶ms); } + }; + // Export Selection's mark on the options dialog that opened. + if let Some(o) = app.ui.dialog.as_mut().filter(|_| selection) { + o.fields.insert(SELECTION_FIELD.into(), json!(true)); } + r } diff --git a/crates/ui-egui/src/dialogs/png_options.rs b/crates/ui-egui/src/dialogs/png_options.rs index 04257bc64d..98d6225c17 100644 --- a/crates/ui-egui/src/dialogs/png_options.rs +++ b/crates/ui-egui/src/dialogs/png_options.rs @@ -53,7 +53,11 @@ fn format_of(d: &Dialog) -> Option<&'static Format> { /// Open the options of raster format `f` for an export whose params (format, path, artboard /// choice) are `params`. pub fn open(app: &mut VectorcraftApp, f: &Format, mut params: Value) { - let size = app.session.active().and_then(|st| export_size(&st.doc, ¶ms)); + let selection = params[super::export_as::SELECTION_FIELD] == true; + let size = app.session.active().and_then(|st| match selection { + true => vectorcraft_engine::cmd::fileio::selection_doc(st, "document.exportSelection", false).ok().map(|(_, r)| (r.width(), r.height())), + false => export_size(&st.doc, ¶ms), + }); if let Some(o) = params.as_object_mut() { o.insert("ppi".into(), json!(72)); o.extend(defaults(app, f)); @@ -340,5 +344,5 @@ pub(super) fn choice(ui: &mut egui::Ui, d: &mut Dialog, key: &str, ids: &[&str], fn confirm(app: &mut VectorcraftApp, d: &Dialog) -> Result { let (format, path) = (d.str("format"), d.str("path")); app.ui.dialog = None; - io::export(app, Some(&format), Some(path), &form::params(d)) + io::export(app, Some(&format), Some(path), &super::export_as::exported(d, form::params(d))) } diff --git a/crates/ui-egui/src/dialogs/save_pdf.rs b/crates/ui-egui/src/dialogs/save_pdf.rs index 2e19375994..225c2cee52 100644 --- a/crates/ui-egui/src/dialogs/save_pdf.rs +++ b/crates/ui-egui/src/dialogs/save_pdf.rs @@ -6,7 +6,8 @@ //! `ui.dialog.set`. OK runs `document.exportPdf` through [`io::export_pdf`]; the Summary is //! `document.pdfSettings`. Save Preset… names the settings as a preset (`pdf.preset.save`). //! Opened from Export As with Use Artboards off (`__artBounds`), the dialog has no Artboards -//! section, and OK writes one page of the visible art's bounds through [`io::export`]. +//! section, and OK writes one page of the visible art's bounds through [`io::export`]; opened +//! from Export Selection (`__selection` too), the page is the selection's crop. //! //! The same dialog edits a preset (kind `pdfPreset`, from Edit → PDF Presets): a `name` and //! `description` instead of the preset and artboards, and OK saves the preset. @@ -154,7 +155,7 @@ fn confirm(app: &mut VectorcraftApp, d: &Dialog) -> Result { } let r = match SaveMode::of(&d.str("__save")) { Some(mode) => io::save_pdf(app, mode, params(d)), - None if d.bool(ART_BOUNDS) => export_art(app, params(d)), + None if d.bool(ART_BOUNDS) => export_art(app, d), None => io::export_pdf(app, params(d)), }; // A question the write asked (replace the file the document was opened from?) stays open. @@ -164,12 +165,14 @@ fn confirm(app: &mut VectorcraftApp, d: &Dialog) -> Result { r } -/// A page of the art's bounds (Export As with Use Artboards off): written as Export As writes -/// other formats ([`io::export`]), with the dialog's options and a saved preset written out. -fn export_art(app: &mut VectorcraftApp, mut p: Value) -> Result { +/// A page of the art's bounds (Export As with Use Artboards off), or of the selection's crop +/// (Export Selection): written as Export As writes other formats ([`io::export`]), with the +/// dialog's options and a saved preset written out. +fn export_art(app: &mut VectorcraftApp, d: &Dialog) -> Result { + let mut p = params(d); let path = p.as_object_mut().and_then(|o| o.remove("path")).and_then(|v| v.as_str().map(str::to_string)); let p = pdf::expand_preset(&app.session, "file.exportAs", &p).map_err(|e| e.to_string())?.into_owned(); - io::export(app, Some("pdf"), path, &p) + io::export(app, Some("pdf"), path, &super::export_as::exported(d, p)) } /// The settings as `pdf.preset.save` params (without the artboards and path). diff --git a/crates/ui-egui/src/dialogs/svg_options.rs b/crates/ui-egui/src/dialogs/svg_options.rs index ac568dbace..b477bb35c0 100644 --- a/crates/ui-egui/src/dialogs/svg_options.rs +++ b/crates/ui-egui/src/dialogs/svg_options.rs @@ -129,15 +129,23 @@ fn options(d: &Dialog) -> Value { fn confirm(app: &mut VectorcraftApp, d: &Dialog) -> Result { let opts = options(d); - if Mode::of(d) == Mode::Export && d.bool("useArtboards") { + let selection = d.bool(super::export_as::SELECTION_FIELD); + if Mode::of(d) == Mode::Export && d.bool("useArtboards") && !selection { let n = app.session.active().map_or(0, |st| st.doc.artboards.len()); let pick: vectorcraft_engine::cmd::fileio::ArtboardPick = serde_json::from_value(opts.clone()).map_err(|e| e.to_string())?; pick.resolve(n)?; } - // Remembered for next time, without the document's artboard range. + // Remembered for next time, without the document's artboard range. Export Selection shows no + // Use Artboards, so the one remembered before stays. let mut last = opts.clone(); if let Some(o) = last.as_object_mut() { o.remove("range"); + if selection { + match app.ui.svg_options.get("useArtboards") { + Some(v) => o.insert("useArtboards".into(), v.clone()), + None => o.remove("useArtboards"), + }; + } } app.ui.svg_options = last; app.ui.dialog = None; @@ -145,6 +153,7 @@ fn confirm(app: &mut VectorcraftApp, d: &Dialog) -> Result { if let Some(p) = d.fields.get("path").filter(|p| p.is_string()) { params["path"] = p.clone(); } + let params = super::export_as::exported(d, params); let id = match Mode::of(d) { Mode::Export => "file.export.svg", Mode::Save => "file.saveAs", @@ -177,8 +186,11 @@ fn check(ui: &mut egui::Ui, d: &mut Dialog, key: &str, label: &str, enabled: boo fn body(app: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) -> bool { option_fields(ui, d, false); + // Export Selection writes the selection's crop, without artboards to choose. if Mode::of(d) == Mode::Export { - artboards(app, ui, d); + if !d.bool(super::export_as::SELECTION_FIELD) { + artboards(app, ui, d); + } } else { check(ui, d, "preserveEditing", tl!("Preserve Editing Capabilities"), true); } diff --git a/crates/ui-egui/src/dialogs/tests_export_selection.rs b/crates/ui-egui/src/dialogs/tests_export_selection.rs new file mode 100644 index 0000000000..15fe559974 --- /dev/null +++ b/crates/ui-egui/src/dialogs/tests_export_selection.rs @@ -0,0 +1,238 @@ +//! Export Selection (`document.exportSelection` without params): the picked format's options +//! dialog follows the save panel, as in Export As, and writes the selection alone. + +use std::cell::Cell; +use std::rc::Rc; + +use serde_json::json; +use vectorcraft_plugins::wat::{self as tpl, Echo}; + +use super::tests_export::{Written, app_saving, frame, kind, names, set}; +use super::*; + +/// The last file written, as text. +fn last_text(written: &Written) -> String { + String::from_utf8_lossy(&written.borrow().last().unwrap().1).into_owned() +} + +/// The width and height in a PNG's header. +fn png_size(png: &[u8]) -> (u32, u32) { + let n = |at: usize| u32::from_be_bytes([png[at], png[at + 1], png[at + 2], png[at + 3]]); + (n(16), n(20)) +} + +/// Pick `path` in the next save panels. +fn picking(app: &mut VectorcraftApp, path: &str) { + let picked = path.to_string(); + app.services.pick_save = Some(Box::new(move |_| Some(picked.clone()))); +} + +/// How many paths a native document's JSON (or the input an exporter plug-in is given) holds. +fn paths_in(bytes: &[u8]) -> usize { + String::from_utf8_lossy(vectorcraft_format::json_part(bytes)).matches("\"anchors\"").count() +} + +/// A PDF continues in Save PDF, without its artboard choice, and its options apply: the page is +/// the selected 10 m rectangle's crop (not the art's bounds, which take in the ellipse), with a +/// `UserUnit` once "Write pages longer than 200 in at full size in points" is turned off. +#[test] +fn export_selection_to_pdf_continues_in_save_pdf() { + let (mut app, written) = app_saving(2, |_| "/out/sel.pdf".into()); + app.run("shape.rectangle", json!({"x": 0, "y": 100, "width": 28_346.46, "height": 50})).unwrap(); + app.run("document.exportSelection", json!({})).unwrap(); + assert_eq!(kind(&app), Some("savePdf"), "the PDF options follow"); + let d = app.ui.dialog.as_ref().unwrap(); + assert_eq!(d.str("path"), "/out/sel.pdf"); + assert!(d.bool("__artBounds") && d.bool(export_as::SELECTION_FIELD)); + assert!(written.borrow().is_empty(), "nothing is written before OK"); + set(&mut app, "longPagesInPoints", json!(false)); + frame(&mut app); + assert_eq!(confirm(&mut app).unwrap()["path"], "/out/sel.pdf"); + assert!(app.ui.dialog.is_none()); + let bytes = written.borrow()[0].1.clone(); + assert!(String::from_utf8_lossy(&bytes).contains("/UserUnit"), "the dialog's option applies"); + let pdf = vectorcraft_pdf::import(&bytes).unwrap(); + assert_eq!(pdf.artboards.len(), 1); + let page = pdf.artboards[0].rect; + assert!((28_346.0..28_350.0).contains(&page.width()), "{}", page.width()); + assert_eq!(page.height().round(), 50.0, "the selection's crop"); +} + +/// Every format with an options dialog shows it after the save panel, and OK writes the +/// selection alone, cropped to the selected 10×5 pt rectangle: the PNG at 72 ppi, the SVG's view +/// box, the EPS bounding box and the DXF's extents. The mark is not kept with the options +/// remembered for the next export. +#[test] +fn export_selection_continues_in_each_format_s_options() { + let (mut app, written) = app_saving(2, |_| String::new()); + app.run("shape.rectangle", json!({"x": 40, "y": 30, "width": 10, "height": 5})).unwrap(); + for (ext, options) in [("svg", "svgOptions"), ("png", "pngOptions"), ("eps", "epsOptions"), ("dxf", "dxfOptions")] { + let path = format!("/out/sel.{ext}"); + picking(&mut app, &path); + app.run("document.exportSelection", json!({})).unwrap(); + assert_eq!(kind(&app), Some(options), "{ext}"); + assert!(app.ui.dialog.as_ref().unwrap().bool(export_as::SELECTION_FIELD), "{ext}"); + if ext == "png" { + assert_eq!(app.ui.dialog.as_ref().unwrap().fields["__size"], json!([10.0, 5.0]), "the selection's size"); + } + frame(&mut app); + confirm(&mut app).unwrap(); + assert!(app.ui.dialog.is_none(), "{ext}"); + assert_eq!(names(&written).last(), Some(&path), "{ext}"); + let text = last_text(&written); + match ext { + "png" => assert_eq!(png_size(&written.borrow().last().unwrap().1), (10, 5), "10×5 pt at 72 ppi"), + "svg" => assert!(text.contains(r#"viewBox="0 0 10 5""#), "{text}"), + "eps" => assert!(text.contains("%%BoundingBox: 0 0 10 5"), "{text}"), + _ => { + let at = text.find("$EXTMAX").unwrap_or_else(|| panic!("no extents: {text}")); + let max: Vec = text[at..].lines().skip(1).collect::>().chunks(2).take(2).map(|c| c[1].trim().parse().unwrap()).collect(); + // In millimeters, DXF Options' default unit: 10×5 pt. + assert!((max[0] - 10.0 * 25.4 / 72.0).abs() < 1e-3 && (max[1] - 5.0 * 25.4 / 72.0).abs() < 1e-3, "the selection's extents: {max:?}"); + } + } + } + assert_eq!(names(&written).len(), 4, "one file each"); + assert!(app.ui.eps_options.get(export_as::SELECTION_FIELD).is_none(), "EPS Options does not remember the mark"); + assert!(app.ui.svg_options.get(export_as::SELECTION_FIELD).is_none()); +} + +/// Text Export Options opens with Selection Only on, and OK writes the selected type alone. +#[test] +fn export_selection_to_text_writes_the_selected_type() { + let (mut app, written) = app_saving(1, |_| "/out/sel.txt".into()); + app.run("text.create", json!({"x": 10, "y": 10, "text": "Left out"})).unwrap(); + app.run("text.create", json!({"x": 10, "y": 30, "text": "Selected"})).unwrap(); + app.run("document.exportSelection", json!({})).unwrap(); + assert_eq!(kind(&app), Some("txtOptions")); + assert!(app.ui.dialog.as_ref().unwrap().bool("selectionOnly")); + frame(&mut app); + confirm(&mut app).unwrap(); + assert_eq!(names(&written), ["/out/sel.txt"]); + assert_eq!(last_text(&written), "Selected\n"); +} + +/// SVG Options opened by Export Selection hides Use Artboards, and its OK leaves the Use +/// Artboards setting that the next Export As SVG starts from as it was. +#[test] +fn export_selection_to_svg_keeps_the_remembered_use_artboards() { + let (mut app, _) = app_saving(2, |_| "/out/sel.svg".into()); + app.run("file.export.svg", json!({})).unwrap(); + set(&mut app, "useArtboards", json!(true)); + confirm(&mut app).unwrap(); + assert_eq!(app.ui.svg_options["useArtboards"], true); + app.run("shape.rectangle", json!({"x": 40, "y": 30, "width": 10, "height": 5})).unwrap(); + app.run("document.exportSelection", json!({})).unwrap(); + assert_eq!(kind(&app), Some("svgOptions")); + confirm(&mut app).unwrap(); + assert_eq!(app.ui.svg_options["useArtboards"], true, "{}", app.ui.svg_options); +} + +/// A format without an options dialog (a native document) and an exporter plug-in, with or +/// without parameters, are given the selection alone: fewer paths than the whole document. +#[test] +fn export_selection_without_a_dialog_and_to_plug_ins_writes_the_selection() { + let (mut app, written) = app_saving(1, |_| String::new()); + app.run("shape.rectangle", json!({"x": 40, "y": 30, "width": 10, "height": 5})).unwrap(); + app.run("file.exportAs", json!({"path": "/out/all.vectorcraft"})).unwrap(); + let all = paths_in(&written.borrow().last().unwrap().1); + picking(&mut app, "/out/sel.vectorcraft"); + app.run("document.exportSelection", json!({})).unwrap(); + assert!(app.ui.dialog.is_none(), "written at once"); + assert_eq!(names(&written).last().map(String::as_str), Some("/out/sel.vectorcraft")); + let selected = paths_in(&written.borrow().last().unwrap().1); + assert!(selected >= 1 && selected < all, "{selected} of {all} paths"); + + for (id, ext, params) in + [("test.ui.selwhole", "tsw", "{}"), ("test.ui.selparams", "tsp", r#"{"density":{"type":"number","min":0,"max":10,"default":4}}"#)] + { + let wasm = wat::parse_str(tpl::echo_exporter(&tpl::exporter(id, &format!(r#"["{ext}"]"#), params), Echo::Input)).unwrap(); + app.run("plugin.install", json!({"dataBase64": vectorcraft_format::base64_encode(&wasm)})).unwrap(); + picking(&mut app, &format!("/out/sel.{ext}")); + app.run("document.exportSelection", json!({})).unwrap(); + if params != "{}" { + assert_eq!(kind(&app), Some(plugin::EXPORT_KIND), "{id} asks for its parameters"); + frame(&mut app); + confirm(&mut app).unwrap(); + } + assert!(app.ui.dialog.is_none(), "{id}"); + assert_eq!(names(&written).last(), Some(&format!("/out/sel.{ext}"))); + let given = paths_in(&written.borrow().last().unwrap().1); + app.run("plugin.remove", json!({"id": id})).unwrap(); + assert_eq!(given, selected, "{id} is given the selection"); + } +} + +/// The question before replacing the file the document was opened from runs the export again as +/// an Export Selection: the PNG written after OK is the selection's. +#[test] +fn export_selection_after_the_question_before_losing_writes_the_selection() { + let (mut app, written) = app_saving(1, |_| "/out/sel.png".into()); + app.run("shape.rectangle", json!({"x": 40, "y": 30, "width": 10, "height": 5})).unwrap(); + let st = app.session.doc_mut().unwrap(); + st.imported_from = Some("/out/sel.png".into()); + st.import_losses = vec!["hidden type".into()]; + app.run("document.exportSelection", json!({})).unwrap(); + assert_eq!(kind(&app), Some("pngOptions")); + frame(&mut app); + assert_eq!(confirm(&mut app).unwrap()["pending"], super::confirm::KIND); + assert!(written.borrow().is_empty(), "nothing is written before OK"); + confirm(&mut app).unwrap(); + assert_eq!(names(&written), ["/out/sel.png"]); + assert_eq!(png_size(&written.borrow()[0].1), (10, 5), "the selection's crop"); +} + +/// `file.exportAs {path, exportSelection: true}` writes the selection without a dialog; a value +/// that is not true or false is refused. +#[test] +fn export_as_with_export_selection_writes_the_selection() { + let (mut app, written) = app_saving(1, |_| String::new()); + app.run("shape.rectangle", json!({"x": 40, "y": 30, "width": 10, "height": 5})).unwrap(); + app.run("file.exportAs", json!({"path": "/out/sel.png", "exportSelection": true, "useArtboards": true})).unwrap(); + assert_eq!(png_size(&written.borrow()[0].1), (10, 5)); + let e = app.run("file.exportAs", json!({"path": "/out/sel.png", "exportSelection": "yes"})).unwrap_err(); + assert!(e.contains("exportSelection must be true or false"), "{e}"); + assert_eq!(names(&written).len(), 1); +} + +/// Appearance of Black › Printing / Exporting set to rich black draws a CMYK black as RGB black in +/// a PNG from Export As and from Export Selection, as `document.export` draws it. +#[test] +fn rich_black_reaches_files_from_the_export_dialogs() { + let (mut app, written) = app_saving(1, |_| "/out/sel.png".into()); + app.run("file.new", json!({"width": 40, "height": 30, "colorMode": "cmyk"})).unwrap(); + app.run("paint.setStroke", json!({"none": true})).unwrap(); + app.run("paint.setFill", json!({"color": {"c": 0, "m": 0, "y": 0, "k": 1}})).unwrap(); + app.run("shape.rectangle", json!({"x": 0, "y": 0, "width": 40, "height": 30})).unwrap(); + let px = |w: &Written| { + let w = w.borrow(); + image::load_from_memory(&w.last().unwrap().1).unwrap().to_rgba8().get_pixel(10, 10).0 + }; + app.run("file.exportAs", json!({"path": "/out/all.png"})).unwrap(); + let accurate = px(&written); + assert!(accurate[0] > 10, "accurate: dark grey, {accurate:?}"); + app.session.prefs.black_output = "rich".into(); + app.run("file.exportAs", json!({"path": "/out/all.png"})).unwrap(); + assert_eq!(px(&written), [0, 0, 0, 255], "Export As"); + app.run("document.exportSelection", json!({})).unwrap(); + frame(&mut app); + confirm(&mut app).unwrap(); + assert_eq!(names(&written).last().map(String::as_str), Some("/out/sel.png")); + assert_eq!(px(&written), [0, 0, 0, 255], "Export Selection"); +} + +/// With nothing selected, Export Selection stops before the save panel. +#[test] +fn nothing_selected_stops_before_the_save_panel() { + let (mut app, written) = app_saving(1, |_| "/out/sel.pdf".into()); + app.run("select.none", json!({})).unwrap(); + let asked = Rc::new(Cell::new(false)); + let a = asked.clone(); + app.services.pick_save = Some(Box::new(move |_| { + a.set(true); + Some("/out/sel.pdf".into()) + })); + let e = app.run("document.exportSelection", json!({})).unwrap_err(); + assert!(e.contains("select something to export"), "{e}"); + assert!(!asked.get() && app.ui.dialog.is_none() && written.borrow().is_empty()); +} diff --git a/crates/ui-egui/src/dialogs/text_export.rs b/crates/ui-egui/src/dialogs/text_export.rs index 6701fd65d7..12e7b5d425 100644 --- a/crates/ui-egui/src/dialogs/text_export.rs +++ b/crates/ui-egui/src/dialogs/text_export.rs @@ -1,5 +1,6 @@ -//! Export As → Text: the Text Export Options (encoding, line endings, selection only) for a -//! picked `.txt` path. Fields are `document.export` params (`format`, `path`…). +//! Export As → Text and Export Selection to `.txt`: the Text Export Options (encoding, line +//! endings, selection only) for a picked `.txt` path. Fields are `document.export` params +//! (`format`, `path`…). use serde_json::{Value, json}; @@ -39,14 +40,25 @@ fn body(app: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) -> bool { ui.end_row(); }); ui.add_space(8.0); - let selected = app.session.active().is_some_and(|st| !st.selection.objects.is_empty()); + // Export Selection keeps Selection Only on. + let selected = app.session.active().is_some_and(|st| !st.selection.objects.is_empty()) && !d.bool(super::export_as::SELECTION_FIELD); ui.add_enabled_ui(selected, |ui| form::check(ui, d, "selectionOnly", tl!("Selection Only"))); false } -/// Write the text file (the command runs first, so a bad option keeps the dialog open). +/// Write the text file (the command runs first, so a bad option keeps the dialog open). Opened by +/// Export Selection, it writes `document.exportSelection`'s text. fn confirm(app: &mut VectorcraftApp, d: &Dialog) -> Result { - let path = io::save_command_output(app, "document.export", "txt", form::params(d))?; + let mut params = form::params(d); + let id = if d.bool(super::export_as::SELECTION_FIELD) { + if let Some(o) = params.as_object_mut() { + o.remove("selectionOnly"); + } + "document.exportSelection" + } else { + "document.export" + }; + let path = io::save_command_output(app, id, "txt", params)?; app.ui.dialog = None; Ok(json!({ "path": path })) } diff --git a/crates/ui-egui/src/io.rs b/crates/ui-egui/src/io.rs index 83f463d668..3f6377aae4 100644 --- a/crates/ui-egui/src/io.rs +++ b/crates/ui-egui/src/io.rs @@ -23,7 +23,8 @@ 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, + EXPORT_SELECTION, export_files, exported_with_notes, file_url, open_in_app, open_original, reveal_path, run_to_file, save_command_output_named, + selection_path, }; pub(crate) use open::report_installed; pub(crate) use open::{EXPORT_PRESETS, import_presets, open_with_on, opened, show_opened}; @@ -430,7 +431,9 @@ mod tests { app.session.execute("file.new", &json!({"width": 60, "height": 40})).unwrap(); app.session.execute("shape.rectangle", &json!({"x": 5, "y": 5, "width": 20, "height": 10})).unwrap(); app.services.pick_save = Some(Box::new(|_: &FilePick| Some("/tmp/sel.svg".into()))); - assert_eq!(app.run("document.exportSelection", json!({})).unwrap()["path"], "/tmp/sel.svg"); + app.run("document.exportSelection", json!({})).unwrap(); + assert_eq!(app.ui.dialog.as_ref().map(|d| d.kind.as_str()), Some("svgOptions"), "the picked format's options follow"); + assert_eq!(crate::dialogs::confirm(&mut app).unwrap()["path"], "/tmp/sel.svg"); app.run("document.exportSelection", json!({"path": "/tmp/sel.png"})).unwrap(); let w = written.borrow(); assert!(String::from_utf8_lossy(&w[0].1).contains(" Result<(bool, Value), String> { + let mut p = params.clone(); + let Some(o) = p.as_object_mut() else { return Ok((false, p)) }; + match o.remove(EXPORT_SELECTION) { + None | Some(Value::Null | Value::Bool(false)) => return Ok((false, p)), + Some(Value::Bool(true)) => {} + Some(v) => return Err(format!("{EXPORT_SELECTION} must be true or false, not {v}")), + } + let strip = |o: &mut serde_json::Map| { + for k in fileio::ARTBOARD_PARAMS.iter().chain(&["useArtboards", "selectedOnly"]) { + o.remove(*k); + } + }; + strip(o); + if let Some(svg) = o.get_mut("svg").and_then(Value::as_object_mut) { + strip(svg); + } + Ok((true, p)) +} + /// File → Cancel Export (the progress dialog's Cancel): every export running in the background /// stops and writes nothing more → `{cancelled: n}`. pub fn cancel_exports(app: &mut VectorcraftApp) -> Value { @@ -29,11 +56,16 @@ pub fn hide_exports(app: &mut VectorcraftApp) -> Value { /// options…): every file it writes (one per artboard, linked images) goes next to `path`. The /// document keeps its path. → `{path, warnings, files?}` (`files`: every file when there are /// several). When automation asked for the export, `warnings` also name each option the written -/// format doesn't read, as `document.export`'s do. +/// format doesn't read, as `document.export`'s do. With [`EXPORT_SELECTION`] the file holds the +/// selection alone, cropped to it, as `document.exportSelection` writes it. With Appearance of +/// Black › Printing / Exporting set to rich black, RGB files draw a CMYK black as RGB black, as in +/// `document.export`. pub fn export(app: &mut VectorcraftApp, format: Option<&str>, path: Option, params: &Value) -> Result { // Options given as anything but an object are none: the path and format still reach the // encoder (an exporter plug-in is told the file it writes). let params = &if params.is_object() { params.clone() } else { json!({}) }; + let (selection, params) = take_selection(params)?; + let params = ¶ms; let f = fileio::writable_format(format, path.as_deref())?; // A Shape Builder merge still being computed for a click is exported with the document. let r = app.session.finish_tool_edit(); @@ -42,9 +74,13 @@ pub fn export(app: &mut VectorcraftApp, format: Option<&str>, path: Option st.doc.clone(), - std::borrow::Cow::Owned(d) => std::sync::Arc::new(d), + let doc = if selection { + std::sync::Arc::new(fileio::selection_doc(st, "document.exportSelection", f.id == "txt").map_err(|e| e.to_string())?.0) + } else { + match fileio::export_doc(st, f, params).map_err(|e| e.to_string())? { + std::borrow::Cow::Borrowed(_) => st.doc.clone(), + std::borrow::Cow::Owned(d) => std::sync::Arc::new(d), + } }; // A PDF shows its title in the viewer's title bar only when it is the document's own, not the // file name it was titled after, as `document.export` writes it. @@ -55,6 +91,9 @@ pub fn export(app: &mut VectorcraftApp, format: Option<&str>, path: Option, path: Option, path: Option Result, String> { bytes.ok_or_else(|| "no data".into()) } +/// The file an Export Selection writes, picked in a save panel (the web: the suggested name): a +/// typed extension of any format is kept, and names the format. +pub(crate) fn selection_path(app: &mut VectorcraftApp) -> Result { + target_path_keeping(app, None, "png", |_| true) +} + /// Run an engine command that returns `{dataBase64}` without its `path` and write the bytes to /// that path (else a picked or suggested name) → (path, the command's result without the data). /// The command runs before a path is asked for, so bad params never open a save dialog, except diff --git a/crates/ui-egui/src/menus/run.rs b/crates/ui-egui/src/menus/run.rs index 14e5d053ef..d6795b5fbd 100644 --- a/crates/ui-egui/src/menus/run.rs +++ b/crates/ui-egui/src/menus/run.rs @@ -171,6 +171,8 @@ fn run_ui_part1(app: &mut VectorcraftApp, id: &str, p: &Value) -> Option crate::dialogs::export_selection(app), "document.exportSelection" => { io::save_command_output(app, id, "png", if p.is_object() { p.clone() } else { json!({}) }).map(|p| json!({"path": p})) } diff --git a/crates/ui-egui/src/menus/ui_commands.rs b/crates/ui-egui/src/menus/ui_commands.rs index cd311d45a8..924ac386a2 100644 --- a/crates/ui-egui/src/menus/ui_commands.rs +++ b/crates/ui-egui/src/menus/ui_commands.rs @@ -1331,7 +1331,7 @@ pub const UI_COMMANDS: &[(&str, &str, &str, &str)] = &[ "file.exportAs", "Export As…", "", - "{format?, useArtboards?, range?} opens Export As (then the format's options); with {path, …document.export options} writes the file(s) → {path, warnings, files?}; under automation read roots, an SVG or SVGZ is refused while the document links a JavaScript file outside them, as document.export refuses it", + "{format?, useArtboards?, range?} opens Export As (then the format's options); with {path, …document.export options} writes the file(s) → {path, warnings, files?}; exportSelection: true writes the selection alone, cropped to it, as document.exportSelection writes it (artboard options are not used); under automation read roots, an SVG or SVGZ is refused while the document links a JavaScript file outside them, as document.export refuses it", ), ( "ui.fileInfoDialog", diff --git a/crates/ui-egui/src/tests/saveext.rs b/crates/ui-egui/src/tests/saveext.rs index 815c1c8493..df003ddb01 100644 --- a/crates/ui-egui/src/tests/saveext.rs +++ b/crates/ui-egui/src/tests/saveext.rs @@ -378,6 +378,12 @@ fn every_write_through_a_save_panel_asks_before_replacing() { let d = app.ui.dialog.clone().expect(cmd); assert_eq!((d.kind.as_str(), d.str("path")), (dialogs::confirm::REPLACE, path.to_string()), "{cmd}"); dialogs::confirm(&mut app).unwrap(); + // Export Selection without params continues in the format's options, whose OK writes it. + if cmd == "document.exportSelection" { + assert_eq!(app.ui.dialog.as_ref().map(|d| (d.kind.as_str(), d.str("path"))), Some(("pngOptions", path.to_string())), "{cmd}"); + assert!(w.borrow().is_empty(), "{cmd}: nothing is written before the options' OK"); + dialogs::confirm(&mut app).unwrap(); + } assert_eq!(last(&w), path, "{cmd}"); } // Export As: the question's OK picks the file again, and PNG Options follows. @@ -480,5 +486,8 @@ fn an_export_keeps_a_typed_extension_of_the_format_it_writes() { let (mut app, w) = saving_app("/tmp/sel.pdf"); app.run("select.all", json!({})).unwrap(); app.run("document.exportSelection", json!({})).unwrap(); + // Save PDF follows the save panel, and its OK writes the file. + assert_eq!(app.ui.dialog.as_ref().map(|d| (d.kind.as_str(), d.str("path"))), Some(("savePdf", "/tmp/sel.pdf".to_string()))); + dialogs::confirm(&mut app).unwrap(); assert_eq!(last(&w), "/tmp/sel.pdf", "Export Selection takes its format from the typed name"); } diff --git a/docs/control-protocol.md b/docs/control-protocol.md index 40ad89d4b4..ad736eaab8 100644 --- a/docs/control-protocol.md +++ b/docs/control-protocol.md @@ -123,7 +123,7 @@ the two can still win that race. | Background Save / Export | `app.save`, `app.export {path}`, `file.save`, `file.saveAs`, `file.saveCopy`, `file.exportForScreens {folder, …}` | with the `backgroundSave` / `backgroundExport` preferences on (the default) the desktop app writes the file from a snapshot on a worker thread: the call returns `{path, background: true, status}` at once (`file.exportForScreens`: `{background: true, status}`, and the files are written into the folder from a snapshot taken at the call; an `app.export` given an option the format doesn't read: also `warnings`, naming the option), `ui.inspect` lists the jobs still running in `background` (labels such as `"Saving a.vectorcraft"`), and the document counts as saved (as of the snapshot) once its file is written. Quit and Close wait for them. A job that fails reports the error in the status bar only (`ui.inspect` → `ui.status`), a path outside `--automation-write-root` included. With the preferences off (`prefs.set`), saves return once written. `app.save {path?, wait: true}` and `app.export {path, wait: true}` wait for the jobs already running, write the file before they reply whatever the preferences are set to, and return its result or the error. When the save asks first (the question before replacing a file that opened with losses, or on macOS and Linux the file dialog for an untitled document without `path`), the reply is `{pending}` or `"cancelled"` as before, and the save that runs after the answer uses the preferences. A `wait` that is not `true`, `false` or `null` is refused with "`wait` must be true or false", and nothing is written | | `engine.execute` `file.exportForScreens` | `{}` / `{…document.exportForScreens params}` | without params opens `exportForScreens` on the settings the document last exported with (`document.exportSettings`). While the Artboard tool is in use, the artboards it has selected (its `selected` option, the rows the Artboards panel highlights) are checked instead, and `select` is `range`, or `all` when every artboard is selected. Fields: `tab` (`artboards` \| `assets` \| `layers` \| `selection`; OK reads Export Layer or Export Selection on those), `layers` ([layer id…] checked), `selectionMode` (`one` \| `each`), `name` (the file name pattern of `document.exportForScreens`), `counterStart`, `folders` (`none` \| `rows` \| `format` \| `item` \| `itemFormat`), `exportPreset` (the preset applied last), `__savePreset` (the name typed to save one), `select` (`all` \| `range` \| `full`), `range` ("1-3, 5"), `boards` (a bool per artboard; checking them sets `select` and `range`), `includeBleed`, `folder` (desktop; default the Desktop, else home), `openLocation`, `subfolders`, `prefix`, `preset` (`""` \| `mobile` \| `density`), `formats` (rows `{format, scale: "2x" \| "100w" \| "100h" \| "72ppi", suffix, quality?, folder?}`), `settings` (`{png \| png8 \| jpg \| webp \| gif \| svg \| pdf: {…options}}`, the gear's Format Settings; `__settings` names the format shown). `ui.dialog.confirm` exports (a bad range or no folder keeps it open); on the web OK reads "Download" and the files download (several as one `.zip`). With params it runs `document.exportForScreens`: no `folder` returns the files (or with `zip: true` one zip) as `dataBase64`; with `openLocation` the first file is shown in the file manager. When `warnings` is not empty, an export into a folder or a download shows the first in the status bar as Save As shows a save's notes | | Export for Screens' Assets tab, Asset Export panel | `ui.dialog.set` `tab: "assets"`, `assets: [asset id…]`; `window.panel {panel: "assetExport"}` | on the Assets tab of `exportForScreens` the `assets` field lists the checked asset ids (all of them when it opens); `ui.dialog.confirm` runs `document.exportForScreens {assets}` (nothing checked keeps it open) and OK reads "Export Asset". The Asset Export panel acts through `assets.*` and shares the format rows with the dialog (`assets.settings.set`). The dialog has the format presets menu (☰). In the dialog, `exportPreset` names the preset applied last (`ui.dialog.set` of it applies the preset it names, as picking it does; a name that is not a preset's is an error and leaves the dialog as it was), `__exportPresets` lists the presets, and `__savePreset` holds the name being typed after Save Preset…. The panel menu applies presets through `assets.settings.set {exportPreset}` and holds Save As Preset…, Save Changes, Delete Preset, Import Preset… and Export Preset…. Import and Export use picked files. | -| `engine.execute` `file.exportSelection` | `{multiple?}` | File › Export Selection…: collects the selected objects as one asset (`assets.add`; one per object with `multiple: true`) and opens `exportForScreens` on its Assets tab with just them checked → `{assets}`. `document.exportSelection` still exports the selection in one go, without the dialog | +| `engine.execute` `file.exportSelection` | `{multiple?}` | File › Export Selection…: collects the selected objects as one asset (`assets.add`; one per object with `multiple: true`) and opens `exportForScreens` on its Assets tab with just them checked → `{assets}`. `document.exportSelection` with params still exports the selection in one go, without the dialog; with no params or `{}` the save panel picks the file and its format's options dialog follows, as after Export As, and the call returns `null` while the dialog is open (OK returns what `file.exportAs` returns, without `format` or `bounds`). `file.exportAs {path, exportSelection: true, …options}` writes the selection without a dialog | | CSS Properties panel | `window.panel {panel: "cssProperties"}`; `engine.execute` `css.copy` / `css.exportFile` | the panel shows `css.selection` of the selection (after Generate CSS, while nothing is selected, `css.generate`); its menu holds the options the commands take (`units`, `position`, `dimensions`, `unnamed`, `rasterize`). `css.copy {scope?, …options}` copies the CSS to the clipboard → `{css, rules}`; `css.exportFile {path?, scope?, …options}` writes `css.export` to `path` (else a picked file; the web downloads it) with the pictures of rasterized art next to it → `{path, rules, images, warnings}`, and the status bar shows `warnings` as Export As shows an export's notes | | `ui.dialog.*` on `importPdf` | `{field: "page" \| "allPages" \| "range" \| "cropTo" \| "password", value}` | opening (`app.open`, `file.open`) a PDF, `.ai` or `.ait` with a password, or one with several pages unless it is a `.ai` or `.ait` with its editing data or carries its VectorCraft document (those open whole), or placing (`file.place` without `page`) any of them with several pages or a password, opens the Import PDF dialog (`mode`: `open` or `place`; `pageCount`). While `__locked` is true, set `password` and `ui.dialog.confirm` to unlock it (a one-page file then opens or is placed). Then set `allPages: false` and `range` ("2-3, 5") to open some pages, or `page` to place one, and `cropTo` (`bounding`, `art`, `crop`, `trim`, `bleed`, `media`); `ui.dialog.confirm` opens or places them (placing keeps the other `file.place` params, such as `link`, `template` and `at`, in `__place`). `document.open {pages, cropTo, password}` and `file.place {page, crop, password}` do the same without the dialog | diff --git a/docs/mcp.md b/docs/mcp.md index 84c75a81d0..e58af42b1c 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -1063,7 +1063,14 @@ apps (`editingData`), and always comes back with the warning `Preserve editing w bounds would replace the document's artboards when reopened`; with `pdfCompatible: false` it is refused, whatever `editingData` says. A legacy version 7 `.ai` (`aiVersion: "7"`) of the art's bounds is written as before. Export As PDF continues in the Save PDF dialog with Use Artboards on or off; with it off, the dialog has no -Artboards section and the page is the bounds of the visible art. +Artboards section and the page is the bounds of the visible art. `document.exportSelection` run in the app with no +params or `{}` (a shortcut, or an automation call) opens the save panel, and the picked format's options dialog +follows as after Export As: Save PDF without its Artboards section, SVG Options without its artboards, the raster +options, EPS Options, DXF Options without Export Selected Art Only, Text Export Options with Selection Only on, or an +exporter plug-in's parameters. Other formats are written at once. OK writes the selection alone, cropped to it, as +`document.exportSelection` writes it, with the chosen options. While a dialog is open the call returns `null`, and +OK returns what `file.exportAs` returns, which has no `format` or `bounds` (Text Export Options: `{path}`). To +write without a dialog, pass `path` or any option. `file.exportAs {path, exportSelection: true, …options}` writes the selection the same way. Raster exports default to the document's raster effects resolution. An explicit `scale` sets pixels per point (72 times that value in ppi); `options.ppi` wins over either scale option. CLI `run --export` and diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 296bd84b2b..a8481b9845 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -1684,6 +1684,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur In a `.ai` opened from its editing data, Bas Relief in an art style takes its Detail, Smoothness and Light direction from the style's settings block, and an object other than type with it opens as itself with the live effect. A settings block that VectorCraft can't read (malformed, another effect's, a value out of range or a key it doesn't know) keeps the object's drawn look (part of #637; [raster effects reference](https://helpx.adobe.com/illustrator/desktop/special-effects-styles/apply-filter-effects/raster-effects-reference.html), [Photoshop File Formats Specification](https://www.adobe.com/devnet-apps/photoshop/fileformatashtml/), [scripting the filter gallery](https://community.adobe.com/questions-712/scripting-support-for-the-filter-gallery-1064646); `crates/eps/src/import/ai/style/tests.rs`, `crates/eps/src/import/ai/tests.rs`). PDF export and `.ai` saves write a page with a side longer than 14,400 pt with a `UserUnit` (PDF 1.6), its longer side divided by 14,400 and rounded up to the next thousandth, so that no side is longer than 14,400 units (ISO 32000-1, Annex C.2); a PDF 1.5 file with such a page is written as PDF 1.6 with PDF 1.5's encryption, PDF/X files and PDF 1.3 and 1.4 files write it in points (in PDF 1.3 and 1.4, a page past 32,767 pt that needs a real number past 32,767 is refused with an error naming it), a page whose short side is 3 pt or longer but would be under 3 units at that `UserUnit` gets a smaller one that keeps the short side at 3 units, and the export's warnings give each such page's size and how it is written (#6382). The Save PDF dialog's "Write pages longer than 200 in at full size in points" (`longPagesInPoints`, on by default; `.ai` saves leave it off) writes such pages in points without a `UserUnit` at every PDF version, in files without a PDF/X or PDF/A standard, so readers that ignore `UserUnit` show them at full size. Export As PDF with Use Artboards off also opens the Save PDF dialog, without its Artboards section, and writes one page of the visible art's bounds with the dialog's options (`document.exportPdf {useArtboards: false}`; `crates/ui-egui/src/dialogs/tests_export.rs`, `crates/engine/src/cmd/fileio/tests_pdf.rs`). + `document.exportSelection` run without params (a shortcut) takes the same route after its save panel: a picked PDF opens the Save PDF dialog without its Artboards section, and SVG, the raster formats, EPS, DXF, text and exporter plug-ins with parameters open their options, whose OK writes the selection alone, cropped to it, with the chosen options (`crates/ui-egui/src/dialogs/tests_export_selection.rs`). A `.ai` export with Use Artboards off writes one page of the visible art's bounds without the native document or the editing copy for other apps, with a warning, so the file does not reopen with one artboard in place of the document's; without PDF content it is refused (`crates/engine/src/cmd/fileio/tests_pdfedit.rs`). PDF export warns when a PDF/X file has no bleed on a side where the document's bleed has one, and writes the file as asked. In a PDF without a standard, the warning for a page longer than 14,400 pt written with a `UserUnit` names "Write pages longer than 200 in at full size in points" (`crates/engine/src/cmd/fileio/tests_pdf.rs`). From 1494e2c230f173335c64baa71bad705dee4b860d Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Fri, 13 Nov 2026 21:50:05 -0800 Subject: [PATCH 04/11] Type: Tsume narrows the space around each character's ink Character panel > Tsume, shown with Show East Asian Options, takes away a percentage of the blank space on each side of a character's ink, inside its own width, in horizontal type. The value is from 0 to 100, 0 by default. 50 halves that space and 100 sets the ink against the characters before and after it; the glyph keeps its shape. A character without ink, such as a space, keeps its width, and a ligature or a letter with its marks takes the value of its first character. The font's pair kerning, tracking, manual kerning and Insert Aki are added after it, and justification keeps it. Vertical type keeps its spacing. - text.setFormat and text.setRangeStyle take `tsume`; a value that is not a number or is outside 0 to 100 is refused, naming the key. Character styles carry it, and a file holding a value outside the range opens with the nearer end. - The Character panel's Tsume field has 0, 25, 50, 75 and 100 in its menu, and Reset Panel sets it to 0. Character Style Options shows it in Advanced Character Formats. - Live SVG text places each character Tsume moves, and type on a path with Tsume is written as outlines. Docs: docs/mcp.md (Tsume, Character Style Options fields), docs/target-app-parity.md. --- crates/doc/src/text.rs | 2 +- crates/doc/src/text/char_style.rs | 16 +- crates/doc/src/text/object.rs | 2 + crates/doc/src/text/tabs_lists.rs | 3 +- crates/doc/src/text/tests.rs | 28 ++++ crates/engine/src/cmd/panelcmds.rs | 7 +- crates/engine/src/cmd/textedit.rs | 19 ++- crates/engine/src/cmd/textstyles.rs | 10 +- crates/engine/src/cmd/textstyles/tests.rs | 5 +- crates/engine/src/cmd/typecmd/area_tests.rs | 55 +++++++ crates/svg/src/export/text.rs | 16 +- crates/svg/tests/it/text_layout.rs | 29 ++++ crates/text/src/harakat.rs | 1 + crates/text/src/shape.rs | 94 ++++++++++-- crates/text/src/tests.rs | 1 + crates/text/src/tests/tsume.rs | 143 ++++++++++++++++++ .../ui-egui/src/dialogs/char_style_options.rs | 70 ++++++++- 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/character.rs | 16 +- crates/ui-egui/src/panels/character/tests.rs | 42 ++++- docs/mcp.md | 27 +++- docs/target-app-parity.md | 1 + 48 files changed, 579 insertions(+), 35 deletions(-) create mode 100644 crates/text/src/tests/tsume.rs diff --git a/crates/doc/src/text.rs b/crates/doc/src/text.rs index 440c379283..c3c1464cfc 100644 --- a/crates/doc/src/text.rs +++ b/crates/doc/src/text.rs @@ -18,7 +18,7 @@ mod wrap; pub use area::{AreaFit, AreaOptions, PathAlign, PathEffect, TextFlow, TextWrap, VerticalAlign, WrapShape}; use char_style::yes; pub use char_style::{ - CharAlign, CharPosition, CharStyle, Digits, FontHeightReference, GlyphChoice, MAX_AKI, MAX_WARICHU_GAP, ScriptMetrics, WARICHU_LINES, + CharAlign, CharPosition, CharStyle, Digits, FontHeightReference, GlyphChoice, MAX_AKI, MAX_TSUME, MAX_WARICHU_GAP, ScriptMetrics, WARICHU_LINES, WARICHU_SCALE, WarichuAlign, }; pub use composite::{ diff --git a/crates/doc/src/text/char_style.rs b/crates/doc/src/text/char_style.rs index 16b63e0809..ef3cf29dc9 100644 --- a/crates/doc/src/text/char_style.rs +++ b/crates/doc/src/text/char_style.rs @@ -44,6 +44,14 @@ pub struct CharStyle { /// Insert Aki Right: space added after the character in reading order, as [`Self::aki_left`]. #[serde(default, rename = "akiRight", skip_serializing_if = "crate::skip::is_default")] pub aki_right: f64, + /// Tsume (Character panel, East Asian options): the percentage of the blank space on each side + /// of the character's ink, inside its own width, that is taken away, 0 to [`MAX_TSUME`]. 50 + /// halves that space and 100 sets the ink edge to edge; the glyph itself is not scaled. A + /// cluster takes the value of its first character. Tracking, kerning and Insert Aki are added + /// after it. Horizontal type only. + /// + #[serde(default, skip_serializing_if = "crate::skip::is_default")] + pub tsume: f64, /// Use glyph shapes for automatic pair spacing; false uses font metrics. #[serde(default, rename = "opticalKerning", skip_serializing_if = "crate::skip::is_default")] pub optical_kerning: bool, @@ -440,6 +448,9 @@ fn hundred() -> f64 { /// The largest Insert Aki Left or Right ([`CharStyle::aki_left`]), in 1/1000 em: one em. pub const MAX_AKI: f64 = 1000.0; +/// The largest Tsume ([`CharStyle::tsume`]), in percent: all the blank space beside the ink. +pub const MAX_TSUME: f64 = 100.0; + /// The number of lines a warichu block may be stacked on ([`CharStyle::warichu_lines`]). pub const WARICHU_LINES: std::ops::RangeInclusive = 1..=6; @@ -534,6 +545,7 @@ impl Default for CharStyle { kerning_before: 0.0, aki_left: 0.0, aki_right: 0.0, + tsume: 0.0, optical_kerning: false, baseline_shift: 0.0, h_scale: 100.0, @@ -588,7 +600,8 @@ impl CharStyle { /// becomes the nearer end. A leading of 0, which legacy `.ai` import stores, stays, and a /// baseline shift that is not a number becomes 0. The Tate-chu-yoko Up/Down and Left/Right /// offsets are kept as the baseline shift is. Insert Aki Left and Right are kept from 0 to - /// [`MAX_AKI`], and one that is not a number becomes 0. The Warichu Settings Lines and Scale + /// [`MAX_AKI`], and one that is not a number becomes 0; Tsume is kept from 0 to [`MAX_TSUME`] the + /// same way. The Warichu Settings Lines and Scale /// are kept to [`WARICHU_LINES`] and [`WARICHU_SCALE`]; a gap or scale that is not a number becomes its default. The gap is kept to 0 to /// [`crate::MAX_COORD`], as the baseline shift is, since a scaled type object holds it in its /// own units. @@ -606,6 +619,7 @@ impl CharStyle { let aki = |v: f64| if v.is_nan() { 0.0 } else { v.clamp(0.0, MAX_AKI) }; self.aki_left = aki(self.aki_left); self.aki_right = aki(self.aki_right); + self.tsume = if self.tsume.is_nan() { 0.0 } else { self.tsume.clamp(0.0, MAX_TSUME) }; self.warichu_lines = self.warichu_lines.clamp(*WARICHU_LINES.start(), *WARICHU_LINES.end()); self.warichu_gap = if self.warichu_gap.is_nan() { default.warichu_gap } else { self.warichu_gap.clamp(0.0, crate::MAX_COORD) }; self.warichu_scale = diff --git a/crates/doc/src/text/object.rs b/crates/doc/src/text/object.rs index dd4002dd60..9af4d9c5dc 100644 --- a/crates/doc/src/text/object.rs +++ b/crates/doc/src/text/object.rs @@ -507,6 +507,7 @@ impl crate::Document { st.tcy_left_right = num("tcyLeftRight").unwrap_or(st.tcy_left_right); st.aki_left = num("akiLeft").unwrap_or(st.aki_left); st.aki_right = num("akiRight").unwrap_or(st.aki_right); + st.tsume = num("tsume").unwrap_or(st.tsume); st.warichu_gap = num("warichuGap").unwrap_or(st.warichu_gap); st.warichu_scale = num("warichuScale").unwrap_or(st.warichu_scale); st.keep_dimensions_in_range(); @@ -532,6 +533,7 @@ impl crate::Document { ("tcyLeftRight", Some(st.tcy_left_right)), ("akiLeft", Some(st.aki_left)), ("akiRight", Some(st.aki_right)), + ("tsume", Some(st.tsume)), ("leading", st.leading), ]; for (k, v) in kept { diff --git a/crates/doc/src/text/tabs_lists.rs b/crates/doc/src/text/tabs_lists.rs index b8531b402d..26622fa115 100644 --- a/crates/doc/src/text/tabs_lists.rs +++ b/crates/doc/src/text/tabs_lists.rs @@ -357,7 +357,7 @@ impl ParaList { /// The style the marker is set in: `first` (the style of the paragraph's first character) /// without its baseline shift, rotation, superscript or subscript, tracking, manual kerning, - /// Insert Aki, tate-chu-yoko, warichu, underline and strikethrough. + /// Insert Aki, Tsume, tate-chu-yoko, warichu, underline and strikethrough. pub fn marker_style(first: &CharStyle) -> CharStyle { CharStyle { baseline_shift: 0.0, @@ -368,6 +368,7 @@ impl ParaList { kerning_before: 0.0, aki_left: 0.0, aki_right: 0.0, + tsume: 0.0, optical_kerning: first.optical_kerning, tate_chu_yoko: false, warichu: false, diff --git a/crates/doc/src/text/tests.rs b/crates/doc/src/text/tests.rs index 0f474d2ed8..c3afafb6e1 100644 --- a/crates/doc/src/text/tests.rs +++ b/crates/doc/src/text/tests.rs @@ -129,6 +129,34 @@ fn insert_aki_is_saved_only_when_set() { assert_eq!((d.char_styles[0].attrs["akiLeft"].as_f64(), d.char_styles[0].attrs["akiRight"].as_f64()), (Some(MAX_AKI), Some(0.0))); } +/// Tsume is 0 by default and saved only when not 0; styles written before it read as 0, and a +/// value out of range from a file, in a run or a character style, is kept from 0 to 100. +#[test] +fn tsume_is_saved_only_when_set_and_kept_in_range() { + let st = CharStyle::default(); + assert_eq!(st.tsume, 0.0); + let saved = serde_json::to_value(&st).unwrap(); + assert!(saved.get("tsume").is_none()); + let read = serde_json::from_value::(saved).unwrap(); + assert_eq!(read.tsume, 0.0); + let set = CharStyle { tsume: 37.5, ..CharStyle::default() }; + let saved = serde_json::to_value(&set).unwrap(); + assert_eq!(saved.get("tsume").and_then(serde_json::Value::as_f64), Some(37.5)); + assert_eq!(serde_json::from_value::(saved).unwrap(), set); + for (stored, kept) in [(-5.0, 0.0), (250.0, MAX_TSUME), (f64::NAN, 0.0), (f64::INFINITY, MAX_TSUME), (50.0, 50.0)] { + let mut damaged = CharStyle { tsume: stored, ..CharStyle::default() }; + damaged.keep_dimensions_in_range(); + assert_eq!(damaged.tsume, kept, "{stored}"); + } + let mut d = crate::Document::new(100.0, 100.0); + for tsume in [500, -1] { + let attrs = serde_json::json!({ "tsume": tsume }); + d.char_styles.push(TextStyleDef { name: format!("Tight {tsume}"), attrs: attrs.as_object().cloned().unwrap_or_default() }); + } + d.keep_type_values_in_range(); + assert_eq!((d.char_styles[0].attrs["tsume"].as_f64(), d.char_styles[1].attrs["tsume"].as_f64()), (Some(MAX_TSUME), Some(0.0))); +} + /// Each Indic numeral system draws 0–9 (and the Arabic-Indic sets) as the ten digits of its /// script's block; every system saves under its id, which reads back, and other names are refused. #[test] diff --git a/crates/engine/src/cmd/panelcmds.rs b/crates/engine/src/cmd/panelcmds.rs index f9c4b10049..d879a546ab 100644 --- a/crates/engine/src/cmd/panelcmds.rs +++ b/crates/engine/src/cmd/panelcmds.rs @@ -67,7 +67,7 @@ pub fn specs() -> Vec { "Character / Paragraph", [], None, - "{ids?|id?, kerning?: 1/1000 em|\"auto\"|\"optical\", baselineShift?: pt, hScale?: %, vScale?: %, rotation?: deg counter-clockwise (0: upright), underline?, strikethrough?, allCaps?, smallCaps?: bool, position?: \"normal\"|\"superscript\"|\"subscript\" (sizes from Document Setup), antiAlias?: \"none\"|\"sharp\"|\"crisp\"|\"strong\" (Character panel › Anti-aliasing Method, how the type's edges are drawn as pixels; it sets each targeted type object, with or without a range; each frame of a thread keeps its own method; graph labels keep it for every label of their kind when the graph is drawn again; new type: \"sharp\"), leftIndent?, rightIndent?, firstLineIndent?, spaceBefore?, spaceAfter?: pt (±1296), list?: \"none\"|\"bullet\"|\"number\", bullet?: \"none\"|\"dot\"|\"dash\"|\"circle\"|\"square\", number?: \"none\"|\"arabic\"|\"upperLetters\"|\"lowerLetters\"|\"upperRoman\"|\"lowerRoman\"|\"twoDigits\"|\"threeDigits\", listAlign?: \"left\"|\"right\", listLevel?: 1..9 (Paragraph panel › Bullets and Numbered List: each paragraph of a list starts with its marker, drawn in the font, size and color of the paragraph's first character, and its text starts at the next tab stop after the marker, the left indent counting as a stop; a bulleted list's marker is its bullet, and a numbered list's is the paragraph's number and a period: 1, 2, 3; A to Z, then AA, BB; a to z, then aa, bb; I, II, III; i, ii, iii; 01 to 99, then 100; 001 to 999, then 1000; letters and roman numerals past 3999 in Arabic digits; the numbers count the story's numbered paragraphs, its threaded frames included, from 1 after a paragraph in no list, and an empty or bulleted paragraph is not counted; list \"bullet\" keeps a list's bullet, else a dot, and list \"number\" keeps a numbered list's style, else arabic; without list, a number style makes a numbered list and a bullet makes a bulleted one; \"none\" in any of the three turns the list off; a paragraph that joins a list with left and first-line indents of 0 gets 18 and -18 unless the call sets an indent; a horizontal right-to-left list paragraph has its indents mirrored, measured from the right; listAlign \"right\" (Bullets and Numbering…'s Alignment) puts the marker against the text, and \"left\" (the default) puts it at the first line's start, the text starting at the same place with either; it sets the paragraphs that are in a list or that the call puts in one, and a list keeps it when it changes between bullets and numbers; listLevel (Tab and Shift+Tab at the start of a list paragraph, Bullets and Numbering…'s Level) is a whole number from 1 to 9 that sets the paragraphs in a list or that the call puts in one, a list keeping it when it changes between bullets and numbers, and a list turned on without listLevel starting at level 1; each level past the first sets the paragraph 18 pt further in than its left indent, and each level counts its own numbers: a list paragraph with text sets the counts of the levels deeper than its own back to 0, so a deeper numbered paragraph after it counts from 1, and a paragraph back at a shallower level goes on with that level's count), hyphenate?: bool, wordsLongerThan?: letters 1..25 (5), afterFirst?, beforeLast?: letters 1..15 (3), hyphenLimit?: lines 0..25 (0: no limit), hyphenationZone?: pt 0..1296 (0; Single-line Composer only), hyphenateCapitalized?: bool (true) (Paragraph panel menu › Hyphenation…: only words longer than wordsLongerThan break, keeping afterFirst letters before the hyphen and beforeLast after it; at most hyphenLimit lines in a row end with a hyphen; a line whose text already ends within hyphenationZone of its right edge breaks before the next word instead of hyphenating it; the counts are rounded, and every value is clamped to its range), wordSpacing?: % 0..1000 (80, 100, 133), letterSpacing?: % of a word space -100..500 (0, 0, 0), glyphScaling?: % 50..200 (100, 100, 100): each [min, desired, max] or {min?, desired?, max?} (Paragraph panel menu › Justification…: Desired applies to every line, Minimum and Maximum bound what justified lines may use; values are clamped to their range, and Minimum ≤ Desired ≤ Maximum must hold afterwards or nothing changes), autoLeading?: % of the size 0..500 (120; the leading of characters whose leading is Auto), singleWordJustification?: \"fullJustify\"|\"alignLeft\"|\"alignCenter\"|\"alignRight\" (a justified line holding one word), mojikumi?: \"none\"|\"yakumonoHankaku\"|\"gyomatsuYakumonoHankaku\"|\"gyomatsuYakumonoZenkaku\"|\"yakumonoZenkaku\"|\"lineEndHalf\" (Mojikumi Set: Japanese punctuation spacing), kinsoku?: \"none\"|\"hard\"|\"soft\" (Kinsoku Set: Soft lets 々, ー and small kana start a line), kinsokuType?: \"pushInFirst\"|\"pushOutFirst\"|\"pushOutOnly\" (Paragraph panel menu › Kinsoku Shori Type: how area type keeps those characters off a line's ends; pushInFirst sets them on the line when its gaps next to CJK characters can close up enough, by at most a tenth of an em each, pushOutFirst does so only when moving the character before them to the next line would leave the line more than an em short, and pushOutOnly, the default, moves it unless Burasagari hangs an overflowing comma or full stop; no effect with kinsoku \"none\"), bunriKinshi?: bool (Paragraph panel menu › Bunri-Kinshi: a line never breaks inside an inseparable pair, two em dashes, two horizontal ellipses, two two-dot leaders or the two halves of a vertical kana repeat mark, and justification adds no space inside one; kept but without effect with kinsoku \"none\"; off by default), direction?: \"auto\"|\"leftToRight\"|\"rightToLeft\" (paragraph direction; auto: from each paragraph's first strong character), leadingModel?: \"romanBaseline\"|\"emBoxTop\" (leading measured baseline to baseline, or em box top to top), charAlign?: \"romanBaseline\"|\"emBoxTop\"|\"emBoxCenter\"|\"emBoxBottom\"|\"icfTop\"|\"icfBottom\" (where characters smaller than the largest on their line line up with it), 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, as text.setRangeStyle: a tate-chu-yoko block moves up or down, right or left, and on a vertical path toward or away from the path's start, to its left or right), warichu?: bool, warichuLines?: 1..6, warichuGap?: pt 0..100, warichuScale?: % 10..100, warichuAlign?: \"auto\"|\"left\"|\"center\"|\"right\"|\"justifyLeft\"|\"justifyCenter\"|\"justifyRight\"|\"justifyAll\" (Character panel menu › Warichu and Warichu Settings, as text.setRangeStyle: the characters set smaller and stacked on lines inside their line), akiLeft?, akiRight?: 1/1000 em 0..1000 (Character panel › Insert Aki Left and Right, with Show East Asian Options, as text.setRangeStyle: space before and after each character in reading order), standardVerticalRoman?: bool (Character panel menu › Standard Vertical Roman Alignment, as text.setRangeStyle), 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, as text.setRangeStyle: the height of the font that the size measures), language?: name|tag (Character panel › Language, as text.setRangeStyle), fontAxes?: {\"wght\": 650, …} (Character panel › Variable Font, as text.setRangeStyle: each axis named set in its units and clamped to its range, null back to the style's own setting), burasagari?: \"none\"|\"standard\"|\"forced\" (Paragraph panel menu › Burasagari None/Regular/Force: an East Asian comma or full stop ending an area type line hangs outside it: when it doesn't fit, or always; new type: \"standard\"), romanHangingPunctuation?: bool (Paragraph panel menu › Roman Hanging Punctuation: quotation marks, hyphens, full stops and commas starting or ending a line hang outside the margin, asterisks, tildes, ellipses, dashes, colons and semicolons by half; off by default), koreanLineBreakAnywhere?: bool (Paragraph panel menu › Korean Line Break Anywhere: a line may break between any two Hangul syllables; off, a word with Hangul in it breaks only at its spaces and punctuation, or where it is wider than the line; new type: false, paragraphs saved before the option: true), kashida?: \"none\"|\"short\"|\"medium\"|\"long\"|\"stylistic\" (Paragraph panel › Insert Kashidas: justified lines of horizontal area type lengthen each Arabic word with a kashida between its last pair of joined letters, at most a quarter, half or whole em long, or whole tatweels up to an em for stylistic, before they widen their word spaces; none by default), composer?: \"singleLine\"|\"everyLine\" (line breaking, Every-line by default), start?: byte, end?: byte} (baselineShift is in document points and hScale includes the object transform; groups include their text descendants; with a range: the character attributes style that range and the paragraph attributes apply to the paragraphs it touches; without: all the text, and graph labels keep the character and paragraph attributes for every label of their kind when the graph is drawn again, the graph still setting each label's alignment; a number or switch given as another type fails the call, and a null value leaves its attribute as it is, so a call with only null values fails with nothing to change; a call that gives the values the text already has records no undo step). Or {paragraphIndents: [{id, start: byte where the paragraph starts, leftIndent?, firstLineIndent?: pt -1296..1296}…]} alone (Tabs panel indent markers over paragraphs with indents of their own): each entry sets one paragraph of a type object, at least one indent each, at most 100000 entries; every entry is checked before anything changes, and an unknown key, a value out of range, an object that is not type, a byte where no paragraph starts or two entries for one paragraph with other values fail the call; all of it is one edit, a threaded story flowing again once, and when no paragraph's indents change nothing is recorded → {ids}", + "{ids?|id?, kerning?: 1/1000 em|\"auto\"|\"optical\", baselineShift?: pt, hScale?: %, vScale?: %, rotation?: deg counter-clockwise (0: upright), underline?, strikethrough?, allCaps?, smallCaps?: bool, position?: \"normal\"|\"superscript\"|\"subscript\" (sizes from Document Setup), antiAlias?: \"none\"|\"sharp\"|\"crisp\"|\"strong\" (Character panel › Anti-aliasing Method, how the type's edges are drawn as pixels; it sets each targeted type object, with or without a range; each frame of a thread keeps its own method; graph labels keep it for every label of their kind when the graph is drawn again; new type: \"sharp\"), leftIndent?, rightIndent?, firstLineIndent?, spaceBefore?, spaceAfter?: pt (±1296), list?: \"none\"|\"bullet\"|\"number\", bullet?: \"none\"|\"dot\"|\"dash\"|\"circle\"|\"square\", number?: \"none\"|\"arabic\"|\"upperLetters\"|\"lowerLetters\"|\"upperRoman\"|\"lowerRoman\"|\"twoDigits\"|\"threeDigits\", listAlign?: \"left\"|\"right\", listLevel?: 1..9 (Paragraph panel › Bullets and Numbered List: each paragraph of a list starts with its marker, drawn in the font, size and color of the paragraph's first character, and its text starts at the next tab stop after the marker, the left indent counting as a stop; a bulleted list's marker is its bullet, and a numbered list's is the paragraph's number and a period: 1, 2, 3; A to Z, then AA, BB; a to z, then aa, bb; I, II, III; i, ii, iii; 01 to 99, then 100; 001 to 999, then 1000; letters and roman numerals past 3999 in Arabic digits; the numbers count the story's numbered paragraphs, its threaded frames included, from 1 after a paragraph in no list, and an empty or bulleted paragraph is not counted; list \"bullet\" keeps a list's bullet, else a dot, and list \"number\" keeps a numbered list's style, else arabic; without list, a number style makes a numbered list and a bullet makes a bulleted one; \"none\" in any of the three turns the list off; a paragraph that joins a list with left and first-line indents of 0 gets 18 and -18 unless the call sets an indent; a horizontal right-to-left list paragraph has its indents mirrored, measured from the right; listAlign \"right\" (Bullets and Numbering…'s Alignment) puts the marker against the text, and \"left\" (the default) puts it at the first line's start, the text starting at the same place with either; it sets the paragraphs that are in a list or that the call puts in one, and a list keeps it when it changes between bullets and numbers; listLevel (Tab and Shift+Tab at the start of a list paragraph, Bullets and Numbering…'s Level) is a whole number from 1 to 9 that sets the paragraphs in a list or that the call puts in one, a list keeping it when it changes between bullets and numbers, and a list turned on without listLevel starting at level 1; each level past the first sets the paragraph 18 pt further in than its left indent, and each level counts its own numbers: a list paragraph with text sets the counts of the levels deeper than its own back to 0, so a deeper numbered paragraph after it counts from 1, and a paragraph back at a shallower level goes on with that level's count), hyphenate?: bool, wordsLongerThan?: letters 1..25 (5), afterFirst?, beforeLast?: letters 1..15 (3), hyphenLimit?: lines 0..25 (0: no limit), hyphenationZone?: pt 0..1296 (0; Single-line Composer only), hyphenateCapitalized?: bool (true) (Paragraph panel menu › Hyphenation…: only words longer than wordsLongerThan break, keeping afterFirst letters before the hyphen and beforeLast after it; at most hyphenLimit lines in a row end with a hyphen; a line whose text already ends within hyphenationZone of its right edge breaks before the next word instead of hyphenating it; the counts are rounded, and every value is clamped to its range), wordSpacing?: % 0..1000 (80, 100, 133), letterSpacing?: % of a word space -100..500 (0, 0, 0), glyphScaling?: % 50..200 (100, 100, 100): each [min, desired, max] or {min?, desired?, max?} (Paragraph panel menu › Justification…: Desired applies to every line, Minimum and Maximum bound what justified lines may use; values are clamped to their range, and Minimum ≤ Desired ≤ Maximum must hold afterwards or nothing changes), autoLeading?: % of the size 0..500 (120; the leading of characters whose leading is Auto), singleWordJustification?: \"fullJustify\"|\"alignLeft\"|\"alignCenter\"|\"alignRight\" (a justified line holding one word), mojikumi?: \"none\"|\"yakumonoHankaku\"|\"gyomatsuYakumonoHankaku\"|\"gyomatsuYakumonoZenkaku\"|\"yakumonoZenkaku\"|\"lineEndHalf\" (Mojikumi Set: Japanese punctuation spacing), kinsoku?: \"none\"|\"hard\"|\"soft\" (Kinsoku Set: Soft lets 々, ー and small kana start a line), kinsokuType?: \"pushInFirst\"|\"pushOutFirst\"|\"pushOutOnly\" (Paragraph panel menu › Kinsoku Shori Type: how area type keeps those characters off a line's ends; pushInFirst sets them on the line when its gaps next to CJK characters can close up enough, by at most a tenth of an em each, pushOutFirst does so only when moving the character before them to the next line would leave the line more than an em short, and pushOutOnly, the default, moves it unless Burasagari hangs an overflowing comma or full stop; no effect with kinsoku \"none\"), bunriKinshi?: bool (Paragraph panel menu › Bunri-Kinshi: a line never breaks inside an inseparable pair, two em dashes, two horizontal ellipses, two two-dot leaders or the two halves of a vertical kana repeat mark, and justification adds no space inside one; kept but without effect with kinsoku \"none\"; off by default), direction?: \"auto\"|\"leftToRight\"|\"rightToLeft\" (paragraph direction; auto: from each paragraph's first strong character), leadingModel?: \"romanBaseline\"|\"emBoxTop\" (leading measured baseline to baseline, or em box top to top), charAlign?: \"romanBaseline\"|\"emBoxTop\"|\"emBoxCenter\"|\"emBoxBottom\"|\"icfTop\"|\"icfBottom\" (where characters smaller than the largest on their line line up with it), 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, as text.setRangeStyle: a tate-chu-yoko block moves up or down, right or left, and on a vertical path toward or away from the path's start, to its left or right), warichu?: bool, warichuLines?: 1..6, warichuGap?: pt 0..100, warichuScale?: % 10..100, warichuAlign?: \"auto\"|\"left\"|\"center\"|\"right\"|\"justifyLeft\"|\"justifyCenter\"|\"justifyRight\"|\"justifyAll\" (Character panel menu › Warichu and Warichu Settings, as text.setRangeStyle: the characters set smaller and stacked on lines inside their line), akiLeft?, akiRight?: 1/1000 em 0..1000 (Character panel › Insert Aki Left and Right, with Show East Asian Options, as text.setRangeStyle: space before and after each character in reading order), tsume?: % 0..100 (Character panel › Tsume, with Show East Asian Options, as text.setRangeStyle: the share of the blank space beside each character's ink taken away in horizontal type), standardVerticalRoman?: bool (Character panel menu › Standard Vertical Roman Alignment, as text.setRangeStyle), 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, as text.setRangeStyle: the height of the font that the size measures), language?: name|tag (Character panel › Language, as text.setRangeStyle), fontAxes?: {\"wght\": 650, …} (Character panel › Variable Font, as text.setRangeStyle: each axis named set in its units and clamped to its range, null back to the style's own setting), burasagari?: \"none\"|\"standard\"|\"forced\" (Paragraph panel menu › Burasagari None/Regular/Force: an East Asian comma or full stop ending an area type line hangs outside it: when it doesn't fit, or always; new type: \"standard\"), romanHangingPunctuation?: bool (Paragraph panel menu › Roman Hanging Punctuation: quotation marks, hyphens, full stops and commas starting or ending a line hang outside the margin, asterisks, tildes, ellipses, dashes, colons and semicolons by half; off by default), koreanLineBreakAnywhere?: bool (Paragraph panel menu › Korean Line Break Anywhere: a line may break between any two Hangul syllables; off, a word with Hangul in it breaks only at its spaces and punctuation, or where it is wider than the line; new type: false, paragraphs saved before the option: true), kashida?: \"none\"|\"short\"|\"medium\"|\"long\"|\"stylistic\" (Paragraph panel › Insert Kashidas: justified lines of horizontal area type lengthen each Arabic word with a kashida between its last pair of joined letters, at most a quarter, half or whole em long, or whole tatweels up to an em for stylistic, before they widen their word spaces; none by default), composer?: \"singleLine\"|\"everyLine\" (line breaking, Every-line by default), start?: byte, end?: byte} (baselineShift is in document points and hScale includes the object transform; groups include their text descendants; with a range: the character attributes style that range and the paragraph attributes apply to the paragraphs it touches; without: all the text, and graph labels keep the character and paragraph attributes for every label of their kind when the graph is drawn again, the graph still setting each label's alignment; a number or switch given as another type fails the call, and a null value leaves its attribute as it is, so a call with only null values fails with nothing to change; a call that gives the values the text already has records no undo step). Or {paragraphIndents: [{id, start: byte where the paragraph starts, leftIndent?, firstLineIndent?: pt -1296..1296}…]} alone (Tabs panel indent markers over paragraphs with indents of their own): each entry sets one paragraph of a type object, at least one indent each, at most 100000 entries; every entry is checked before anything changes, and an unknown key, a value out of range, an object that is not type, a byte where no paragraph starts or two entries for one paragraph with other values fail the call; all of it is one edit, a threaded story flowing again once, and when no paragraph's indents change nothing is recorded → {ids}", has_doc, set_format ), @@ -588,6 +588,7 @@ fn set_format(s: &mut Session, p: &Value) -> Result { "warichuAlign", "akiLeft", "akiRight", + "tsume", "standardVerticalRoman", "noBreak", "digits", @@ -632,6 +633,7 @@ fn set_format(s: &mut Session, p: &Value) -> Result { let tcy_left_right = super::textedit::tcy_offset_param(p, "tcyLeftRight", C)?; let aki_left = super::textedit::aki_param(p, "akiLeft", C)?; let aki_right = super::textedit::aki_param(p, "akiRight", C)?; + let tsume = super::textedit::tsume_param(p, C)?; let warichu_lines = super::textedit::warichu_lines_param(p, C)?; let warichu_gap = super::textedit::warichu_number_param(p, "warichuGap", C)?; let warichu_scale = super::textedit::warichu_number_param(p, "warichuScale", C)?; @@ -816,6 +818,9 @@ fn set_format(s: &mut Session, p: &Value) -> Result { if let Some(v) = aki_right { st.aki_right = v; } + if let Some(v) = tsume { + st.tsume = v; + } if let Some(v) = flag("standardVerticalRoman") { st.standard_vertical_roman = v; } diff --git a/crates/engine/src/cmd/textedit.rs b/crates/engine/src/cmd/textedit.rs index 0fd7c7d97d..9ed7f3252b 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 0.1..1296 (outside it: an error), leading?: pt|\"auto\", tracking?, kerning?: 1/1000 em|\"auto\"|\"optical\" (the space after each character), kerningBefore?: 1/1000 em -1000..10000 (the space before a character that starts a line by itself: the first character of the text, of a paragraph or after a forced line break; no effect elsewhere; 0 by default), baselineShift?: pt, hScale?: %, vScale?: %, rotation?: deg counter-clockwise (0: upright), 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; on a vertical path a positive Up/Down moves it toward the path's start and a positive Left/Right to the path's left; 0 by default; automatic digit blocks and horizontal type do not move; neither turns tateChuYoko on), warichu?: bool (Character panel menu › Warichu, with Show East Asian Options: the characters are set at warichuScale percent of their size and stacked on warichuLines lines inside their line, one above the other in horizontal type and side by side in vertical type, the first line above or to the right; a stretch of them is one block with the settings of its first character that stays whole on one line and may run past its end; type on a path sets them at their own size), warichuLines?: 1..6 (default 2), warichuGap?: pt 0..100 (the space between the lines, default 0), warichuScale?: % 10..100 (default 50), warichuAlign?: \"auto\"|\"left\"|\"center\"|\"right\"|\"justifyLeft\"|\"justifyCenter\"|\"justifyRight\"|\"justifyAll\" (Warichu Settings: how the lines line up along the block, which is as long as its longest line; the justify values spread every line but the last, which is set left, centered or right, or spread with justifyAll; auto, the default, sets them as justifyLeft; left and right are top and bottom in vertical type; none of them turns warichu on), akiLeft?, akiRight?: 1/1000 em 0..1000 (Character panel › Insert Aki Left and Right, with Show East Asian Options: space before and after each character in reading order, on its left and right in left-to-right type, on its right and left in right-to-left type, above and below it in vertical type; a ligature or a letter with its marks takes the values of its first character; line wrapping counts it, alignment keeps it at a line's ends and justification does not stretch or narrow it; 0 by default; a value that is not a number or is outside the range is refused, naming the key), 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)}", + "{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 0.1..1296 (outside it: an error), leading?: pt|\"auto\", tracking?, kerning?: 1/1000 em|\"auto\"|\"optical\" (the space after each character), kerningBefore?: 1/1000 em -1000..10000 (the space before a character that starts a line by itself: the first character of the text, of a paragraph or after a forced line break; no effect elsewhere; 0 by default), baselineShift?: pt, hScale?: %, vScale?: %, rotation?: deg counter-clockwise (0: upright), 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; on a vertical path a positive Up/Down moves it toward the path's start and a positive Left/Right to the path's left; 0 by default; automatic digit blocks and horizontal type do not move; neither turns tateChuYoko on), warichu?: bool (Character panel menu › Warichu, with Show East Asian Options: the characters are set at warichuScale percent of their size and stacked on warichuLines lines inside their line, one above the other in horizontal type and side by side in vertical type, the first line above or to the right; a stretch of them is one block with the settings of its first character that stays whole on one line and may run past its end; type on a path sets them at their own size), warichuLines?: 1..6 (default 2), warichuGap?: pt 0..100 (the space between the lines, default 0), warichuScale?: % 10..100 (default 50), warichuAlign?: \"auto\"|\"left\"|\"center\"|\"right\"|\"justifyLeft\"|\"justifyCenter\"|\"justifyRight\"|\"justifyAll\" (Warichu Settings: how the lines line up along the block, which is as long as its longest line; the justify values spread every line but the last, which is set left, centered or right, or spread with justifyAll; auto, the default, sets them as justifyLeft; left and right are top and bottom in vertical type; none of them turns warichu on), akiLeft?, akiRight?: 1/1000 em 0..1000 (Character panel › Insert Aki Left and Right, with Show East Asian Options: space before and after each character in reading order, on its left and right in left-to-right type, on its right and left in right-to-left type, above and below it in vertical type; a ligature or a letter with its marks takes the values of its first character; line wrapping counts it, alignment keeps it at a line's ends and justification does not stretch or narrow it; 0 by default; a value that is not a number or is outside the range is refused, naming the key), tsume?: % 0..100 (Character panel › Tsume, with Show East Asian Options: the share of the blank space on each side of each character's ink, inside its own width, that is taken away in horizontal type, the glyph keeping its shape; 100 sets the ink edge to edge; a character without ink, such as a space, keeps its width; a ligature or a letter with its marks takes the value of its first character; tracking, kerning and Insert Aki are added after it, and justification keeps it; vertical type keeps the characters' spacing; 0 by default; a value that is not a number or is outside the range is refused), 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 ), @@ -468,6 +468,8 @@ pub(crate) struct CharChange { /// Insert Aki Left and Right, in 1/1000 em. aki_left: Option, aki_right: Option, + /// Tsume, in percent. + tsume: Option, standard_vertical_roman: Option, no_break: Option, digits: Option, @@ -545,6 +547,16 @@ pub(crate) fn aki_param(p: &Value, key: &str, cmd: &str) -> Result> if (lo..=hi).contains(&v) { Ok(Some(v)) } else { Err(bad(cmd, format!("`{key}` must be from {lo} to {hi}"))) } } +/// `tsume` (Tsume): a percentage from 0 to [`vectorcraft_doc::text::MAX_TSUME`]. Any other value is +/// an error. +pub(crate) fn tsume_param(p: &Value, cmd: &str) -> Result> { + let key = "tsume"; + let Some(v) = p.get(key).filter(|v| !v.is_null()) else { return Ok(None) }; + let v = v.as_f64().filter(|v| v.is_finite()).ok_or_else(|| bad(cmd, format!("`{key}` must be a number")))?; + let (lo, hi) = (0.0, vectorcraft_doc::text::MAX_TSUME); + if (lo..=hi).contains(&v) { Ok(Some(v)) } else { Err(bad(cmd, format!("`{key}` must be from {lo} to {hi}"))) } +} + /// `digits: "default"|"arabic"|"hindi"|"farsi"|"devanagari"|…` ([`Digits::id`]) → the numeral /// system the digits are drawn in. /// @@ -824,6 +836,7 @@ impl CharChange { warichu_align: warichu_align_param(p, cmd)?, aki_left: aki_param(p, "akiLeft", cmd)?, aki_right: aki_param(p, "akiRight", cmd)?, + tsume: tsume_param(p, cmd)?, standard_vertical_roman: flag("standardVerticalRoman"), no_break: flag("noBreak"), digits: digits_param(p, cmd)?, @@ -888,6 +901,7 @@ impl CharChange { && self.warichu_align.is_none() && self.aki_left.is_none() && self.aki_right.is_none() + && self.tsume.is_none() && self.standard_vertical_roman.is_none() && self.no_break.is_none() && self.digits.is_none() @@ -976,6 +990,9 @@ impl CharChange { if let Some(v) = self.aki_right { st.aki_right = v; } + if let Some(v) = self.tsume { + st.tsume = v; + } if let Some(v) = self.standard_vertical_roman { st.standard_vertical_roman = v; } diff --git a/crates/engine/src/cmd/textstyles.rs b/crates/engine/src/cmd/textstyles.rs index ea1a7a22bd..17d453abf4 100644 --- a/crates/engine/src/cmd/textstyles.rs +++ b/crates/engine/src/cmd/textstyles.rs @@ -222,6 +222,7 @@ pub fn char_attrs(v: &CharStyle) -> Map { ("warichuAlign", json!(v.warichu_align)), ("akiLeft", json!(v.aki_left)), ("akiRight", json!(v.aki_right)), + ("tsume", json!(v.tsume)), ("standardVerticalRoman", json!(v.standard_vertical_roman)), ("noBreak", json!(v.no_break)), ("digits", json!(v.digits)), @@ -578,6 +579,10 @@ fn attrs_param(d: &Document, p: &Value, kind: Kind, verb: &str, kept: Option<&Ma return Err(bad(&c, format!("`{key}` must be from {lo} to {hi}"))); } } + let (key, lo, hi) = ("tsume", 0.0, vectorcraft_doc::text::MAX_TSUME); + if changed(key) && !(lo..=hi).contains(&st.tsume) { + return Err(bad(&c, format!("`{key}` must be from {lo} to {hi}"))); + } let lines = vectorcraft_doc::text::WARICHU_LINES; if changed("warichuLines") && !lines.contains(&st.warichu_lines) { return Err(bad(&c, format!("`warichuLines` must be a whole number from {} to {}", lines.start(), lines.end()))); @@ -1075,12 +1080,12 @@ fn unused(s: &mut Session, kind: Kind) -> Result { /// The styles of `kind` in `src` that load, with their source names so that art in the file keeps /// referring to them: not the Normal style, and not one whose attributes don't read, whose sizes, -/// scales, leading, shifts, Insert Aki, Warichu Settings or stroke width are out of the range `charStyle.new` takes, whose +/// scales, leading, shifts, Insert Aki, Tsume, Warichu Settings or stroke width are out of the range `charStyle.new` takes, whose /// indents, spacing, Hyphenation or Justification options or tab stops are out of the range /// `paraStyle.new` takes, or whose name is empty, which are skipped. → (the styles, the names in /// `src` of those skipped) fn loadable(src: &Document, kind: Kind) -> (Vec, Vec) { - const RANGED: [&str; 21] = [ + const RANGED: [&str; 22] = [ "size", "h_scale", "v_scale", @@ -1088,6 +1093,7 @@ fn loadable(src: &Document, kind: Kind) -> (Vec, Vec) { "baseline_shift", "akiLeft", "akiRight", + "tsume", "tcyUpDown", "tcyLeftRight", "warichuLines", diff --git a/crates/engine/src/cmd/textstyles/tests.rs b/crates/engine/src/cmd/textstyles/tests.rs index 3e0727ce4a..c8da4b578c 100644 --- a/crates/engine/src/cmd/textstyles/tests.rs +++ b/crates/engine/src/cmd/textstyles/tests.rs @@ -397,6 +397,8 @@ fn character_style_dimensions_are_checked_before_a_definition_changes() { json!({"tcyLeftRight": -1296.1}), json!({"akiLeft": -0.1}), json!({"akiRight": 1000.1}), + json!({"tsume": -0.1}), + json!({"tsume": 100.1}), ] { let key = attrs.as_object().unwrap().keys().next().unwrap(); for cmd in ["charStyle.edit", "charStyle.setAttrs", "charStyle.new"] { @@ -419,7 +421,8 @@ fn character_style_dimensions_are_checked_before_a_definition_changes() { "h_scale": 1.0, "v_scale": 10000.0, "baseline_shift": -1296.0, "tcyUpDown": 1296.0, "tcyLeftRight": -1296.0, - "akiLeft": 0.0, "akiRight": 1000.0 + "akiLeft": 0.0, "akiRight": 1000.0, + "tsume": 100.0 }}), ) .unwrap(); diff --git a/crates/engine/src/cmd/typecmd/area_tests.rs b/crates/engine/src/cmd/typecmd/area_tests.rs index c68334e2c0..aa052d5469 100644 --- a/crates/engine/src/cmd/typecmd/area_tests.rs +++ b/crates/engine/src/cmd/typecmd/area_tests.rs @@ -1822,6 +1822,61 @@ fn insert_aki_is_a_character_attribute_set_one_side_at_a_time() { assert_eq!(s.doc().unwrap().history.undo.len(), undo, "no undo step for a refused call"); } +/// Tsume (Character panel, East Asian options): a character attribute in percent, 0 by default +/// and saved only when not 0. Both commands set it in one undo step, a call that gives the value +/// the text already has records none, and a value that is not a number or lies outside 0 to 100 is +/// refused, naming the key, before anything changes; a character style captured from the +/// characters carries it. +#[test] +fn tsume_is_a_character_attribute() { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 400, "height": 400})).unwrap(); + let id = s.execute("text.create", &json!({"x": 10, "y": 50, "text": "(日本)"})).unwrap()["id"].as_u64().unwrap(); + let runs = |s: &Session| match &s.doc().unwrap().doc.node(NodeId(id)).unwrap().kind { + NodeKind::Text(t) => t.runs.clone(), + _ => panic!("text"), + }; + let tsume = |s: &Session| runs(s).iter().map(|r| (r.text.clone(), r.style.tsume)).collect::>(); + let saved = serde_json::to_value(&runs(&s)[0].style).unwrap(); + assert!(saved.get("tsume").is_none()); + s.execute("select.set", &json!({"ids": [id]})).unwrap(); + s.execute("text.setFormat", &json!({"tsume": 50})).unwrap(); + assert_eq!(tsume(&s), [("(日本)".to_string(), 50.0)]); + let undo = s.doc().unwrap().history.undo.len(); + s.execute("text.setFormat", &json!({"tsume": 50})).unwrap(); + assert_eq!(s.doc().unwrap().history.undo.len(), undo, "no undo step without a change"); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(tsume(&s), [("(日本)".to_string(), 0.0)], "one undo step"); + s.execute("edit.redo", &json!({})).unwrap(); + assert_eq!(tsume(&s), [("(日本)".to_string(), 50.0)]); + + let paren = "(".len(); + s.execute("text.setRangeStyle", &json!({"id": id, "start": 0, "end": paren, "tsume": 100})).unwrap(); + assert_eq!(tsume(&s), [("(".to_string(), 100.0), ("日本)".to_string(), 50.0)]); + let saved = serde_json::to_value(&runs(&s)[0].style).unwrap(); + assert_eq!(saved["tsume"], json!(100.0)); + let back = vectorcraft_format::load(&vectorcraft_format::save(&s.doc().unwrap().doc, false)).unwrap(); + let NodeKind::Text(t) = &back.node(NodeId(id)).unwrap().kind else { panic!("text") }; + let reopened: Vec<_> = t.runs.iter().map(|r| (r.text.clone(), r.style.tsume)).collect(); + assert_eq!(reopened, tsume(&s)); + let attrs = super::super::textstyles::char_attrs(&runs(&s)[0].style); + assert_eq!(attrs["tsume"], json!(100.0)); + + let before = tsume(&s); + let undo = s.doc().unwrap().history.undo.len(); + for (cmd, p, want) in [ + ("text.setFormat", json!({"tsume": "50"}), "`tsume` must be a number"), + ("text.setFormat", json!({"tsume": -1}), "`tsume` must be from 0 to 100"), + ("text.setRangeStyle", json!({"id": id, "tsume": true}), "`tsume` must be a number"), + ("text.setRangeStyle", json!({"id": id, "start": 0, "end": paren, "tsume": 100.5}), "`tsume` must be from 0 to 100"), + ] { + let e = s.execute(cmd, &p).unwrap_err(); + assert!(matches!(&e, EngineError::BadParams { msg, .. } if msg == want), "{cmd} {p}: {e}"); + } + assert_eq!(tsume(&s), before); + assert_eq!(s.doc().unwrap().history.undo.len(), undo, "no undo step for a refused call"); +} + /// Standard Vertical Roman Alignment (Character panel menu, East Asian options): a character /// attribute, off by default and saved only when on. Documents written before it read as off; a /// character style captured from the characters carries it. diff --git a/crates/svg/src/export/text.rs b/crates/svg/src/export/text.rs index 2b9139f19b..ccd5f56806 100644 --- a/crates/svg/src/export/text.rs +++ b/crates/svg/src/export/text.rs @@ -283,6 +283,11 @@ impl Writer<'_> { self.warn("type on a path with Insert Aki is outlined to keep its look"); return self.text_outlines(n, t); } + // Tsume does as well. + if t.runs.iter().any(|r| r.style.tsume != 0.0) { + self.warn("type on a path with Tsume is outlined to keep its look"); + return self.text_outlines(n, t); + } // A `` holds the characters of the text, not the bullets or numbers drawn // before them. if !lay.markers.is_empty() { @@ -715,13 +720,13 @@ const PLACE_TOLERANCE: f64 = 1e-6; /// starts a new segment at each character the layout draws elsewhere than a reader continuing /// the segment would ([`reader_advances`]): a Justify All line's letter gaps, the gaps between /// the characters of a justified Japanese line, and the spacing Mojikumi changes. Text with Insert -/// Aki does so on every line, with `fewer` too. +/// Aki or Tsume does so on every line, with `fewer` too. fn text_lines(t: &TextObject, lay: &vectorcraft_text::TextLayout, fewer: bool) -> Vec { let plain = t.plain_text(); let mut lines = Vec::with_capacity(lay.lines.len()); let (mut kerned, mut unkerned) = (None, None); - // Insert Aki moves characters from where a reader draws them, with `fewer` too. - let aki = t.runs.iter().any(|r| r.style.aki_left != 0.0 || r.style.aki_right != 0.0); + // Insert Aki and Tsume move characters from where a reader draws them, with `fewer` too. + let aki = t.runs.iter().any(|r| r.style.aki_left != 0.0 || r.style.aki_right != 0.0 || r.style.tsume != 0.0); for line in &lay.lines { let para = plain.as_bytes().get(..line.start).map_or(0, |b| b.iter().filter(|&&c| c == b'\n').count()); let style = t.para_at(para); @@ -885,11 +890,12 @@ fn reader_advances(t: &TextObject, lay: &vectorcraft_text::TextLayout, kerning: if !kerning { r.style.kerning.get_or_insert(0.0); } - // A reader puts no space before a line's first character (`kerningBefore`) and no Insert - // Aki: the segment starts that much further along instead. + // A reader puts no space before a line's first character (`kerningBefore`), no Insert + // Aki and no Tsume: the segment starts that much further along instead. r.style.kerning_before = 0.0; r.style.aki_left = 0.0; r.style.aki_right = 0.0; + r.style.tsume = 0.0; } let relaid; let glyphs = if set == *t { diff --git a/crates/svg/tests/it/text_layout.rs b/crates/svg/tests/it/text_layout.rs index 25d4d27d68..0678f39f8a 100644 --- a/crates/svg/tests/it/text_layout.rs +++ b/crates/svg/tests/it/text_layout.rs @@ -196,6 +196,35 @@ fn insert_aki_places_each_moved_character() { assert!(!out.svg.contains("` where the character is +/// drawn, so a reader draws the text where the canvas does, with fewer ``s asked for too. +/// On a path the type is outlined. +#[test] +fn tsume_places_each_moved_character() { + let tight = CharStyle { tsume: 50.0, ..style(12.0) }; + let runs = + vec![run("H", style(12.0)), run("O", tight.clone()), run("H ", style(12.0)), run("OHO\nHO", CharStyle { tsume: 100.0, ..style(12.0) })]; + for justify in [Justify::Left, Justify::Center, Justify::Right] { + assert_drawn_in_place(&area_doc(runs.clone(), 200.0, justify)); + } + let d = area_doc(runs, 200.0, Justify::Left); + let plain = tspans(&export(&area_doc(vec![run("HOH OHO\nHO", style(12.0))], 200.0, Justify::Left), &ExportOptions::default())).len(); + let fewer = tspans(&export(&d, &ExportOptions { fewer_tspans: true, ..Default::default() })).len(); + assert!(fewer > plain, "each moved character is placed with fewer tspans too: {plain} {fewer}"); + let mut t = TextObject::point(Point::ZERO, "", style(12.0)); + t.runs = vec![run("HO", tight)]; + let mut line = BezPath::new(); + line.move_to((20.0, 100.0)); + line.line_to((280.0, 100.0)); + t.kind = TextKind::OnPath { path: PathData::from_bezpath(&line), start: 0.0, end: None }; + let mut d = Document::new(300.0, 200.0); + let n = Node::new(d.alloc_id(), NodeKind::Text(Box::new(t))); + let l = d.layers[0].id; + d.insert(Some(l), 0, n).unwrap(); + let out = vectorcraft_svg::export_full(&d, &ExportOptions::default(), None); + assert!(!out.svg.contains(" bool { kerning_before: _, aki_left: _, aki_right: _, + tsume: _, optical_kerning, baseline_shift, h_scale, diff --git a/crates/text/src/shape.rs b/crates/text/src/shape.rs index ff79ce9a4c..befcbed41f 100644 --- a/crates/text/src/shape.rs +++ b/crates/text/src/shape.rs @@ -13,7 +13,7 @@ use kurbo::Shape; use skrifa::MetadataProvider; use skrifa::charmap::Charmap; use skrifa::instance::Size; -use vectorcraft_doc::text::{INLINE_CHAR, MAX_AKI}; +use vectorcraft_doc::text::{INLINE_CHAR, MAX_AKI, MAX_TSUME}; use vectorcraft_doc::{CharStyle, Digits, InlineArt}; use crate::features::{OtFeatures, ligatures_suppressed_by}; @@ -969,9 +969,9 @@ fn source_run_at(runs: &[(Range, &CharStyle)], span: &Range, byte: /// Can one glyph draw characters in styles `a` and `b`? OpenType features already apply to their /// own character ranges during shaping, so feature-only differences do not split a cluster. /// Horizontal type does not read Standard Vertical Roman Alignment or the tate-chu-yoko offsets. -/// The space before a line's first character ([`space_before`]) and Insert Aki ([`aki`]) come -/// from the cluster's first character, so they do not split a ligature or a letter from its marks -/// either. Warichu Settings count only on characters marked Warichu ([`warichu_alike`]). +/// The space before a line's first character ([`space_before`]), Insert Aki ([`aki`]) and Tsume +/// ([`tighten`]) come from the cluster's first character, so they do not split a ligature or a +/// letter from its marks either. Warichu Settings count only on characters marked Warichu ([`warichu_alike`]). fn drawn_alike(a: &CharStyle, b: &CharStyle, vertical: bool) -> bool { // Exhaustive, like `shapes_alike`: compare borrowed fields without copying feature lists. let CharStyle { @@ -986,6 +986,7 @@ fn drawn_alike(a: &CharStyle, b: &CharStyle, vertical: bool) -> bool { kerning_before, aki_left, aki_right, + tsume, optical_kerning, baseline_shift, h_scale, @@ -1034,6 +1035,7 @@ fn drawn_alike(a: &CharStyle, b: &CharStyle, vertical: bool) -> bool { kerning_before == &a.kerning_before && aki_left == &a.aki_left && aki_right == &a.aki_right + && tsume == &a.tsume && offsets == other_offsets && font_family == &b.font_family && font_style == &b.font_style @@ -1094,7 +1096,7 @@ pub(crate) fn warichu_alike(a: &CharStyle, b: &CharStyle) -> bool { /// 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`]), the space before a line's first character ([`space_before`]), -/// Insert Aki ([`aki`]), the OpenType features (each run's apply to its own characters, as features +/// Insert Aki ([`aki`]), Tsume ([`tighten`]), 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 { @@ -1111,6 +1113,7 @@ fn shapes_alike(a: &CharStyle, b: &CharStyle) -> bool { kerning_before: _, aki_left: _, aki_right: _, + tsume: _, optical_kerning: _, baseline_shift: _, h_scale, @@ -1332,6 +1335,9 @@ struct Shaped { /// from their letters and the character-level pass matches ([`crate::harakat`]); empty /// otherwise (glyphs keep their shaping-cluster start). chars: Vec, + /// Each glyph's x advance without the font's pair kerning, the width Tsume measures, when + /// horizontal type with Tsume sets the same glyphs without it; empty otherwise. + unkerned: Vec, } /// The features that turn the whole-text features `base` into `own` over the clusters `range`, put @@ -1443,6 +1449,8 @@ fn shape_segment(text: &str, context: &Range, seg: &Segment, feats: &OtFe } ranged }; + // Horizontal type with Tsume on any of the segment's characters. + let tsume_here = !feats.vertical && runs.get(source_runs.clone()).unwrap_or_default().iter().any(|(_, s)| s.tsume > 0.0); // 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. @@ -1452,7 +1460,7 @@ fn shape_segment(text: &str, context: &Range, seg: &Segment, feats: &OtFe .filter(|l| l.len() <= 35 && l.bytes().all(|b| b.is_ascii_alphanumeric() || b == b'-')) .and_then(|l| l.parse::().ok()); let mut shaped = Shaped::default(); - let Shaped { raw, proportional, vpal, chars } = &mut shaped; + let Shaped { raw, proportional, vpal, chars, unkerned } = &mut shaped; let hb_shaped = face.hb().map(|hb| { let shaper = face.shaper.shaper(&hb).instance(face.instance.as_ref()).build(); // The baseline and character-level pass use exactly the same resolved features. @@ -1531,6 +1539,16 @@ fn shape_segment(text: &str, context: &Range, seg: &Segment, feats: &OtFe *proportional = full.iter().zip(raw).map(|(a, b)| a.2 != b.2).collect(); } } + if tsume_here { + // Tsume measures each glyph on its own width: the same glyphs set without the + // font's pair kerning (`kern` and `apkn`) give it. + let mut bare = along_features.clone(); + bare.extend([b"kern", b"apkn"].map(|t| Feature::new(Tag::new(t), 0, range.start..=range.end))); + let bare = shape(&bare, along); + if same_glyphs(&bare) { + *unkerned = bare.iter().map(|g| g.2).collect(); + } + } if feats.proportional_vertical(st) { // `vpal` adjusts heights, which only top-to-bottom shaping applies: the same glyphs set // top to bottom with it (and the vertical kerning that goes with it, `vkrn` and `vapk`) @@ -1577,7 +1595,7 @@ fn shape_segment(text: &str, context: &Range, seg: &Segment, feats: &OtFe shaped }; let first_optical = if optical(range.start) { Some(range.start) } else { boundaries().find(|&b| optical(b)) }; - let Shaped { raw, proportional, vpal, chars } = if let Some(first) = first_optical.filter(|_| !feats.vertical) { + let Shaped { raw, proportional, vpal, chars, unkerned } = if let Some(first) = first_optical.filter(|_| !feats.vertical) { // The pair entering Optical keeps the left character's font kerning. Subsequent // Optical pairs use outline spacing, so font pair features stop at its second character. let optical_off = if first == range.start { @@ -1711,11 +1729,16 @@ fn shape_segment(text: &str, context: &Range, seg: &Segment, feats: &OtFe let last_in_cluster = gi + 1 == n || raw[gi + 1].1 as usize != cl; let ch = first_char(cl); let mut adv = xa as f64 * k * hs; + // The glyph's own width, without the font's pair kerning and before the tracking, manual + // kerning and Insert Aki after it: what Tsume measures. + let mut own = unkerned.get(gi).map_or(adv, |&u| f64::from(u) * k * hs); // The chosen glyph takes its own advance. let chosen = chosen_at(gi); let gid = match chosen { Some(c) => { - adv += (face.advance(c.gid) - face.advance(gid)) * k * hs; + let wider = (face.advance(c.gid) - face.advance(gid)) * k * hs; + adv += wider; + own += wider; c.gid } None => gid, @@ -1723,7 +1746,8 @@ fn shape_segment(text: &str, context: &Range, seg: &Segment, feats: &OtFe let mut kern_shift = 0.0; let mut tracked = 0.0; let (mut aki_shift, mut aki_in_adv) = (0.0, 0.0); - if ch == SOFT_HYPHEN || ch == '\u{2028}' { + let invisible = ch == SOFT_HYPHEN || ch == '\u{2028}'; + if invisible { adv = 0.0; } else { let manual_kern = gst.kerning.map(|v| v / 1000.0 * spacing_em).unwrap_or(0.0); @@ -1785,9 +1809,61 @@ fn shape_segment(text: &str, context: &Range, seg: &Segment, feats: &OtFe cursive, hyphen_language: crate::hyphen::Language::from_tag(gst.language.as_deref()), }); + // Tsume takes its share of the blank space beside the cluster's ink once the cluster is + // complete, in horizontal type. + if last_in_cluster && !invisible && !feats.vertical && gst.tsume > 0.0 { + let first = out.iter().rposition(|g| g.byte != cl).map_or(0, |i| i + 1); + tighten(out.get_mut(first..).unwrap_or_default(), own, gst.tsume); + } } } +/// Tsume ([`CharStyle::tsume`]) on one cluster: `glyphs` are its glyphs in the order they are +/// drawn along the line, the last one with `own` as its width before the tracking, kerning and +/// Insert Aki its advance holds. `tsume` percent of the blank space between the cluster's own width +/// and its ink, on each side, comes out of the cluster's advance, and its glyphs are drawn the +/// left side's share earlier. A cluster without ink, such as a space, or with a width that is not +/// positive, keeps its place and width. +fn tighten(glyphs: &mut [SGlyph], own: f64, tsume: f64) { + let Some(last) = glyphs.len().checked_sub(1) else { return }; + let share = if tsume.is_nan() { 0.0 } else { tsume.clamp(0.0, MAX_TSUME) / 100.0 }; + let (mut pen, mut ink): (f64, Option<(f64, f64)>) = (0.0, None); + for (i, g) in glyphs.iter().enumerate() { + if let Some((a, b)) = ink_across(&g.face, g.gid) { + let (a, b) = (pen + g.dx + a * g.sx, pen + g.dx + b * g.sx); + let (lo, hi) = (a.min(b), a.max(b)); + ink = Some(ink.map_or((lo, hi), |(l, h)| (l.min(lo), h.max(hi)))); + } + pen += if i == last { own } else { g.adv }; + } + let Some((lo, hi)) = ink.filter(|(lo, hi)| lo.is_finite() && hi.is_finite()) else { return }; + if !(pen.is_finite() && pen > 0.0) { + return; + } + let left = lo.clamp(0.0, pen) * share; + let right = (pen - hi).clamp(0.0, pen) * share; + for g in glyphs.iter_mut() { + g.dx -= left; + } + if let Some(g) = glyphs.get_mut(last) { + g.adv -= left + right; + } +} + +/// The left and right edges of glyph `gid`'s outline in `face`, in font units; `None` for a glyph +/// without an outline (a space, or a bitmap or color glyph without one). +fn ink_across(face: &FontFace, gid: u32) -> Option<(f64, f64)> { + let font = face.skrifa()?; + let glyph = font.outline_glyphs().get(skrifa::GlyphId::new(gid))?; + let mut pen = crate::fontdb::FlipPen(kurbo::BezPath::new()); + glyph.draw(skrifa::outline::DrawSettings::unhinted(Size::unscaled(), face.location()), &mut pen).ok()?; + if pen.0.elements().is_empty() { + return None; + } + let b = pen.0.bounding_box(); + Some((b.x0, b.x1)) +} + #[cfg(test)] mod optical_tests { use super::*; diff --git a/crates/text/src/tests.rs b/crates/text/src/tests.rs index e7e4f33ff6..423984a630 100644 --- a/crates/text/src/tests.rs +++ b/crates/text/src/tests.rs @@ -30,6 +30,7 @@ mod shape_plans; mod snap; mod svg_glyphs; mod sysfonts; +mod tsume; mod typo; mod variable; mod versions; diff --git a/crates/text/src/tests/tsume.rs b/crates/text/src/tests/tsume.rs new file mode 100644 index 0000000000..72f0d69f19 --- /dev/null +++ b/crates/text/src/tests/tsume.rs @@ -0,0 +1,143 @@ +//! Tsume (`CharStyle::tsume`): the share of the blank space on each side of a character's ink, +//! inside its own width, that horizontal type takes away, the glyph keeping its shape. +use super::{area, db, point, style}; +use crate::*; +use kurbo::Shape; +use vectorcraft_doc::{CharStyle, Justify}; + +/// Where glyph `byte` is drawn along a horizontal line. +fn drawn_x(l: &TextLayout, byte: usize) -> f64 { + l.glyphs.iter().find(|g| g.byte == byte).map(|g| g.xf.as_coeffs()[4]).unwrap() +} + +/// The left and right edges of glyph `byte`'s ink. +fn ink(l: &TextLayout, byte: usize) -> (f64, f64) { + let b = l.glyphs.iter().find(|g| g.byte == byte).map(|g| g.outline.bounding_box()).unwrap(); + (b.x0, b.x1) +} + +/// `t` with `f` applied to the style of the character at `byte`. +fn with(t: &TextObject, byte: usize, f: impl Fn(&mut CharStyle)) -> TextObject { + let mut t = t.clone(); + let end = edit::next_char(&t.plain_text(), byte); + edit::style_range(&mut t.runs, byte, end, f); + t +} + +/// How far each of `bytes` moved along the line from `a` to `b`. +fn moves(a: &TextObject, b: &TextObject, bytes: &[usize]) -> Vec { + let (la, lb) = (layout(db(), a), layout(db(), b)); + bytes.iter().map(|&i| drawn_x(&lb, i) - drawn_x(&la, i)).collect() +} + +fn near(values: &[f64], expected: &[f64]) -> bool { + values.len() == expected.len() && values.iter().zip(expected).all(|(v, e)| (v - e).abs() < 1e-6) +} + +/// The blank space before and after the O of "HOH" in `t`'s layout: between the start of its +/// width and its ink, and between its ink and the next character. +fn o_bearings(t: &TextObject) -> (f64, f64) { + let l = layout(db(), t); + let (x0, x1) = ink(&l, 1); + (x0 - drawn_x(&l, 1), drawn_x(&l, 2) - x1) +} + +#[test] +fn tsume_takes_its_share_of_the_space_beside_the_ink() { + let plain = point("HOH", style(100.0)); + let (before, after) = o_bearings(&plain); + assert!(before > 1.0 && after > 1.0, "{before} {after}"); + let plain_layout = layout(db(), &plain); + let width = ink(&plain_layout, 1).1 - ink(&plain_layout, 1).0; + for tsume in [25.0, 50.0, 100.0] { + let tight = with(&plain, 1, |s| s.tsume = tsume); + let share = tsume / 100.0; + // The O's ink moves back by its share of the space before it; the H after it also moves + // back by the share of the space after the O. + let l = layout(db(), &tight); + let (x0, x1) = ink(&l, 1); + let moved = (x0 - ink(&plain_layout, 1).0, ink(&l, 2).0 - ink(&plain_layout, 2).0); + assert!((moved.0 + before * share).abs() < 1e-6, "{tsume}: {moved:?}"); + assert!((moved.1 + (before + after) * share).abs() < 1e-6, "{tsume}: {moved:?}"); + // The glyph keeps its shape, and the H before it stays. + assert!((x1 - x0 - width).abs() < 1e-6, "{tsume}: {} {width}", x1 - x0); + assert!((ink(&l, 0).0 - ink(&plain_layout, 0).0).abs() < 1e-9); + } + // At 100% the O's ink starts where the H before it ends its width, and the next H starts its + // width where the O's ink ends. + let l = layout(db(), &with(&plain, 1, |s| s.tsume = 100.0)); + let cell_start = drawn_x(&plain_layout, 1); + assert!((ink(&l, 1).0 - cell_start).abs() < 1e-6 && (drawn_x(&l, 2) - ink(&l, 1).1).abs() < 1e-6, "{:?} {cell_start}", ink(&l, 1)); +} + +#[test] +fn zero_out_of_range_and_spaces_keep_the_layout() { + let plain = point("H O", style(100.0)); + let base = layout(db(), &plain); + for (tsume, at) in [(0.0, 2), (f64::NAN, 2), (-50.0, 2), (100.0, 1)] { + // 0, a value that is not a number, a negative value, and a space at 100%. + let l = layout(db(), &with(&plain, at, |s| s.tsume = tsume)); + let xs: Vec = l.glyphs.iter().map(|g| g.xf.as_coeffs()[4]).collect(); + let base_xs: Vec = base.glyphs.iter().map(|g| g.xf.as_coeffs()[4]).collect(); + assert_eq!(xs, base_xs, "{tsume} at {at}"); + } + // A value above 100 takes all the space, as 100 does. + let most = moves(&plain, &with(&plain, 2, |s| s.tsume = 500.0), &[2]); + let full = moves(&plain, &with(&plain, 2, |s| s.tsume = 100.0), &[2]); + assert!(near(&most, &full) && full[0] < -1.0, "{most:?} {full:?}"); +} + +#[test] +fn tracking_and_insert_aki_are_added_after_the_tsume() { + let plain = point("HOH", style(100.0)); + let tight = moves(&plain, &with(&plain, 1, |s| s.tsume = 50.0), &[0, 1, 2]); + let spaced = moves(&plain, &with(&plain, 1, |s| (s.tsume, s.aki_left, s.tracking) = (50.0, 250.0, 100.0)), &[0, 1, 2]); + // 25 pt of Aki before the O, and 10 pt of tracking after it, on top of the Tsume. + assert!(tight[1] < -1.0 && tight[2] < tight[1], "{tight:?}"); + assert!(near(&spaced, &[0.0, tight[1] + 25.0, tight[2] + 35.0]), "{tight:?} {spaced:?}"); +} + +#[test] +fn the_font_kerning_is_added_after_the_tsume() { + // An L, with blank space after its ink, before a letter the font kerns it with: its Tsume is + // the same as before an H, which the font does not kern it with. + let lh = point("LH", style(100.0)); + let kerned = ["LT", "LV", "LY", "LW"] + .map(|s| point(s, style(100.0))) + .into_iter() + .find(|t| drawn_x(&layout(db(), t), 1) < drawn_x(&layout(db(), &lh), 1) - 1.0); + let Some(kerned) = kerned else { panic!("the test font kerns none of the pairs") }; + let before_kerned = moves(&kerned, &with(&kerned, 0, |s| s.tsume = 100.0), &[1]); + let before_h = moves(&lh, &with(&lh, 0, |s| s.tsume = 100.0), &[1]); + assert!(near(&before_kerned, &before_h) && before_h[0] < -10.0, "{before_kerned:?} {before_h:?}"); +} + +#[test] +fn a_letter_with_its_mark_takes_the_tsume_of_the_letter() { + let plain = point("H\u{301}O", style(100.0)); + let after = "H\u{301}".len(); + let letter = moves(&plain, &with(&plain, 0, |s| s.tsume = 100.0), &[after]); + let mark = moves(&plain, &with(&plain, 1, |s| s.tsume = 100.0), &[after]); + assert!(letter[0] < -1.0 && near(&mark, &[0.0]), "{letter:?} {mark:?}"); +} + +#[test] +fn justification_keeps_the_tsume() { + let plain = area("HOH HOH", style(20.0), Rect::new(0.0, 0.0, 400.0, 400.0), Justify::JustifyAll); + let tight = with(&plain, 1, |s| s.tsume = 100.0); + let (lp, lt) = (layout(db(), &plain), layout(db(), &tight)); + // The O stays nearer the H before it by the space Tsume took before its ink. + let gap = |l: &TextLayout| ink(l, 1).0 - ink(l, 0).1; + assert!(gap(<) < gap(&lp) - 1.0, "{} {}", gap(&lp), gap(<)); +} + +#[test] +fn vertical_type_keeps_its_spacing() { + for text in ["§§", "ab"] { + let mut plain = point(text, style(20.0)); + plain.xf = Affine::IDENTITY; + plain.vertical = true; + let centers = |t: &TextObject| layout(db(), t).glyphs.iter().map(|g| g.outline.bounding_box().center()).collect::>(); + assert_eq!(centers(&with(&plain, 0, |s| s.tsume = 100.0)), centers(&plain), "{text}"); + } +} diff --git a/crates/ui-egui/src/dialogs/char_style_options.rs b/crates/ui-egui/src/dialogs/char_style_options.rs index f34363e5b7..e807df7b01 100644 --- a/crates/ui-egui/src/dialogs/char_style_options.rs +++ b/crates/ui-egui/src/dialogs/char_style_options.rs @@ -26,9 +26,10 @@ const NUMBERS: &[&str] = &["size", "leading", "kerning", "tracking"]; /// The on, off or unset checkboxes. const SWITCHES: &[&str] = &["underline", "strikethrough", "standardVerticalRoman"]; /// The attributes of Advanced Character Formats, in the order the section shows them. -const ADVANCED: &[&str] = &["h_scale", "v_scale", "baseline_shift", "tcyUpDown", "tcyLeftRight", "rotation", "akiLeft", "akiRight", "language"]; +const ADVANCED: &[&str] = + &["h_scale", "v_scale", "baseline_shift", "tcyUpDown", "tcyLeftRight", "rotation", "akiLeft", "akiRight", "tsume", "language"]; /// The number fields of Advanced Character Formats. -const ADVANCED_NUMBERS: &[&str] = &["h_scale", "v_scale", "baseline_shift", "tcyUpDown", "tcyLeftRight", "rotation", "akiLeft", "akiRight"]; +const ADVANCED_NUMBERS: &[&str] = &["h_scale", "v_scale", "baseline_shift", "tcyUpDown", "tcyLeftRight", "rotation", "akiLeft", "akiRight", "tsume"]; /// The Insert Aki Left and Right fields, which take the Character panel's presets and stop at /// [`vectorcraft_doc::text::MAX_AKI`]. const AKI: [&str; 2] = ["akiLeft", "akiRight"]; @@ -86,6 +87,7 @@ pub(super) const SPEC: DialogSpec = DialogSpec { "rotation", "akiLeft", "akiRight", + "tsume", "language", "fill", "fill_tint", @@ -275,6 +277,7 @@ fn optional_number(d: &Dialog, key: &str) -> Result, String> { "rotation" => tl!("Character Rotation"), "akiLeft" => tl!("Insert Aki Left"), "akiRight" => tl!("Insert Aki Right"), + "tsume" => tl!("Tsume"), "fill_tint" => tl!("Fill Tint"), "stroke_tint" => tl!("Stroke Tint"), "stroke_width" => tl!("Weight"), @@ -322,6 +325,8 @@ fn optional_number(d: &Dialog, key: &str) -> Result, String> { // Insert Aki stops at 0 and at the largest value the style takes, as the Character // panel's fields do. let n = if AKI.contains(&key) { n.clamp(0.0, vectorcraft_doc::text::MAX_AKI) } else { n }; + // Tsume stops at 0% and 100% the same way. + let n = if key == "tsume" { n.clamp(0.0, vectorcraft_doc::text::MAX_TSUME) } else { n }; Ok(Some(json!(n))) } } @@ -600,7 +605,7 @@ pub(crate) fn reset_basic(d: &mut Dialog) { } } -/// Reset Panel in Advanced Character Formats: its nine attributes become unset. +/// Reset Panel in Advanced Character Formats: its ten attributes become unset. pub(crate) fn reset_advanced(d: &mut Dialog) { d.fields.insert("__resetAdvanced".into(), json!(true)); for key in ADVANCED_NUMBERS { @@ -755,8 +760,8 @@ pub(crate) fn basic_body(ui: &mut egui::Ui, d: &mut Dialog) { } /// Advanced Character Formats: Horizontal Scale, Vertical Scale, Baseline Shift, the Tate-chu-yoko -/// Up/Down and Left/Right, Character Rotation, Insert Aki Left and Right (with the Character -/// panel's presets) and Language, each empty (unset) or set. +/// Up/Down and Left/Right, Character Rotation, Insert Aki Left and Right and Tsume (with the +/// Character panel's presets) and Language, each empty (unset) or set. pub(crate) fn advanced_body(ui: &mut egui::Ui, d: &mut Dialog) { egui::Grid::new("character-style-advanced").num_columns(2).spacing([18.0, 8.0]).show(ui, |ui| { for (key, caption, unit) in [ @@ -787,6 +792,17 @@ pub(crate) fn advanced_body(ui: &mut egui::Ui, d: &mut Dialog) { } ui.end_row(); } + let presets: Vec = crate::panels::character::TSUME_PRESETS.iter().map(f64::to_string).collect(); + let presets: Vec<&str> = presets.iter().map(String::as_str).collect(); + let row = ui.label(tl!("Tsume")); + let value = if d.fields.get("tsume").is_none_or(Value::is_null) { String::new() } else { d.str("tsume") }; + ui.horizontal(|ui| { + if let Some(v) = ui.label_inputs_by(row.id, |ui| crate::widgets::text_presets(ui, (KIND, "tsume"), &value, &presets, 160.0)) { + d.fields.insert("tsume".into(), json!(v)); + } + ui.label("%"); + }); + ui.end_row(); let row = ui.label(tl!("Language:")); let names: Vec<&str> = std::iter::once(" ").chain(vectorcraft_doc::setup::LANGUAGES.iter().map(|(name, _, _)| *name)).collect(); let current = d.fields.get("language").and_then(Value::as_str).unwrap_or("").to_string(); @@ -1847,6 +1863,50 @@ mod tests { assert!(t.runs.iter().all(|r| (r.style.aki_left, r.style.aki_right) == (250.0, 1000.0))); } + /// Tsume in Advanced Character Formats: empty is unset, an explicit 0 stays set, a typed value + /// stops at 0 and 100, Reset Panel unsets it, and the field is drawn and commits to the style + /// and the text set in it. + #[test] + fn tsume_field_shows_sets_resets_and_commits() { + let d = dialog_for(json!({"size": 12.0})); + assert_eq!(d.fields["tsume"], json!("")); + let mut d = dialog_for(json!({"tsume": 50.0})); + assert_eq!(d.fields["tsume"], json!(50.0)); + for (typed, kept) in [("0", 0.0), ("25.5", 25.5), ("150", 100.0), ("-5", 0.0)] { + d.fields.insert("tsume".into(), json!(typed)); + assert_eq!(edited_attrs(&d).unwrap(), attrs(json!({"tsume": kept})), "{typed}"); + } + d.fields.insert("tsume".into(), json!("")); + assert_eq!(edited_attrs(&d).unwrap(), attrs(json!({}))); + d.fields.insert("tsume".into(), json!("tight")); + assert_eq!(edited_attrs(&d).unwrap_err(), "Tsume must be a number"); + let mut d = dialog_for(json!({"tsume": 50.0, "size": 12.0})); + d.fields.insert("section".into(), json!("advanced")); + reset_section(&mut d); + assert_eq!(edited_attrs(&d).unwrap(), attrs(json!({"size": 12.0}))); + + let mut app = app(); + app.run("charStyle.new", json!({"name": "Body", "attrs": {"size": 24.0}})).unwrap(); + let id = app.run("text.create", json!({"x": 10, "y": 40, "text": "Text"})).unwrap()["id"].as_u64().unwrap(); + app.run("charStyle.apply", json!({"name": "Body", "id": id})).unwrap(); + open(&mut app, "Body").unwrap(); + let ctx = egui::Context::default(); + crate::theme::install_fonts(&ctx); + let screen = egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(900.0, 900.0)); + let mut d = app.ui.dialog.clone().unwrap(); + let input = egui::RawInput { screen_rect: Some(screen), ..Default::default() }; + let mut out = ctx.run_ui(input, |ui| advanced_body(ui, &mut d)); + let texts = texts_of(&out); + out.textures_delta.clear(); + assert!(texts.iter().any(|(text, _)| text == "Tsume"), "Tsume is drawn: {texts:?}"); + app.ui.dialog.as_mut().unwrap().fields.insert("tsume".into(), json!("75")); + super::super::confirm(&mut app).unwrap(); + let state = app.session.doc().unwrap(); + assert_eq!(state.doc.char_styles[0].attrs, attrs(json!({"size": 24.0, "tsume": 75.0}))); + let vectorcraft_doc::NodeKind::Text(t) = &state.doc.node(vectorcraft_doc::NodeId(id)).unwrap().kind else { panic!("text") }; + assert!(t.runs.iter().all(|r| r.style.tsume == 75.0)); + } + /// The negated tags of the twenty stylistic sets: an explicit empty Stylistic Sets choice. fn no_sets() -> Vec { vectorcraft_text::EXTRA_FEATURES[..20].iter().map(|s| format!("-{s}")).collect() diff --git a/crates/ui-egui/src/i18n/bg.tsv b/crates/ui-egui/src/i18n/bg.tsv index 1f469c69c9..3efa7fccd5 100644 --- a/crates/ui-egui/src/i18n/bg.tsv +++ b/crates/ui-egui/src/i18n/bg.tsv @@ -2586,6 +2586,7 @@ font-technology Variable Променливи Proportional Metrics Пропорционални метрики Insert Aki Left Вмъкване на аки отляво Insert Aki Right Вмъкване на аки отдясно + Tsume Цуме Character Direction Посока на знаците Digits Цифри Arabic Арабски diff --git a/crates/ui-egui/src/i18n/bs.tsv b/crates/ui-egui/src/i18n/bs.tsv index a23133933a..abab434790 100644 --- a/crates/ui-egui/src/i18n/bs.tsv +++ b/crates/ui-egui/src/i18n/bs.tsv @@ -1570,6 +1570,7 @@ lighting Light Svjetlo Proportional Metrics Proporcionalna metrika Insert Aki Left Umetni aki lijevo Insert Aki Right Umetni aki desno + Tsume Tsume Character Direction Smjer znakova Digits Cifre Arabic Arapski diff --git a/crates/ui-egui/src/i18n/ca.tsv b/crates/ui-egui/src/i18n/ca.tsv index 373eb0b79d..6bed026393 100644 --- a/crates/ui-egui/src/i18n/ca.tsv +++ b/crates/ui-egui/src/i18n/ca.tsv @@ -3356,6 +3356,7 @@ digits Malayalam Malaiàlam Proportional Metrics Mètriques proporcionals Insert Aki Left Insereix aki a l'esquerra Insert Aki Right Insereix aki a la dreta + Tsume Tsume Proportional Lining Xifres proporcionals alineades Position: Posició: Default Position Posició predeterminada diff --git a/crates/ui-egui/src/i18n/cs.tsv b/crates/ui-egui/src/i18n/cs.tsv index 7178be3143..e9f0c1f5a7 100644 --- a/crates/ui-egui/src/i18n/cs.tsv +++ b/crates/ui-egui/src/i18n/cs.tsv @@ -1598,6 +1598,7 @@ lighting Light Světlo Proportional Metrics Proporcionální metrika Insert Aki Left Vložit aki vlevo Insert Aki Right Vložit aki vpravo + Tsume Tsume Character Direction Směr znaků Digits Číslice Arabic Arabské diff --git a/crates/ui-egui/src/i18n/da.tsv b/crates/ui-egui/src/i18n/da.tsv index 433c6af3d6..404e6c1fba 100644 --- a/crates/ui-egui/src/i18n/da.tsv +++ b/crates/ui-egui/src/i18n/da.tsv @@ -1506,6 +1506,7 @@ lighting Light Lys Proportional Metrics Proportionale metrikker Insert Aki Left Indsæt aki til venstre Insert Aki Right Indsæt aki til højre + Tsume Tsume Character Direction Tegnretning Digits Cifre Arabic Arabisk diff --git a/crates/ui-egui/src/i18n/de.tsv b/crates/ui-egui/src/i18n/de.tsv index 951f6f9c37..6cca4c48fb 100644 --- a/crates/ui-egui/src/i18n/de.tsv +++ b/crates/ui-egui/src/i18n/de.tsv @@ -2630,6 +2630,7 @@ mark-style Japanese Japanisch Proportional Metrics Proportionale Metrik Insert Aki Left Aki links einfügen Insert Aki Right Aki rechts einfügen + Tsume Tsume Character Direction Zeichenrichtung Digits Ziffern Arabic Arabisch diff --git a/crates/ui-egui/src/i18n/el.tsv b/crates/ui-egui/src/i18n/el.tsv index 62e3adc491..10c16e2ca2 100644 --- a/crates/ui-egui/src/i18n/el.tsv +++ b/crates/ui-egui/src/i18n/el.tsv @@ -3425,6 +3425,7 @@ theme Appearance Εμφάνιση Proportional Metrics Αναλογικές μετρικές Insert Aki Left Εισαγωγή aki αριστερά Insert Aki Right Εισαγωγή aki δεξιά + Tsume Tsume Arabic Αραβικά Hindi Χίντι Farsi Περσικά diff --git a/crates/ui-egui/src/i18n/es.tsv b/crates/ui-egui/src/i18n/es.tsv index d1c6aa53af..0bb69b2ac4 100644 --- a/crates/ui-egui/src/i18n/es.tsv +++ b/crates/ui-egui/src/i18n/es.tsv @@ -2664,6 +2664,7 @@ font-technology Variable Variables Proportional Metrics Métrica proporcional Insert Aki Left Insertar aki a la izquierda Insert Aki Right Insertar aki a la derecha + Tsume Tsume Character Direction Dirección de caracteres Digits Dígitos Arabic Árabes diff --git a/crates/ui-egui/src/i18n/fi.tsv b/crates/ui-egui/src/i18n/fi.tsv index 45e47fd355..cbd9126586 100644 --- a/crates/ui-egui/src/i18n/fi.tsv +++ b/crates/ui-egui/src/i18n/fi.tsv @@ -1573,6 +1573,7 @@ lighting Light Valo Proportional Metrics Suhteelliset metriikat Insert Aki Left Lisää aki vasemmalle Insert Aki Right Lisää aki oikealle + Tsume Tsume Character Direction Merkkien suunta Digits Numerot Arabic Arabia diff --git a/crates/ui-egui/src/i18n/fr.tsv b/crates/ui-egui/src/i18n/fr.tsv index 057dce6872..020249fba0 100644 --- a/crates/ui-egui/src/i18n/fr.tsv +++ b/crates/ui-egui/src/i18n/fr.tsv @@ -2656,6 +2656,7 @@ font-technology Variable Variables Proportional Metrics Métrique proportionnelle Insert Aki Left Insérer un aki à gauche Insert Aki Right Insérer un aki à droite + Tsume Tsume Character Direction Direction des caractères Digits Chiffres Arabic Arabes diff --git a/crates/ui-egui/src/i18n/hi.tsv b/crates/ui-egui/src/i18n/hi.tsv index 9d056e085a..c675e8c62d 100644 --- a/crates/ui-egui/src/i18n/hi.tsv +++ b/crates/ui-egui/src/i18n/hi.tsv @@ -3694,6 +3694,7 @@ antiAlias Sharp शार्प Proportional Metrics आनुपातिक मीट्रिक Insert Aki Left बाईं ओर आकी जोड़ें Insert Aki Right दाईं ओर आकी जोड़ें + Tsume त्सुमे Proportional Oldstyle आनुपातिक ओल्डस्टाइल Protect Corners कॉर्नर को सुरक्षित रखें Proximity Guides निकटता गाइड diff --git a/crates/ui-egui/src/i18n/hu.tsv b/crates/ui-egui/src/i18n/hu.tsv index c2f1a52cd9..f2193f9f5a 100644 --- a/crates/ui-egui/src/i18n/hu.tsv +++ b/crates/ui-egui/src/i18n/hu.tsv @@ -3222,6 +3222,7 @@ mark-style Japanese Japán Proportional Metrics Arányos metrika Insert Aki Left Aki beszúrása balra Insert Aki Right Aki beszúrása jobbra + Tsume Tsume Proportional Lining Arányos modern Position: Pozíció: Default Position Alapértelmezett pozíció diff --git a/crates/ui-egui/src/i18n/id.tsv b/crates/ui-egui/src/i18n/id.tsv index a36c629332..02cd2ecd6f 100644 --- a/crates/ui-egui/src/i18n/id.tsv +++ b/crates/ui-egui/src/i18n/id.tsv @@ -1506,6 +1506,7 @@ lighting Light Cahaya Proportional Metrics Metrik proporsional Insert Aki Left Sisipkan aki kiri Insert Aki Right Sisipkan aki kanan + Tsume Tsume Character Direction Arah karakter Digits Digit Arabic Arab diff --git a/crates/ui-egui/src/i18n/it.tsv b/crates/ui-egui/src/i18n/it.tsv index 0ac2816b07..978afec841 100644 --- a/crates/ui-egui/src/i18n/it.tsv +++ b/crates/ui-egui/src/i18n/it.tsv @@ -2706,6 +2706,7 @@ mark-style Japanese Giapponese Proportional Metrics Metrica proporzionale Insert Aki Left Inserisci aki a sinistra Insert Aki Right Inserisci aki a destra + Tsume Tsume Character Direction Direzione caratteri Digits Cifre Arabic Arabe diff --git a/crates/ui-egui/src/i18n/ja.tsv b/crates/ui-egui/src/i18n/ja.tsv index 71de1ce91c..ee3c06bfcd 100644 --- a/crates/ui-egui/src/i18n/ja.tsv +++ b/crates/ui-egui/src/i18n/ja.tsv @@ -52,6 +52,7 @@ font-technology Variable バリアブル Proportional Metrics プロポーショナルメトリクス Insert Aki Left 前にアキを挿入 Insert Aki Right 後ろにアキを挿入 + Tsume 文字ツメ Character Direction 文字の方向 Digits 数字 Arabic アラビア数字 diff --git a/crates/ui-egui/src/i18n/ka.tsv b/crates/ui-egui/src/i18n/ka.tsv index 2cff51d94b..a4eeb727df 100644 --- a/crates/ui-egui/src/i18n/ka.tsv +++ b/crates/ui-egui/src/i18n/ka.tsv @@ -1443,6 +1443,7 @@ lighting Light სინათლე Proportional Metrics პროპორციული მეტრიკა Insert Aki Left აკის ჩასმა მარცხნივ Insert Aki Right აკის ჩასმა მარჯვნივ + Tsume ცუმე Character Direction სიმბოლოების მიმართულება Digits ციფრები Arabic არაბული diff --git a/crates/ui-egui/src/i18n/ko.tsv b/crates/ui-egui/src/i18n/ko.tsv index 4715a413f4..277ba94b19 100644 --- a/crates/ui-egui/src/i18n/ko.tsv +++ b/crates/ui-egui/src/i18n/ko.tsv @@ -46,6 +46,7 @@ font-technology Variable 가변 Proportional Metrics 비례 메트릭 Insert Aki Left 앞 아키 삽입 Insert Aki Right 뒤 아키 삽입 + Tsume 츠메 Character Direction 문자 방향 Digits 숫자 Arabic 아라비아어 diff --git a/crates/ui-egui/src/i18n/nl.tsv b/crates/ui-egui/src/i18n/nl.tsv index 94957fb782..9136088b9c 100644 --- a/crates/ui-egui/src/i18n/nl.tsv +++ b/crates/ui-egui/src/i18n/nl.tsv @@ -2023,6 +2023,7 @@ lighting Light Licht Proportional Metrics Proportionele metingen Insert Aki Left Aki links invoegen Insert Aki Right Aki rechts invoegen + Tsume Tsume Character Direction Tekenrichting Digits Cijfers Arabic Arabisch diff --git a/crates/ui-egui/src/i18n/pl.tsv b/crates/ui-egui/src/i18n/pl.tsv index 48f2671c2d..398a132865 100644 --- a/crates/ui-egui/src/i18n/pl.tsv +++ b/crates/ui-egui/src/i18n/pl.tsv @@ -1511,6 +1511,7 @@ lighting Light Światło Proportional Metrics Metryka proporcjonalna Insert Aki Left Wstaw aki z lewej Insert Aki Right Wstaw aki z prawej + Tsume Tsume Character Direction Kierunek znaków Digits Cyfry Arabic Arabskie diff --git a/crates/ui-egui/src/i18n/pt-br.tsv b/crates/ui-egui/src/i18n/pt-br.tsv index f6e54552d4..8c932fc75e 100644 --- a/crates/ui-egui/src/i18n/pt-br.tsv +++ b/crates/ui-egui/src/i18n/pt-br.tsv @@ -2230,6 +2230,7 @@ antiAlias Sharp Nítido Proportional Metrics Métrica Proporcional Insert Aki Left Inserir Aki à Esquerda Insert Aki Right Inserir Aki à Direita + Tsume Tsume Character Direction Direção de Caracteres Digits Dígitos Arabic Arábicos diff --git a/crates/ui-egui/src/i18n/ru.tsv b/crates/ui-egui/src/i18n/ru.tsv index 8dc8336089..2c665847b3 100644 --- a/crates/ui-egui/src/i18n/ru.tsv +++ b/crates/ui-egui/src/i18n/ru.tsv @@ -2693,6 +2693,7 @@ mark-style Japanese Японский Proportional Metrics Пропорциональные метрики Insert Aki Left Вставить аки слева Insert Aki Right Вставить аки справа + Tsume Цумэ Character Direction Направление символов Digits Цифры Arabic Арабские diff --git a/crates/ui-egui/src/i18n/sv.tsv b/crates/ui-egui/src/i18n/sv.tsv index cfb9d9734b..3257eea28a 100644 --- a/crates/ui-egui/src/i18n/sv.tsv +++ b/crates/ui-egui/src/i18n/sv.tsv @@ -2601,6 +2601,7 @@ font-technology Variable Variabla Proportional Metrics Proportionella mått Insert Aki Left Infoga aki till vänster Insert Aki Right Infoga aki till höger + Tsume Tsume Character Direction Teckenriktning Digits Siffror Arabic Arabiska diff --git a/crates/ui-egui/src/i18n/tr.tsv b/crates/ui-egui/src/i18n/tr.tsv index 2b3e843ba3..ff73450aa6 100644 --- a/crates/ui-egui/src/i18n/tr.tsv +++ b/crates/ui-egui/src/i18n/tr.tsv @@ -2448,6 +2448,7 @@ shape Open Açık Proportional Metrics Orantılı Metrikler Insert Aki Left Sola Aki Ekle Insert Aki Right Sağa Aki Ekle + Tsume Tsume Digits Rakamlar Arabic Arapça Hindi Hintçe diff --git a/crates/ui-egui/src/i18n/uk.tsv b/crates/ui-egui/src/i18n/uk.tsv index 50690fa0c7..fe95325a35 100644 --- a/crates/ui-egui/src/i18n/uk.tsv +++ b/crates/ui-egui/src/i18n/uk.tsv @@ -2698,6 +2698,7 @@ mark-style Japanese Японський Proportional Metrics Пропорційні метрики Insert Aki Left Вставити акі ліворуч Insert Aki Right Вставити акі праворуч + Tsume Цуме Character Direction Напрямок символів Digits Цифри Arabic Арабські diff --git a/crates/ui-egui/src/i18n/vi.tsv b/crates/ui-egui/src/i18n/vi.tsv index d8469bce53..61ef698a95 100644 --- a/crates/ui-egui/src/i18n/vi.tsv +++ b/crates/ui-egui/src/i18n/vi.tsv @@ -3011,6 +3011,7 @@ mark-style Japanese Kiểu Nhật Proportional Metrics Số liệu theo tỷ lệ Insert Aki Left Chèn aki bên trái Insert Aki Right Chèn aki bên phải + Tsume Tsume Proportional Lining Số tỷ lệ đứng Position: Vị trí: Default Position Vị trí mặc định diff --git a/crates/ui-egui/src/i18n/zh-hans.tsv b/crates/ui-egui/src/i18n/zh-hans.tsv index d9d9110276..eac6bdc3ef 100644 --- a/crates/ui-egui/src/i18n/zh-hans.tsv +++ b/crates/ui-egui/src/i18n/zh-hans.tsv @@ -2561,6 +2561,7 @@ font-script Japanese 汉字 Proportional Metrics 比例度量 Insert Aki Left 在前方插入空白 Insert Aki Right 在后方插入空白 + Tsume 比例间距 Character Direction 字符方向 Digits 数字 Arabic 阿拉伯数字 diff --git a/crates/ui-egui/src/i18n/zh-hant.tsv b/crates/ui-egui/src/i18n/zh-hant.tsv index a0d987fe69..5dc06037f5 100644 --- a/crates/ui-egui/src/i18n/zh-hant.tsv +++ b/crates/ui-egui/src/i18n/zh-hant.tsv @@ -3776,6 +3776,7 @@ font-script Japanese 漢字 Proportional Metrics 比例度量 Insert Aki Left 在前方插入空白 Insert Aki Right 在後方插入空白 + Tsume 比例間距 Character Direction 字元方向 Digits 數字 Arabic 阿拉伯數字 diff --git a/crates/ui-egui/src/panels/character.rs b/crates/ui-egui/src/panels/character.rs index 44ae643e86..9926855375 100644 --- a/crates/ui-egui/src/panels/character.rs +++ b/crates/ui-egui/src/panels/character.rs @@ -25,6 +25,8 @@ pub const SCALE_PRESETS: [f64; 8] = [25.0, 50.0, 75.0, 90.0, 100.0, 110.0, 125.0 /// Insert Aki Left and Right presets, in thousandths of an em: none, a quarter, half and a whole /// full-width space. pub const AKI_PRESETS: [f64; 4] = [0.0, 250.0, 500.0, 1000.0]; +/// Tsume presets, in percent. +pub const TSUME_PRESETS: [f64; 5] = [0.0, 25.0, 50.0, 75.0, 100.0]; /// The stored character style: the selected range's (or the caret's) while the Type /// tool edits text, else the first selected text object's first run (including groups). With it, the paragraph @@ -795,7 +797,7 @@ fn cell(ui: &mut Ui, label: &str, tip: &str, add: impl FnOnce(&mut Ui)) { /// The Character panel's grid of fields, `w` wide: Font Size, Leading, Kerning and Tracking, /// then with `more` the scales, Baseline Shift and Character Rotation, and a row with the /// Anti-aliasing Method dropdown under Character Rotation (`s`: the style shown), and with Show East -/// Asian Options on a row with Insert Aki Left and Right. With Show Font +/// Asian Options on a row with Insert Aki Left and Right and a row with Tsume. With Show Font /// Height Options on, a row under Font Size holds the Set Font Height Reference dropdown. The /// Properties panel shows its first rows. pub(crate) fn metrics_grid(app: &mut VectorcraftApp, ui: &mut Ui, id: &str, s: &CharStyle, w: f32, more: bool) { @@ -891,6 +893,14 @@ pub(crate) fn metrics_grid(app: &mut VectorcraftApp, ui: &mut Ui, id: &str, s: & }); } ui.end_row(); + // Tsume: the share of the blank space beside each character's ink taken away. + cell(ui, "→A←", tl!("Tsume"), |ui| { + if let Some(v) = widgets::spin_plain(ui, "ch-tsume", shared(app, |s| s.tsume), "%", 1, fw, 1.0, 0.0, &TSUME_PRESETS) { + // The field stops at the largest value the command takes. + format(app, json!({ "tsume": v.min(vectorcraft_doc::text::MAX_TSUME) })); + } + }); + ui.end_row(); } }); } @@ -1612,7 +1622,7 @@ fn fractional_widths_on(app: &VectorcraftApp) -> bool { /// and Small Caps off, the normal position (neither superscript nor subscript), Roman Baseline /// character alignment, Proportional Metrics, Tate-chu-yoko, Warichu, Standard Vertical Roman Alignment and /// No Break off, Tate-chu-yoko Settings Up/Down and Left/Right 0, the default Warichu Settings (2 lines, no -/// line gap, 50%, Auto alignment), Insert Aki Left and Right 0, the Default character direction, +/// line gap, 50%, Auto alignment), Insert Aki Left and Right 0, Tsume 0, the Default character direction, /// Kashidas on, the Default digits and the Em Box font height reference, whether or not Show East Asian /// Options, Show Indic Options and Show Font Height Options are on. Inside an undo group that is /// already open, the reset is part of that group's step. @@ -1632,7 +1642,7 @@ fn reset_panel(app: &mut VectorcraftApp) { "allCaps": false, "smallCaps": d.small_caps.is_some(), "position": d.position.id(), "charAlign": d.char_align, "proportionalMetrics": d.proportional_metrics, "tateChuYoko": d.tate_chu_yoko, "tcyUpDown": d.tcy_up_down, "tcyLeftRight": d.tcy_left_right, "akiLeft": d.aki_left, "akiRight": d.aki_right, - "warichu": d.warichu, "warichuLines": d.warichu_lines, "warichuGap": d.warichu_gap, "warichuScale": d.warichu_scale, + "tsume": d.tsume, "warichu": d.warichu, "warichuLines": d.warichu_lines, "warichuGap": d.warichu_gap, "warichuScale": d.warichu_scale, "warichuAlign": d.warichu_align, "standardVerticalRoman": d.standard_vertical_roman, "noBreak": d.no_break, "charDirection": "default", "kashidas": d.kashidas, "digits": d.digits, "fontHeightReference": d.font_height_reference diff --git a/crates/ui-egui/src/panels/character/tests.rs b/crates/ui-egui/src/panels/character/tests.rs index 7a2a96efb2..64a8fa0359 100644 --- a/crates/ui-egui/src/panels/character/tests.rs +++ b/crates/ui-egui/src/panels/character/tests.rs @@ -780,6 +780,41 @@ fn insert_aki_fields_show_with_the_east_asian_options() { assert!(left(&app).iter().all(|v| *v == 1000.0), "{:?}", left(&app)); } +/// Tsume is a field of the panel with the East Asian options only, and shows the selected +/// characters' value, blank where they differ. A value typed past 100 sets 100. +#[test] +fn tsume_field_shows_with_the_east_asian_options() { + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + app.run("file.new", json!({"width": 300, "height": 200})).unwrap(); + let id = app.run("text.create", json!({"x": 20, "y": 50, "text": "(日本)"})).unwrap()["id"].as_u64().unwrap(); + app.run("select.set", json!({"ids": [id]})).unwrap(); + app.run("text.setFormat", json!({"tsume": 37.5})).unwrap(); + let text = crate::tests_labels::painted_text(&mut app, show); + assert!(!text.contains("→A←") && !text.contains("37.5"), "{text}"); + app.session.prefs.show_east_asian_options = true; + let text = crate::tests_labels::painted_text(&mut app, show); + assert!(text.contains("→A←") && text.contains("37.5"), "{text}"); + // The characters differ: the field is blank. + app.run("text.setRangeStyle", json!({"id": id, "start": 3, "end": 12, "tsume": 62.5})).unwrap(); + let text = crate::tests_labels::painted_text(&mut app, show); + assert!(!text.contains("37.5") && !text.contains("62.5"), "{text}"); + app.run("text.setFormat", json!({"tsume": 62.5})).unwrap(); + use super::super::tests_appearance::{click, frame_events, text_rect}; + let ctx = egui::Context::default(); + let texts = frame_events(&ctx, &mut app, vec![], show); + click(&ctx, &mut app, text_rect(&texts, "62.5%").center(), show); + let key = |key, modifiers| egui::Event::Key { key, physical_key: None, pressed: true, repeat: false, modifiers }; + frame_events(&ctx, &mut app, vec![key(egui::Key::A, egui::Modifiers::COMMAND)], show); + frame_events(&ctx, &mut app, vec![egui::Event::Text("150".into())], show); + frame_events(&ctx, &mut app, vec![key(egui::Key::Enter, egui::Modifiers::NONE)], show); + frame_events(&ctx, &mut app, vec![], show); + let tsume = |app: &VectorcraftApp| match &app.session.active().unwrap().doc.node(NodeId(id)).unwrap().kind { + NodeKind::Text(t) => t.runs.iter().map(|r| r.style.tsume).collect::>(), + _ => panic!("text"), + }; + assert!(tsume(&app).iter().all(|v| *v == 100.0), "{:?}", tsume(&app)); +} + /// The panel menu's Show Font Height Options shows the Set Font Height Reference dropdown, /// which shows the selected type's reference and sets the one picked (one undo step); Hide /// Font Height Options hides it again. @@ -1444,6 +1479,7 @@ fn reset_panel_resets_every_attribute_but_the_font() { s.tcy_left_right != d.tcy_left_right, s.aki_left != d.aki_left, s.aki_right != d.aki_right, + s.tsume != d.tsume, s.standard_vertical_roman != d.standard_vertical_roman, s.no_break != d.no_break, s.char_direction != d.char_direction, @@ -1458,16 +1494,16 @@ fn reset_panel_resets_every_attribute_but_the_font() { "id": id, "tracking": 50, "leading": 30, "kerning": 20, "baselineShift": 3, "hScale": 120, "vScale": 90, "rotation": 15, "underline": true, "strikethrough": true, "allCaps": true, "smallCaps": true, "position": "superscript", "charAlign": "emBoxCenter", "proportionalMetrics": true, "tateChuYoko": true, "tcyUpDown": 2, "tcyLeftRight": -3, - "akiLeft": 250, "akiRight": 500, "standardVerticalRoman": true, "noBreak": true, "charDirection": "rightToLeft", "digits": "hindi", "fontHeightReference": "capHeight" + "akiLeft": 250, "akiRight": 500, "tsume": 50, "standardVerticalRoman": true, "noBreak": true, "charDirection": "rightToLeft", "digits": "hindi", "fontHeightReference": "capHeight" }), ) .unwrap(); }; let plain = runs(&app)[0].1.clone(); - assert_eq!(differs(&plain, &CharStyle::default()), [false; 24]); + assert_eq!(differs(&plain, &CharStyle::default()), [false; 25]); custom(&mut app); let styled = runs(&app)[0].1.clone(); - assert_eq!(differs(&styled, &CharStyle::default()), [true; 24]); + assert_eq!(differs(&styled, &CharStyle::default()), [true; 25]); app.run("select.set", json!({"ids": [id]})).unwrap(); reset_panel(&mut app); assert_eq!(runs(&app), [("One 12".to_string(), plain.clone())]); diff --git a/docs/mcp.md b/docs/mcp.md index f756bc8c97..016e7e26d0 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -8747,6 +8747,28 @@ Insert Aki values; save it with Preserve Editing to keep them editable. {"name":"run_command","arguments":{"command":"text.setRangeStyle","params":{"id":12,"start":0,"end":3,"akiRight":500}}} ``` +## Tsume + +`text.setFormat` and `text.setRangeStyle {tsume?}` set Tsume of the characters, a percentage that narrows the space +around them ([the help page](https://helpx.adobe.com/illustrator/using/formatting-asian-characters.html)). The range, +the default and how the space is measured are VectorCraft's own rules: the value is from 0 to 100, 0 by default and +saved only when not 0, and it is the share of the blank space on each side of a character's ink, inside the +character's own width, that is taken away. 50 halves that space, and 100 sets the ink against the characters before and +after it; the glyph keeps its shape. A character without ink, such as a space, keeps its width. A ligature or a letter +with its marks takes the value of its first character. Tracking, kerning and Insert Aki are added after it, and +justification keeps it. Tsume applies to horizontal type; vertical type keeps its spacing. Character styles carry it +(`charStyle.new`, `charStyle.setAttrs` and `charStyle.edit` refuse a value outside the range). A value that is not a +number is refused with `` `tsume` must be a number ``, and one outside the range with `` `tsume` must be from 0 to +100 ``. One undo step, and a call that gives the value the characters already have records none. With Show East Asian +Options on, the Character panel shows a Tsume field, blank where the selected characters differ, with 0, 25, 50, 75 and +100 in its menu, and a value typed past 100 sets 100; Reset Panel sets it to 0. Character Style Options shows it in +Advanced Character Formats. Live SVG text places each character Tsume moves, and type on a path with Tsume is written +as outlines. + +```json +{"name":"run_command","arguments":{"command":"text.setRangeStyle","params":{"id":12,"start":0,"end":3,"tsume":50}}} +``` + ## Composite Fonts A composite font sets each class of character in a font of its own @@ -9449,7 +9471,7 @@ attribute map in one undo step. `to` is trimmed and must be unique. The command while preserving local overrides; Undo/Redo restores the definition and its users. The `charStyleOptions` dialog exposes `name`, `font_family`, `font_style`, `size`, `leading`, `kerning`, `tracking`, `case`, `position`, `underline`, `strikethrough`, `charAlign`, `h_scale`, `v_scale`, `baseline_shift`, `tcyUpDown`, -`tcyLeftRight`, `rotation`, `akiLeft`, `akiRight`, `language`, `fill`, `fill_tint`, `stroke`, `stroke_tint`, `stroke_width`, `overprint_fill`, `overprint_stroke`, +`tcyLeftRight`, `rotation`, `akiLeft`, `akiRight`, `tsume`, `language`, `fill`, `fill_tint`, `stroke`, `stroke_tint`, `stroke_width`, `overprint_fill`, `overprint_stroke`, `ot_figure`, `ot_position`, `ot_liga`, `ot_calt`, `ot_dlig`, `ot_swsh`, `ot_salt`, `ot_titl`, `ot_ordn`, `ot_frac`, `ot_sets`, `section` (`general`, `basic`, `advanced`, `color` or `opentype`), and `preview`. An empty field leaves its attribute unset. `leading` and `kerning` also accept `Auto` to store an explicit automatic value. @@ -9457,7 +9479,8 @@ Advanced Character Formats holds `h_scale` and `v_scale` (percent, 1 to 10000), `baseline_shift` (points, -1296 to 1296), the Tate-chu-yoko Settings `tcyUpDown` and `tcyLeftRight` (points, -1296 to 1296, as `text.setRangeStyle` takes them; an explicit 0 stays distinct from unset), `rotation` (degrees, stored from -180 to 180), Insert Aki Left and Right `akiLeft` and `akiRight` (thousandths of an em, with the Character panel's 0, 250, 500 and 1000 in their menus; a value typed below 0 or past 1000 sets 0 or -1000) and `language` (a name or tag from Document +1000), Tsume `tsume` (percent, with the Character panel's 0, 25, 50, 75 and 100 in its menu; a value typed below 0 or +past 100 sets 0 or 100; an explicit 0 stays distinct from unset) and `language` (a name or tag from Document Setup's languages; English: USA stores the explicit default `null`, and a tag not in the list that the style already has is kept). Language sets the language the characters are shaped in; it does not change hyphenation or spelling. diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 296bd84b2b..ac9cad339a 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -1381,6 +1381,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur Character panel menu › Tate-chu-yoko Settings… (with the East Asian options; `ui.tateChuYokoSettings`, `text.setFormat` or `text.setRangeStyle {tcyUpDown, tcyLeftRight}`, in points, 0 by default) moves a marked tate-chu-yoko block in vertical type up by a positive Up/Down and right by a positive Left/Right ([format East Asian characters in vertical text](https://helpx.adobe.com/illustrator/desktop/design-with-text/edit-format-text/format-east-asian-characters-in-vertical-text.html), [formatting Asian characters](https://helpx.adobe.com/illustrator/using/formatting-asian-characters.html)); the block moves as one piece with its first character's values and keeps its one em down the column, on a vertical path it moves toward the path's start and to the path's left, automatic digit blocks do not move, Reset Panel sets both back to 0, and character styles and the native file keep them. A file whose character styles hold a Tate-chu-yoko Up/Down or Left/Right offset past the coordinate limit (`MAX_COORD`) either way opens with the nearer end, as the type's own offsets do; an offset within it stays as saved. Character panel › Insert Aki Left and Insert Aki Right (with the East Asian options; `text.setFormat` or `text.setRangeStyle {akiLeft, akiRight}`, thousandths of an em from 0 to 1000, 0 by default) add space before and after each selected character, measured as tracking is, and kept as set through justification ([the help page](https://helpx.adobe.com/illustrator/using/formatting-asian-characters.html)); before and after follow the reading order in vertical and right-to-left type, character styles carry both values (Character Style Options › Advanced Character Formats edits them), and a file holding a value outside the range opens with the nearer end. + Character panel › Tsume (with the East Asian options; `text.setFormat` or `text.setRangeStyle {tsume}`) narrows the space around each selected character ([the help page](https://helpx.adobe.com/illustrator/using/formatting-asian-characters.html)); by VectorCraft's own rule the value is a percentage from 0 to 100, 0 by default, of the blank space on each side of the character's ink that horizontal type takes away without changing the glyph's shape, with tracking, kerning and Insert Aki added after it. Character styles carry it (Character Style Options › Advanced Character Formats edits it), and a file holding a value outside the range opens with the nearer end. The Character panel's All Caps, Underline and Strikethrough buttons and its menu's All Caps, Underline, Strikethrough, Proportional Metrics, Tate-chu-yoko and No Break show off while only some of the selected characters have the attribute, and choosing one then turns it on for all of them, as `type.underline` (Cmd+Shift+U) does for underlining; the Character Alignment and Digits submenus check no item while the selected characters differ. `text.setFormat` refuses a value that is not true or false for its on/off attributes, as `text.setRangeStyle` does. - **Transparency:** opacity masks (clip/invert/disable/link), exported as SVG `` and PDF soft masks. The constant alpha soft mask that PDF and `.ai` export draw a CMYK page with transparency through is white, so applications that read every soft mask by its luminosity show the page too (#1359, part of #1286). Isolation mode and opacity mask editing exclude each other ([transparency and blending modes](https://web.archive.org/web/20230102045151/https://helpx.adobe.com/illustrator/using/transparency-blending-modes.html)): in isolation mode a click or Alt-click on the mask thumbnail does not edit the mask, and Make Mask is off for one object; while a mask is edited, Enter Isolation Mode and Object › Perspective › Edit Text are off, the Contextual Task Bar leaves out Isolate, and Quick Actions leave out Isolate Group. From 6c26233a63444920b06dd2b2a8bc69470beb4e5a Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Fri, 13 Nov 2026 22:02:56 -0800 Subject: [PATCH 05/11] Clipboard: quitting on Linux takes every window off screen before the clipboard wait Quitting can wait up to 27 s for the clipboard handles to let go before eframe closes the windows. winit cannot hide a native Wayland window, so the main window stayed on screen for that wait, and the X11 windows of floating panels and documents were not hidden at all. - A native Wayland main window is unmapped by attaching no buffer to its surface. The window and its connection stay open for the Wayland clipboard. An X11 main window, XWayland included, is hidden as before. - On Linux the X11 windows of floating panels and documents are recorded as eframe creates and drops them (viewport hooks), and quitting unmaps them through a second X11 connection before the wait. - The new module apps/vectorcraft/src/quit_windows.rs replaces app::hide_before_teardown, with tests of which windows are hidden or unmapped. portal_save's Wayland surface helper is shared with it. --- apps/vectorcraft/src/app.rs | 29 +-- apps/vectorcraft/src/main.rs | 10 +- apps/vectorcraft/src/portal_save.rs | 11 +- apps/vectorcraft/src/quit_windows.rs | 280 +++++++++++++++++++++++++++ apps/vectorcraft/src/tests.rs | 18 -- docs/development.md | 2 +- docs/target-app-parity.md | 3 +- 7 files changed, 299 insertions(+), 54 deletions(-) create mode 100644 apps/vectorcraft/src/quit_windows.rs diff --git a/apps/vectorcraft/src/app.rs b/apps/vectorcraft/src/app.rs index 126b671877..dddaf0a91e 100644 --- a/apps/vectorcraft/src/app.rs +++ b/apps/vectorcraft/src/app.rs @@ -108,37 +108,14 @@ impl eframe::App for App { self.app.start_pending_update(); #[cfg(target_os = "macos")] super::mac_cursor::release(); + // Dropping the app can wait for the clipboard handles to let go: the windows leave the + // screen first (`quit_windows`). #[cfg(not(any(target_os = "macos", windows)))] - hide_before_teardown(self.winit_window.as_deref()); + super::quit_windows::before_teardown(self.winit_window.as_deref()); end_before_teardown(self.frames); } } -/// The main window as [`hide_before_teardown`] sees it. -#[cfg(not(any(target_os = "macos", windows)))] -pub(crate) trait QuittingWindow { - fn hide(&self); -} - -#[cfg(not(any(target_os = "macos", windows)))] -impl QuittingWindow for winit::window::Window { - fn hide(&self) { - self.set_visible(false); - } -} - -/// On Linux and the BSDs, hide the main window before eframe drops the app. Dropping the app waits -/// up to [`vectorcraft_ui_egui::CLIPBOARD_PROBE_STOP_WAIT`] for the clipboard handles to let go, and -/// the window would stay on screen for that time. The window is hidden, not closed: the Wayland -/// clipboard reads text on the window's connection. winit hides X11 windows (XWayland included) and -/// cannot hide a Wayland window. -#[cfg(not(any(target_os = "macos", windows)))] -pub(crate) fn hide_before_teardown(window: Option<&W>) { - if let Some(window) = window { - window.hide(); - } -} - impl App { /// The frame's times for the hang report, and its last stage ([`hang_beacon::after_frame`]): a /// focused, shown window whose windows had input waits for the renderer and the next frame, diff --git a/apps/vectorcraft/src/main.rs b/apps/vectorcraft/src/main.rs index fdbec59bfc..68bc069e7e 100644 --- a/apps/vectorcraft/src/main.rs +++ b/apps/vectorcraft/src/main.rs @@ -60,6 +60,8 @@ mod portal_request; mod portal_save; mod prefs_dir; mod printing; +#[cfg(not(any(target_os = "macos", windows)))] +mod quit_windows; #[cfg(not(target_os = "macos"))] mod single_instance; #[cfg(windows)] @@ -90,8 +92,6 @@ mod titlebar; use app::bring_window_forward; use app::native_input_hook; use app::open_or_hold; -#[cfg(all(test, not(any(target_os = "macos", windows))))] -use app::{QuittingWindow, hide_before_teardown}; use host_services::services; #[cfg(all(test, feature = "wgpu"))] use prefs::{KEEP_UI_PREFS, decode_saved_prefs, keep_prefs_copy, prefs_bytes}; @@ -122,7 +122,7 @@ struct App { /// Keeps the custom title bar's window borderless (Windows, #1758). window_frame: Option, /// The main window; used to read and set maximized state the UI layer does not see on Linux (#3635), - /// and to hide the window on quit before the clipboard handles let go (`app::hide_before_teardown`). + /// and to take the window off screen on quit before the clipboard handles let go (`quit_windows`). winit_window: Option>, /// A saved or fitted geometry asked for maximize once the window is mapped (Linux). pending_maximize: bool, @@ -410,6 +410,10 @@ fn main() -> std::process::ExitCode { })); options }; + // Quitting takes the X11 windows of floating panels and documents off screen with the main + // window, so eframe's windows are recorded as it makes and drops them (`quit_windows`). + #[cfg(target_os = "linux")] + let options = eframe::NativeOptions { viewport_hooks: Some(std::sync::Arc::new(quit_windows::Hooks)), ..options }; #[cfg(feature = "wgpu")] let options = { let mut options = options; diff --git a/apps/vectorcraft/src/portal_save.rs b/apps/vectorcraft/src/portal_save.rs index 6d1a8734b2..65970c87f7 100644 --- a/apps/vectorcraft/src/portal_save.rs +++ b/apps/vectorcraft/src/portal_save.rs @@ -242,7 +242,7 @@ fn parent(window: &winit::window::Window) -> (String, Option<()>) { /// (), made as rfd's `window_identifier` /// makes it. #[cfg(target_os = "linux")] -mod wayland { +pub(crate) mod wayland { use wayland_client::globals::{GlobalListContents, registry_queue_init}; use wayland_client::protocol::wl_registry::{self, WlRegistry}; use wayland_client::protocol::wl_surface::WlSurface; @@ -308,9 +308,10 @@ mod wayland { } } - /// `window`'s Wayland connection and surface, from its raw handles. + /// `window`'s Wayland connection and surface, from its raw handles. The caller holds `window` + /// while it uses them (`quit_windows` unmaps the main window's surface through them). #[allow(unsafe_code)] - fn surface(window: &winit::window::Window) -> Option<(Connection, WlSurface)> { + pub(crate) fn surface(window: &winit::window::Window) -> Option<(Connection, WlSurface)> { use wayland_backend::sys::client::{Backend, ObjectId}; use winit::raw_window_handle::{HasDisplayHandle as _, HasWindowHandle as _, RawDisplayHandle, RawWindowHandle}; let (RawWindowHandle::Wayland(w), RawDisplayHandle::Wayland(d)) = @@ -319,8 +320,8 @@ mod wayland { return None; }; // SAFETY: the display is winit's open `wl_display`, which outlives `window` (the caller - // holds it until the export is dropped); a backend made from a foreign display does not - // close it when dropped. + // holds it while it uses the connection, and the export holds it until it is dropped); a + // backend made from a foreign display does not close it when dropped. let backend = unsafe { Backend::from_foreign_display(d.display.as_ptr().cast()) }; let conn = Connection::from_backend(backend); // SAFETY: the pointer is the window's live `wl_surface` proxy on that display. diff --git a/apps/vectorcraft/src/quit_windows.rs b/apps/vectorcraft/src/quit_windows.rs new file mode 100644 index 0000000000..36497f4f4f --- /dev/null +++ b/apps/vectorcraft/src/quit_windows.rs @@ -0,0 +1,280 @@ +//! Takes the app's windows off screen when it quits on Linux and the BSDs, before eframe drops the +//! app. Dropping the app waits up to [`vectorcraft_ui_egui::CLIPBOARD_PROBE_STOP_WAIT`] for the +//! clipboard handles to let go, and eframe closes the windows only after that wait. +//! +//! The main window is hidden on X11 (XWayland included). winit cannot hide a Wayland window, so on +//! Linux a Wayland main window is unmapped instead: its surface gets no buffer, and the window stays +//! open with the connection the Wayland clipboard copies and reads on. On Linux the X11 windows of +//! floating panels and documents are recorded as eframe creates and drops them ([`Hooks`]), and +//! quitting unmaps them through an X11 connection of its own. On native Wayland, floating panels and +//! documents are normally drawn inside the main window. + +#[cfg(target_os = "linux")] +use std::collections::HashMap; +#[cfg(target_os = "linux")] +use std::sync::{LazyLock, Mutex, MutexGuard, PoisonError}; + +#[cfg(target_os = "linux")] +use winit::raw_window_handle::{HasWindowHandle as _, RawWindowHandle}; + +/// The main window as quitting sees it. +pub(crate) trait QuittingWindow { + /// The window is a Wayland surface. + fn is_wayland(&self) -> bool; + /// Hides an X11 window. + fn hide(&self); + /// Takes a Wayland window off screen and keeps it open. + fn unmap(&self); +} + +impl QuittingWindow for winit::window::Window { + #[cfg(target_os = "linux")] + fn is_wayland(&self) -> bool { + matches!(self.window_handle().map(|h| h.as_raw()), Ok(RawWindowHandle::Wayland(_))) + } + + /// The BSDs have no Wayland unmap: their windows are hidden through winit. + #[cfg(not(target_os = "linux"))] + fn is_wayland(&self) -> bool { + false + } + + fn hide(&self) { + self.set_visible(false); + } + + #[cfg(target_os = "linux")] + fn unmap(&self) { + unmap_wayland(self); + } + + #[cfg(not(target_os = "linux"))] + fn unmap(&self) { + self.set_visible(false); + } +} + +/// Takes the main window and the floating windows `x11` lists off screen: a Wayland main window is +/// unmapped and any other main window hidden, and `unmap_x11` unmaps the floating windows. +pub(crate) fn off_screen(main: Option<&W>, x11: &[u32], unmap_x11: impl FnOnce(&[u32])) { + if let Some(window) = main { + if window.is_wayland() { + window.unmap(); + } else { + window.hide(); + } + } + if !x11.is_empty() { + unmap_x11(x11); + } +} + +/// [`off_screen`] with the running app's main window and the floating windows [`Hooks`] recorded. +/// Runs on quit, before eframe drops the app. +pub(crate) fn before_teardown(main: Option<&winit::window::Window>) { + #[cfg(target_os = "linux")] + off_screen(main, &CHILD_WINDOWS.x11(), unmap_x11); + #[cfg(not(target_os = "linux"))] + off_screen(main, &[], |_| {}); +} + +/// The X11 windows of floating panels and documents, by viewport, while eframe holds them. +#[cfg(target_os = "linux")] +#[derive(Default)] +pub(crate) struct ChildWindows(Mutex>); + +#[cfg(target_os = "linux")] +impl ChildWindows { + /// eframe made `viewport`'s window, with `handle`. The main window, and windows of other window + /// systems, are not recorded; a window made again for the same viewport replaces its record. + pub(crate) fn created(&self, viewport: egui::ViewportId, handle: Option) { + let mut windows = self.lock(); + match handle.and_then(x11_window) { + Some(id) if viewport != egui::ViewportId::ROOT => windows.insert(viewport, id), + _ => windows.remove(&viewport), + }; + } + + /// eframe dropped `viewport`'s window. + pub(crate) fn destroyed(&self, viewport: egui::ViewportId) { + self.lock().remove(&viewport); + } + + /// The recorded windows, in id order. + pub(crate) fn x11(&self) -> Vec { + let mut ids: Vec = self.lock().values().copied().collect(); + ids.sort_unstable(); + ids.dedup(); + ids + } + + fn lock(&self) -> MutexGuard<'_, HashMap> { + self.0.lock().unwrap_or_else(PoisonError::into_inner) + } +} + +/// The X11 window id of `handle`, None for another window system. +#[cfg(target_os = "linux")] +fn x11_window(handle: RawWindowHandle) -> Option { + match handle { + RawWindowHandle::Xlib(h) => u32::try_from(h.window).ok(), + RawWindowHandle::Xcb(h) => Some(h.window.get()), + _ => None, + } +} + +/// The running app's floating windows, which [`Hooks`] records. +#[cfg(target_os = "linux")] +static CHILD_WINDOWS: LazyLock = LazyLock::new(ChildWindows::default); + +/// Records the windows eframe creates and drops in [`CHILD_WINDOWS`]; set as +/// [`eframe::NativeOptions::viewport_hooks`]. +#[cfg(target_os = "linux")] +pub(crate) struct Hooks; + +#[cfg(target_os = "linux")] +impl eframe::ViewportHooks for Hooks { + fn window_created(&self, viewport: egui::ViewportId, window: &winit::window::Window) { + CHILD_WINDOWS.created(viewport, window.window_handle().ok().map(|h| h.as_raw())); + } + + fn window_destroyed(&self, viewport: egui::ViewportId) { + CHILD_WINDOWS.destroyed(viewport); + } +} + +/// Unmaps the X11 `windows` through a connection of its own, and waits until the server has done so. +/// An id whose window is gone gets an error, which is ignored. +#[cfg(target_os = "linux")] +fn unmap_x11(windows: &[u32]) { + use x11rb::protocol::xproto::ConnectionExt as _; + let conn = match x11rb::connect(None) { + Ok((conn, _)) => conn, + Err(e) => { + log::warn!("couldn't reach the X server to hide the floating windows on quit: {e}"); + return; + } + }; + for &window in windows { + if let Ok(cookie) = conn.unmap_window(window) { + cookie.ignore_error(); + } + } + // The server answers once it has handled the requests before this one. + if let Ok(cookie) = conn.get_input_focus() { + let _ = cookie.reply(); + } +} + +/// Attaches no buffer to `window`'s Wayland surface and commits it, which unmaps the window: the +/// compositor stops showing it, and the window and its connection stay open. +#[cfg(target_os = "linux")] +fn unmap_wayland(window: &winit::window::Window) { + let Some((conn, surface)) = super::portal_save::wayland::surface(window) else { return }; + surface.attach(None, 0, 0); + surface.commit(); + if let Err(e) = conn.flush() { + log::warn!("couldn't reach the compositor to hide the window on quit: {e}"); + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::cell::RefCell; + + /// A main window that records what quitting did to it. + struct Window<'a> { + wayland: bool, + done: &'a RefCell>, + } + + impl QuittingWindow for Window<'_> { + fn is_wayland(&self) -> bool { + self.wayland + } + fn hide(&self) { + self.done.borrow_mut().push("hide".into()); + } + fn unmap(&self) { + self.done.borrow_mut().push("unmap".into()); + } + } + + fn quit(wayland: Option, x11: &[u32]) -> Vec { + let done = RefCell::new(Vec::new()); + let window = wayland.map(|wayland| Window { wayland, done: &done }); + off_screen(window.as_ref(), x11, |ids| done.borrow_mut().push(format!("unmap x11 {ids:?}"))); + done.into_inner() + } + + /// winit cannot hide a Wayland window: quitting unmaps it, and never only asks winit to hide it. + #[test] + fn quitting_unmaps_a_wayland_main_window() { + assert_eq!(quit(Some(true), &[]), ["unmap"]); + } + + /// An X11 main window (XWayland included) is hidden, and the floating panels' and documents' X11 + /// windows are unmapped with it. + #[test] + fn quitting_hides_an_x11_main_window_and_its_floating_windows() { + assert_eq!(quit(Some(false), &[]), ["hide"]); + assert_eq!(quit(Some(false), &[7, 9]), ["hide", "unmap x11 [7, 9]"]); + } + + /// Without a main window (none was made), the floating windows still go, and with none at all + /// nothing is done. + #[test] + fn quitting_without_a_main_window() { + assert_eq!(quit(None, &[3]), ["unmap x11 [3]"]); + assert!(quit(None, &[]).is_empty()); + } + + #[cfg(target_os = "linux")] + fn xlib(id: std::os::raw::c_ulong) -> Option { + Some(RawWindowHandle::Xlib(winit::raw_window_handle::XlibWindowHandle::new(id))) + } + + #[cfg(target_os = "linux")] + fn viewport(name: &str) -> egui::ViewportId { + egui::ViewportId::from_hash_of(name) + } + + /// The floating windows eframe made are recorded until it drops them; the main window, which + /// quitting reaches through winit, is not. + #[cfg(target_os = "linux")] + #[test] + fn floating_x11_windows_are_recorded_while_they_exist() { + let windows = ChildWindows::default(); + windows.created(egui::ViewportId::ROOT, xlib(1)); + windows.created(viewport("Layers"), xlib(9)); + let xcb = winit::raw_window_handle::XcbWindowHandle::new(std::num::NonZeroU32::new(5).expect("not zero")); + windows.created(viewport("Untitled-2"), Some(RawWindowHandle::Xcb(xcb))); + windows.created(viewport("Color"), xlib(7)); + assert_eq!(windows.x11(), [5, 7, 9]); + windows.destroyed(viewport("Color")); + assert_eq!(windows.x11(), [5, 9]); + // A viewport that eframe has already dropped, or never made, is no error. + windows.destroyed(viewport("Color")); + windows.destroyed(viewport("Swatches")); + assert_eq!(windows.x11(), [5, 9]); + } + + /// A window made again for a viewport replaces its record, and one of another window system, or + /// one whose handle could not be read, removes it. + #[cfg(target_os = "linux")] + #[test] + fn a_window_made_again_replaces_its_record() { + let windows = ChildWindows::default(); + windows.created(viewport("Layers"), xlib(9)); + windows.created(viewport("Layers"), xlib(11)); + assert_eq!(windows.x11(), [11]); + let surface = std::ptr::NonNull::::dangling(); + let wayland = winit::raw_window_handle::WaylandWindowHandle::new(surface); + windows.created(viewport("Layers"), Some(RawWindowHandle::Wayland(wayland))); + assert!(windows.x11().is_empty()); + windows.created(viewport("Layers"), xlib(11)); + windows.created(viewport("Layers"), None); + assert!(windows.x11().is_empty()); + } +} diff --git a/apps/vectorcraft/src/tests.rs b/apps/vectorcraft/src/tests.rs index 99ca880bc9..ba18e58802 100644 --- a/apps/vectorcraft/src/tests.rs +++ b/apps/vectorcraft/src/tests.rs @@ -487,21 +487,3 @@ fn the_linux_desktop_entry_opens_every_readable_format() { } } } - -/// On Linux and the BSDs, quitting hides the main window before eframe drops the app, whose -/// clipboard handles may take up to `CLIPBOARD_PROBE_STOP_WAIT` to let go. -#[cfg(not(any(target_os = "macos", windows)))] -#[test] -fn quitting_hides_the_window_before_the_clipboards_let_go() { - struct Window(std::cell::Cell); - impl QuittingWindow for Window { - fn hide(&self) { - self.0.set(self.0.get() + 1); - } - } - let window = Window(std::cell::Cell::new(0)); - hide_before_teardown(Some(&window)); - assert_eq!(window.0.get(), 1, "the window was not hidden"); - // Without a window (none was made), there is nothing to hide. - hide_before_teardown::(None); -} diff --git a/docs/development.md b/docs/development.md index 82ffbd4a5a..3f8d9ccec6 100644 --- a/docs/development.md +++ b/docs/development.md @@ -126,7 +126,7 @@ On GNOME/Wayland, enable **Preferences › User Interface › System Title Bar** The window runs natively on Wayland (eframe's `wayland` feature) and on X11. On machines with two GPUs it renders on the one the desktop runs on unless Preferences say otherwise; see [Desktop graphics processor](#desktop-graphics-processor) (#502). The system clipboard (`apps/vectorcraft/src/clipboard.rs`) is arboard with its `wayland-data-control` feature: - **Clipboard:** under Wayland arboard talks to the compositor through the data-control protocol (wlroots compositors such as Sway and Hyprland, KDE Plasma), so bitmaps (`image/png`), SVG markup and text copied in any app paste (#398). Where the compositor lacks the protocol (GNOME's Mutter, [arboard#223](https://github.com/1Password/arboard/issues/223)), arboard falls back to the X11 clipboard through XWayland, which only holds what X11 apps copied. Where there is no X11 either (the Flatpak on a Wayland session: `--socket=fallback-x11` gives it none), Copy and Paste use the compositor's core data-device protocol (`wl_data_device`) on the window's own connection, and only while the window has focus. One Copy offers the PNG as `image/png`, the SVG as `image/svg+xml` (with Include SVG Code on) and the text as `text/plain;charset=utf-8`, `UTF8_STRING` and `text/plain`; a format with no data is not offered. Paste reads `image/svg+xml`, then text, then `image/png`, and blank text or text that only names a picture gives way to the PNG; the Paste menu item is enabled by the types offered, without reading any data. Whether the clipboard still holds VectorCraft's Copy is told by its data source, which the compositor cancels when another app copies. A read takes 64 MiB at most and fails beyond that. Reads run on a worker thread: text waits 2 s at most on the UI thread, SVG and PNG 10 s on the paste's background thread. An owner that never sends its data costs one read its wait, and later reads return at once until it answers. When a text read gets no answer in its 2 s, the paste waits on its background thread for that read's answer, up to 10 s after the read was sent, then reads the clipboard again unless it holds VectorCraft's Copy. Another app's paste of a Copy is sent on a thread of its own, 8 at once at most; a paste beyond them gets nothing. A clipboard neither backend opens is tried again after 1 s, doubling to a minute (a Copy or Paste of the user's at most once a second), and logged once when it goes and once when it comes back: before, the background Paste check reconnected and logged a warning every 250 ms. egui's own text paste into fields goes through smithay-clipboard and works either way. -- **Clipboard owners that never answer (#510):** reading the clipboard waits on the app that owns it: arboard gives up on an X11 owner after 4 s for each format it tries, and a Wayland read waits for the owner to send its data. So on Linux the check that enables the Paste menu items runs on a background thread with a second clipboard handle (`crates/ui-egui/src/clipboard_probe.rs`, installed as `Services::clipboard_probe`), only while nothing is copied in the app and a document is open, and the window keeps drawing. Every arboard read on Linux and the BSDs, including the UI thread's (a paste, and a Copy or Cut checking what the clipboard held), runs on a worker thread with a handle of its own and waits 2 s at most: an owner that never sends its data costs one read that wait, and later reads fail at once until it answers. A paste's bitmap read waits up to 10 s on the paste's background thread, so the UI thread does not wait for it. When one of a paste's reads gets no answer in its 2 s, or a paste follows a read that has not been answered yet, such as the paste's own check whether the clipboard still holds what VectorCraft copied, the paste waits on that thread for that read's answer, up to 10 s after the read was sent, and pastes what it holds. When the answer is what VectorCraft copied, holds nothing to paste, or does not come, the paste takes the internal clipboard. On X11, quitting within 25 s of a read that got no answer waits for that read to end, so the app's own handle is the last one and hands what it copied to the clipboard manager when one runs. That includes a read of the Paste menu check's handle: quitting waits up to 27 s for the background thread to drop it before the app drops its own, and quits after that wait even when the handle has not been dropped. The app hides its main window before these waits (`hide_before_teardown` in `apps/vectorcraft/src/app.rs`), so an X11 window, an XWayland one included, closes at once; winit cannot hide a native Wayland window, which stays on screen until the waits end. Windows and macOS keep the check on the UI thread: Windows only asks which formats the clipboard holds, macOS asks the pasteboard server. +- **Clipboard owners that never answer (#510):** reading the clipboard waits on the app that owns it: arboard gives up on an X11 owner after 4 s for each format it tries, and a Wayland read waits for the owner to send its data. So on Linux the check that enables the Paste menu items runs on a background thread with a second clipboard handle (`crates/ui-egui/src/clipboard_probe.rs`, installed as `Services::clipboard_probe`), only while nothing is copied in the app and a document is open, and the window keeps drawing. Every arboard read on Linux and the BSDs, including the UI thread's (a paste, and a Copy or Cut checking what the clipboard held), runs on a worker thread with a handle of its own and waits 2 s at most: an owner that never sends its data costs one read that wait, and later reads fail at once until it answers. A paste's bitmap read waits up to 10 s on the paste's background thread, so the UI thread does not wait for it. When one of a paste's reads gets no answer in its 2 s, or a paste follows a read that has not been answered yet, such as the paste's own check whether the clipboard still holds what VectorCraft copied, the paste waits on that thread for that read's answer, up to 10 s after the read was sent, and pastes what it holds. When the answer is what VectorCraft copied, holds nothing to paste, or does not come, the paste takes the internal clipboard. On X11, quitting within 25 s of a read that got no answer waits for that read to end, so the app's own handle is the last one and hands what it copied to the clipboard manager when one runs. That includes a read of the Paste menu check's handle: quitting waits up to 27 s for the background thread to drop it before the app drops its own, and quits after that wait even when the handle has not been dropped. The app takes its windows off screen before these waits (`apps/vectorcraft/src/quit_windows.rs`). An X11 main window, an XWayland one included, is hidden. winit cannot hide a native Wayland window, so on Linux the app attaches no buffer to the main window's surface, which unmaps it while the window and its connection stay open for the Wayland clipboard. On Linux the X11 windows of floating panels and documents are unmapped through a second X11 connection. On native Wayland, floating panels and documents are normally drawn inside the main window; on the BSDs a native Wayland main window stays on screen until the waits end. Windows and macOS keep the check on the UI thread: Windows only asks which formats the clipboard holds, macOS asks the pasteboard server. - **Copied files:** files copied in a file manager (`text/uri-list`; `CF_HDROP` on Windows, file URLs on macOS) paste as the first one that is art (SVG, PDF, EPS, EMF/WMF or a bitmap; a `.ai` or `.ait` file from the editing data it holds, else as the PDF or PostScript it holds), before the clipboard's other formats (which include the files' paths as text). - **Dropping files on the window does not work under Wayland:** winit 0.30, which eframe 0.37 runs on, has no Wayland drag and drop ([winit#1881](https://github.com/rust-windowing/winit/issues/1881), added in winit 0.31; [egui#1563](https://github.com/emilk/egui/issues/1563)), so no `dropped_files` arrive. Until eframe moves to winit 0.31: copy the files in the file manager and paste them, use File › Place or Open, or open the window through XWayland, where drops work: set **Preferences › Performance › Linux Display Server** to **X11 (XWayland)** and restart (#1151), or start the app with `WAYLAND_DISPLAY= vectorcraft`. The preference (`linuxDisplayServer`, read at startup in `apps/vectorcraft/src/main.rs`, shown on Linux only) has winit open the window on X11 (`EventLoopBuilderExtX11::with_x11`) when the session is Wayland, `DISPLAY` is set and the X11 libraries winit loads are installed (`apps/vectorcraft/src/xwayland.rs` reads `ldconfig -p` and the usual library directories); otherwise the window opens on Wayland and the log says why. Unlike clearing `WAYLAND_DISPLAY`, it leaves the clipboard on the compositor's own. - **Pens and pen displays under Wayland (#491, #2028):** winit 0.30 doesn't bind the Wayland tablet protocol (`zwp_tablet_v2`; tablet input arrives in winit 0.31, [winit#4318](https://github.com/rust-windowing/winit/pull/4318), [egui#7731](https://github.com/emilk/egui/pull/7731)). Since Plasma 6.3, KWin no longer turns tablet input into pointer input for such apps ([kwin!6336](https://invent.kde.org/plasma/kwin/-/merge_requests/6336), commit [25c65abd](https://invent.kde.org/plasma/kwin/-/commit/25c65abddd95eb87fb892a8594faccbf509ff62f)), and GNOME's Mutter does not turn it into pointer input either (that commit's message): on KWin the pen moves the compositor's cursor over the window, but the app gets no motion or presses and shows none of its own cursors. Until eframe moves to winit 0.31, the app runs under XWayland, which supports the tablet protocol and hands the pen to X11 apps as a pointer: Preferences › Performance › Linux Display Server's Automatic (the default) and X11 (XWayland) When a Pen Is Attached (`x11WithPen`) open the window through XWayland when `/proc/bus/input/devices` lists a pen as the app starts, a device with the `BTN_TOOL_PEN` key and the `ABS_X` and `ABS_Y` axes, and log `pen found: opening the window through XWayland`, as PhotoCraft does (#4967; `VECTORCRAFT_NATIVE_WAYLAND=1` keeps the window on Wayland). For a pen attached after the app started, either run the app under XWayland (Preferences › Performance › Linux Display Server › X11 (XWayland); `WAYLAND_DISPLAY= vectorcraft`, or `env -u WAYLAND_DISPLAY vectorcraft`; for a desktop launcher, put `env WAYLAND_DISPLAY=` before the command in its `Exec=` line), or turn KWin's deprecated emulation back on for the whole session by setting `KWIN_WAYLAND_EMULATE_TABLET=1` in KWin's environment (for example in a file under `~/.config/environment.d/` or in `/etc/environment`) and logging in again. The pen then works as a mouse. Under XWayland the app also reads the pen's pressure (next item); with KWin's emulation on native Wayland the pen has no pressure. Other compositors that don't emulate a pointer for tablets behave the same way. diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 296bd84b2b..38ca382fa0 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -1742,7 +1742,8 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur - `file.print`, `print.preview` and `print.setup` take a print preset by name (`preset`: `[Default]` or a saved preset, in any case): its settings are used instead of the document's, with `settings` over them, `print.setup` saves them with the document, and an unknown name is refused with the list of presets. - Print's Copies per Sheet (`copiesPerSheet`, `gutter`; off by default) prints as many copies of each artboard, with its bleed and marks, as fit the imageable area, across then down, as one grid placed like a single copy; the preview shows them, and an artboard that does not fit prints one copy per sheet with a warning (#1283). - Place and the Links panel, Package (placed `.vectorcraft` documents packaged with their own linked files and fonts, relinked to the copies: #777), File Info, Print with print presets, PostScript output and print tiling, slices and Save for Web, Export for Screens and Asset Export, CSS Properties, PNG/PDF/SVG/text clipboard flavours; files copied in a file manager paste as art (SVG, PDF, EMF/WMF, bitmaps) on every desktop platform, and Linux/Wayland reads the compositor's clipboard (#398); on Linux the Paste menu checks the clipboard off the UI thread, so an X11 owner that never answers can't freeze the window (#510); screenshots and other copied pictures paste as embedded images at the view centre everywhere, the web included (#457). - - On X11, quitting while the Paste menu's background check waits on a clipboard owner that never answers waits up to 27 s for that check to drop its clipboard handle, so the app's own handle is the last one dropped when the check drops its handle within that wait, and the app quits after the wait when it has not; the window is hidden before that wait, so an X11 window closes at once (`crates/ui-egui/src/tests/sysclip_probe.rs`, `apps/vectorcraft/src/clipboard.rs`, `apps/vectorcraft/src/tests.rs`). + - On X11, quitting while the Paste menu's background check waits on a clipboard owner that never answers waits up to 27 s for that check to drop its clipboard handle, so the app's own handle is the last one dropped when the check drops its handle within that wait, and the app quits after the wait when it has not; the window is hidden before that wait, so an X11 window closes at once (`crates/ui-egui/src/tests/sysclip_probe.rs`, `apps/vectorcraft/src/clipboard.rs`, `apps/vectorcraft/src/quit_windows.rs`). + - On Linux, quitting takes every window off screen before that wait: a native Wayland main window is unmapped (its surface gets no buffer) and stays open with the connection the Wayland clipboard uses, and the X11 windows of floating panels and documents are unmapped with the hidden main window (`apps/vectorcraft/src/quit_windows.rs`). - File › Place reads RTF files as area type with their formatting: each run's font, size, bold and italic, underline, strikethrough, caps, superscript or subscript, baseline shift and color, and each paragraph's alignment, indents, space before and after and direction. Table cells are separated by tabs; hidden text, pictures, headers, footers and notes are left out (`file.place`, `crates/engine/src/tests/textimport.rs`). - File › Place reads Word `.docx` documents as area type with the same formatting, taken from the document defaults, the paragraph and character styles (their toggle properties reversing each other) and direct formatting, the theme's fonts included. Table rows are paragraphs with their cells separated by tabs; hidden and deleted text, field instructions, pictures, headers, footers and notes are left out (`file.place`, `crates/engine/src/tests/textimport.rs`). - A Word paragraph justified with a low, medium or high kashida length places justified with Insert Kashidas set to Short, Medium or Long (`file.place`, `crates/engine/src/tests/textimport.rs`). From 5a4020a6c040a69c74cbeda5ba7db7370fdf64d1 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Fri, 13 Nov 2026 21:42:53 -0800 Subject: [PATCH 06/11] Graphs: graphic styles leave a design's art in a series unlinked graphicStyle.new linked the object it was made from, the art of a column or marker design in a graph series included, and graphicStyle.redefine gave the new look to every object still linked to the style. Redefining the style from another object then restyled that art, which the graph draws again from the design. - graphicStyle.new makes a style from the art without linking it. - graphicStyle.redefine leaves the art's look as it was and unlinks it, as an edited object is, whether the art is a linked object (as in a document saved before this change) or the source. Docs: docs/mcp.md, the params text of graphicStyle.new and graphicStyle.redefine. --- crates/engine/src/cmd/graph/tests.rs | 83 ++++++++++++++++++++++++++++ crates/engine/src/cmd/style.rs | 19 ++++--- docs/mcp.md | 4 +- 3 files changed, 98 insertions(+), 8 deletions(-) diff --git a/crates/engine/src/cmd/graph/tests.rs b/crates/engine/src/cmd/graph/tests.rs index c997e803d5..a8306278ab 100644 --- a/crates/engine/src/cmd/graph/tests.rs +++ b/crates/engine/src/cmd/graph/tests.rs @@ -508,6 +508,89 @@ fn paint_targets_leave_a_graph_designs_copy_out() { assert_eq!(super::paint_targets(d, &[copy, art, mark]), [mark]); } +/// The art of a marker design in series 1 of a line graph: the design's copy and a path inside it. +fn marker_design_art() -> (Session, NodeId, NodeId) { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 800, "height": 800})).unwrap(); + save_design(&mut s, "Pill"); + let id = graph(&mut s, "line"); + s.execute("graph.marker", &json!({"id": id.0, "seriesIndexes": [1], "design": "Pill"})).unwrap(); + let marker = series(s.doc().unwrap().doc.node(id).unwrap(), 1).children().unwrap()[1].clone(); + assert!(matches!(marker.kind, NodeKind::Group { .. }), "the design's art"); + let mut art = vec![]; + marker.walk(&mut |m| { + if matches!(m.kind, NodeKind::Path { .. }) { + art.push(m.id); + } + }); + (s, marker.id, art[0]) +} + +/// The ids `graphicStyle.list` reports as linked to style `name`. +fn linked_to(s: &mut Session, name: &str) -> Vec { + let list = s.execute("graphicStyle.list", &json!({})).unwrap(); + let style = list["styles"].as_array().unwrap().iter().find(|g| g["name"] == name).unwrap().clone(); + let mut ids: Vec = style["linked"].as_array().unwrap().iter().map(|v| v.as_u64().unwrap()).collect(); + ids.sort_unstable(); + ids +} + +/// Graphic styles and the art of a column or marker design in a series (the design's copy and a path inside it): +/// `graphicStyle.new` makes a style from the art without linking it; `graphicStyle.redefine` leaves the art's look as +/// it was and unlinks it, whether the art is linked (as in a document saved before) or the source, while the other +/// linked objects follow the new look. +#[test] +fn graphic_styles_leave_a_graph_designs_art_unlinked() { + let make = |marker: bool| { + if marker { + marker_design_art() + } else { + let (s, _, copy, art, _) = design_art(); + (s, copy, art) + } + }; + let rows = |s: &Session, id: NodeId| s.doc().unwrap().doc.node(id).unwrap().appearance.clone(); + let link = |s: &Session, id: NodeId| s.doc().unwrap().doc.node(id).unwrap().extras().graphic_style; + let link_directly = |s: &mut Session, ids: &[NodeId], style: u32| { + let d = std::sync::Arc::make_mut(&mut s.doc_mut().unwrap().doc); + for id in ids { + d.node_mut(*id).unwrap().extras_mut().graphic_style = Some(style); + } + }; + let rect = |s: &mut Session| { + NodeId(s.execute("shape.rectangle", &json!({"x": 700, "y": 700, "width": 10, "height": 10})).unwrap()["id"].as_u64().unwrap()) + }; + for marker in [false, true] { + for source in [0, 1] { + let at = format!("marker {marker}, source {source}"); + // New: the style is made from the art, which stays unlinked. + let (mut s, copy, art) = make(marker); + let source = [copy, art][source]; + s.execute("graphicStyle.new", &json!({"id": source.0, "name": "Art"})).unwrap(); + assert_eq!(link(&s, source), None, "{at}"); + assert_eq!(linked_to(&mut s, "Art"), Vec::::new(), "{at}"); + // Redefine from another object: linked art keeps its look and is unlinked; a linked rectangle follows. + let follower = rect(&mut s); + s.execute("graphicStyle.apply", &json!({"name": "Art", "ids": [follower.0]})).unwrap(); + let style = link(&s, follower).unwrap(); + link_directly(&mut s, &[copy, art], style); + let (copy_was, art_was) = (rows(&s, copy), rows(&s, art)); + let (blue_rect, blue) = blue_style(&mut s, "Blue", false); + s.execute("graphicStyle.redefine", &json!({"name": "Art", "id": blue_rect.0})).unwrap(); + assert_eq!((rows(&s, copy), rows(&s, art)), (copy_was.clone(), art_was.clone()), "{at}"); + assert_eq!((link(&s, copy), link(&s, art)), (None, None), "{at}"); + assert_eq!(rows(&s, follower).fill_paint(), blue, "{at}"); + assert_eq!(linked_to(&mut s, "Art"), [follower.0.min(blue_rect.0), follower.0.max(blue_rect.0)], "{at}"); + // Redefine from the art itself: the art is unlinked and the linked rectangle takes the art's look. + link_directly(&mut s, &[copy, art], style); + s.execute("graphicStyle.redefine", &json!({"name": "Art", "id": source.0})).unwrap(); + assert_eq!((rows(&s, copy), rows(&s, art)), (copy_was, art_was), "{at}"); + assert_eq!((link(&s, copy), link(&s, art)), (None, None), "{at}"); + assert_eq!(linked_to(&mut s, "Art"), [follower.0.min(blue_rect.0), follower.0.max(blue_rect.0)], "{at}"); + } + } +} + /// The art of a marker design in a series is refused too. #[test] fn effect_commands_refuse_a_marker_designs_art() { diff --git a/crates/engine/src/cmd/style.rs b/crates/engine/src/cmd/style.rs index 370664f795..dfaf414bf5 100644 --- a/crates/engine/src/cmd/style.rs +++ b/crates/engine/src/cmd/style.rs @@ -40,7 +40,7 @@ pub fn specs() -> Vec { "New Graphic Style", ["Window", "Graphic Styles"], None, - "{name?, id?} a style from object `id` or the first selected object: its appearance, opacity, blend mode, isolate and knockout (a group without fills or strokes of its own lends its topmost object's, type its characters'; a graph series group with no fill, stroke, effect, opacity, blend mode, isolation, knockout or style link of its own stands for the topmost path directly in it; placed gradients are kept relative to its bounds); links the object. Names are made unique → {name}", + "{name?, id?} a style from object `id` or the first selected object: its appearance, opacity, blend mode, isolate and knockout (a group without fills or strokes of its own lends its topmost object's, type its characters'; a graph series group with no fill, stroke, effect, opacity, blend mode, isolation, knockout or style link of its own stands for the topmost path directly in it; placed gradients are kept relative to its bounds); links the object, unless it is the art of a graph design in a series. Names are made unique → {name}", has_doc, style_new ), @@ -68,7 +68,7 @@ pub fn specs() -> Vec { "Redefine Graphic Style", ["Window", "Appearance"], None, - "{name?, id?} replace the style (default: the one the object was last linked to) with the look of object `id` or the first selected object (a graph series group with no fill, stroke, effect, opacity, blend mode, isolation, knockout or style link of its own: the topmost path directly in it); the objects still linked to it update, and that object becomes linked. A linked graph series group passes the style to the paths directly in it, other than the source and paths linked to the style whose look was edited, and is left cleared (as appearance.clear) and unlinked. On a linked path directly in a graph series, the graph also stores the paint, weight, opacity and blend mode of the topmost visible fill and stroke and the object's opacity and blend mode (exceptions: docs/mcp.md); on a graph label, the graph stores the label's topmost visible fill and stroke rows and object opacity and blend mode for every label of its kind → {name}", + "{name?, id?} replace the style (default: the one the object was last linked to) with the look of object `id` or the first selected object (a graph series group with no fill, stroke, effect, opacity, blend mode, isolation, knockout or style link of its own: the topmost path directly in it); the objects still linked to it update, and that object becomes linked. The art of a graph design in a series keeps its look and is unlinked, as the source or as a linked object. A linked graph series group passes the style to the paths directly in it, other than the source and paths linked to the style whose look was edited, and is left cleared (as appearance.clear) and unlinked. On a linked path directly in a graph series, the graph also stores the paint, weight, opacity and blend mode of the topmost visible fill and stroke and the object's opacity and blend mode (exceptions: docs/mcp.md); on a graph label, the graph stores the label's topmost visible fill and stroke rows and object opacity and blend mode for every label of its kind → {name}", has_doc, style_redefine ), @@ -392,8 +392,10 @@ fn style_new(s: &mut Session, p: &Value) -> Result { None => d.new_graphic_style_name(), }; let g = GraphicStyle { id: d.next_graphic_style_id(), ..GraphicStyle::of(name.clone(), captured(n), n) }; + // The art of a graph design in a series is not linked: the graph draws it again from the design. + let link = (!super::graph::is_design_art(d, src)).then_some(g.id); s.edit("New Graphic Style", |d, _| { - set_link(d, &[src], Some(g.id)); + set_link(d, &[src], link); d.graphic_styles.push(g); Ok(()) })?; @@ -491,10 +493,12 @@ fn style_redefine(s: &mut Session, p: &Value) -> Result { s.edit_named("Redefine Graphic Style “{name}”", &name, false, |d, _| { let id = d.graphic_style_id(i); let old = std::mem::replace(&mut d.graphic_styles[i], GraphicStyle { name: name.clone(), id, ..look }); - // Objects still linked follow the new definition; edited ones are unlinked (they keep - // their look), and the source keeps its own. + // Objects still linked follow the new definition; edited ones are unlinked (they keep their look), and the + // source keeps its own. The art of a graph design in a series is unlinked and keeps its look too, as the + // source or as a linked object: the graph draws it again from the design. let linked = objects_where(d, |n| n.extras().graphic_style == Some(id) && n.id != src); - let (follow, edited): (Vec, Vec) = linked.into_iter().partition(|nid| d.node(*nid).is_some_and(|n| in_sync(n, &old))); + let (follow, edited): (Vec, Vec) = + linked.into_iter().partition(|nid| !super::graph::is_design_art(d, *nid) && d.node(*nid).is_some_and(|n| in_sync(n, &old))); set_link(d, &edited, None); // A linked graph series group passes the style to the paths directly inside it, so the graph stores it, and // goes back to the empty appearance the graph draws it with. The source and the edited paths keep their look. @@ -509,7 +513,8 @@ fn style_redefine(s: &mut Session, p: &Value) -> Result { } super::appearance::capture_series_writes(&before, d, &follow_marks, (true, true), (true, true), false, true); super::graph::capture_label_rows(d, &follow_marks); - set_link(d, &[src], Some(id)); + let link = (!super::graph::is_design_art(d, src)).then_some(id); + set_link(d, &[src], link); Ok(()) })?; Ok(json!({ "name": name })) diff --git a/docs/mcp.md b/docs/mcp.md index f756bc8c97..829fa598d1 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -3649,7 +3649,9 @@ A graphic style holds an appearance (fills, strokes, effects) plus opacity, blen object's, type its characters'). `graphicStyle.apply {name, ids?}` gives the objects the style and links them; `add: true` adds the style on top of the existing appearance instead. Linked objects stay linked while they keep the style's look: editing their appearance or transparency breaks the link, and `graphicStyle.redefine {name?, id?}` -updates only the objects still linked. `graphicStyle.list` returns each style with the ids linked to it and the +updates only the objects still linked. The art of a column or marker design in a graph series is never linked: +`graphicStyle.new` can make a style from it, and `graphicStyle.redefine` leaves its look as it was and unlinks it, +whether it is the source or a linked object. `graphicStyle.list` returns each style with the ids linked to it and the style of the first selected object; `select.same.graphicStyle` selects an object's fellow users. Styles keep placed gradients relative to the bounds of the object they were made from, and each object a style is applied to gets them at the same place relative to its own bounds. From 96d5cd78a9b5ce3e6ea866d23c66d1adbab943a4 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Fri, 13 Nov 2026 22:26:22 -0800 Subject: [PATCH 07/11] Revolve: overlapping faces draw in the order of their depths where they overlap Revolve sorted its faces by the depth of each face's middle only, so a tilted profile with long edges, or a profile of several contours, could draw a nearer face under a farther one where the two overlap. - A revolved face carries its view-space plane (none when its corners lie off one plane or reach the eye), and Revolve (Classic)'s facets keep it. - Revolve and Revolve (Classic) put each surface's faces through the local depth order Extrude & Bevel uses for its fill and strokes: when two overlapping faces are drawn the wrong way round, every pair of overlapping faces goes in the order of their depths at the middle of the overlap, within the same 4,000,000-comparison limit. A surface already in the right order keeps it. - A group's members, sorted together by depth, go through the same order across members. Docs: docs/revolve.md, docs/effects-parity.md, docs/target-app-parity.md. --- crates/effects/src/revolve.rs | 79 +++++--- crates/effects/src/solids.rs | 32 +++ .../effects/src/solids/tests_revolve_order.rs | 183 ++++++++++++++++++ crates/three-d/src/lib.rs | 13 ++ crates/three-d/src/solid.rs | 8 +- crates/three-d/src/tests.rs | 32 +++ crates/three-d/src/visibility.rs | 1 + docs/effects-parity.md | 2 +- docs/revolve.md | 2 +- docs/target-app-parity.md | 1 + 10 files changed, 323 insertions(+), 30 deletions(-) create mode 100644 crates/effects/src/solids/tests_revolve_order.rs diff --git a/crates/effects/src/revolve.rs b/crates/effects/src/revolve.rs index 74b23340cd..4b23c3af8c 100644 --- a/crates/effects/src/revolve.rs +++ b/crates/effects/src/revolve.rs @@ -370,7 +370,9 @@ fn group_shadow(id: NodeId, fx: &Effect, shapes: &[Profile], b: Rect, options: R Ok(crate::solids::shadow::scoped_art(id, &caster, settings, cast, crate::solids::has_masks(shapes))) } -/// One object's surface of revolution about the axis of `b`, and each face's depth. `copies` counts +/// One object's surface of revolution about the axis of `b`, and each face it draws ([`Drawn`]), +/// back to front: by depth, and where two faces overlap, by their depths there +/// ([`crate::solids::surface_order`]). `copies` counts /// the bytes of the paint copies of every surface and graphic of the object. Revolve (Classic) draws every face /// with its classic surface, as Extrude & Bevel (Classic) does. Revolve's graphics, with the /// symbols of `doc`, are drawn on the faces of the side ([`crate::solids::graphics::surface_parts`]): @@ -388,7 +390,7 @@ fn surface( blend: vectorcraft_doc::color::BlendMode, expand: bool, copies: &mut Copies, -) -> Option<(Vec, Node)> { +) -> Option<(Vec, Node)> { // A loaded file or a profile edited past the limits keeps drawing its source path instead // of crashing the frame. Commands validate the same evaluator and report its errors. if fx.id == CLASSIC_REVOLVE { @@ -397,9 +399,12 @@ fn surface( if copies.surfaces > MAX_PAINT_COPIES { return None; } - let depths = facets.iter().map(|f| f.depth).collect(); + let order = crate::solids::surface_order(facets.iter().map(|f| (f.contours.first().map_or(&[][..], Vec::as_slice), f.plane, f.depth))); + let facets = in_order(facets, &order); + let drawn = + facets.iter().map(|f| Drawn { depth: f.depth, points: f.contours.first().cloned().unwrap_or_default(), plane: f.plane }).collect(); let p = merged_params(&fx.id, &fx.params); - return Some((depths, crate::solids::classic::surface(n, facets, &paint, opacity, blend, &p))); + return Some((drawn, crate::solids::classic::surface(n, facets, &paint, opacity, blend, &p))); } let colored = options.light_color != [1.0; 3] || options.lights.is_some(); let faces = vectorcraft_three_d::revolve(path, b, options).ok()?; @@ -407,6 +412,8 @@ fn surface( if copies.surfaces > MAX_PAINT_COPIES { return None; } + let order = crate::solids::surface_order(faces.iter().map(|f| (f.points.as_slice(), f.plane, f.depth))); + let faces = in_order(faces, &order); let graphics = crate::solids::mapped_graphics(&merged_params(&fx.id, &fx.params)); let shade = |face: &vectorcraft_three_d::Face, c: Color| graphic_paint(face, c, colored); // Graphics past the limits are left out, and the surface draws without them and their paints' @@ -437,21 +444,28 @@ fn surface( && n.has_default_transparency()) .then(|| vectorcraft_three_d::visible_faces(&faces).ok()) .flatten(); - let faces = visible.unwrap_or_else(|| { - faces - .into_iter() - .map(|f| vectorcraft_three_d::VisibleFace { - path: f.path(), - brightness: f.brightness, - depth: f.depth, - gradient: f.gradient, - illumination: f.illumination, - light_gradient: f.light_gradient, - rgb_gradient: f.rgb_gradient, - }) - .collect() - }); - let depths: Vec = faces.iter().map(|f: &vectorcraft_three_d::VisibleFace| f.depth).collect(); + let (faces, keys): (Vec, Vec) = match visible { + Some(visible) => { + let keys = visible.iter().map(|f| Drawn { depth: f.depth, points: vec![], plane: None }).collect(); + (visible, keys) + } + None => { + let keys = faces.iter().map(|f| Drawn { depth: f.depth, points: f.points.clone(), plane: f.plane }).collect(); + let faces = faces + .into_iter() + .map(|f| vectorcraft_three_d::VisibleFace { + path: f.path(), + brightness: f.brightness, + depth: f.depth, + gradient: f.gradient, + illumination: f.illumination, + light_gradient: f.light_gradient, + rgb_gradient: f.rgb_gradient, + }) + .collect(); + (faces, keys) + } + }; let children = faces .into_iter() .map(|face| { @@ -517,7 +531,21 @@ fn surface( let mut group = Node::group(n.id, children); group.opacity = opacity; group.blend = blend; - Some((depths, group)) + Some((keys, group)) +} + +/// A face [`surface`] draws: its depth and, for [`crate::solids::surface_order`], its polygon on the +/// drawing and its plane (none for a face trimmed to the part of it that shows). +struct Drawn { + depth: f64, + points: Vec, + plane: Option, +} + +/// `items` in `order`, a permutation of their indices. +fn in_order(items: Vec, order: &[usize]) -> Vec { + let mut slots: Vec> = items.into_iter().map(Some).collect(); + order.iter().filter_map(|&k| slots.get_mut(k).and_then(Option::take)).collect() } /// A surface of one object about the bounds of `path`, built with options derived from `fx`. @@ -934,14 +962,17 @@ fn group_art(doc: Option<&Document>, n: &Node, expand: bool) -> Option { } else { let count = surfaces.len(); let mut faces = vec![]; - for (member, (depths, s, scopes)) in surfaces.into_iter().enumerate() { + for (member, (drawn, s, scopes)) in surfaces.into_iter().enumerate() { let (opacity, blend) = (s.opacity, s.blend); if let NodeKind::Group { children, .. } = s.kind { - faces.extend(depths.into_iter().zip(children).map(|(depth, face)| (depth, member, opacity, blend, scopes, face))); + faces.extend(drawn.into_iter().zip(children).map(|(drawn, face)| (drawn, member, opacity, blend, scopes, face))); } } - // A stable sort: faces at the same depth keep the members' order. - faces.sort_by(|a, b| a.0.total_cmp(&b.0)); + // A stable sort: faces at the same depth keep the members' order. Overlapping faces, of one + // member or of two, then go in the order of their depths where they overlap. + faces.sort_by(|a, b| a.0.depth.total_cmp(&b.0.depth)); + let order = crate::solids::surface_order(faces.iter().map(|(d, ..)| (d.points.as_slice(), d.plane, d.depth))); + let faces = in_order(faces, &order); if opaque { vec![Arc::new(Node::group(n.id, faces.into_iter().map(|(.., face)| face).collect()))] } else { diff --git a/crates/effects/src/solids.rs b/crates/effects/src/solids.rs index 7500af0617..2378fed00a 100644 --- a/crates/effects/src/solids.rs +++ b/crates/effects/src/solids.rs @@ -1261,6 +1261,35 @@ fn draw_order(faces: &[Placed], sealed: bool) -> Vec { local_order(faces, order) } +/// The order, back to front, of the faces of one or more Revolve surfaces `faces`, given back to +/// front by depth: each face's polygon on the drawing, its plane and its depth. When two +/// overlapping faces are drawn the wrong way round for their depths where they overlap, every pair +/// of overlapping faces with a convex polygon and a plane goes in the order of its depths there, +/// the face nearer the viewer after the other ([`local_order`]), and each step draws the face that +/// comes first in the given order of those whose faces before are drawn, so a face without a plane +/// or a convex polygon can also move. Otherwise the order given. +pub(crate) fn surface_order<'a>(faces: impl IntoIterator, f64)>) -> Vec { + let placed: Vec = faces + .into_iter() + .map(|(points, plane, depth)| { + let local = plane.is_some() && convex(points).is_some(); + // Every face counts as one of a stroke's on one shape, so each overlapping pair is compared. + Placed { + depth, + side: true, + away: false, + bounds: polygon_bounds(points), + member: 0, + paints: Paints::Other, + outline: if local { points.to_vec() } else { vec![] }, + plane: plane.filter(|_| local), + } + }) + .collect(); + let order = (0..placed.len()).collect(); + local_order(&placed, order) +} + /// `shapes`, the members of group or layer `n`, reshaped by the visible geometry effects `before` /// its 3D effect, about the bounds of all of them. pub(crate) fn reshape_members(n: &Node, before: &[Effect], shapes: &mut [Profile]) { @@ -2174,3 +2203,6 @@ mod tests_sources; #[cfg(test)] mod tests_shadow; + +#[cfg(test)] +mod tests_revolve_order; diff --git a/crates/effects/src/solids/tests_revolve_order.rs b/crates/effects/src/solids/tests_revolve_order.rs new file mode 100644 index 0000000000..96650cf2f8 --- /dev/null +++ b/crates/effects/src/solids/tests_revolve_order.rs @@ -0,0 +1,183 @@ +use serde_json::json; + +use super::*; +use crate::revolve::{CLASSIC_REVOLVE, REVOLVE, revolve_art, revolve_options}; + +/// The first pair of faces `(polygon, plane)`, in the order given, drawn the wrong way round where +/// their polygons overlap: the earlier one nearer the viewer at the middle of the overlap. +fn misordered(faces: &[(Vec, Option)]) -> Option<(usize, usize)> { + for (i, (a, pa)) in faces.iter().enumerate() { + let Some(pa) = pa.filter(|_| convex(a).is_some()) else { continue }; + for (j, (b, pb)) in faces.iter().enumerate().skip(i + 1) { + let Some(pb) = pb else { continue }; + let Some(turn) = convex(b) else { continue }; + if !rects_overlap(polygon_bounds(a), polygon_bounds(b)) { + continue; + } + let Some(mid) = centroid(&clip_convex(a, b, turn)) else { continue }; + let (Some(da), Some(db)) = (pa.depth_at(mid), pb.depth_at(mid)) else { continue }; + if da - db > 1e-6 * (1.0 + da.abs().max(db.abs())) { + return Some((i, j)); + } + } + } + None +} + +/// An open, vase-like profile of straight edges. +fn vase() -> PathData { + let mut b = BezPath::new(); + b.move_to((30.0, 20.0)); + for p in [(90.0, 20.0), (55.0, 65.0), (95.0, 110.0), (30.0, 145.0)] { + b.line_to(p); + } + PathData::from_bezpath(&b) +} + +/// The `k`th of five closed 14 × 20 pt rectangles stepping down and away from the axis, as the +/// dashes of a dashed line's outline do. +fn dash(k: usize) -> PathData { + let (x, y) = (30.0 + 12.0 * k as f64, 20.0 + 26.0 * k as f64); + let mut b = BezPath::new(); + b.move_to((x, y)); + b.line_to((x + 14.0, y)); + b.line_to((x + 14.0, y + 20.0)); + b.line_to((x, y + 20.0)); + b.close_path(); + PathData::from_bezpath(&b) +} + +/// The five [`dash`]es as one compound path. +fn dashes() -> PathData { + PathData::new((0..5).flat_map(|k| dash(k).subpaths).collect()) +} + +/// A tilted three-quarter turn of [`vase`], whose faces sorted by their middles' depths draw a +/// nearer face under a farther one where the two overlap. +fn params() -> Value { + json!({"angle": 270, "offset": 12, "rotationX": -30, "rotationY": 0, "perspectiveAngle": 0, "segments": 24, "cap": true}) +} + +/// A three-quarter turn of [`dashes`], steeply tilted and in wide perspective, whose classic facets +/// sorted by their middles' depths draw a nearer facet under a farther one where the two overlap. +fn classic_params() -> Value { + json!({"angle": 270, "offset": 12, "rotationX": -60, "rotationY": 0, "perspectiveAngle": 80, "cap": true}) +} + +fn node(path: PathData) -> Node { + Node::path(NodeId(1), path, Appearance::basic(Paint::solid(Color::rgb(0.1, 0.7, 0.8)), Paint::None, 0.0)) +} + +/// The faces of `faces` (polygon, plane and outline) in the order `art` draws their outlines. +fn drawn_order(art: &Node, faces: &[(Vec, Option, PathData)]) -> Vec<(Vec, Option)> { + let mut out = vec![]; + art.walk(&mut |n| { + if let Some(path) = n.path_data() + && let Some((points, plane, _)) = faces.iter().find(|(.., outline)| outline == path) + { + out.push((points.clone(), *plane)); + } + }); + out +} + +/// Of a tilted, revolved vase, a face sorted by the depth of its middle goes under a farther face +/// that it is in front of where the two overlap. The surface draws the nearer face last there, on +/// its own and as a member of a revolved group. +#[test] +fn a_revolved_surface_draws_the_nearer_of_two_overlapping_faces_last() { + let path = vase(); + let faces = vectorcraft_three_d::revolve(&path, path.bounds().unwrap(), revolve_options(¶ms())).unwrap(); + let by_depth: Vec<_> = faces.iter().map(|f| (f.points.clone(), f.plane)).collect(); + assert!(misordered(&by_depth).is_some(), "the faces by depth are drawn the wrong way round somewhere"); + let keyed: Vec<_> = faces.iter().map(|f| (f.points.clone(), f.plane, f.path())).collect(); + let mut n = node(path.clone()); + n.appearance.effects.push(crate::new_effect(REVOLVE, ¶ms()).unwrap()); + let drawn = drawn_order(&revolve_art(&n).unwrap(), &keyed); + assert_eq!(drawn.len(), faces.len()); + assert_eq!(misordered(&drawn), None, "an object"); + let mut g = Node::group(NodeId(9), vec![Arc::new(node(path))]); + g.appearance.effects.push(crate::new_effect(REVOLVE, ¶ms()).unwrap()); + let drawn = drawn_order(&revolve_art(&g).unwrap(), &keyed); + assert_eq!(drawn.len(), faces.len()); + assert_eq!(misordered(&drawn), None, "a group's member"); +} + +/// The faces of a group's members, sorted together by the depths of their middles, go in the order +/// of their depths where they overlap, those of two members as well as those of one. +#[test] +fn a_revolved_groups_members_draw_the_nearer_of_two_overlapping_faces_last() { + let p = json!({"angle": 270, "offset": 12, "rotationX": -30, "rotationY": 0, "perspectiveAngle": 0, "segments": 16, "cap": true}); + let members: Vec = (0..5).map(dash).collect(); + let b = dashes().bounds().unwrap(); + let mut faces: Vec<_> = members.iter().flat_map(|m| vectorcraft_three_d::revolve(m, b, revolve_options(&p)).unwrap()).collect(); + faces.sort_by(|a, b| a.depth.total_cmp(&b.depth)); + let by_depth: Vec<_> = faces.iter().map(|f| (f.points.clone(), f.plane)).collect(); + assert!(misordered(&by_depth).is_some(), "the faces by depth are drawn the wrong way round somewhere"); + let keyed: Vec<_> = faces.iter().map(|f| (f.points.clone(), f.plane, f.path())).collect(); + let mut g = Node::group(NodeId(9), members.into_iter().map(|m| Arc::new(node(m))).collect()); + g.appearance.effects.push(crate::new_effect(REVOLVE, &p).unwrap()); + let drawn = drawn_order(&revolve_art(&g).unwrap(), &keyed); + assert_eq!(drawn.len(), faces.len()); + assert_eq!(misordered(&drawn), None); +} + +/// Revolve (Classic) draws the nearer of two overlapping facets last where they overlap, too. +#[test] +fn a_classic_revolved_surface_draws_the_nearer_of_two_overlapping_facets_last() { + let path = dashes(); + // Revolve (Classic) sweeps its surface unshaded, with one facet for each cell. + let classic = vectorcraft_three_d::Revolve { + shade: false, + smooth_shading: false, + lights: None, + light_color: [1.0; 3], + ..revolve_options(&crate::merged_params(CLASSIC_REVOLVE, &classic_params())) + }; + let facets = vectorcraft_three_d::revolve_facets(&path, path.bounds().unwrap(), classic).unwrap(); + let by_depth: Vec<_> = facets.iter().map(|f| (f.contours[0].clone(), f.plane)).collect(); + assert!(misordered(&by_depth).is_some(), "the facets by depth are drawn the wrong way round somewhere"); + let keyed: Vec<_> = facets.iter().map(|f| (f.contours[0].clone(), f.plane, f.path())).collect(); + let mut n = node(path); + n.appearance.effects.push(crate::new_effect(CLASSIC_REVOLVE, &classic_params()).unwrap()); + let drawn = drawn_order(&revolve_art(&n).unwrap(), &keyed); + assert_eq!(drawn.len(), facets.len()); + assert_eq!(misordered(&drawn), None); +} + +/// A surface whose faces by depth are already drawn the right way round wherever they overlap keeps +/// that order. +#[test] +fn a_surface_already_in_order_keeps_it() { + let path = vase(); + let p = json!({"angle": 360, "offset": 12, "rotationX": 0, "rotationY": 0, "segments": 24, "cap": true}); + let faces = vectorcraft_three_d::revolve(&path, path.bounds().unwrap(), revolve_options(&p)).unwrap(); + let by_depth: Vec<_> = faces.iter().map(|f| (f.points.clone(), f.plane)).collect(); + assert_eq!(misordered(&by_depth), None); + let order = surface_order(faces.iter().map(|f| (f.points.as_slice(), f.plane, f.depth))); + assert_eq!(order, (0..faces.len()).collect::>()); +} + +/// Of two overlapping faces, the one nearer the viewer where they overlap goes after the other, +/// whatever their depths; a face without a plane, and one that overlaps neither, keep their places. +/// Two faces at the same depth there keep their order. +#[test] +fn surface_order_puts_the_nearer_face_where_they_overlap_last() { + let square = |x: f64| vec![Point::new(x, 0.0), Point::new(x + 10.0, 0.0), Point::new(x + 10.0, 10.0), Point::new(x, 10.0)]; + // z = 10 - x: at the middle of its overlap with the next square (x 7.5) it is at z 2.5. + let r = std::f64::consts::FRAC_1_SQRT_2; + let sloped = Some(DepthPlane { normal: [r, 0.0, r], offset: 10.0 * r, center: Point::ZERO, eye: None }); + let flat = |z: f64| Some(DepthPlane { normal: [0.0, 0.0, 1.0], offset: z, center: Point::ZERO, eye: None }); + let (a, b, apart) = (square(0.0), square(5.0), square(40.0)); + let faces = [(&a[..], sloped, 5.0), (&apart[..], flat(0.0), 1.0), (&b[..], flat(3.0), 3.0)]; + assert_eq!(surface_order(faces), vec![0, 1, 2], "the flat face, nearer at the overlap, is already last"); + let faces = [(&b[..], flat(2.0), 2.0), (&apart[..], flat(0.0), 3.0), (&a[..], sloped, 5.0)]; + assert_eq!(surface_order(faces), vec![0, 1, 2], "the sloped face, nearer at the overlap, is already last"); + // The sloped face's middle (x 5) is at z 5, but the flat face at z 3 is nearer at the overlap. + let faces = [(&b[..], flat(3.0), 3.0), (&apart[..], flat(0.0), 4.0), (&a[..], sloped, 5.0)]; + assert_eq!(surface_order(faces), vec![1, 2, 0], "the flat face is nearer at the overlap"); + let faces = [(&b[..], None, 3.0), (&apart[..], flat(0.0), 4.0), (&a[..], sloped, 5.0)]; + assert_eq!(surface_order(faces), vec![0, 1, 2], "no plane"); + let faces = [(&b[..], flat(2.5), 3.0), (&a[..], sloped, 5.0)]; + assert_eq!(surface_order(faces), vec![0, 1], "the same depth there"); +} diff --git a/crates/three-d/src/lib.rs b/crates/three-d/src/lib.rs index 309aa77409..8f19760d8f 100644 --- a/crates/three-d/src/lib.rs +++ b/crates/three-d/src/lib.rs @@ -371,6 +371,10 @@ pub struct Face { /// For a face of the swept side (not an end cap): where its corners lie on the side surface, /// for a graphic mapped onto it. pub surface: Option, + /// The face's plane in view space and the projection it was drawn with, for its depth at a + /// point of the drawing ([`DepthPlane::depth_at`]). `None` when its corners lie more than + /// 0.001 pt off one plane or a corner is at or past the eye. + pub plane: Option, } /// Where a face of Revolve's swept side lies on the side surface, unrolled flat: x runs around the @@ -638,6 +642,14 @@ fn project_face(vertices: &[V3], normals: Option<&[V3]>, o: Revolve, origin: Poi normal = V3(-normal.0, -normal.1, -normal.2); } let flat_brightness = brightness(&o, normal, light); + // As for an extruded face: no plane when a corner lies off it, or when the projection holds a + // corner just in front of the eye, so the drawing no longer matches the plane's projection. + let centre = vertices.iter().fold(V3::default(), |s, v| V3(s.0 + v.0, s.1 + v.1, s.2 + v.2)); + let centre = V3(centre.0 / vertices.len() as f64, centre.1 / vertices.len() as f64, centre.2 / vertices.len() as f64); + let offset = normal.dot(centre); + let held = eye.is_some_and(|e| vertices.iter().any(|v| e - v.2 < e * 1e-3)); + let flat = !held && vertices.iter().all(|v| (normal.dot(*v) - offset).abs() <= 1e-3); + let plane = flat.then_some(DepthPlane { normal: [normal.0, normal.1, normal.2], offset, center: origin, eye }); let points: Vec<_> = vertices .iter() .map(|v| { @@ -707,6 +719,7 @@ fn project_face(vertices: &[V3], normals: Option<&[V3]>, o: Revolve, origin: Poi cap: false, visibility_pieces: None, surface: None, + plane, })) } diff --git a/crates/three-d/src/solid.rs b/crates/three-d/src/solid.rs index 49b0738d9e..6567a2baaa 100644 --- a/crates/three-d/src/solid.rs +++ b/crates/three-d/src/solid.rs @@ -293,8 +293,8 @@ pub struct Facet { /// (its front). `None` for every other facet. pub end: Option, /// The face's plane in view space and the projection it was drawn with, for its depth at a - /// point of the drawing ([`Facet::depth_at`]). `None` for Revolve's faces and for a face whose - /// corners lie more than 0.001 pt off one plane (a piece of a twisted wall). + /// point of the drawing ([`Facet::depth_at`]). `None` for a face whose corners lie more than + /// 0.001 pt off one plane (a piece of a twisted wall) or reach the eye. pub plane: Option, /// The contours in view space, before projection, for tests that measure depths. #[cfg(test)] @@ -365,7 +365,7 @@ impl Facet { PathData::from_bezpath(&bp) } - /// Revolve's face `face` as a facet: its polygon, curved outline, depth, normal and lighting. + /// Revolve's face `face` as a facet: its polygon, curved outline, depth, plane, normal and lighting. /// A face that joins the profile's ends round the axis is a side; a cap is an end. pub(crate) fn of_face(face: crate::Face) -> Self { Facet { @@ -380,7 +380,7 @@ impl Facet { // A revolved cap is not one of the planar faces graphics map onto. end: None, away: false, - plane: None, + plane: face.plane, #[cfg(test)] view: vec![], } diff --git a/crates/three-d/src/tests.rs b/crates/three-d/src/tests.rs index fe42769ebb..5dba29c480 100644 --- a/crates/three-d/src/tests.rs +++ b/crates/three-d/src/tests.rs @@ -375,3 +375,35 @@ fn malformed_and_degenerate_inputs_are_bounded() { assert!(revolve(&p, Rect::new(f64::NAN, 0.0, 1.0, 1.0), Revolve::default()).is_err()); assert!(revolve(&PathData::default(), b, Revolve::default()).unwrap().is_empty()); } + +/// A revolved face carries its plane: the depth it gives at each corner of its drawing is the +/// corner's view-space depth, with and without perspective. A face whose corners lie off one plane, +/// or with a corner at or past the eye, has none. +#[test] +fn a_revolved_face_measures_its_depth_on_its_plane() { + let o = Revolve { rotation_x: -30.0, rotation_y: 25.0, ..Default::default() }.sanitized(); + let origin = Point::new(50.0, 80.0); + let light = V3(0.0, 0.0, 1.0); + let [a, b, c, d] = [V3(-20.0, -10.0, 5.0), V3(20.0, -12.0, -15.0), V3(25.0, 15.0, -20.0), V3(-18.0, 14.0, 4.0)]; + // The fourth corner moved onto the plane of the first three. + let n = b.sub(a).cross(c.sub(a)); + let d = V3(d.0, d.1, a.2 - (n.0 * (d.0 - a.0) + n.1 * (d.1 - a.1)) / n.2); + let flat = [a, b, c, d]; + for eye in [None, Some(400.0)] { + let face = project_face(&flat, None, o, origin, eye, light).unwrap().unwrap(); + let plane = face.plane.unwrap(); + for (p, v) in face.points.iter().zip(&flat) { + let depth = plane.depth_at(*p).unwrap(); + assert!((depth - v.2).abs() < 1e-6, "{eye:?}: {depth} against {}", v.2); + } + let twisted = [a, b, c, V3(d.0, d.1, d.2 + 1.0)]; + assert!(project_face(&twisted, None, o, origin, eye, light).unwrap().unwrap().plane.is_none(), "{eye:?}"); + } + let past = [a, b, V3(c.0, c.1, 500.0)]; + assert!(project_face(&past, None, o, origin, Some(400.0), light).unwrap().unwrap().plane.is_none()); + assert!(project_face(&past, None, o, origin, Some(1000.0), light).unwrap().unwrap().plane.is_some()); + // Every face of a revolved profile of straight edges has one. + let (p, b) = cylinder(); + let faces = revolve(&p, b, Revolve { rotation_x: -30.0, perspective: 35.0, ..Default::default() }).unwrap(); + assert!(!faces.is_empty() && faces.iter().all(|f| f.plane.is_some())); +} diff --git a/crates/three-d/src/visibility.rs b/crates/three-d/src/visibility.rs index 424242828b..3ed491ae10 100644 --- a/crates/three-d/src/visibility.rs +++ b/crates/three-d/src/visibility.rs @@ -303,6 +303,7 @@ mod tests { cap: false, visibility_pieces: None, surface: None, + plane: None, } } diff --git a/docs/effects-parity.md b/docs/effects-parity.md index b546cb557e..1457e35bc6 100644 --- a/docs/effects-parity.md +++ b/docs/effects-parity.md @@ -178,7 +178,7 @@ VectorCraft choices; an original approximation, never compared with another appl | Item | Ours | |---|---| -| Revolve | initial: live, editable profile, rotation and light gizmos, default-on Cap with continuous curved rims, smooth vector shading and round seam joins, SVG/PDF output, Expand Appearance ([revolve.md](revolve.md), #846) | +| Revolve | initial: live, editable profile, rotation and light gizmos, default-on Cap with continuous curved rims, smooth vector shading and round seam joins, overlapping faces drawn in the order of their depths where they overlap, SVG/PDF output, Expand Appearance ([revolve.md](revolve.md), #846) | | Extrude & Bevel, Inflate, Rotate | initial: live, round seam joins, depth, twist, taper, solid or hollow caps, five original bevel shapes, Inflate volume, both sides, twist, taper and cap, Rotate of every fill and stroke, groups and layers turned about one shared axis (#4506), contours that cross, touch or overlap merged first ([three-d.md](three-d.md)), Position presets, rotation and light gizmos, SVG/PDF output, Expand Appearance ([three-d.md](three-d.md)) | | Materials panel, lighting, ray-traced render | Window › 3D and Materials panel with the object types (Plane, Extrude, Revolve, Inflate) and the Object, Rotation and Lighting settings of the applied effect, and Quick Actions › Expand as Wireframes ([three-d.md](three-d.md#3d-and-materials-panel)); one to ten directional lights with per-light Color (opaque RGB, white by default), Intensity, Rotation, Height, Softness and neutral Ambient Light, Revolve and colored Inflate use smooth vector shading; Extrude & Bevel and Rotate retain planar shading; original Standard, Diffuse, Top Left and Right lighting rigs; hard-edged shadows behind or below the object from the first light ([three-d.md](three-d.md#shadows)); the Materials tab's Graphics, document symbols mapped onto Extrude & Bevel's caps and onto a plane, moved with an artboard handle, and wrapped around Revolve's side ([three-d.md](three-d.md#graphics)); no base materials, graphics on Inflate or on Extrude & Bevel's walls and bevels, soft shadows or ray tracing | | 3D (Classic): Extrude & Bevel, Revolve, Rotate | Extrude & Bevel (Classic) initial: live, round seam joins, Extrude Depth, Cap, five original bevel shapes with Height and Bevel Extent In or Out, Position presets and rotation gizmo, Perspective, Wireframe, No Shading, Diffuse and Plastic surfaces with one to ten lights, Light Intensity, Ambient Light, Highlight Intensity and Size, Blend Steps, Black, Custom or no Shading Color, Draw Hidden Faces ([create 3D objects](https://helpx.adobe.com/illustrator/desktop/special-effects-styles/create-3d-graphics/create-3d-objects.html)); Revolve (Classic) initial: live, round seam joins, Angle, Cap, Offset from the left or right edge, Position presets and rotation gizmo, Perspective, the same four surfaces, lights, highlight, Blend Steps and Shading Color, groups and layers revolved about one shared axis (same page); Rotate (Classic) initial: live, round seam joins, every fill and stroke turned as one plane, Position presets and rotation gizmo, Perspective, No Shading and Diffuse surfaces with lights, Ambient Light, Blend Steps and Shading Color ([rotate objects in 3D](https://helpx.adobe.com/illustrator/desktop/special-effects-styles/create-3d-graphics/rotate-objects-in-3d.html)); Map Art: none | diff --git a/docs/revolve.md b/docs/revolve.md index e52770fd65..7e80ee3e24 100644 --- a/docs/revolve.md +++ b/docs/revolve.md @@ -102,7 +102,7 @@ with the standard command. - One Revolve per source object. Edit its options rather than applying another. - Object-level Revolve uses the top visible fill, falling back to a stroke. Per-item Revolve uses that item's paint. - Smooth diffuse shading, one to ten directional lights, no textures, cast shadows or physical materials. Smooth shading interpolates lighting in projected triangles; it does not smooth the silhouette or add specular highlights. -- Faces use back-to-front depth sorting. Caps facing the camera cover their rim seams; caps facing away sit behind the body. Intersecting profiles can have incorrect visibility. +- Faces are drawn back to front by the depths of their centers. When that order draws the nearer of two overlapping faces of a surface first, every pair of overlapping faces goes in the order of their depths at the middle of where they overlap; the sort compares at most 4,000,000 pairs (VectorCraft's own limit), and the pairs past that keep the depths of their centers. Caps facing the camera cover their rim seams; a cap facing away is drawn behind the body except where it is nearer than a body face it overlaps. Intersecting profiles can have incorrect visibility. - Curves use sixteen samples per segment, and as few as four when a surface would otherwise pass its limits. Surfaces are limited to 16,384 profile points and 163,840 faces, the members of a group together. A straight segment takes one point, a curve takes at least four, and each contour takes one. Smooth shaded side quads become two triangles and share the face limit. A continuous cap retains its original radial mesh cost within this limit. Projected ring curves have a 0.001-point fitting tolerance and a shared 32,768-curve limit. - Axis, bounds and ordinary selection handles continue to refer to the editable 2D source profile. - Extrude & Bevel, Inflate and Rotate are described in [three-d.md](three-d.md). Materials remain unimplemented. diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 296bd84b2b..458fb01c52 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -1244,6 +1244,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur - A 3D effect on one fill or stroke ([the help page](https://helpx.adobe.com/illustrator/using/effects.html)) leaves the object's other fills and strokes drawn with the path's or compound path's fill rule, so an even-odd compound path keeps its holes in them; this holds for Extrude & Bevel, Inflate, Rotate and Revolve, and for the Classic Extrude & Bevel, Rotate and Revolve. Tests: `solids::tests::a_fill_or_stroke_without_the_effect_keeps_the_even_odd_hole`, `revolve::tests::a_fill_without_the_revolve_keeps_the_even_odd_hole`. - With caps and opaque paint, Extrude & Bevel (both kinds) and Inflate draw each side face of the fill's solid before the faces of the same object's strokes that overlap it (strokes on closed paths without dashes, a width profile or a brush, in a solid color or an opaque gradient), so the stroke's walls cover the fill's walls at concave corners instead of showing stripes of the fill. Tests: `solids::tests::a_fills_walls_are_drawn_before_the_stroke_faces_over_them`, `solids::tests::the_draw_order_moves_a_fill_wall_before_the_stroke_faces_it_overlaps`, `solid::tests::facets_tell_the_sides_from_the_ends_and_the_faces_turned_away`. - When the fill and strokes are at full opacity with Normal blending, under a stroke that leaves parts of the fill's edge uncovered (dashed, with a width profile or a brush, on an open subpath, or in a paint that may show through), Extrude & Bevel (both kinds) and Inflate cut each stroke face that passes through a face of the fill's solid, or of another of the shape's strokes, where their depths meet, and draw the faces of the shape's fill and strokes that overlap in the order of their depths where they overlap, instead of by the depths of their centers. Inflate orders a plain stroke the same way, since its fill can rise in front of the stroke. The fill no longer paints over a nearer dash, and a dash's end no longer covers the fill wall in front of it; a fill wall in a gap between dashes stays visible. The same order applies inside a group member's own opacity mask or blending mode, to a gradient with transparent stops under a colored light with graphics on its caps, and to Inflate in gradients under a colored light. Tests: `solids::tests::fill_walls_and_strokes_that_leave_gaps_draw_in_the_order_of_their_depths_where_they_overlap`, `solids::tests::local_order_puts_overlapping_fill_walls_and_stroke_faces_in_depth_order`, `solids::tests::a_stroke_face_through_a_fill_wall_is_cut_where_their_depths_meet`, `solids::tests::a_masked_members_fill_and_stroke_faces_go_in_one_order`, `solids::tests::a_colored_inflate_in_gradients_draws_its_faces_in_one_order`, `solids::tests::a_depth_cycle_keeps_the_cap_last`, `solids::graphics::tests::a_transparent_gradient_with_a_graphic_keeps_the_shared_face_order`. + - Revolve and Revolve (Classic) draw two overlapping faces of a surface in the order of their depths where they overlap, instead of by the depths of their centers, on one object and across a group's members, so a tilted profile with long edges or a profile of several contours no longer draws a nearer face under a farther one. A surface whose faces were already in the right order keeps it. Tests: `solids::tests_revolve_order::a_revolved_surface_draws_the_nearer_of_two_overlapping_faces_last`, `solids::tests_revolve_order::a_revolved_groups_members_draw_the_nearer_of_two_overlapping_faces_last`, `solids::tests_revolve_order::a_classic_revolved_surface_draws_the_nearer_of_two_overlapping_facets_last`, `solids::tests_revolve_order::a_surface_already_in_order_keeps_it`, `solids::tests_revolve_order::surface_order_puts_the_nearer_face_where_they_overlap_last`, `tests::a_revolved_face_measures_its_depth_on_its_plane`. - Under a colored light, an Inflate fill and strokes in solid colors, opaque with Normal blending, also draw their faces in that one order, inside a group member's own opacity mask too, instead of each solid as one composite with the stroke's faces over every fill face. Each face keeps the composite's paint, light ramps and seam width; a lone solid, the solids of an object with a translucent or blended fill or stroke, and such solids holding more than 30,720 faces together (VectorCraft's own limit) are still drawn as one composite each. Tests: `solids::tests::colored_solid_fills_and_strokes_share_face_depth_order`, `solids::tests::a_colored_solid_face_on_its_own_keeps_the_composites_colors`, `solids::tests::colored_solid_inflate_batches_channels_and_clips_once_per_surface`, `solids::tests::colored_solids_past_the_face_limit_draw_as_composites`. - Without a twist, a tapered Extrude & Bevel wall (and each bevel band) is one flat face; only a twist cuts walls into slices, so no seam between slices paints a light tick or a light line on a tapered, untwisted wall (#7019). Test: `solid::tests::a_tapered_wall_is_one_flat_face_and_a_twisted_one_is_sliced`. - Extrude & Bevel, Inflate and Rotate apply to type: it makes a solid of its glyph outlines, painted with its own fills and strokes or, when it has none, its characters' colour (the first run's), and stays type, so editing the text reshapes the solid (#4715). From f4f90d3e9970ed913ecc1d3ce497746ad8c159c0 Mon Sep 17 00:00:00 2001 From: jayzed80 Date: Sat, 14 Nov 2026 07:14:51 +0000 Subject: [PATCH 08/11] Appearance: a group's own fills and strokes follow a clip group's clipping path, not the art it hides (#7289) A Fill + Offset Path below a group's Contents painted every clipped member of a clip group among its members, so art hidden outside the clipping path bulged the offset outline. A clip group (or clipping layer) now adds the region its clipping path clips to. --- crates/effects/src/group.rs | 15 +++++++++---- crates/effects/src/group/tests.rs | 35 +++++++++++++++++++++++++++++++ 2 files changed, 46 insertions(+), 4 deletions(-) diff --git a/crates/effects/src/group.rs b/crates/effects/src/group.rs index bb2f605a91..4df0a105ce 100644 --- a/crates/effects/src/group.rs +++ b/crates/effects/src/group.rs @@ -661,8 +661,9 @@ pub fn has_container_appearance(n: &Node) -> bool { /// The regions a group's or layer's own fills and strokes paint, in document space, each with its /// fill rule: every visible member path and compound path, type as its glyph outlines and live /// objects evaluated; a compound shape as its area, with the symbol instances among its members -/// as their symbols' art from `doc`; the members of a clip group or clipping layer without its -/// clipping path. Images, other symbol instances and guides add nothing. A Repeat counts the +/// as their symbols' art from `doc`; a clip group or clipping layer (`n` itself or one among its +/// members) as the region its clipping path clips to ([`crate::clip::clip_outline`]), so members +/// it hides add nothing. Images, other symbol instances and guides add nothing. A Repeat counts the /// Repeats in the art of `doc`'s symbols that its instances show toward its instance budget. pub fn member_shapes(doc: Option<&Document>, n: &Node) -> Vec<(PathData, FillRule)> { fn push(doc: Option<&Document>, n: &Node, out: &mut Vec<(PathData, FillRule)>) { @@ -690,8 +691,14 @@ pub fn member_shapes(doc: Option<&Document>, n: &Node) -> Vec<(PathData, FillRul } } fn members(doc: Option<&Document>, n: &Node, out: &mut Vec<(PathData, FillRule)>) { - let clip = usize::from(n.clips()); - for c in n.children().into_iter().flatten().skip(clip) { + // A clip group shows its members only inside its clipping path: its outline is that + // region, not the clipped members' own shapes, which may reach far outside it (#7289). + if n.clips() { + let clip = n.children().and_then(|ch| ch.first()); + out.extend(clip.and_then(|c| crate::clip::clip_outline(c)).map(|(bp, rule)| (PathData::from_bezpath(&bp), rule))); + return; + } + for c in n.children().into_iter().flatten() { push(doc, c, out); } } diff --git a/crates/effects/src/group/tests.rs b/crates/effects/src/group/tests.rs index c6e2a78c73..8e00718c01 100644 --- a/crates/effects/src/group/tests.rs +++ b/crates/effects/src/group/tests.rs @@ -1757,3 +1757,38 @@ fn a_sublayers_ruler_guide_in_a_hidden_or_locked_group_stays_hidden_or_locked() assert_eq!(guides, [(NodeId(20), !hidden, locked)], "hidden {hidden}, locked {locked}"); } } + +/// A group's own Fill + Offset Path below its contents follows the outline of a clip group among +/// its members, its clipping path, not the clipped art it hides outside that path (#7289). +#[test] +fn a_groups_own_fill_follows_a_clip_groups_clipping_path_not_its_hidden_art() { + let mut clip = square_at_100(); + clip.id = NodeId(2); + if let NodeKind::Path { clipping, .. } = &mut clip.kind { + *clipping = true; + } + // The clipped art reaches 100 pt past the clipping path on either side. + let hidden = Node::path(NodeId(3), shapes::rectangle(Rect::new(0.0, 100.0, 300.0, 200.0)), black()); + let clipped = Node::new(NodeId(5), NodeKind::Group { children: vec![Arc::new(clip), Arc::new(hidden)], clip: true }); + let mut g = Node::group(NodeId(10), vec![Arc::new(clipped.clone())]); + let mut fill = vectorcraft_doc::FillLayer::new(Paint::solid(Color::rgb(1.0, 0.0, 0.0))); + fill.effects.push(offset_10()); + g.appearance.items = vec![AppearanceItem::Fill(fill)]; + g.appearance.set_contents_at(1); + let near = |a: Rect, b: Rect| (a.x0 - b.x0).abs() < 0.5 && (a.y0 - b.y0).abs() < 0.5 && (a.x1 - b.x1).abs() < 0.5 && (a.y1 - b.y1).abs() < 0.5; + let expected = Rect::new(90.0, 90.0, 210.0, 210.0); + + let art = evaluate_container(None, &g).unwrap(); + let ch = art.children().unwrap(); + assert_eq!(ch.len(), 2, "the fill's art below the clip group"); + assert_eq!(ch[0].id, NodeId(10)); + let b = drawn_bounds(&ch[0]).unwrap(); + assert!(near(b, expected), "the offset fill hugs the clipping path: {b:?}"); + + // The clip group itself with that fill: the same outline. + let mut own = clipped; + own.appearance = g.appearance.clone(); + assert_eq!(member_shapes(None, &own).len(), 1); + let b = member_shapes(None, &own)[0].0.bounds().unwrap(); + assert!(near(b, Rect::new(100.0, 100.0, 200.0, 200.0)), "the clipping path's region: {b:?}"); +} From a2e98feb646111f6555885c1bb9c215aba4ad340 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Sat, 14 Nov 2026 02:18:24 -0800 Subject: [PATCH 09/11] i18n: keep bracketed field names untranslated in fr rows --- crates/ui-egui/src/i18n/fr.tsv | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/crates/ui-egui/src/i18n/fr.tsv b/crates/ui-egui/src/i18n/fr.tsv index df0ba85bc8..61a35d5db5 100644 --- a/crates/ui-egui/src/i18n/fr.tsv +++ b/crates/ui-egui/src/i18n/fr.tsv @@ -7568,7 +7568,7 @@ breakText Glyphs Glyphes @msg `alignOn` must be a string `alignOn` doit être une chaîne @msg `align` is start, center or end `align` vaut start, center ou end @msg `align` must be a string `align` doit être une chaîne -@msg `anchors` must be an array of [subpath, anchor] pairs `anchors` doit être une liste de paires [sous-tracé, point d’ancrage] +@msg `anchors` must be an array of [subpath, anchor] pairs `anchors` doit être une liste de paires [subpath, anchor] @msg `anchors` must list anchor indices (0, 1, 2…), not {k} `anchors` doit lister des indices de points d’ancrage (0, 1, 2…), pas {k} @msg `anchors` must list anchor indices (0, 1, 2…), not {v} `anchors` doit lister des indices de points d’ancrage (0, 1, 2…), pas {v} @msg `answer` for this {kind} dialog must be one of: {_1} `answer` pour cette boîte de dialogue {kind} doit valoir l’une de ces valeurs : {_1} @@ -7632,7 +7632,7 @@ breakText Glyphs Glyphes @msg `fidelity` must be from {lo} to {hi} pt `fidelity` doit être compris entre {lo} et {hi} pt @msg `files` must be 1 to 1000 paths of font files the last text.findFontFiles search found `files` doit contenir de 1 à 1000 chemins de fichiers de police trouvés par la dernière recherche text.findFontFiles @msg `focal` and `radius` cannot be used together `focal` et `radius` ne peuvent pas être utilisés ensemble -@msg `focal` must be an array of [subpath, anchor] pairs `focal` doit être une liste de paires [sous-tracé, point d’ancrage] +@msg `focal` must be an array of [subpath, anchor] pairs `focal` doit être une liste de paires [subpath, anchor] @msg `focal` names [{si}, {ai}], which is not an anchor of the path `focal` désigne [{si}, {ai}], qui n’est pas un point d’ancrage du tracé @msg `folder` must be an absolute folder path `folder` doit être le chemin absolu d’un dossier @msg `fontAxes` must be an object of axis tags and values, such as {"wght"} `fontAxes` doit être un objet de balises d’axe et de valeurs, comme {"wght"} From d97bb27d585f88637d2a501db9d5919fe97152b0 Mon Sep 17 00:00:00 2001 From: bflatastic Date: Sat, 14 Nov 2026 17:46:50 +0700 Subject: [PATCH 10/11] i18n: French keeps [subpath, anchor] in English #7307's French message rows ran CI before #7392 made bracketed field names stay in English, and two of them translate [subpath, anchor], so main fails translated_messages_keep_the_field_names_and_values_their_english_lists. --- crates/ui-egui/src/i18n/fr.tsv | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/crates/ui-egui/src/i18n/fr.tsv b/crates/ui-egui/src/i18n/fr.tsv index 07209a70dd..2396640141 100644 --- a/crates/ui-egui/src/i18n/fr.tsv +++ b/crates/ui-egui/src/i18n/fr.tsv @@ -7567,7 +7567,7 @@ breakText Glyphs Glyphes @msg `alignOn` must be a string `alignOn` doit être une chaîne @msg `align` is start, center or end `align` vaut start, center ou end @msg `align` must be a string `align` doit être une chaîne -@msg `anchors` must be an array of [subpath, anchor] pairs `anchors` doit être une liste de paires [sous-tracé, point d’ancrage] +@msg `anchors` must be an array of [subpath, anchor] pairs `anchors` doit être une liste de paires [subpath, anchor] @msg `anchors` must list anchor indices (0, 1, 2…), not {k} `anchors` doit lister des indices de points d’ancrage (0, 1, 2…), pas {k} @msg `anchors` must list anchor indices (0, 1, 2…), not {v} `anchors` doit lister des indices de points d’ancrage (0, 1, 2…), pas {v} @msg `answer` for this {kind} dialog must be one of: {_1} `answer` pour cette boîte de dialogue {kind} doit valoir l’une de ces valeurs : {_1} @@ -7631,7 +7631,7 @@ breakText Glyphs Glyphes @msg `fidelity` must be from {lo} to {hi} pt `fidelity` doit être compris entre {lo} et {hi} pt @msg `files` must be 1 to 1000 paths of font files the last text.findFontFiles search found `files` doit contenir de 1 à 1000 chemins de fichiers de police trouvés par la dernière recherche text.findFontFiles @msg `focal` and `radius` cannot be used together `focal` et `radius` ne peuvent pas être utilisés ensemble -@msg `focal` must be an array of [subpath, anchor] pairs `focal` doit être une liste de paires [sous-tracé, point d’ancrage] +@msg `focal` must be an array of [subpath, anchor] pairs `focal` doit être une liste de paires [subpath, anchor] @msg `focal` names [{si}, {ai}], which is not an anchor of the path `focal` désigne [{si}, {ai}], qui n’est pas un point d’ancrage du tracé @msg `folder` must be an absolute folder path `folder` doit être le chemin absolu d’un dossier @msg `fontAxes` must be an object of axis tags and values, such as {"wght"} `fontAxes` doit être un objet de balises d’axe et de valeurs, comme {"wght"} From c764c59825516cd9848b71fb5a8a803974e62fb9 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Sat, 14 Nov 2026 03:50:57 -0800 Subject: [PATCH 11/11] SVG import: plaintext finds its first strong character across tspans With `unicode-bidi: plaintext`, SVG import looked for the first strong character of an element in each text node on its own and kept the first answer. An isolate that opened in one text node and closed in a later one hid the rest of that node, and the node then answered left to right. For `⁧ab⁩א` the element ran left to right, where UAX #9 P2 skips the isolate and finds the right-to-left letter after it (https://www.unicode.org/reports/tr9/#P2). The characters of the whole `` are now read once, in order, and each isolate initiator is matched with its PDI (UAX #9 BD9). Each element's first strong character is then found in constant time, so nested `plaintext` elements do not read the same characters again. An isolate that opens inside an element and closes after it hides the rest of that element. vectorcraft-svg takes `unicode-bidi` 0.3.18, which vectorcraft-text already uses, for the bidi classes. --- Cargo.lock | 1 + crates/svg/Cargo.toml | 2 + crates/svg/src/import/text.rs | 99 +++++++++++++++++++++++++----- crates/svg/tests/it/import_text.rs | 6 ++ 4 files changed, 92 insertions(+), 16 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 476af0dc89..f3e85a08a7 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -6823,6 +6823,7 @@ dependencies = [ "skrifa 0.47.0", "svgtypes 0.16.1", "thiserror 2.0.21", + "unicode-bidi", "usvg 0.48.1", "vectorcraft-brush", "vectorcraft-color", diff --git a/crates/svg/Cargo.toml b/crates/svg/Cargo.toml index 7708d23c06..36744c8de5 100644 --- a/crates/svg/Cargo.toml +++ b/crates/svg/Cargo.toml @@ -31,6 +31,8 @@ image = { workspace = true } # Colour / transform / length parsing for the fallback (MIT OR Apache-2.0; already used by usvg). svgtypes = "0.16" simplecss = "0.2.2" +# Bidi classes for the first strong character of `unicode-bidi: plaintext` text (MIT OR Apache-2.0; already used by vectorcraft-text). +unicode-bidi = "0.3.18" [dev-dependencies] # Reading embedded font subsets back. diff --git a/crates/svg/src/import/text.rs b/crates/svg/src/import/text.rs index 1a221a69f1..87e100f1b9 100644 --- a/crates/svg/src/import/text.rs +++ b/crates/svg/src/import/text.rs @@ -711,9 +711,9 @@ struct Ctx { sizes: RefCell>, weights: RefCell>, elems: RefCell>>, - /// Whether the first strong character of each element read so far is right to left - /// ([`Self::first_strong_rtl`]); `None` where it holds none. - strong: RefCell>>, + /// The first strong characters of each `` read so far, by the `` + /// ([`Self::first_strong_rtl`]). + strong: RefCell>>, /// The path each `` read so far follows ([`Self::text_path`]); `None` where it has /// no usable one. The styles of a `` instance share it, so each path is found and parsed /// once however many `` elements show its text. @@ -1006,22 +1006,39 @@ impl Ctx { } /// Whether the first strong character (UAX #9 P2) of the characters [`Self::collect`] reads - /// from element `n` is right to left; `None` when they hold none. Each element is read once. + /// from element `n` is right to left; `None` when they hold none. The characters of the whole + /// `` are read once, as one sequence, so an isolate that opens in one text node and + /// closes in a later one is skipped whole ([`FirstStrong`]). fn first_strong_rtl(&self, n: XNode) -> Option { - if let Some(s) = self.strong.borrow().get(&n.id()) { - return *s; + // The `` (or outermost element) whose characters hold those of `n`. + let mut root = n; + while let Some(p) = root.parent_element().filter(|_| root.tag_name().name() != "text" && self.is_inline_child(root)) { + root = p; } - let s = n.children().find_map(|c| { - if c.is_text() { - text_first_strong_rtl(c.text().unwrap_or("")) - } else if self.is_inline_child(c) { - self.first_strong_rtl(c) - } else { - None - } + let strong = self.strong.borrow().get(&root.id()).cloned(); + let strong = strong.unwrap_or_else(|| { + let mut chars = vec![]; + let mut spans = HashMap::new(); + self.inline_chars(root, &mut chars, &mut spans); + let s = Rc::new(FirstStrong::new(&chars, spans)); + self.strong.borrow_mut().insert(root.id(), s.clone()); + s }); - self.strong.borrow_mut().insert(n.id(), s); - s + strong.of(n.id()) + } + + /// Appends the characters of element `n` and of its shown inline children to `chars`, in + /// document order, and the range each of those elements holds to `spans`. + fn inline_chars(&self, n: XNode, chars: &mut Vec, spans: &mut HashMap>) { + let start = chars.len(); + for c in n.children() { + if c.is_text() { + chars.extend(c.text().unwrap_or("").chars()); + } else if self.is_inline_child(c) { + self.inline_chars(c, chars, spans); + } + } + spans.insert(n.id(), start..chars.len()); } /// Whether child `c` of a `` content element holds characters of the text: a shown @@ -1774,6 +1791,56 @@ fn text_first_strong_rtl(text: &str) -> Option { (!vectorcraft_text::paragraph_is_rtl(&format!("{line}\u{5D0}"), None)).then_some(false) } +/// The first strong characters (UAX #9 P2) of the elements of one ``, found for each element +/// in constant time from one pass over the text's characters. An isolate initiator is skipped to +/// its matching PDI (UAX #9 BD9), and one without a matching PDI to the end; paragraph separators +/// count as spaces, as in [`text_first_strong_rtl`]. +struct FirstStrong { + /// The characters each element holds: a range of indices into `next`. + spans: HashMap>, + /// For each character and for the end: the index of the first strong character from there on, + /// isolates skipped, and whether it is right to left; `None` when there is none. + next: Vec>, +} + +impl FirstStrong { + fn new(chars: &[char], spans: HashMap>) -> Self { + use unicode_bidi::BidiClass::{AL, FSI, L, LRI, PDI, R, RLI}; + let class = |c: char| unicode_bidi::bidi_class(c); + // The matching PDI of each isolate initiator. + let mut pdi = vec![None; chars.len()]; + let mut open = vec![]; + for (i, &c) in chars.iter().enumerate() { + match class(c) { + LRI | RLI | FSI => open.push(i), + PDI => { + if let Some(at) = open.pop() { + pdi[at] = Some(i); + } + } + _ => {} + } + } + let mut next = vec![None; chars.len() + 1]; + for (i, &c) in chars.iter().enumerate().rev() { + next[i] = match class(c) { + L => Some((i, false)), + R | AL => Some((i, true)), + LRI | RLI | FSI => pdi[i].and_then(|p| next[p + 1]), + _ => next[i + 1], + }; + } + Self { spans, next } + } + + /// Whether the first strong character of element `n` is right to left; `None` when it holds + /// none. An isolate that opens inside `n` and closes after it hides the rest of `n`. + fn of(&self, n: roxmltree::NodeId) -> Option { + let span = self.spans.get(&n)?; + self.next.get(span.start).copied().flatten().filter(|&(at, _)| at < span.end).map(|(_, rtl)| rtl) + } +} + /// A left-aligned point text of `runs` at the origin (for measuring). fn with_runs(runs: Vec) -> TextObject { let mut t = TextObject::point(Point::ZERO, "", CharStyle::default()); diff --git a/crates/svg/tests/it/import_text.rs b/crates/svg/tests/it/import_text.rs index 472d7517e4..0bc8c34dfd 100644 --- a/crates/svg/tests/it/import_text.rs +++ b/crates/svg/tests/it/import_text.rs @@ -587,6 +587,12 @@ fn unicode_bidi_values_the_text_model_cannot_hold_are_reported() { (r#"אבab"#.to_string(), "ab", Ltr), (format!("{far}"), "\u{5D0}", Rtl), (format!("\n12 {far}"), "\u{5D0}", Rtl), + // An isolate that opens in one text node and closes in a later one is skipped whole. + ("⁧ab⁩א".to_string(), "\u{5D0}", Rtl), + ("⁧abcd⁩א".to_string(), "\u{5D0}", Rtl), + // An isolate that opens in a nested `plaintext` element and closes after it hides the rest of that element. + (r#"⁧ab⁩א"#.to_string(), "\u{5D0}", Rtl), + (r#"⁧ab⁩א"#.to_string(), "ab", Ltr), ] { let (d, _) = import_with_report(&svg(&format!(r#"x {body}"#))).unwrap();